---
title: Web Capabilities
description: 通过显式 Capability binding 发布后端 HTTP endpoint，并调用获准的上游 origin。
---

后端 HTTP 由两组相互独立的 Capability 配对组成。入站流量把 Web Ingress 绑定到
一个或多个 `lenso.http.endpoint@1` provider；出站流量把 consumer 绑定到提供
`lenso.http.client@1` 的策略受限 HTTP Egress。

第一次构建后端时，请先按照[开发 Web 后端](/docs/zh/web)完成 Host 前置条件、
Endpoint Crate、Linked Registration、Ingress 配置、真实 Socket 调用与移除。完成后
再回到本页查阅 HTTP Contract、Auth、OpenAPI、Middleware 与 Egress 的详细参考。

```mermaid
flowchart LR
  H[HTTP client] --> I[Web Ingress]
  I --> E[HTTP Endpoint providers]
  I --> O[可选 OpenAPI document]
  O -. 显式绑定的 descriptions .-> E
  M[Business Plugin] --> C[HTTP Client Capability]
  C --> X[HTTP Egress]
  X --> U[Allowed upstream]
```

这些后端契约与 App 自己的浏览器 UI 分开。Web Shell、Browser Adapter 与 UI
Contribution Plugin 负责组合页面和生成的浏览器 client，并不拥有后端 HTTP route。

## 发布入站 Endpoint

使用 endpoint macro，让 `describe` 与 `handle` 共用同一份编译期检查的 route table：

```rust title="src/http.rs"
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>`：

```rust title="src/http.rs"
#[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`：

```rust title="src/http.rs"
#[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 必须显式完成三个选择：

1. 把已发布的 `lenso-openapi` crate 选为一个 `lenso.openapi` Instance。
2. 把需要写入文档的 Endpoint provider 绑定到该 Instance 的 `many`
   `lenso.http.endpoint@1` requirement。
3. 把 OpenAPI Instance 自己提供的 HTTP Endpoint 绑定到 Web Ingress。

生成的 OpenAPI 3.1 文档只包含这个显式绑定的子集。Plugin 不会全局发现 provider，
也不会从 listener 推断公开 server URL。不可变 configuration 控制文档 metadata 与
静态 route：

```json title="lenso.openapi configuration"
{
  "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：

```rust title="src/http.rs"
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](/docs/zh/web/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 设置不可变配置：

```json
{
  "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。

```rust
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：

```rust
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 自动安装。
