Web Capabilities
通过显式 Capability binding 发布后端 HTTP endpoint,并调用获准的上游 origin。
后端 HTTP 由两组相互独立的 Capability 配对组成。入站流量把 Web Ingress 绑定到
一个或多个 lenso.http.endpoint@1 provider;出站流量把 consumer 绑定到提供
lenso.http.client@1 的策略受限 HTTP Egress。
第一次构建后端时,请先按照开发 Web 后端完成 Host 前置条件、 Endpoint Crate、Linked Registration、Ingress 配置、真实 Socket 调用与移除。完成后 再回到本页查阅 HTTP Contract、Auth、OpenAPI、Middleware 与 Egress 的详细参考。
这些后端契约与 App 自己的浏览器 UI 分开。Web Shell、Browser Adapter 与 UI Contribution Plugin 负责组合页面和生成的浏览器 client,并不拥有后端 HTTP route。
发布入站 Endpoint
使用 endpoint macro,让 describe 与 handle 共用同一份编译期检查的 route table:
use lenso::prelude::*;
use lenso_capability_http_endpoint::{
prelude::*,
response::{self, HeaderValue, StatusCode, header},
};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct CreateOrder {
total_cents: u64,
}
#[derive(Serialize)]
struct CreatedOrder<'a> {
id: &'a str,
}
#[lenso::plugin]
#[derive(Clone, Debug, Default)]
struct OrdersHttp;
#[endpoint]
impl OrdersHttp {
#[post("orders.create", "/orders")]
async fn create(
&self,
_context: Ctx,
Json(_order): Json<CreateOrder>,
) -> Result<HandleResponse, EndpointHandleInvocationError> {
Ok(response::json(
StatusCode::CREATED,
&CreatedOrder { id: "order-42" },
)?.with_header(
&header::LOCATION,
&HeaderValue::from_static("/orders/order-42"),
)?)
}
}
#[endpoint] 汇总同一个 impl 中的 handler。可使用 #[get]、#[post]、
#[put]、#[patch]、#[delete]、#[head]、#[options] 与 #[query];每个属性依次声明
稳定 route ID 和 path。旧的 http_endpoint! 静态表仍可用于生成代码或兼容已有
Plugin。
#[query] 表示 HTTP QUERY 方法:它适合安全、幂等、但需要结构化请求体的查询。
不要把它与 URL Query String 混淆;URL 参数使用 QueryParams<T>:
#[endpoint]
impl OrdersHttp {
#[query("orders.search", "/orders/search")]
async fn search(
&self,
Json(filter): Json<SearchFilter>,
QueryParams(page): QueryParams<Page>,
) -> Result<HandleResponse, EndpointHandleInvocationError> {
self.search_orders(filter, page).await
}
}
QUERY 请求携带内容时必须声明正确的 Content-Type;Json<T> 会在 Handler 前执行
这一检查。旧名称 Query<T> 暂时保留用于迁移,但新代码应使用 QueryParams<T>。
Endpoint 拥有 HTTP 形态的业务行为。Web Ingress 拥有 listener 与协议机制、route 匹配、path parameter、大小与并发限制、deadline、断连 cancel、request ID 以及 稳定的 HTTP error mapping。route 冲突会让 activate 失败。
常规响应使用 response::json、response::problem、response::text 和
response::empty。它们接收强类型状态码、设置正确的 content type,并让序列化失败
继续走 Endpoint error path。需要 Location、WWW-Authenticate 或其他显式 header
时,使用接收已校验 http header name/value 的 HandleResponse::with_header。只有
少见的二进制响应体才需要直接构造生成的 wire DTO。
添加可选 OpenAPI 文档
OpenAPI 支持是普通的 opt-in Plugin,不是 Ingress 设置。Endpoint 作者可以给 route
附加 OpenAPI 3.1 Operation Object,同时继续由 route declaration 权威定义 method、
path 与稳定的 operationId:
#[endpoint]
impl OrdersHttp {
#[get("orders.read", "/orders/{order_id}")]
#[openapi({
summary: "Read an order",
responses: {
"200": {
description: "Order",
content: {
"application/json": {
schema: {
"type": "object",
required: ["id"],
properties: { id: { "type": "string" } }
}
}
}
}
}
})]
async fn read(
&self,
Path(path): Path<OrderPath>,
) -> Result<HandleResponse, EndpointHandleInvocationError> {
// 业务 Endpoint 仍是最终权威。
self.read_order(path.order_id).await
}
}
JSON-like DSL 的普通 identifier key 不需要引号;"type"、
"application/json"、"$ref" 等 key 仍使用引号。原有 raw JSON string 写法继续
兼容,http_endpoint! route table 也可以使用
openapi = openapi_operation!({ ... })。macro 会在编译期校验 DSL,并拒绝作者另行
提供可能与稳定 route ID 漂移的 operationId。method 与 path 始终来自 route
declaration。
注解本身仍然是可选的。没有 OpenAPI metadata 的 route 依旧有效;只有显式选择的 OpenAPI Plugin 把它纳入文档时,assembler 才会为它补上最小 fallback response。
要发布文档,App Composition 必须显式完成三个选择:
- 把已发布的
lenso-openapicrate 选为一个lenso.openapiInstance。 - 把需要写入文档的 Endpoint provider 绑定到该 Instance 的
manylenso.http.endpoint@1requirement。 - 把 OpenAPI Instance 自己提供的 HTTP Endpoint 绑定到 Web Ingress。
生成的 OpenAPI 3.1 文档只包含这个显式绑定的子集。Plugin 不会全局发现 provider, 也不会从 listener 推断公开 server URL。不可变 configuration 控制文档 metadata 与 静态 route:
{
"title": "Orders API",
"version": "1.0.0",
"description": "Public order operations",
"document_path": "/openapi.json",
"servers": [{"url": "https://api.example.com"}],
"components": {
"securitySchemes": {
"bearer": {"type": "http", "scheme": "bearer"}
}
}
}
这里有意不提供 enabled 字段。删除 lenso.openapi Instance 及其 binding 就会移除
/openapi.json,不会改动业务 Endpoint 或 Web Ingress。Swagger UI、Redoc、页面、
asset 与 navigation 仍属于 App 或 Console 持有的 UI 关注点。
对入站请求进行认证
Web Ingress 从一个 Authorization header 中选出 credential,并把 scheme 与 value
放入 HandleRequest::credential;它本身不会认证调用者。Endpoint 在 activate 时解析
显式绑定的 AuthClient,提交与协议无关的 evidence,再把 Auth 返回的
ActorAssertion 附加到 invocation context,然后调用业务 Capability:
use std::rc::Rc;
use lenso_auth_sdk::ActorAssertion;
use lenso_capability_auth::AuthClient;
use lenso_capability_http_endpoint::{
EndpointHandleInvocationError, ExtractorFuture, FromRequest,
HandleRequest, HandleResponse, Path, endpoint,
};
use lenso_http_auth::{
AuthClientSource, AuthenticatedHttpActor, extract_authenticated_actor,
};
use lenso_kernel::InvocationContext;
use serde::Deserialize;
#[derive(Deserialize)]
struct OrderPath {
order_id: String,
}
struct UserActor {
subject: String,
}
impl AuthenticatedHttpActor for UserActor {
const KIND: &'static str = "user";
fn from_assertion(assertion: &ActorAssertion) -> Self {
Self { subject: assertion.subject().to_owned() }
}
}
impl AuthClientSource for OrdersHttp {
fn auth_client(&self) -> Result<Rc<AuthClient>, EndpointHandleInvocationError> {
Ok(self.auth.clone())
}
}
impl FromRequest<OrdersHttp> for UserActor {
fn from_request<'a>(
provider: &'a OrdersHttp,
context: &'a mut InvocationContext,
request: &'a HandleRequest,
) -> ExtractorFuture<'a, Self> {
extract_authenticated_actor(provider, context, request)
}
}
#[endpoint]
impl OrdersHttp {
#[get("orders.read", "/orders/{order_id}")]
async fn read(
&self,
actor: UserActor,
context: InvocationContext,
Path(path): Path<OrderPath>,
) -> Result<HandleResponse, EndpointHandleInvocationError> {
self.handle_authenticated(context, actor.subject, path.order_id).await
}
}
extract_authenticated_actor 统一处理 evidence 转换、Auth 调用、稳定的 401/403 响应以及
assertion 附加。OrdersHttp::auth 与 Orders client 是从 ActivateContext::dependencies() 一次性创建、
并在 deactivate 时释放的 client;不要在每个请求里重复解析 binding。目标 Orders
Plugin 仍必须校验 assertion 的 proof、issuer、有效期和 operation audience,然后再投影
为自己的 typed actor。Provider 与目标侧的完整代码见 Auth Plugin。
extractor 按 handler 参数顺序运行。它可以等待 provider 上保存的 client、为后续
extractor 和 handler 增强 context、返回明确响应,或保留 Endpoint failure。内置
extractor 包括 Path<T>、QueryParams<T>、Json<T> 与 RequestId。无效 path、query
或 JSON 会在 handler 运行前被拒绝。
middleware 更适合自然包裹多个 handler 参数的行为,例如 tracing 或共享响应策略。
认证通常更适合 UserActor 或 AdminActor extractor,因为 handler 签名会直接显示
期望的身份类型。这些是应用自己实现 AuthenticatedHttpActor 的 HTTP 入口身份类型;
Lenso 不强加一套通用 user 模型。它与目标侧验证后才投影的 TypedActor 有意分开。
AdminActor 只应该表示一种不同的、已经认证的 actor kind,不应借 extractor
直接授予角色、tenant access 或 resource ownership;这些决定仍属于目标 Plugin。
handle_authenticated 是 Endpoint 自己的应用代码:它携带上述 assertion context
调用 self.orders,用 response::json 序列化成功结果,并把目标 Plugin 的授权拒绝
显式映射成 403 Problem Details。
请显式保持以下映射:
| 结果 | Endpoint 响应 |
|---|---|
| credential 缺失、无效、过期或已撤销 | 401 Problem Details 和 WWW-Authenticate |
| actor 有效,但无权调用目标 operation | Endpoint 把目标 Plugin 的授权拒绝映射为 403 |
| path、query 或 body 格式错误 | 400 |
| JSON extractor 缺少 content type 或 content type 不受支持 | 415 |
| Auth 或业务 Capability 不可用 | 503 |
| response 序列化或 header 构造失败 | Endpoint internal failure,映射为 503 |
Ingress 拥有 transport error;Auth 拥有 evidence authentication;目标业务 Plugin 拥有最终授权。Auth 成功不等于获得所有业务权限。
在 Plan 中为一个 Ingress Instance 设置不可变配置:
{
"bind_address": "0.0.0.0:8080",
"max_request_body_bytes": 1048576,
"max_request_head_bytes": 16384,
"max_concurrent_requests": 128,
"request_timeout_millis": 30000
}
默认值是临时 loopback listener。Web Ingress 会在 invocation 前移除 credential 与 hop-by-hop header,并替换不可信的外部 request ID。
调用上游服务
HTTP Egress 至少需要一个精确的 http 或 https origin。allowed_origins 中不能
包含 path、credential、query 或 fragment。
use lenso_http_egress::HttpEgressConfig;
use std::time::Duration;
let config = HttpEgressConfig::new(["https://api.example.test"])?
.with_transfer_limits(1024 * 1024, 16 * 1024, 4 * 1024 * 1024, 32 * 1024)?
.with_max_concurrent_requests(64)?
.with_timeouts(Duration::from_secs(5), Duration::from_secs(30))?;
从 consumer 已声明并绑定的 dependencies 解析生成 client:
use lenso_capability_http_client::{
ClientClient, ClientInvocationError, SendRequest, SendRequestHeadersItem,
};
use lenso_kernel::{PluginDependencies, RuntimeFailure};
fn client(dependencies: &PluginDependencies) -> Result<ClientClient, RuntimeFailure> {
ClientClient::from_dependencies(dependencies)
}
async fn send(client: &ClientClient) -> Result<i64, ClientInvocationError> {
let response = client.send(SendRequest {
method: "POST".into(),
url: "https://api.example.test/orders".into(),
headers: vec![SendRequestHeadersItem {
name: "content-type".into(),
value: "application/json".into(),
}],
body: br#"{"total_cents":4200}"#.as_slice().into(),
}).await?;
Ok(response.status)
}
生成的 Domain Error 包括 destination_not_allowed、invalid_request、
request_too_large、response_too_large、timeout 与 transport_failure。
当前 provider 不会自动跟随 redirect、使用 system proxy、管理 cookie 或重试。
两个 HTTP contract 都是 portable 的,并有生成的 TypeScript binding。但当前维护的 Ingress 与 Egress provider 是 linked Rust Plugin;Bun Plugin 仍需通过 host 支持的 execution path 与 binding 获得 Capability。contract portable 不等于 provider 自动安装。