Web Capabilities
Publish backend HTTP endpoints and call allowed upstream origins through explicit Capability bindings.
Backend HTTP uses two independent Capability pairs. Inbound traffic binds Web
Ingress to one or more lenso.http.endpoint@1 providers. Outbound traffic
binds a consumer to the policy-bounded HTTP Egress provider of
lenso.http.client@1.
If you are building your first backend, follow Build a Web backend first. That tutorial covers the Host prerequisite, Endpoint crate, linked registration, Ingress configuration, real socket calls, and removal. Return to this page for the detailed HTTP contract, Auth, OpenAPI, middleware, and Egress reference.
These backend contracts are separate from target-owned browser UI. Web Shell, Browser Adapter, and UI Contribution Plugins compose pages and generated browser clients; they do not own backend HTTP routing.
Publish an inbound endpoint
Use the endpoint macro so describe and handle share one compile-time checked
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] collects the handlers in one impl. Use #[get], #[post],
#[put], #[patch], #[delete], #[head], #[options], or #[query]; each attribute
declares a stable route ID followed by its path. The http_endpoint! static
table remains available for generated code and existing Plugins.
#[query] declares the HTTP QUERY method for safe, idempotent searches that
need structured request content. It is different from a URL query string;
decode URL parameters with 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
}
}
A QUERY request with content must declare the correct Content-Type.
Json<T> enforces this before the handler runs. The old Query<T> extractor
name remains temporarily available for migration; new code should use
QueryParams<T>.
The endpoint owns HTTP-shaped business behavior. Web Ingress owns listener and protocol mechanics, route matching, path parameters, size and concurrency limits, deadlines, disconnect cancellation, request IDs, and stable HTTP error mapping. Route collisions fail activation.
Use response::json, response::problem, response::text, and
response::empty for common responses. They accept typed status codes, set the
appropriate content type, and keep serialization failures on the Endpoint
error path. HandleResponse::with_header accepts validated http header names
and values when a response needs Location, WWW-Authenticate, or another
explicit header. Construct the generated wire DTO directly only for unusual
binary response bodies.
Add an optional OpenAPI document
OpenAPI support is an ordinary opt-in Plugin, not an Ingress setting. Endpoint
authors can attach an OpenAPI 3.1 Operation Object to a route while keeping the
route declaration authoritative for its method, path, and stable
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> {
// The business Endpoint remains the final authority.
self.read_order(path.order_id).await
}
}
The JSON-like DSL accepts ordinary identifier keys without quotes. Quote keys
such as "type", "application/json", and "$ref". The previous raw JSON
string form remains compatible, and http_endpoint! route tables can use
openapi = openapi_operation!({ ... }). The macros validate the DSL at compile
time and reject an author-supplied operationId that could drift from the
stable route ID. Method and path always come from the route declaration.
The annotation itself remains optional. Routes without OpenAPI metadata are valid and receive a minimal fallback response only if an explicitly selected OpenAPI Plugin includes them in its document.
To publish a document, App Composition must make all three choices explicitly:
- Select one published
lenso-openapicrate as alenso.openapiInstance. - Bind the Endpoint providers to document to that Instance’s
manylenso.http.endpoint@1requirement. - Bind the OpenAPI Instance’s own HTTP Endpoint to Web Ingress.
Only that bound subset appears in the generated OpenAPI 3.1 document. The Plugin does not discover providers globally or infer a public server URL from the listener. Its immutable configuration controls document metadata and the static 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"}
}
}
}
There is deliberately no enabled field. Removing the lenso.openapi
Instance and its bindings removes /openapi.json without changing the
business Endpoints or Web Ingress. Swagger UI, Redoc, pages, assets, and
navigation remain application- or Console-owned UI concerns.
Authenticate an inbound request
Web Ingress selects one Authorization credential and places its scheme and
value in HandleRequest::credential. It does not authenticate the caller. The
Endpoint resolves the explicitly bound AuthClient during activation, submits
the protocol-neutral evidence, and attaches the returned ActorAssertion to
the invocation context before calling the business 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 owns evidence conversion, Auth invocation, stable 401/403
responses, and assertion attachment. OrdersHttp::auth and its Orders client are created once from
ActivateContext::dependencies() and released during deactivation; do not
resolve bindings for every request. The target Orders Plugin must still verify
the assertion proof, issuer, validity, and operation audience before projecting
it into its own typed actor. See Auth Plugin for the
provider and target-side code.
Extractors run in handler-argument order. They may await clients stored on the
provider, enrich the context for later extractors and the handler, return an
intentional response, or preserve an Endpoint failure. Path<T>, QueryParams<T>,
Json<T>, and RequestId are built in. Invalid path, query, or JSON input is
rejected before the handler runs.
Use middleware for behavior that naturally wraps many handler arguments, such
as tracing or a shared response policy. Authentication is usually clearer as a
UserActor or AdminActor extractor because the expected identity kind is
visible in the handler signature. These are application-owned HTTP-edge identity
types implementing AuthenticatedHttpActor; Lenso does not impose one universal
user model. This trait is deliberately different from target-side TypedActor.
AdminActor should
only mean a distinct authenticated actor kind. Do not use the extractor to grant
roles, tenant access, or resource ownership—the target Plugin still owns those
decisions.
handle_authenticated is Endpoint-owned application code: it calls
self.orders with the assertion-bearing context, serializes the success value
with response::json, and maps the target’s authorization rejection to an
explicit 403 Problem Details response.
Keep these mappings intentional:
| Outcome | Endpoint response |
|---|---|
| Missing, invalid, expired, or revoked credential | 401 Problem Details and WWW-Authenticate |
| Valid actor without permission for the target operation | Endpoint maps the target Plugin’s authorization rejection to 403 |
| Malformed path, query, or body | 400 |
| JSON extractor with a missing or unsupported content type | 415 |
| Auth or business Capability unavailable | 503 |
| Response serialization or header construction fails | internal Endpoint failure, mapped to 503 |
Ingress owns transport errors; Auth owns evidence authentication; the target business Plugin owns the final authorization decision. A successful Auth call must not be treated as blanket permission.
Configure one immutable Ingress Instance in the Plan:
{
"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
}
The default is an ephemeral loopback listener. Web Ingress strips credential and hop-by-hop headers before invocation and replaces untrusted external request IDs.
Call an upstream service
HTTP Egress requires at least one exact http or https origin. Paths,
credentials, queries, and fragments are invalid in allowed_origins.
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))?;
Resolve the generated client from the consumer’s declared dependencies:
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)
}
The generated Domain Errors are destination_not_allowed, invalid_request,
request_too_large, response_too_large, timeout, and
transport_failure. The current provider does not follow redirects, use
system proxies, manage cookies, or retry requests implicitly.
Both HTTP contracts are portable and their TypeScript projections are
published by @lenso/bun.
However, the maintained Ingress and Egress providers are linked Rust Plugins;
a Bun Plugin must still receive the Capability through an execution path and
binding supported by its host. Contract portability is not automatic provider
installation.