Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

lesto tutorial

This tutorial takes you from zero to a complete API, one concept at a time. Every chapter stands on its own: it has the full code, the command to try it, and what you will see in the interactive documentation. If you know FastAPI you will feel at home; if you don’t, you don’t need to.

Prerequisites: stable Rust (rustup), curl and a browser.

#ChapterWhat you learn
1First stepsCreate the project, the first route, automatic documentation
2Path parametersPath<T>, types, several parameters, what happens when the type doesn’t fit
3Query parametersQuery<T>, optionals, defaults, validation
4Request bodyJson<T>, deserialization and validation with garde
5Validationgarde rules, nested structures, custom rules
6ResponsesReturn types, status codes, Result, empty responses
7Error handlingHttpError, RFC 9457, documented errors
8State and dependenciesState<S>, Extension, custom extractors (lesto’s Depends)
9SecurityBearer, API keys, Basic, OAuth2, verifying tokens
10Bigger projectsModules, nest, tags, route order
11Middleware and axumtower layers, CORS, logging, dropping down to axum
12TestingTesting the API without opening a port
13Databasessqlx stores with the db feature: transactions, principals, permissions
14AWS LambdaThe same binary behind API Gateway with the lambda feature
15ObservabilityRequest and store spans, OpenTelemetry semantic conventions, OTLP
16MCPRoutes as tools, resources and prompts for AI agents, both protocol eras, authentication
AFrom FastAPI to lestoCorrespondence table
BCommon problemsCompile errors and how to fix them
CWhy axum and not actix-webThe reasons behind the choice of core
DLeaving lestoThe way out, step by step: nobody is locked in
EPerformanceWhat lesto costs per request and at build time

Every snippet in this tutorial is compiled and tested in examples/99-tutorial (cargo test -p tutorial), so the documentation cannot drift from the API. The other examples grow with the chapters: examples/01-hello (chapter 1), examples/02-notes (a complete CRUD API, chapter 13), examples/03-lambda (chapter 14), examples/05-routers (chapter 10).

To read this tutorial as a website (sidebar, search, dark theme): cargo install mdbook && mdbook serve docs --open.

1. First steps

Create the project

cargo new hello-lesto
cd hello-lesto

Open Cargo.toml and add lesto. That is the whole list:

[dependencies]
lesto = "0.3"

Serialization (serde), JSON schemas (schemars), validation (garde), the async runtime (tokio) and logging (tracing) come with it. #[lesto::model] (chapter 4) derives through lesto’s copies of serde, schemars and garde, and #[lesto::main] / #[lesto::test] are tokio’s #[tokio::main] and #[tokio::test] through lesto::tokio, with the same options (#[lesto::main(flavor = "current_thread")]). The one exception is the database chapter: sqlx’s FromRow derive needs sqlx in your manifest (chapter 13 says why).

The first route

Replace src/main.rs:

use lesto::prelude::*;

/// Greets the world.
#[lesto::get("/hello")]
async fn root() -> &'static str {
    "Hello, lesto!"
}

#[lesto::main]
async fn main() -> std::io::Result<()> {
    App::new()
        .title("Hello API")
        .routes(routes![root])
        .serve()
        .await
}

Run it with the lesto CLI (install it once with cargo install lesto-cli):

lesto dev

lesto dev builds the project, starts it, and rebuilds and restarts it every time you save a file, like fastapi dev. It also keeps the port open while the code compiles, so a request sent during a rebuild waits for the new version instead of failing. Plain cargo run works too, without the reload.

The terminal shows one line per request as it arrives (chapter 11 has the rest: JSON, filters):

2026-10-03T09:15:00.120456Z  INFO lesto::app: listening on http://127.0.0.1:8000
2026-10-03T09:15:04.551027Z  INFO GET /hello 200 87µs

lesto openapi prints the OpenAPI document instead of serving it (lesto openapi -o api.json writes a file), for client generators and CI checks. It runs your main up to App::serve, which writes the document and returns.

In another terminal:

curl http://127.0.0.1:8000/hello
Hello, lesto!

What you wrote

Line by line:

  • use lesto::prelude::* imports everything a route file normally needs: App, routes!, the extractors (Json, Query, Path, State), HttpError and the serde, garde and schemars derives.
  • #[lesto::get("/hello")] declares a GET /hello route. There are also post, put, patch, delete, head, options.
  • /// Greets the world. is not just a comment: it becomes the operation’s summary in the documentation. A following paragraph, separated by a blank line, becomes the description (Markdown allowed).
  • async fn root() -> &'static str: the return type decides both the HTTP response and its documentation. A string is text/plain.
  • App::new()...routes(routes![root]) registers the routes. routes![] takes a list of annotated functions.
  • .serve() binds LESTO_HOST:LESTO_PORT (default 127.0.0.1:8000; PORT alone also works, for Heroku and Cloud Run) and serves until Ctrl-C or SIGTERM. The shutdown is graceful: the port closes at once, requests already running finish, then serve returns. .serve_at("0.0.0.0:9000") takes an explicit address. Under lesto dev (or systemd socket activation) it serves on the socket it inherits instead; the address is then ignored.

Automatic documentation

Open http://127.0.0.1:8000/docs: that is the Scalar API reference, generated from the code you just wrote. You will see GET /hello with the summary “Greets the world.” and a 200 response of type text/plain.

Open http://127.0.0.1:8000/swagger: the same documentation with the classic Swagger UI.

Open http://127.0.0.1:8000/openapi.json: the raw OpenAPI 3.1 document that feeds both pages. You can hand it to any client generator. The operationId is {function}_{path}_{method} with every non-alphanumeric character turned into _ (FastAPI’s convention), so it stays unique when two modules have a list function; operation_id = "..." in the attribute overrides it.

{
  "openapi": "3.1.0",
  "info": { "title": "Hello API", "version": "0.1.0" },
  "paths": {
    "/hello": {
      "get": {
        "summary": "Greets the world.",
        "operationId": "hello_hello_get",
        "responses": {
          "200": {
            "description": "OK",
            "content": { "text/plain; charset=utf-8": { "schema": { "type": "string" } } }
          }
        }
      }
    }
  }
}

You did not write a line of OpenAPI: it is derived from the function’s types. That is the thread running through the whole tutorial.

API metadata

App::new()
    .title("Hello API")
    .version("1.0.0")
    .description("An example API. Supports **Markdown**.")
    .server("https://api.example.com")

To move or disable the pages:

App::new()
    .docs_url(Some("/reference")) // Scalar elsewhere
    .swagger_url(None)            // no Swagger UI
    .openapi_url(None)            // no document: disables the pages too

Recap

  • A route is an async fn with #[lesto::get("/path")].
  • The doc comment becomes the operation’s description.
  • The return type decides response and documentation.
  • /docs, /swagger and /openapi.json come for free.

Next: Path parameters.

2. Path parameters

A path parameter is a variable part of the URL, like the id in /items/42.

One parameter

use lesto::prelude::*;

/// Returns the received id.
#[lesto::get("/items/{item_id}")]
async fn read_item(Path(item_id): Path<u64>) -> String {
    format!("item {item_id}")
}

{item_id} in the path declares the parameter; Path<u64> extracts and converts it. Path(item_id) is a plain Rust pattern that opens the wrapper and hands you the value.

curl http://127.0.0.1:8000/items/42
item 42

The type matters

You declared u64, so lesto converts and checks. Try a value that is not an integer:

curl http://127.0.0.1:8000/items/foo
{
  "type": "about:blank",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "1 validation error",
  "instance": "/items/foo",
  "errors": [
    { "in": "path", "pointer": "/item_id", "detail": "Cannot parse `foo` to a `u64`", "code": "value_error" }
  ]
}

A 422 response with a standard body (RFC 9457, covered in the errors chapter) that says exactly which parameter failed and why. Inside the handler you already work with a valid u64.

In the documentation the parameter shows as integer, required, with format uint64.

Several parameters

With two or more parameters you can use a tuple, in the order they appear in the path:

#[lesto::get("/users/{user_id}/items/{item_id}")]
async fn read_user_item(Path((user_id, item_id)): Path<(u64, u64)>) -> String {
    format!("user {user_id}, item {item_id}")
}

or a struct, more readable when there are many. Field names must match the names in the path:

#[lesto::model]
struct UserItemPath {
    user_id: u64,
    item_id: u64,
}

#[lesto::get("/users/{user_id}/items/{item_id}")]
async fn read_user_item(Path(p): Path<UserItemPath>) -> String {
    format!("user {}, item {}", p.user_id, p.item_id)
}

Note the two derives: Deserialize (serde) to read the values, JsonSchema (schemars) to document them. You will see them on almost every type that goes through the API.

Describing a parameter

A doc comment on the field becomes the parameter’s description:

#[lesto::model]
struct UserItemPath {
    /// Id of the owning user.
    user_id: u64,
    /// Id of the item.
    item_id: u64,
}

Enums

If a parameter may only take a few values, use an enum: conversion fails for other values and the documentation shows the list.

#[lesto::model]
#[serde(rename_all = "lowercase")]
enum ModelName {
    Alexnet,
    Resnet,
    Lenet,
}

#[lesto::get("/models/{name}")]
async fn get_model(Path(name): Path<ModelName>) -> String {
    match name {
        ModelName::Alexnet => "Deep Learning FTW!".into(),
        ModelName::Lenet => "LeCNN all the images".into(),
        ModelName::Resnet => "Have some residuals".into(),
    }
}

#[serde(rename_all = "lowercase")] makes /models/alexnet acceptable. schemars reads serde’s attributes, so the documentation shows lowercase names as well.

Route order

Routes are matched by structure, not by registration order: /users/me and /users/{id} coexist, and /users/me wins because it is more specific. You do not need to worry about the order in routes![].

Recap

  • {name} in the path + Path<T> in the handler.
  • The type T does conversion, validation and documentation.
  • Tuple or struct for several parameters; enum for closed sets of values.
  • A value that cannot be converted yields a detailed 422, with no code on your side.

Next: Query parameters.

3. Query parameters

Everything after the ? in the URL: /items?skip=0&limit=10.

In lesto, query parameters are declared with a struct and extracted with Query<T>.

use lesto::prelude::*;

#[lesto::model]
struct Pagination {
    /// How many items to skip.
    #[garde(skip)]
    #[serde(default)]
    skip: usize,
    /// How many items to return (1..=100).
    #[garde(range(min = 1, max = 100))]
    #[serde(default = "default_limit")]
    limit: usize,
}

fn default_limit() -> usize {
    10
}

/// List items.
#[lesto::get("/items")]
async fn list_items(Query(q): Query<Pagination>) -> String {
    format!("skip={} limit={}", q.skip, q.limit)
}
curl "http://127.0.0.1:8000/items?skip=20&limit=5"
skip=20 limit=5
curl "http://127.0.0.1:8000/items"
skip=0 limit=10

Three derives, three jobs

  • Deserialize reads the values from the query string. With #[serde(default)] a missing field takes its default value; with #[serde(default = "fn")] you pick one.
  • JsonSchema produces the documentation: every field becomes an in: query parameter, with type, default and the description taken from the doc comment.
  • Validate (garde) adds the rules: here limit must be between 1 and 100.

Every field needs a #[garde(...)] attribute: a rule, or #[garde(skip)] if there is nothing to check. If you would rather not annotate rule-less fields, put #[garde(allow_unvalidated)] on the struct.

When validation fails

curl "http://127.0.0.1:8000/items?limit=500"
{
  "type": "about:blank",
  "title": "Unprocessable Entity",
  "status": 422,
  "detail": "1 validation error",
  "instance": "/items",
  "errors": [
    { "in": "query", "pointer": "/limit", "detail": "greater than 100", "code": "value_error" }
  ]
}

The same happens with a wrong type (?limit=abc): code is value_error and detail explains.

Optional parameters

An Option<T> field is optional and, when absent, is None. In the documentation it shows as required: false.

#[lesto::model]
#[garde(allow_unvalidated)]
struct Search {
    q: Option<String>,
    #[serde(default)]
    exact: bool,
}

To validate the content of an Option use inner: #[garde(inner(length(min = 3)))] checks the string only when it is present.

Repeated parameters

A key that appears several times (?tag=rust&tag=web) collects into a Vec<T>. Give the field #[serde(default)] so that a query string without the key is an empty list rather than a 422.

#[lesto::model]
struct Tags {
    #[garde(inner(length(min = 1)))]
    #[serde(default)]
    tag: Vec<String>,
}

/// List items with any of the tags.
#[lesto::get("/tags")]
async fn list_tags(Query(t): Query<Tags>) -> String {
    t.tag.join(",")
}
curl "http://127.0.0.1:8000/tags?tag=rust&tag=web"
rust,web

In the documentation the parameter has an array schema; OpenAPI’s default for query arrays (style: form, explode: true) is exactly the repeated-key form.

What ends up in the documentation

For the Pagination struct above, the GET /items operation has two parameters:

{ "name": "skip",  "in": "query", "required": false, "description": "How many items to skip.",
  "schema": { "type": "integer", "format": "uint", "minimum": 0, "default": 0 } }
{ "name": "limit", "in": "query", "required": false, "description": "How many items to return (1..=100).",
  "schema": { "type": "integer", "format": "uint", "minimum": 1, "maximum": 100, "default": 10 } }

Note minimum: 1 and maximum: 100: schemars also reads #[garde(...)] attributes, so the validation rules show up in the schema without being duplicated.

Recap

  • A struct with Deserialize + JsonSchema + Validate, extracted with Query<T>.
  • #[serde(default)] for defaults, Option<T> for optionals, Vec<T> for repeated keys.
  • Rules with #[garde(...)], #[garde(skip)] where none apply.
  • Type and validation errors yield a 422 with a pointer to the field.

Next: Request body.

4. Request body

When the client has to send data (create, update) it sends it in the request body, usually as JSON. In lesto the body is declared with a struct and extracted with Json<T>.

use lesto::prelude::*;

#[lesto::model]
struct Item {
    /// Item name.
    #[garde(length(min = 1, max = 100))]
    name: String,
    #[garde(skip)]
    description: Option<String>,
    /// Price in euros.
    #[garde(range(min = 0.0))]
    price: f64,
    #[garde(skip)]
    tax: Option<f64>,
}

#[lesto::model]
struct ItemOut {
    name: String,
    price_with_tax: f64,
}

/// Create an item.
#[lesto::post("/items", status = 201)]
async fn create_item(Json(item): Json<Item>) -> Json<ItemOut> {
    let tax = item.tax.unwrap_or(0.0);
    Json(ItemOut {
        name: item.name,
        price_with_tax: item.price + tax,
    })
}
curl -X POST http://127.0.0.1:8000/items \
  -H 'Content-Type: application/json' \
  -d '{"name": "Keyboard", "price": 45.0, "tax": 9.9}'
HTTP/1.1 201 Created
{"name":"Keyboard","price_with_tax":54.9}

#[lesto::model]: where the derives come from

A type that travels as JSON needs four derives: serde’s Serialize and Deserialize (the wire format), schemars’ JsonSchema (the OpenAPI schema) and garde’s Validate (the rules). #[lesto::model] writes them for you, through lesto’s own copies of those crates, which is why your Cargo.toml does not list them. The two structs above expand to roughly:

#[derive(Serialize, Deserialize, JsonSchema, Validate)]
#[serde(crate = "::lesto::serde")]
#[schemars(crate = "::lesto::schemars")]
struct Item { /* the fields, as written */ }

#[derive(Serialize, Deserialize, JsonSchema, Validate)]
#[serde(crate = "::lesto::serde")]
#[schemars(crate = "::lesto::schemars")]
#[garde(allow_unvalidated)]          // ItemOut has no rules at all
struct ItemOut { /* ... */ }

Two things to know:

  • Rules are all or nothing. A model with no #[garde(..)] anywhere (ItemOut) accepts every field as is. As soon as one field has a rule (Item), every field needs one: a rule, skip, or dive for a nested model. That is what catches a forgotten dive, which would otherwise leave a nested struct unchecked.
  • Your other derives stay yours. Write #[derive(Debug, Clone)] below the attribute as usual; field attributes (#[serde(rename = ..)], #[schemars(..)], #[garde(..)]) work unchanged.

The plain derives still work, if you prefer to spell them out or need one without the others: then serde, schemars and garde must be dependencies of your crate, as with any derive.

What Json<T> does for you

In order:

  1. Checks that Content-Type is JSON (application/json or application/*+json). Otherwise it answers 415 Unsupported Media Type.
  2. Reads the body and deserializes it into T. Missing fields, wrong types or malformed JSON yield a 422 that names the field.
  3. Runs garde validation. Every violated rule becomes an entry in errors.
  4. Hands you a valid T. Inside the handler there is nothing left to re-check.

In the documentation the operation has a required requestBody with the Item schema and a 201 response with the ItemOut schema. Both schemas end up in components.schemas, named after the struct.

A wrong body

Missing field:

curl -X POST http://127.0.0.1:8000/items -H 'Content-Type: application/json' \
  -d '{"name": "Keyboard"}'
{
  "type": "about:blank", "title": "Unprocessable Entity", "status": 422,
  "detail": "1 validation error", "instance": "/items",
  "errors": [
    { "in": "body", "pointer": "/price", "detail": "missing field `price`", "code": "missing" }
  ]
}

Violated rule:

curl -X POST http://127.0.0.1:8000/items -H 'Content-Type: application/json' \
  -d '{"name": "", "price": -1}'
{
  "...": "...",
  "detail": "2 validation errors",
  "errors": [
    { "in": "body", "pointer": "/name",  "detail": "length is lower than 1", "code": "value_error" },
    { "in": "body", "pointer": "/price", "detail": "lower than 0",           "code": "value_error" }
  ]
}

Invalid JSON:

{ "errors": [ { "in": "body", "pointer": "", "detail": "expected value at line 1 column 2", "code": "json_invalid" } ] }

The empty pointer means the whole document.

Body + path + query together

Extractors combine freely. lesto works out where each value comes from.

#[lesto::put("/items/{item_id}")]
async fn update_item(
    Path(item_id): Path<u64>,
    Query(q): Query<Pagination>,
    Json(item): Json<Item>,
) -> String {
    format!("update {item_id} ({} chars, skip={})", item.name.len(), q.skip)
}

One rule, inherited from axum: the body goes last, because it consumes the request.

Optional fields and defaults

The same serde rules you saw for queries apply:

#[lesto::model]
#[garde(allow_unvalidated)]
struct Item {
    name: String,
    #[serde(default)]
    tags: Vec<String>,          // absent → empty vector
    #[serde(default = "one")]
    quantity: u32,              // absent → 1
    note: Option<String>,       // absent → None
}

fn one() -> u32 { 1 }

A field with #[serde(default)] or of type Option does not appear in the schema’s required.

camelCase names

If the client wants priceWithTax instead of price_with_tax:

#[lesto::model]
#[serde(rename_all = "camelCase")]
struct ItemOut {
    name: String,
    price_with_tax: f64,
}

schemars follows serde: the schema uses priceWithTax too.

Recap

  • Json<T> with T: Deserialize + JsonSchema + Validate.
  • Content type, parsing and validation are automatic; the handler receives a valid value.
  • Errors say which field (pointer) and why (detail, code).
  • Path, query and body combine; the body goes last.

Next: Validation.

5. Validation

Validation is handled by garde. This chapter collects the most used rules and the situations you will run into first.

The rules

Every field of a #[derive(Validate)] struct needs exactly one #[garde(...)] attribute (or skip). Several rules combine with commas.

RuleApplies toExample
length(min = a, max = b)strings, Vec, maps#[garde(length(min = 1, max = 64))]
byte_length(...)strings (bytes instead of chars)#[garde(byte_length(max = 255))]
range(min = a, max = b)numbers#[garde(range(min = 1, max = 100))]
emailstrings#[garde(email)]
urlstrings#[garde(url)]
ip, ipv4, ipv6strings#[garde(ipv4)]
alphanumeric, asciistrings#[garde(alphanumeric)]
contains("x"), prefix("x"), suffix("x")strings#[garde(prefix("sk_"))]
pattern(r"...")strings (regex feature)#[garde(pattern(r"^[a-z]+$"))]
matches(other)equal to another field#[garde(matches(password))]
requiredOption<T> must be Some#[garde(required)]
inner(...)applies the rule to the elements#[garde(inner(length(min = 1)))]
divevalidates a nested struct#[garde(dive)]
custom(fn)your own function#[garde(custom(is_even))]
skipno check#[garde(skip)]

email and url need the garde features of the same name. lesto turns them on through its own email and url features, which are on by default; default-features = false on lesto drops them (and garde’s regex and idna dependencies) when you do not use those rules. pattern needs regex.

A complete example

use lesto::prelude::*;

#[lesto::model]
struct Address {
    #[garde(length(min = 1))]
    street: String,
    #[garde(length(min = 2, max = 2), ascii)]
    country: String,
}

#[lesto::model]
struct SignUp {
    #[garde(length(min = 3, max = 32), alphanumeric)]
    username: String,
    #[garde(email)]
    email: String,
    #[garde(length(min = 8))]
    password: String,
    #[garde(matches(password))]
    password_confirm: String,
    /// Age; optional but, when present, at least 18.
    #[garde(inner(range(min = 18)))]
    age: Option<u8>,
    /// At least one address, each of them valid.
    #[garde(length(min = 1), dive)]
    addresses: Vec<Address>,
    #[garde(custom(no_spaces))]
    display_name: String,
}

fn no_spaces(value: &str, _ctx: &()) -> garde::Result {
    if value.contains(' ') {
        return Err(garde::Error::new("must not contain spaces"));
    }
    Ok(())
}

#[lesto::post("/signup", status = 201)]
async fn signup(Json(body): Json<SignUp>) -> String {
    format!("welcome {}", body.username)
}

A body with two addresses, the second one without a street, produces:

{
  "errors": [
    { "in": "body", "pointer": "/addresses/1/street", "detail": "length is lower than 1", "code": "value_error" }
  ]
}

The pointer walks through structs and arrays: /addresses/1/street is the street field of the second address. It is a JSON Pointer (RFC 6901), so a client can map the error onto the right form field.

What reaches the documentation

schemars knows garde’s attributes and translates them into JSON Schema constraints:

gardeJSON Schema
length(min, max) on a stringminLength, maxLength
length(min, max) on a VecminItems, maxItems
range(min, max)minimum, maximum
emailformat: email
urlformat: uri
pattern(r)pattern
requiredthe field leaves Option and enters required

A custom rule has no translation: if you want it documented, say so in the field’s doc comment.

Structs without rules

If a struct has nothing to validate but still has to go through Json<T> or Query<T>:

#[lesto::model]
#[garde(allow_unvalidated)]
struct Filters {
    tag: Option<String>,
    archived: bool,
}

Validating outside the extractors

Validate is an ordinary trait. You can call it wherever you like:

let report = body.validate();       // Result<(), garde::Report>

and turn the report into an HTTP error with lesto::ValidationError::from_garde("body", &report), which is exactly what Json<T> does.

Recap

  • One #[garde(...)] attribute per field, or allow_unvalidated on the struct.
  • dive for nested structs, inner for the elements of Option and Vec.
  • custom(fn) for your own rules: fn(&T, &()) -> garde::Result.
  • Constraints end up in the OpenAPI schema as well.

Next: Responses.

6. Responses

The handler’s return type decides what goes on the wire and what appears in the documentation.

JSON

#[lesto::model]
struct User {
    id: u64,
    name: String,
}

/// Current user.
#[lesto::get("/me")]
async fn me() -> Json<User> {
    Json(User { id: 1, name: "Ada".into() })
}

Json<T> as output serializes T with Content-Type: application/json. It needs Serialize for the value and JsonSchema for the documentation: the 200 response references the User schema.

A Vec<T> is documented as an array:

#[lesto::get("/users")]
async fn list() -> Json<Vec<User>> { .. }     // schema: { type: array, items: $ref User }

The status code

By default a successful response is 200. To change it, use status on the attribute:

#[lesto::post("/users", status = 201)]
async fn create() -> Json<User> { .. }

#[lesto::delete("/users/{id}", status = 204)]
async fn delete(Path(_id): Path<u64>) {}

The status applies both at runtime and in the documentation. A handler returning () produces an empty body, and with status = 204 that is the classic deletion response.

status = N rewrites every 200 the handler returns, and the status is not visible in the signature. The alternative puts it in the type:

#[lesto::post("/jobs")]
async fn enqueue(Json(job): Json<Job>) -> Accepted<Json<JobId>> {
    Accepted(Json(queue(job)))                         // 202, documented as 202
}

#[lesto::delete("/items/{id}")]
async fn delete_item(Path(id): Path<u64>) -> NoContent {
    remove(id);
    NoContent                                          // 204, no body
}

Created<T>, Accepted<T> and NoContent (all in the prelude) wrap any response and are documented under their own status. They only turn a 200 into their status, so an inner failure (a body that cannot be serialized) still answers 500.

Text and HTML

#[lesto::get("/health")]
async fn health() -> &'static str { "ok" }               // text/plain

#[lesto::get("/page")]
async fn page() -> lesto::axum::response::Html<String> { .. }   // text/html

Result: success or error

In most handlers something can go wrong. The natural return type is a Result with HttpError as the error:

/// One user by id.
#[lesto::get("/users/{id}", responses(404))]
async fn get_user(Path(id): Path<u64>) -> Result<Json<User>, HttpError> {
    find(id).map(Json).ok_or_else(|| HttpError::not_found(format!("user {id} not found")))
}

The documentation shows 200 with User, 404 (thanks to responses(404)) and a default response for other errors. The next chapter goes deeper into HttpError.

With ? you can propagate errors like in any Rust function:

async fn handler(Json(body): Json<Input>) -> Result<Json<Output>, HttpError> {
    let user = load_user(body.user_id).await?;   // if load_user returns HttpError
    Ok(Json(process(user)))
}

If your service function returns a different error, implement From<YourError> for HttpError and ? will convert.

Input model different from output model

Like response_model in FastAPI, use two structs: one for the incoming body (no id, no computed fields, with the password) and one for the output (with id, without password).

#[lesto::model]
struct UserIn {
    #[garde(length(min = 1))] name: String,
    #[garde(length(min = 8))] password: String,
}

#[lesto::model]
struct UserOut {
    id: u64,
    name: String,
}

#[lesto::post("/users", status = 201)]
async fn create_user(Json(input): Json<UserIn>) -> Json<UserOut> {
    let id = save(&input.name, &input.password);
    Json(UserOut { id, name: input.name })
}

The password cannot end up in the response: the type does not contain it.

One model, several views: views(..)

When the input and output structs are the same fields minus a few, writing them by hand duplicates every attribute. Declare the model once and let lesto generate the views:

#[lesto::model(views(Create(author, text), Update(text?)))]
#[derive(Clone)]
struct Note {
    #[garde(skip)]
    id: u64,
    /// Who wrote it.
    #[garde(length(min = 1, max = 64))]
    author: String,
    #[garde(length(min = 1))]
    text: String,
}

/// Create a note.
#[lesto::post("/notes", status = 201)]
async fn create_note(Json(body): Json<NoteCreate>) -> Json<Note> {
    let mut note = Note { id: next_id(), author: String::new(), text: String::new() };
    note.apply_create(body);          // copies author and text
    Json(note)
}

/// Change the text of a note.
#[lesto::patch("/notes/{id}")]
async fn update_note(Path(id): Path<u64>, Json(body): Json<NoteUpdate>) -> Json<Note> {
    let mut note = load(id);
    note.apply_update(body);          // text is Option<String>: copied only when present
    Json(note)
}

The attribute generates NoteCreate { author, text } and NoteUpdate { text: Option<String> }. Each field is copied with its doc comment and its serde, garde and schemars attributes, so the views validate and document exactly like the model. The views also inherit the model’s derives and container attributes (#[serde(rename_all = "camelCase")], #[garde(allow_unvalidated)], …). With plain derives, the standalone attribute does the same: #[lesto::views(..)], written above #[derive] so it can see them. Only what describes a request payload is copied: the std derives (Debug, Clone, PartialEq, Default, …), Serialize, Deserialize, JsonSchema, Validate, and the serde, garde, schemars and doc attributes. A #[derive(sqlx::FromRow)] on the model, and the #[sqlx(..)] attributes that go with it, stay on the model.

A field marked with ? becomes Option<T> with #[serde(default)], and its garde rules are wrapped in inner(..) so they still apply when a value is present (at runtime only: schemars does not mirror inner(..) rules into the schema, so the optional field shows as nullable without minLength). For every view you get a Model::apply_<view>(&mut self, view) method that copies the fields back, skipping absent optionals. In the documentation the views are separate schemas, NoteCreate and NoteUpdate, with their own required lists. Naming a field that does not exist is a compile error that lists the available ones.

Other supported return types

TypeResponseDocumentation
Json<T>JSONschema of T
String, &'static strtext/plainstring
()empty bodystatus only
Html<T>text/htmlstring
Result<T, E>T or Eunion of both
(StatusCode, T)T with the given statuslike T (status unknown at compile time)
(HeaderMap, T)T with extra headerslike T
Created<T>, Accepted<T>T with 201 / 202like T, under 201 / 202
NoContent204, empty body204
StatusCode, Response, Redirectas in axumnone

You can document a type of your own by implementing lesto::OperationOutput.

Recap

  • Json<T> for JSON, String for text, () for empty bodies.
  • status = N on the attribute changes the success code, at runtime too; Created<T>, Accepted<T> and NoContent put the status in the type.
  • Result<T, HttpError> for handlers that can fail; responses(404, ...) to document them.
  • Two structs, one in and one out, when the fields differ; #[lesto::views] generates them from one model.

Next: Error handling.

7. Error handling

HttpError

The simplest way to fail is to return an HttpError with a status and a message:

use lesto::prelude::*;

#[lesto::get("/items/{id}", responses(404))]
async fn get_item(Path(id): Path<u64>) -> Result<Json<Item>, HttpError> {
    if id != 1 {
        return Err(HttpError::not_found(format!("item {id} not found")));
    }
    Ok(Json(Item { name: "Keyboard".into() }))
}
curl -i http://127.0.0.1:8000/items/7
HTTP/1.1 404 Not Found
content-type: application/problem+json

{"type":"about:blank","title":"Not Found","status":404,"detail":"item 7 not found","instance":"/items/7"}

Ready-made constructors: bad_request, unauthorized, forbidden, not_found, conflict, internal. For other statuses, HttpError::new(418, "...") or HttpError::new(StatusCode::GONE, "..."). A bare number outside 100..=599 never panics: it becomes a 500 and the value is logged at error, so a status copied from an upstream response is safe to pass through.

The format: RFC 9457

Every error, yours or generated by lesto, is a Problem Details document (RFC 9457) with content type application/problem+json:

MemberMeaning
typeURI identifying the error class. about:blank when the status is enough.
titleShort description, the same for every error of that type. With about:blank it is the HTTP reason phrase.
statusThe HTTP code.
detailExplanation of this occurrence.
instanceURI of the request. lesto fills it in for you.
errorsExtension: the list of failed checks in 422s.

A client can therefore treat every error the same way, and 422s additionally carry the list of fields.

Your own error types

When the status alone is not enough, give the error a type. Clients can then tell errors apart by URI rather than by message:

HttpError::new(402, "Your current balance is 30, but that costs 50.")
    .with_type("https://example.com/probs/out-of-credit")
    .with_title("You do not have enough credit.")
    .with_extension("balance", 30)
    .with_extension("cost", 50)
{
  "type": "https://example.com/probs/out-of-credit",
  "title": "You do not have enough credit.",
  "status": 402,
  "detail": "Your current balance is 30, but that costs 50.",
  "instance": "/purchases",
  "balance": 30,
  "cost": 50
}

with_header adds headers to the response, for example Retry-After on a 429 or WWW-Authenticate on a 401.

A handy pattern is one function per domain error type:

fn out_of_credit(balance: u32, cost: u32) -> HttpError {
    HttpError::new(402, format!("Your current balance is {balance}, but that costs {cost}."))
        .with_type("https://example.com/probs/out-of-credit")
        .with_title("You do not have enough credit.")
        .with_extension("balance", balance)
        .with_extension("cost", cost)
}

Converting your application’s errors

If your service layer has its own error enum, implement From and use ?:

enum RepoError {
    NotFound,
    Conflict(String),
    Db(sqlx::Error),
}

impl From<RepoError> for HttpError {
    fn from(e: RepoError) -> Self {
        match e {
            RepoError::NotFound => HttpError::not_found("resource not found"),
            RepoError::Conflict(msg) => HttpError::conflict(msg),
            RepoError::Db(err) => {
                tracing::error!(?err, "database error");
                HttpError::internal("internal error")     // do not leak details
            }
        }
    }
}

#[lesto::get("/items/{id}", responses(404))]
async fn get_item(State(repo): State<Repo>, Path(id): Path<u64>) -> Result<Json<Item>, HttpError> {
    Ok(Json(repo.get(id).await?))
}

Documenting errors

The Result<T, HttpError> type makes a default response with the Problem schema appear. To list specific statuses use responses(...):

#[lesto::post("/users", status = 201, responses(409, 422))]

Every listed status appears in the documentation with the Problem schema and the reason phrase as description. The 422s of the extractors and the 401s of the security extractors are added on their own.

Errors generated by lesto

SituationStatuscode in errors
Path/query/body cannot be converted422value_error
Required field missing422missing
Malformed JSON422json_invalid
garde rule violated422value_error
Content-Type not JSON415–
Missing credentials (Bearer, ApiKey, Basic)401–
Unknown route404–
Known route, wrong method405–
Handler panicked500– (the panic message stays in the log)

The last three come from the router itself, so even a request that never reaches a handler gets a problem+json body with instance. App::fallback(handler) replaces the 404 when you need something else there (a single-page app, a redirect).

Recap

  • Err(HttpError::not_found("...")), with ? and From for domain errors.
  • Everything is RFC 9457: type, title, status, detail, instance, plus errors in 422s.
  • with_type and with_extension for structured errors; with_header for extra headers.
  • responses(404, 409) to document the statuses the handler may return.

Next: State and dependencies.

8. State and dependencies

Almost every API needs something shared across requests: a database pool, configuration, an HTTP client. And you often want to reuse extraction logic, such as “the authenticated user”. In FastAPI both go through Depends; in lesto, as in axum, they go through extractors.

Shared state: State<S>

Declare a type for the state; it must be Clone (it usually holds Arcs):

use std::sync::{Arc, Mutex};
use lesto::prelude::*;

#[derive(Clone, Default)]
struct AppState {
    counter: Arc<Mutex<u64>>,
}

/// Increment and return the counter.
#[lesto::post("/hits")]
async fn hit(State(state): State<AppState>) -> String {
    let mut n = state.counter.lock().unwrap();
    *n += 1;
    n.to_string()
}

#[lesto::main]
async fn main() -> std::io::Result<()> {
    App::new()
        .routes(routes![hit])
        .with_state(AppState::default())
        .serve()
        .await
}

State<AppState> in the handler says “I want the state”; .with_state(...) on the App provides it. The compiler checks that the two types match: forget with_state, or pass a different type, and the program does not compile. State does not appear in the documentation, because it does not come from the client.

In tests and larger projects an explicit type helps: App::<AppState>::new().

Sub-states with FromRef

If a handler only wants the pool and not the whole state, axum offers FromRef:

use lesto::axum::extract::FromRef;

#[derive(Clone)]
struct AppState {
    db: DbPool,
    config: Arc<Config>,
}

impl FromRef<AppState> for DbPool {
    fn from_ref(s: &AppState) -> DbPool { s.db.clone() }
}

async fn handler(State(db): State<DbPool>) { .. }     // works with with_state(AppState)

With axum’s #[derive(FromRef)] (macros feature) the impl is generated for every field.

Extension<T>

For values inserted by a middleware (a request id, a user authenticated by an external layer) there is Extension<T>, also invisible in the documentation:

async fn handler(Extension(req_id): Extension<RequestId>) { .. }

Custom extractors: lesto’s Depends

An extractor is a type that knows how to build itself from the request. Writing one means implementing FromRequestParts, and optionally OperationInput to tell the documentation what it requires.

The classic example: the current user derived from the Bearer token.

use lesto::axum::extract::FromRequestParts;
use lesto::http::request::Parts;
use lesto::prelude::*;
use lesto::{BearerAuth, OperationBuilder, OperationInput, Rejection};

#[lesto::model]
#[derive(Clone)]
pub struct User {
    pub id: u64,
    pub name: String,
}

/// The authenticated user. Use it as a handler argument.
pub struct CurrentUser(pub User);

impl<S: Send + Sync> FromRequestParts<S> for CurrentUser {
    type Rejection = Rejection;

    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Rejection> {
        // Reuse the Bearer extractor: missing header → 401 with WWW-Authenticate.
        let auth = Bearer::<BearerAuth>::from_request_parts(parts, state).await?;
        // This is where you would verify the token (a session, a key...). For an OpenID
        // Connect JWT, `lesto::oidc::Jwt` does it for you (chapter 9).
        match auth.token() {
            "secret" => Ok(CurrentUser(User { id: 1, name: "Ada".into() })),
            _ => Err(HttpError::unauthorized("invalid token").into()),
        }
    }
}

// The documentation inherits Bearer's: bearerAuth scheme + 401 response.
impl OperationInput for CurrentUser {
    fn describe(builder: &mut OperationBuilder<'_>) {
        Bearer::<BearerAuth>::describe(builder);
    }
}

/// Profile of the authenticated user.
#[lesto::get("/me")]
async fn me(CurrentUser(user): CurrentUser) -> Json<User> {
    Json(user)
}
curl http://127.0.0.1:8000/me -H 'Authorization: Bearer secret'
{"id":1,"name":"Ada"}

Every handler that takes CurrentUser as an argument gets an already verified user, and its documentation shows the lock icon. It plays the same role as user: User = Depends(get_current_user).

If the extractor has nothing to do with the client (it only reads state or extensions), you need no OperationInput at all: an extractor without one is accepted and simply left out of the document. Teams that want every argument documented turn on lesto’s strict-docs feature, which makes a missing impl a compile error; impl OperationInput for MyType {} with an empty body then says “nothing to document” explicitly.

Dependencies with parameters

In FastAPI a dependency can itself take query parameters. In lesto you compose extractors inside your own:

pub struct Page { pub offset: usize, pub limit: usize }

impl<S: Send + Sync> FromRequestParts<S> for Page {
    type Rejection = Rejection;
    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Rejection> {
        let Query(p) = Query::<Pagination>::from_request_parts(parts, state).await?;
        Ok(Page { offset: p.skip, limit: p.limit })
    }
}

impl OperationInput for Page {
    fn describe(builder: &mut OperationBuilder<'_>) {
        Query::<Pagination>::describe(builder);     // documents skip and limit
    }
}

Recap

  • State<S> + with_state(s): shared state, checked by the compiler.
  • Extension<T> for values inserted by middleware.
  • A custom extractor = FromRequestParts (+ OperationInput for the documentation). Extractors compose: Bearer, Query, State inside yours.

Next: Security.

9. Security

lesto ships extractors for the three most common ways of authenticating an HTTP request. Each does two things: it extracts the credentials and registers the security scheme in the documentation, so Scalar and Swagger UI show the Authorize button and a lock on protected operations.

The extractors do not verify credentials: they read the token and hand it to you. Verification (session lookup, key comparison) is yours, usually inside a custom extractor like CurrentUser from the previous chapter. The exception is a JWT issued by an OpenID Connect provider: with the oidc feature lesto verifies it for you (below).

Bearer token

use lesto::prelude::*;

/// Who is calling?
#[lesto::get("/me")]
async fn me(auth: Bearer) -> String {
    format!("token: {}", auth.token())
}
curl http://127.0.0.1:8000/me -H 'Authorization: Bearer abc123'
token: abc123

Without the header:

curl -i http://127.0.0.1:8000/me
HTTP/1.1 401 Unauthorized
www-authenticate: Bearer
content-type: application/problem+json

{"type":"about:blank","title":"Unauthorized","status":401,"detail":"Not authenticated","instance":"/me"}

The documentation gains components.securitySchemes.bearerAuth of type http/bearer, and the operation gets security: [{"bearerAuth": []}] plus a 401 response.

API key

A key in a header, a query parameter or a cookie. The key’s name varies from API to API, so you declare it with a marker type:

struct AdminKey;

impl ApiKeyScheme for AdminKey {
    const NAME: &'static str = "adminKey";      // scheme name in the documentation
    const KEY: &'static str = "X-Admin-Key";    // header name
    // const LOCATION: ApiKeyIn = ApiKeyIn::Header;   default; also Query or Cookie
}

/// Delete everything. Requires the admin key.
#[lesto::delete("/everything", status = 204, responses(403))]
async fn nuke(key: ApiKey<AdminKey>) -> Result<(), HttpError> {
    if key.key() != std::env::var("ADMIN_KEY").unwrap_or_default() {
        return Err(HttpError::forbidden("invalid admin key"));
    }
    Ok(())
}
curl -X DELETE http://127.0.0.1:8000/everything -H 'X-Admin-Key: ...'

For a session cookie:

struct Session;
impl ApiKeyScheme for Session {
    const NAME: &'static str = "session";
    const KEY: &'static str = "sid";
    const LOCATION: ApiKeyIn = ApiKeyIn::Cookie;
}

Basic

#[lesto::get("/admin")]
async fn admin(creds: Basic) -> Result<String, HttpError> {
    if creds.username != "admin" || creds.password != "s3cret" {
        return Err(HttpError::unauthorized("bad credentials"));
    }
    Ok("welcome".into())
}
curl -u admin:s3cret http://127.0.0.1:8000/admin

Note: comparing passwords with != is fine in a tutorial, not in production. Use a constant-time comparison (subtle, constant_time_eq) and hashed passwords.

OpenID Connect: verified tokens

When the tokens come from an identity provider (Keycloak, Auth0, Okta, Entra ID, Cognito, Google…), lesto can verify them before the handler runs. Turn on the oidc feature:

lesto = { version = "0.3", features = ["oidc"] }

and point lesto at the provider’s discovery document. discover fetches it and the provider’s keys (the JWKS) once, at startup:

use lesto::oidc::{Jwt, Oidc, StandardClaims};

/// Who is calling, according to the token.
#[lesto::get("/me")]
async fn me(token: Jwt) -> Json<StandardClaims> {
    Json(token.into_claims())
}

#[lesto::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let auth = Oidc::discover("https://login.example.com/.well-known/openid-configuration")
        .audiences(["api://notes"])     // optional: `aud` must contain one of these
        .clients(["web", "cli"])        // optional: the token must be issued to one of these
        .await?;
    App::new().oidc(auth).routes(routes![me]).serve().await?;
    Ok(())
}

A Jwt argument is a token that passed every check, in this order (the first failure is the one reported):

  1. the algorithm in the token’s header: one the provider announces, and asymmetric. none and HS256/HS384/HS512 are always refused (an HS* token “signed” with the public key is the classic forgery);
  2. the key: the one of the JWKS that the token’s kid names, for that algorithm;
  3. the signature, with that key;
  4. exp and nbf, with 30 seconds of leeway for clock skew (.leeway(Duration));
  5. iss: the provider’s issuer, as the discovery document states it;
  6. aud contains one of the audiences, when you configured some;
  7. the client, when you configured clients: azp, then client_id (RFC 9068), then appid (Entra ID v1), the first present wins.

A missing token answers 401 with WWW-Authenticate: Bearer, like Bearer. A token that fails a check answers 401 with Bearer error="invalid_token" and a detail naming the class of the failure:

HTTP/1.1 401 Unauthorized
www-authenticate: Bearer error="invalid_token", error_description="The token has expired"
content-type: application/problem+json

{"type":"about:blank","title":"Unauthorized","status":401,"detail":"The token has expired","instance":"/me"}

The detail (the verification library’s error, the unknown kid, the client the token named) goes to the log at debug, never to the client, and the token is never logged.

Your own claims

Jwt alone reads StandardClaims (iss, sub, aud, exp, scope/scp and the rest in extra). For the claims your provider adds, name a type that deserializes (#[lesto::model], chapter 4, or your own #[derive(Deserialize)]):

#[lesto::model]
struct Claims {
    sub: String,
    email: String,
}

#[lesto::get("/email")]
async fn my_email(token: Jwt<Claims>) -> String {
    token.claims().email.clone()
}

A valid token whose claims do not fit the type answers 401. Whatever the type, token.subject(), token.scopes(), token.has_scope("notes:write") and token.claim("tenant") read the raw claims.

Protecting a group of routes

A Jwt argument protects one handler. App::protect protects every route of an app, whatever the handlers take; nest that app and the routes next to it stay public:

let admin = App::new()
    .routes(routes![stats, purge])
    .protect(auth.clone().scopes(["admin"]));   // or `.protect(auth.clone())`: any valid token

App::new()
    .oidc(auth)
    .routes(routes![health, me])
    .nest("/admin", admin)

No token or a bad one answers 401; a valid token without every scope listed answers 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope="admin". Scopes are read from scope (space separated) and scp (Entra ID, Okta). A Jwt argument inside a protected app reads the claims the layer already verified. The documentation routes are never protected; every other route of the app is, including one marked public (see below).

The documentation

App::oidc and App::protect register the openIdConnect security scheme pointing at the discovery URL, so Scalar and Swagger UI offer a login; each operation that takes a Jwt or sits in a protected app carries security: [{"openIdConnect": [..]}] and its 401 (and 403).

From the environment

let auth = Oidc::from_env().await?;          // Option<Oidc>
let app = match auth {
    Some(auth) => app.oidc(auth),
    None => app,
};
VariableMeaning
LESTO_OIDC_DISCOVERY_URLthe discovery document; unset, from_env returns Ok(None)
LESTO_OIDC_AUDIENCESaccepted audiences, comma separated
LESTO_OIDC_CLIENTSaccepted clients, comma separated
LESTO_OIDC_LEEWAY_SECSleeway on exp and nbf, in seconds (default 30)

Startup and key rotation

discover fails, and the application does not start, if the discovery document or the keys cannot be fetched or parsed, if the document names an issuer other than the URL it was found under (for a URL ending in /.well-known/openid-configuration, as the specification requires), or if the provider announces only algorithms lesto refuses. Only https URLs are accepted, plus plain http on localhost and loopback addresses (a provider next to the application, tests).

The keys stay in memory; requests never wait for the provider. When a token names a kid lesto does not know, the provider has probably rotated its keys: lesto fetches the JWKS again, at most once a minute (.min_refresh(Duration)), so a flood of tokens with made-up kids cannot turn your service into a client hammering the provider. A Cache-Control: max-age on the JWKS response is honored too. A fetch that fails keeps the keys already known and logs a warning.

Out of scope: opaque tokens (introspection), issuing tokens, login pages, several issuers at once.

Other OAuth2 schemes

For schemes that have URLs and scopes, implement AuthScheme on a marker and pass it to Bearer:

struct OAuth2;

impl AuthScheme for OAuth2 {
    const NAME: &'static str = "oauth2";

    fn scheme() -> SecurityScheme {
        SecurityScheme::oauth2(
            OAuthFlows::password("/token")
                .scope("items:read", "Read items")
                .scope("items:write", "Create and modify items"),
        )
    }

    /// Scopes required by the operations that use this marker.
    fn scopes() -> &'static [&'static str] {
        &["items:read"]
    }
}

#[lesto::get("/items")]
async fn list_items(auth: Bearer<OAuth2>) -> Json<Vec<Item>> { .. }

The operation becomes security: [{"oauth2": ["items:read"]}] and Swagger UI knows how to obtain the token through the password flow. For different scopes create different markers (OAuth2Write, …).

Other constructors: OAuthFlows::authorization_code(auth_url, token_url), OAuthFlows::client_credentials(token_url), OAuthFlows::implicit(auth_url), SecurityScheme::open_id_connect(discovery_url), SecurityScheme::bearer_jwt().

The full pattern: token → user

Combine Bearer with a custom extractor (see chapter 8). The handler receives the user, not the token:

#[lesto::get("/orders")]
async fn my_orders(CurrentUser(user): CurrentUser, State(db): State<Db>) -> Json<Vec<Order>> {
    Json(db.orders_of(user.id))
}

Authentication done by a middleware

If a layer (for instance tower_http::validate_request or a reverse proxy) authenticates before the handler is called, you can still document the requirement:

// 1. as a marker argument, extracting nothing
async fn handler(_: Security<BearerAuth>) { .. }

// 2. as an attribute option, referring to a scheme registered on the App
#[lesto::get("/reports", security(bearerAuth))]
async fn reports() { .. }

App::new()
    .security_scheme("bearerAuth", SecurityScheme::bearer_jwt())

Alternative requirements (any one is enough) and scopes: security(oauth2 = ["items:write"], "adminKey").

Default security for the whole API

App::new()
    .security_scheme("bearerAuth", SecurityScheme::bearer())
    .security("bearerAuth", &[])          // default for every operation

and for the exceptions:

#[lesto::get("/health", public)]         // emits security: [] on this operation
async fn health() -> &'static str { "ok" }

public only changes the documentation. It does not exempt a route from App::protect, which checks every request of its app: put public routes in an app that is not protected.

Recap

  • Bearer, ApiKey<S>, Basic: they extract and document; verification is yours.
  • Jwt<C> (feature oidc): a token verified against an OpenID Connect provider; App::protect requires one on a whole group of routes.
  • One marker type per API key or OAuth2 scheme; Bearer<MyScheme> to use it.
  • Missing credentials → 401 with WWW-Authenticate.
  • security(..), public and App::security for when authentication happens elsewhere.

Next: Bigger projects.

10. Bigger projects

A single main.rs is fine for a tutorial; a real API is split into modules. Here is a layout that works well.

The runnable version of this chapter is examples/05-routers: a shop serving two APIs from one process, /api/app/v1 (products and orders) and /api/analytics/v1 (sales figures), each a module with its own router(), sharing one state and one OpenAPI document (LESTO_PORT=8765 cargo run -p routers, then open /docs).

src/
├── main.rs          # builds the App, mounts the modules, starts
├── state.rs         # AppState
├── errors.rs        # From<RepoError> for HttpError, domain error types
├── auth.rs          # CurrentUser, security schemes
├── users/
│   ├── mod.rs       # pub fn routes() -> RouteSet<AppState>
│   ├── models.rs    # UserIn, UserOut
│   └── handlers.rs  # the annotated handlers
└── items/
    └── ...

A route module

Every module exposes a function returning its own routes:

// src/users/handlers.rs
use lesto::prelude::*;
use crate::state::AppState;
use super::models::{UserIn, UserOut};

/// List users.
#[lesto::get("/", tag = "users")]
pub async fn list(State(state): State<AppState>) -> Json<Vec<UserOut>> { .. }

/// Create a user.
#[lesto::post("/", status = 201, tag = "users", responses(409))]
pub async fn create(State(state): State<AppState>, Json(body): Json<UserIn>) -> Result<Json<UserOut>, HttpError> { .. }

/// One user by id.
#[lesto::get("/{id}", tag = "users", responses(404))]
pub async fn get(State(state): State<AppState>, Path(id): Path<u64>) -> Result<Json<UserOut>, HttpError> { .. }
// src/users/mod.rs
mod handlers;
pub mod models;

use lesto::{routes, RouteSet};
use crate::state::AppState;

pub fn routes() -> RouteSet<AppState> {
    routes![handlers::list, handlers::create, handlers::get]
}

Handlers are pub because routes! references them from another module. The attribute generates, next to the function, a type with the same name and visibility carrying the route metadata: that is why routes![handlers::list] works with any path.

Mounting modules under a prefix

// src/main.rs
mod auth;
mod errors;
mod items;
mod state;
mod users;

use lesto::prelude::*;
use state::AppState;

#[lesto::main]
async fn main() -> std::io::Result<()> {
    let users = App::<AppState>::new().routes(users::routes());
    let items = App::<AppState>::new().routes(items::routes());

    App::new()
        .title("Shop API")
        .version("1.0.0")
        .tag("users", "User management")
        .tag("items", "Catalog")
        .nest("/users", users)
        .nest("/items", items)
        .with_state(AppState::new().await)
        .serve() // LESTO_HOST=0.0.0.0 in a container
        .await
}

nest("/users", app) is FastAPI’s include_router(router, prefix="/users"): the module’s routes become /users (the module’s / route) and /users/{id}, and the documentation is merged with the right prefix. Only the root App serves /docs and /openapi.json.

If you prefer to avoid the prefix and write full paths in the handlers, you can also merge the routes directly: App::new().routes(users::routes()).routes(items::routes()).

Tags

tag = "users" on the attribute groups operations in the documentation. App::tag(name, description) adds the group’s description. An operation can have several tags: tags("users", "admin").

Versioning the API

App::new()
    .nest("/v1", v1_app)
    .nest("/v2", v2_app)

One App per environment

Since App is a value, you can build it in a function and reuse it in tests:

pub fn build_app(state: AppState) -> App<()> {
    App::new()
        .title("Shop API")
        .nest("/users", App::new().routes(users::routes()))
        .with_state(state)
}

Chapter 12 shows how to test it without opening a port.

Recap

  • One module per resource, with pub fn routes() -> RouteSet<AppState>.
  • pub handlers referenced by path in routes![].
  • nest(prefix, app) to mount and prefix; tag to group.

Next: Middleware and axum.

11. Middleware and axum

lesto is a thin layer over axum. Everything that works with axum, in particular the middleware of the tower ecosystem, works here too.

Adding a layer

The most common layers are built in (next section). Anything else from the tower ecosystem goes through layer, here tower-http’s SetResponseHeaderLayer:

tower-http = { version = "0.6", features = ["set-header"] }
use lesto::http::{HeaderValue, header};
use lesto::prelude::*;
use tower_http::set_header::SetResponseHeaderLayer;

#[lesto::main]
async fn main() -> std::io::Result<()> {
    App::new()
        .routes(routes![root])
        .layer(SetResponseHeaderLayer::if_not_present(   // unless the handler set one
            header::CACHE_CONTROL,
            HeaderValue::from_static("no-store"),
        ))
        .serve()
        .await
}

layer applies to the routes registered before the call, as in axum. Put it after your routes / nest calls.

Built-in layers

What almost every service needs is one builder call, with errors answered as problems like the rest:

use std::time::Duration;
use lesto::SecurityHeaders;
use lesto::cors::{Any, CorsLayer};

App::new()
    .routes(routes![root])
    .timeout(Duration::from_secs(10))
    .body_limit(64 * 1024)
    .cors(CorsLayer::new().allow_origin(["https://app.example".parse()?]).allow_headers(Any))
    .compression()
    .request_id()
    .security_headers(SecurityHeaders::new())
  • .timeout(Duration::from_secs(10)): a request still running after ten seconds answers 503 Service Unavailable (problem+json, with instance), and its handler is dropped. Off by default. The clock includes reading the body, so a client that trickles bytes in is cut off as well. lesto::layers::TimeoutLayer is the same thing for a plain axum router.

  • .body_limit(64 * 1024): a larger body answers 413 Payload Too Large. Without the call the limit is axum’s, 2 MB; .body_limit(None) removes it (for uploads behind a proxy that limits them already).

  • .cors(layer): tower-http’s CorsLayer (re-exported as lesto::cors), installed outside everything else, so a 404 or 500 problem carries the CORS headers too and the browser can show it. CorsLayer::permissive() is fine in development; in production name the origins.

  • .compression(): gzip for clients that send Accept-Encoding: gzip (feature compression, on by default).

  • .request_id(): every request gets an x-request-id (a UUID v4, or the one the client or a proxy sent), echoed on the response.

  • .security_headers(SecurityHeaders::new()): every response that does not set them itself gets the headers a browser uses to protect your users, problems and the docs pages included:

    headervalue
    X-Content-Type-Optionsnosniff
    X-Frame-OptionsDENY
    Referrer-Policyno-referrer
    Strict-Transport-Securitymax-age=31536000 (one year)
    Content-Security-Policydefault-src 'none'; frame-ancestors 'none'

    An HTML response (the docs pages, or one of yours) gets only frame-ancestors 'none' as its policy: what a page may load depends on the page. A header your handler sets wins, so one route can answer with a policy of its own. Browsers ignore Strict-Transport-Security over plain http, so it does nothing in development and takes effect behind a proxy that terminates TLS. To change the set:

    SecurityHeaders::new()
        .hsts(None)                              // the domain also serves plain http
        .remove(header::X_FRAME_OPTIONS)         // pages meant to be framed
        .insert(
            HeaderName::from_static("cross-origin-resource-policy"),
            HeaderValue::from_static("same-origin"),
        )

    .hsts(Duration::from_secs(600)) changes the max-age; includeSubDomains and preload are never added for you, because they commit domains other than this service’s. A Content-Security-Policy you insert replaces both built-in policies, pages included.

None of them costs anything when it is not set. Unlike layer, their position in the builder chain does not matter.

Middleware written by you

axum::middleware::from_fn turns a function into a middleware:

use lesto::axum::extract::Request;
use lesto::axum::middleware::{self, Next};
use lesto::axum::response::Response;

async fn add_request_id(req: Request, next: Next) -> Response {
    let id = uuid::Uuid::new_v4().to_string();
    let mut res = next.run(req).await;
    res.headers_mut().insert("x-request-id", id.parse().unwrap());
    res
}

App::new()
    .routes(routes![root])
    .layer(middleware::from_fn(add_request_id))

A middleware can also reject the request by returning an HttpError:

async fn require_json(req: Request, next: Next) -> Result<Response, HttpError> {
    if req.method() == lesto::http::Method::POST
        && !req.headers().get("content-type").is_some_and(|v| v.as_bytes().starts_with(b"application/json"))
    {
        return Err(HttpError::new(415, "JSON only"));
    }
    Ok(next.run(req).await)
}

The response will be a problem+json like every other error.

Coming from axum

An existing axum application does not have to be rewritten to start using lesto. Mount its router as it is and move handlers over one at a time:

let legacy: lesto::axum::Router<AppState> = old_routes();   // your axum code, unchanged

App::new()
    .routes(routes![create_user, get_user])                 // the handlers moved so far
    .merge(legacy)                                          // everything else, as before
    .nest_router("/admin", admin_routes())                  // or under a prefix
    .with_state(state)
    .serve()
    .await

merge and nest_router take a plain axum::Router with the same state type. Its routes are served but not documented: lesto documents a handler from its argument and return types, and it never sees those of a finished router. They do get the rest: problem+json rendering, the panic catcher, the request span, the timeout. App::from(router) starts an app from a router.

Dropping down to axum

When you need something lesto does not expose:

// Modify the underlying Router (routes added this way are not documented)
App::new()
    .routes(routes![root])
    .map_router(|router| router.route("/legacy", lesto::axum::routing::get(legacy_handler)))

// Or take the Router and carry on with axum
let router: lesto::axum::Router = App::new().routes(routes![root]).into_router();
let listener = lesto::tokio::net::TcpListener::bind("0.0.0.0:8000").await?;
lesto::axum::serve(listener, router).await?;

into_router mounts the documentation pages, the problem+json fallbacks (404, 405), the panic catcher (500) and the error middleware, then gives you back an ordinary axum::Router: you can pass it to axum::serve, merge it with other routers, or serve it over TLS with axum-server.

Keeping lesto’s behavior on a plain router

The three layers into_router installs are public, so a router that is not built by lesto at all can have them:

use lesto::layers::{CatchPanicLayer, ProblemLayer, RequestSpanLayer};

let router = lesto::axum::Router::new()
    .route("/legacy", lesto::axum::routing::get(legacy_handler))
    .layer(CatchPanicLayer)
    .layer(ProblemLayer)
    .layer(RequestSpanLayer::new());
  • ProblemLayer gives every RFC 9457 response its instance (the request path).
  • CatchPanicLayer turns a panic into a 500 problem.
  • RequestSpanLayer opens the request span of chapter 15. RequestSpanLayer::with(Trace::off()) is the same layer with the span switched off.

The order above is the one lesto uses: the last layer added is the outermost, so the span sees the status the client sees, and the panic 500 still gets its instance. Inside into_router the three are stacked and added in a single .layer(...) call, because each call makes axum re-box every route.

Shutdown and panics

App::serve (and serve_at, serve_on) already stops gracefully on SIGTERM or Ctrl-C: the listener closes, in-flight requests finish, then the future returns. Kubernetes, systemd and lesto dev all send SIGTERM first, so no extra code is needed.

Waiting has a limit: 30 seconds by default. A handler still running then (a stuck upstream call, a request that never ends) no longer keeps the process alive until the orchestrator sends SIGKILL; the serve future returns, a warn log says so, and main exits. Change the limit with .shutdown_timeout(Duration::from_secs(10)), keeping it below your platform’s grace period (Kubernetes’ terminationGracePeriodSeconds is 30 s), or wait forever with .shutdown_timeout(None).

To stop on something else, use serve_until with your own future:

let listener = lesto::listener(("0.0.0.0", 8000)).await?;
App::new()
    .routes(routes![root])
    .serve_until(listener, async { shutdown_rx.await.ok(); })
    .await?;

If you serve the router with axum::serve yourself, add .with_graceful_shutdown(lesto::shutdown_signal()).

A handler that panics answers 500 as problem+json instead of dropping the connection; the panic message is printed by the panic hook and recorded with tracing::error!, never sent to the client.

Logging

App::serve prints on stdout with nothing to set up: a line when it starts listening, one per request (the access log), and every tracing event at info and above — yours and the ones lesto writes for what it handles itself (500s, panics, timeouts, failed transactions):

2026-10-03T09:15:00.120456Z  INFO lesto::app: listening on http://127.0.0.1:8000
2026-10-03T09:15:01.004311Z  INFO POST /notes 201 8.9ms
2026-10-03T09:15:01.200118Z  WARN GET /notes/7 notes::handlers: slow lookup user=7
2026-10-03T09:15:01.200540Z  INFO GET /notes/7 200 412µs
2026-10-03T09:15:02.381090Z ERROR GET /boom lesto::layers: handler panicked panic=boom
2026-10-03T09:15:02.381233Z ERROR GET /boom 500 71µs

An event that happens during a request starts with the request’s method and path, and inside a store transaction (chapter 13) with the store method too. A log collector wants JSON instead: LESTO_LOG=json writes one flat object per line, the request’s fields next to the event’s.

{"timestamp":"2026-10-03T09:15:01.200118Z","level":"WARN","target":"notes::handlers","message":"slow lookup","http.request.method":"GET","http.route":"/notes/{id}","url.path":"/notes/7","user":7}
{"timestamp":"2026-10-03T09:15:01.200540Z","level":"INFO","target":"lesto::access","message":"GET /notes/7 200","http.request.method":"GET","http.route":"/notes/{id}","url.path":"/notes/7","http.response.status_code":200,"duration_ms":0.412}
variablemeaning
LESTO_LOGtext (the default), json or off
RUST_LOGwhat is printed, info by default: RUST_LOG=info,lesto=debug, RUST_LOG=warn
NO_COLORno colors, even on a terminal (there are none when stdout is not one)

The access log is an event with the target lesto::access, at INFO (ERROR for a 5xx), so RUST_LOG=info,lesto::access=off turns it off and keeps the rest. It shows the path, never the query string, which is application data.

Your own subscriber wins. Install one before serve (a file, another format, a filter reloaded at runtime) and lesto installs nothing. lesto::log::Console is the layer lesto installs, so a subscriber of your own can keep its format. The log feature is on by default; default-features = false leaves it out, and with it tracing-subscriber. Where serve is not the entry point (a worker, a command-line tool), lesto::log::init() does the same thing.

.request_id() adds and echoes an x-request-id; chapter 15 sends the same events, with their trace, to an OpenTelemetry backend.

Using axum’s extractors

All axum extractors can be used in handlers. Those that have nothing to do with the client (HeaderMap, Method, Uri, Request, Bytes, String, State, Extension) do not appear in the documentation. axum::Json<T>, axum::extract::Query<T> and axum::extract::Path<T> are documented like lesto’s but without garde validation: useful when T is an external type you cannot derive Validate on. When T does implement Validate, the rules would be silently skipped, so lesto warns on the argument (“axum::Json<T> … does not run T’s garde rules”); the warning is a deprecation, so #[allow(deprecated)] on the handler keeps an intentional one quiet.

For an axum or third-party extractor lesto does not know, implement OperationInput on a newtype (chapter 8).

Recap

  • .layer(...) for tower middleware, after registering the routes.
  • Graceful shutdown and panic-to-500 are built in; serve_until for a custom shutdown trigger.
  • Logs on stdout out of the box, one line per request: LESTO_LOG=json for a collector, RUST_LOG to filter, a subscriber of your own to replace them.
  • middleware::from_fn for ad hoc middleware; they can return HttpError.
  • map_router and into_router for everything that is pure axum.
  • lesto::layers if you leave lesto but want the problems, the panic catcher or the span.

Next: Testing.

12. Testing

An App turns into a tower service you can call directly, without opening a port. It is the standard way of testing axum, and therefore lesto.

Test dependencies

[dev-dependencies]
tower = { version = "0.5", features = ["util"] }
http-body-util = "0.1"

serde_json comes through lesto (lesto::serde_json), and #[lesto::test] replaces #[tokio::test].

A test

use http_body_util::BodyExt;
use lesto::axum::body::Body;
use lesto::http::{header, Request, StatusCode};
use lesto::prelude::*;
use lesto::serde_json::{self, json, Value};
use tower::ServiceExt;

use crate::{build_app, AppState};

async fn call(app: App<()>, req: Request<Body>) -> (StatusCode, Value) {
    let response = app.into_router().oneshot(req).await.unwrap();
    let status = response.status();
    let bytes = response.into_body().collect().await.unwrap().to_bytes();
    let body = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
    (status, body)
}

#[lesto::test]
async fn creates_a_user() {
    let app = build_app(AppState::for_tests());
    let req = Request::post("/users")
        .header(header::CONTENT_TYPE, "application/json")
        .body(Body::from(json!({"name": "Ada", "password": "correct horse"}).to_string()))
        .unwrap();

    let (status, body) = call(app, req).await;

    assert_eq!(status, StatusCode::CREATED);
    assert_eq!(body["name"], "Ada");
    assert!(body.get("password").is_none());
}

#[lesto::test]
async fn rejects_short_passwords() {
    let app = build_app(AppState::for_tests());
    let req = Request::post("/users")
        .header(header::CONTENT_TYPE, "application/json")
        .body(Body::from(json!({"name": "Ada", "password": "x"}).to_string()))
        .unwrap();

    let (status, body) = call(app, req).await;

    assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
    assert_eq!(body["errors"][0]["pointer"], "/password");
}

oneshot consumes the router: build a fresh App for each request (it is cheap) or keep the Router and use .clone().oneshot(...).

Testing the documentation

App::openapi() returns the document without starting anything. Use it to make sure a refactor does not change the contract:

#[test]
fn openapi_contract() {
    let spec = serde_json::to_value(build_app(AppState::for_tests()).openapi()).unwrap();
    assert!(spec["paths"]["/users"]["post"].is_object());
    assert_eq!(spec["paths"]["/users"]["post"]["responses"]["201"]["content"]["application/json"]["schema"]["$ref"],
               "#/components/schemas/UserOut");
}

A useful pattern is to save openapi_json() to a versioned file and compare it in tests (snapshot): every contract change becomes visible in code review.

Testing a handler directly

Handlers remain ordinary functions: you can call them without HTTP.

#[lesto::test]
async fn health_says_ok() {
    assert_eq!(health().await, "ok");
}

For handlers with extractors, build the values by hand: Json(UserIn { .. }), Path(42), State(state).

Recap

  • app.into_router().oneshot(request) for in-memory end-to-end tests.
  • app.openapi() to test the contract.
  • Handlers are functions: test them directly when that is enough.

Next: Databases: stores and principals.

Appendices: From FastAPI to lesto, Common problems, Why axum and not actix-web.

13. Databases: stores and principals

The db feature of lesto connects sqlx to lesto. It adds one idea, the store: a handler argument that carries a database handle and the principal (who is calling), and runs each of its methods inside its own transaction.

#[lesto::get("/notes")]
async fn list_notes(store: NoteStore<ReadOnly, Public>) -> Result<Json<Vec<Note>>, Error> {
    Ok(Json(store.list().await?))
}

#[lesto::post("/notes", status = 201)]
async fn create_note(
    store: NoteStore<ReadWrite, User>,
    Json(body): Json<NoteCreate>,
) -> Result<Json<Note>, Error> {
    Ok(Json(store.create(body).await?))
}

Read the two signatures as sentences: “a read-only store held by anyone” and “a read-write store held by an authenticated user”. Everything else follows from the types: the 401 when the token is missing, the 403 when a permission is missing, the security scheme in the documentation, the read-only transaction for the first handler and the read-write one for the second.

The full code of this chapter is examples/02-notes in the repository (LESTO_PORT=8765 cargo run -p notes), split the way a real project would be: state.rs, auth.rs, and notes/{model,store,handlers}.rs, with end-to-end tests in tests/api.rs. It also has a PATCH route built on a NoteUpdate view.

Setup

[dependencies]
lesto = { version = "0.3", features = ["sqlite"] }   # or "postgres", "mysql"
sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "sqlite", "derive"] }

sqlite, postgres and mysql each imply the db feature and the matching sqlx driver.

sqlx is the one dependency lesto cannot absorb. Its query functions are all reachable through lesto::db::sqlx (lesto::db::sqlx::query_as(..)), but #[derive(sqlx::FromRow)] and the query! macros generate code that names ::sqlx and offer no way to point it elsewhere, so a crate that uses them lists sqlx itself. Keep the version in step with lesto’s (0.9): two sqlx versions in one build are two incompatible sets of types. Without FromRow (tuples, or a hand-written impl) the line can go.

The database handle lives in your state. Db holds the primary pool and, optionally, a read replica; stores fetch it with axum’s FromRef:

use lesto::axum::extract::FromRef;
use lesto::db::prelude::*;
use sqlx::Sqlite;

#[derive(Clone)]
pub struct AppState {
    pub db: Db<Sqlite>,
    pub tokens: Tokens,
}

impl FromRef<AppState> for Db<Sqlite> {
    fn from_ref(state: &AppState) -> Self {
        state.db.clone()
    }
}
let state = AppState { db: Db::new(pool), tokens: demo_tokens() };
App::<AppState>::new().routes(routes![...]).with_state(state)

With Db::new(primary).with_replica(replica), read-only stores use the replica.

The principal: who is calling

A principal is whoever holds the store. Two kinds exist:

  • Public: anyone, no credentials, never has permissions.
  • your own type, for which you implement Authenticated. That is the only trait you write.
#[derive(Debug, Clone)]
pub struct User {
    pub name: String,
    pub permissions: Vec<String>,
}

impl Authenticated for User {
    type State = AppState;
    type Credential = Bearer;                          // chapter 9: any security extractor

    async fn authenticate(token: Bearer, state: &AppState) -> Result<Self, HttpError> {
        state
            .tokens
            .get(token.token())
            .cloned()
            .ok_or_else(|| HttpError::unauthorized("Unknown token"))
    }

    fn has_permission(&self, permission: &str) -> bool {
        self.permissions.iter().any(|p| p == permission)
    }
}
  • Credential is the extractor that reads the raw credential: Bearer, ApiKey<MyKey>, Basic or your own. Its behavior is inherited: the 401 when it is missing, the WWW-Authenticate header, the security scheme in OpenAPI.
  • authenticate turns the credential into an identity. Here a lookup table; in a real application, a JWT check or a session query. Failing with HttpError::unauthorized answers 401. It runs once per request, however many stores the handler takes: the first store to be extracted keeps the principal in the request extensions and the others share it (store.principal() still hands out a &User; into_principal() gives the Arc<User>).
  • has_permission is asked before every store method that declares a permission.

State is an associated type, so a principal belongs to one application state. Public works with any state.

A verified JWT as the credential

With the oidc feature (chapter 9), the credential can be a token lesto has already verified, and the permissions its scopes:

use lesto::oidc::Jwt;

#[lesto::model]
pub struct TokenClaims {
    sub: String,
    #[serde(default)]
    scope: String,
}

pub struct Caller {
    pub id: String,
    scopes: Vec<String>,
}

impl Authenticated for Caller {
    type State = AppState;
    type Credential = Jwt<TokenClaims>;                // signature, issuer, audience, expiry: done

    async fn authenticate(token: Jwt<TokenClaims>, _: &AppState) -> Result<Self, HttpError> {
        let claims = token.into_claims();
        Ok(Caller {
            id: claims.sub,
            scopes: claims.scope.split_whitespace().map(String::from).collect(),
        })
    }

    fn has_permission(&self, permission: &str) -> bool {
        self.scopes.iter().any(|s| s == permission)   // "notes:write" is a scope
    }
}

App::oidc(auth) must be on the app: without a verifier the request answers 500 and the log says why. The operation is documented with the openIdConnect scheme. How scopes (or roles, or groups) map to permissions is yours to decide: lesto does not guess.

The store

A store is a newtype around lesto::db::Store<M, P, DB> with #[derive(Store)]. The derive forwards extraction and documentation to the inner type and adds Deref, so the store’s methods are available on your type:

#[derive(lesto::db::Store)]
pub struct NoteStore<M, P>(Store<M, P, Sqlite>);

M is the mode (ReadOnly or ReadWrite), P the principal. You write the queries as methods, in impl blocks whose bounds say when they are available:

/// Reads: any mode, any principal.
impl<M: Mode, P> NoteStore<M, P> {
    pub async fn list(&self) -> Result<Vec<Note>, Error> {
        self.read(Anyone, async |conn| {
            sqlx::query_as("SELECT id, author, text FROM notes ORDER BY id")
                .fetch_all(conn)
                .await
        })
        .await
    }

    pub async fn get(&self, id: i64) -> Result<Note, Error> {
        self.read(Anyone, async |conn| {
            sqlx::query_as("SELECT id, author, text FROM notes WHERE id = ?")
                .bind(id)
                .fetch_one(conn)
                .await
                .or_not_found("no such note")
        })
        .await
    }
}

/// Writes: read-write stores held by a `User`.
impl<M: Writable> NoteStore<M, User> {
    pub async fn create(&self, body: NoteCreate) -> Result<Note, Error> {
        let author = self.principal().name.clone();
        self.write("notes:write", async |conn| {
            sqlx::query_as(
                "INSERT INTO notes (author, text) VALUES (?, ?) RETURNING id, author, text",
            )
            .bind(&author)
            .bind(&body.text)
            .fetch_one(conn)
            .await
        })
        .await
    }

    pub async fn delete(&self, id: i64) -> Result<(), Error> {
        self.write("notes:delete", async |conn| {
            let result = sqlx::query("DELETE FROM notes WHERE id = ?")
                .bind(id)
                .execute(conn)
                .await?;
            if result.rows_affected() == 0 {
                return Err(Error::not_found("No such note"));
            }
            Ok(())
        })
        .await
    }
}

read and write do the same four things: check the requirement (first argument), open a transaction, run your closure with the connection, commit. If the closure returns an error, the transaction is rolled back and the error becomes the response. You never write begin or commit.

  • read opens a read-only transaction (BEGIN READ ONLY on Postgres, START TRANSACTION READ ONLY on MySQL, BEGIN DEFERRED on SQLite, which has no read-only mode) on the replica when there is one.
  • write opens a read-write transaction on the primary, and only compiles when M: Writable, that is, on ReadWrite stores. Calling write from a ReadOnly store is a compile error that says so.

The closure receives &mut SqliteConnection (or PgConnection, …), so fetch_all(conn) works as in any sqlx code. The closure may return any error that converts into lesto::db::Error: sqlx::Error (from ?), HttpError, or the constructors Error::not_found(..), Error::conflict(..), Error::bad_request(..). For other error types use result.internal()? (500, cause logged) or the anyhow feature.

Permissions

The first argument of read/write is a Requirement:

  • Anyone: no check.
  • a string such as "notes:write": the principal must answer true from has_permission, otherwise the method fails with 403 before the transaction opens:
{"type":"about:blank","title":"Forbidden","status":403,
 "detail":"Missing permission `notes:write`","instance":"/notes","required_permission":"notes:write"}

A permission string on a Public store does not compile: Public can never hold permissions. The error message says to pass Anyone or to declare an authenticated principal in the handler. For your own combinators (any of, all of), implement Requirement<P>.

Permissions as a type

Strings are easy to misspell, and a misspelled permission is a 403 nobody notices until production. An enum turns it into a compile error, and the rules that go with it (an admin may do everything) live in one place:

#[derive(Debug, Clone, Copy)]
enum Permission {
    ReadNotes,
    WriteNotes,
    Admin,
}

impl Permission {
    fn name(self) -> &'static str {
        match self {
            Permission::ReadNotes => "notes:read",
            Permission::WriteNotes => "notes:write",
            Permission::Admin => "admin",
        }
    }
}

/// Every permission is granted to an admin; otherwise the principal needs that one.
impl<P: Authenticated> Requirement<P> for Permission {
    fn check(&self, principal: &P) -> Result<(), Error> {
        if principal.has_permission(self.name()) || principal.has_permission("admin") {
            Ok(())
        } else {
            Err(Error::Forbidden { permission: self.name() })
        }
    }
}

// then, in a store method:
self.write(Permission::WriteNotes, async |conn| { .. }).await

check runs before the transaction opens, like the built-in string check, and the 403 it answers names the permission. Requirement is in lesto::db::prelude.

Declaring the permission pair once

Passing the permission at every call site means every call site can pass the wrong one. Declare the pair on the store instead:

#[derive(lesto::db::Store)]
#[store(read = "notes:read", write = "notes:write")]
struct NoteStore<M, P>(Store<M, P, Sqlite>);

read, read_with, write and write_with are then generated without the requirement argument — the permission comes from the type, and a method cannot name the wrong one because there is nowhere to name it:

impl<M: Writable> NoteStore<M, User> {
    async fn create(&self, body: NoteCreate) -> Result<Note, Error> {
        self.write(async |conn| { /* ... */ }).await   // "notes:write", always
    }
}

Declare only read for a store that has no writes, only write for one whose reads are public. Whatever you leave undeclared keeps the explicit two-argument form, so a store can mix the two. The generated methods need an authenticated principal, since a permission does: on a Public store use Anyone and the explicit form.

Why two impl blocks

The first block is generic over the principal, so list and get are available on both NoteStore<_, Public> and NoteStore<_, User>. The second is pinned to User, because create needs self.principal().name and a permission. Rust’s type system does the routing: a handler with NoteStore<ReadOnly, Public> simply has no create method.

Row level security: publishing the identity

Sometimes the check belongs in the database, not in the query. Postgres row level security lets a policy decide which rows exist for the caller — but the database has to be told who the caller is. That is transaction_settings, on the principal:

impl Authenticated for User {
    // ...credential, authenticate, has_permission as above...

    fn transaction_settings(&self) -> TransactionSettings {
        TransactionSettings::empty()
            .set("app.user_id", self.id)          // a Uuid
            .set_opt("app.organization_id", self.organization_id)
            .set("app.is_staff", self.is_staff)   // a bool
    }
}

Every transaction this principal opens now starts with:

BEGIN READ ONLY; SET LOCAL app.user_id = '...'; SET LOCAL app.organization_id = '...'; SET LOCAL app.is_staff = 'false'

sent as one round trip, and the policy reads it back:

CREATE POLICY notes_read ON notes FOR SELECT USING (
    owner = nullif(current_setting('app.user_id', true), '')::uuid
    OR coalesce(nullif(current_setting('app.is_staff', true), '')::boolean, false)
);

The store method then has no WHERE owner = $1 at all — the policy is the filter:

async fn list(&self) -> Result<Vec<Note>, Error> {
    self.read(Anyone, async |conn| {
        sqlx::query_as("SELECT id, owner, text FROM notes").fetch_all(conn).await
    })
    .await
}

SET LOCAL ends with the transaction, so nothing is left on the pooled connection: the next request that gets that connection sees no identity, and a Public store on it reads nothing. Public publishes no settings, and a principal that does not implement the method publishes none either — the default is empty, so nothing changes until you ask for it.

What a value may be

set takes a bool, an integer, or a Uuid. That is the whole list, and it is short on purpose: SET LOCAL accepts no bind parameters on any database, so every value here is interpolated into SQL. Those three have text forms that cannot contain a quote, which is what makes the interpolation safe; a String would not, so there is no way to pass one. Setting names are &'static str — from your source, never from a request — and their characters are checked as well.

set_opt writes the empty string for None, which is why the policy above reads it back through nullif(..., ''). Names need a dotted prefix (app.user_id): Postgres rejects an undotted custom setting.

Postgres only

MySQL has no transaction-scoped setting and SQLite has none at all, so a principal that publishes settings on either fails the request with a 500 rather than running the query with the identity quietly missing. If you are on SQLite — as the notes example is — return TransactionSettings::empty() and filter in the query.

One method per use case

A store method is named after what the caller wants to do, not after the query it runs or the table it touches. The handler calls it once:

#[lesto::post("/claims", status = 201)]
async fn create_claim(
    store: ClaimStore<ReadWrite, User>,
    Json(body): Json<ClaimCreate>,
) -> Result<Json<Claim>, Error> {
    Ok(Json(store.claim(body).await?))
}

The shape to avoid is a handler that orchestrates:

// Don't: three methods modelled on the queries, and the handler deciding.
if !store.claimable(body.practitioner_id).await? {   // transaction 1
    return Err(Error::conflict("Already claimed"));
}
if store.caller_owns_a_profile().await? {            // transaction 2
    return Err(Error::conflict("You already have a profile"));
}
Ok(Json(store.create(body).await?))                  // transaction 3

Each method opens its own transaction, so the two checks have stopped being true by the time the third one writes — the race from the previous section, reintroduced by the shape of the code rather than by the isolation level. Moving both checks inside claim’s own write closure is what makes them mean something.

The question to ask of a handler: if two identical requests arrive at the same instant, does this handler read a value that the other one could change before my write? If yes, that read and that write belong in one closure, and therefore in one method.

Several calls are fine when they are independent — two unrelated reads to build one response page. There you are paying latency, not correctness.

Never read on the replica to decide a write

With Db::new(primary).with_replica(replica), read goes to the replica and write to the primary. So this is worse than it looks:

// Don't: the check reads a replica that may be behind, the write lands on the primary.
if store.is_available(id).await? {
    store.reserve(id).await?;
}

The check is not merely one transaction behind the write — it is behind by the replication lag, which is unbounded in principle. A read whose result decides a write goes inside the write closure, where it runs on the primary and inside the same transaction:

self.write("bookings:create", async |conn| {
    let available: bool = sqlx::query_scalar("SELECT .. FOR UPDATE").bind(id)
        .fetch_one(&mut *conn).await?;
    if !available {
        return Err(Error::conflict("Already reserved"));
    }
    // ... the INSERT ...
})
.await

This holds even with no replica configured: writing it this way means adding one later does not silently turn a correct handler into a racy one.

Concurrency: when a check has to hold

A store method that reads a row to decide whether to write has a race in it, and one transaction is not enough to close it. At the default isolation another request can read the same row, reach the same decision, and write too — both transactions are correct on their own and the result is wrong.

Two ways out. Prefer the first.

A unique constraint

CREATE UNIQUE INDEX one_claim_per_practitioner ON claims (practitioner_id);

The loser’s INSERT fails, and lesto::db::Error already answers 409 Already exists for a unique violation — no code to write. This protects the table against every writer, not only against the ones that go through this method, which is why it is the better answer whenever the rule can be expressed as a constraint.

Isolation::Serializable

When the rule cannot be a constraint — it depends on rows in another table, or on the absence of rows — ask the database to serialize the transaction:

impl<M: Writable> ClaimStore<M, User> {
    async fn claim(&self, body: ClaimCreate) -> Result<Claim, Error> {
        self.write_with("claims:submit", Isolation::Serializable, async |conn| {
            // The check and the write it decides are one serializable unit: whatever this
            // reads still holds when the transaction commits.
            let taken: bool = sqlx::query_scalar(
                "SELECT exists (SELECT 1 FROM claims WHERE practitioner_id = $1)",
            )
            .bind(body.practitioner_id)
            .fetch_one(&mut *conn)
            .await?;
            if taken {
                return Err(Error::conflict("Already claimed"));
            }
            sqlx::query_as("INSERT INTO claims (..) VALUES (..) RETURNING ..")
                .fetch_one(conn)
                .await
                .map_err(Error::from)
        })
        .await
    }
}

read_with is the same for reads, where Isolation::Snapshot is usually what you want: several queries in one transaction seeing one stable picture.

What it gives you
Isolation::Defaultwhat the database does on its own. No conflicts to handle.
Isolation::Snapshotone stable snapshot for the transaction. Not enough for check-then-act.
Isolation::Serializablea check made inside the transaction still holds at commit.

Each renders to whatever the database needs — ISOLATION LEVEL ... on Postgres and MySQL, BEGIN IMMEDIATE on SQLite, which has no isolation levels because it only ever provides one. Nothing is refused anywhere.

The conflict is a 409, not a 500

Under Snapshot and Serializable the database can refuse a transaction instead of making it wait. lesto answers that with 409 and Retry-After: 0:

{"type":"about:blank","title":"Conflict","status":409,
 "detail":"Conflicting concurrent change, retry the request","instance":"/claims"}

The request is safe to send again — nothing about it was wrong, it lost a race.

lesto retries for you first. read_with and write_with re-run the closure on a transient conflict, twice by default; the 409 is what a client sees once the budget is spent. Set the budget on the handle:

Db::new(primary).with_conflict_retries(4)   // 0 disables retrying

It lives there, not in a fourth argument, because it is policy rather than a per-call decision.

This is also why read_with and write_with take an AsyncFn where read and write take an AsyncFnOnce: their closure may run more than once. Keep effects the outside world can see — an email, a queue publish — out of it, or the retry does them twice. read and write never retry, because at the database’s own isolation there is no conflict to retry, so the common case keeps the weaker bound.

Lock timeouts are deliberately not in this bucket. A ER_LOCK_WAIT_TIMEOUT or a Postgres 55P03 means somebody held a lock too long, which retrying does not fix, so those stay 500s and stay visible.

The model

Nothing new: a struct with serde, schemars, garde and sqlx::FromRow. The #[lesto::views] macro from chapter 6 gives you the request body for free:

#[lesto::model(views(Create(text)))]
#[derive(Debug, sqlx::FromRow)]
pub struct Note {
    #[garde(skip)]
    pub id: i64,
    #[garde(skip)]
    pub author: String,
    /// The note itself.
    #[garde(length(min = 1, max = 280))]
    pub text: String,
}

Errors

Handlers return Result<_, lesto::db::Error>. The error answers as an RFC 9457 problem and documents itself:

CauseResponse
missing permission403, with required_permission
.or_not_found(..) on a query that found no row404
unique or foreign key violation409
serialization failure or deadlock (40001, 40P01, SQLITE_BUSY)409 with Retry-After: 0
Error::not_found(..), Error::conflict(..), any HttpErrorthat error
other sqlx errors (a bare RowNotFound too), .internal(), anyhow::Error500, detail hidden, cause logged with tracing

A fetch_one that finds nothing is a 500 unless you say otherwise. Only some lookups mean “the resource does not exist”: the note named in the path, yes; the author row a note points at, no — that one missing is a bug, and a 404 would hide it. Mark the first kind with .or_not_found("no such note") (from the prelude); on fetch_optional, None becomes the 404 the same way.

In OpenAPI the operation gets 403, 404, 409 and default responses with the Problem schema, plus 401 and the security scheme when the principal is authenticated.

Trying it

LESTO_PORT=8765 cargo run -p notes
curl -s localhost:8765/notes
# []
curl -s -X POST localhost:8765/notes -H 'Authorization: Bearer bob-token' \
     -H 'Content-Type: application/json' -d '{"text":"hello"}'
# {"id":1,"author":"bob","text":"hello"}
curl -s -X DELETE localhost:8765/notes/1 -H 'Authorization: Bearer bob-token'
# {"type":"about:blank","title":"Forbidden","status":403,"detail":"Missing permission `notes:delete`",
#  "instance":"/notes/1","required_permission":"notes:delete"}
curl -s -X DELETE localhost:8765/notes/1 -H 'Authorization: Bearer alice-token' -o /dev/null -w '%{http_code}\n'
# 204

Several methods, one transaction

Each method opens its own transaction, so two method calls are two transactions. When one use case genuinely needs several pieces of SQL under one commit, share the query, not the method: write it against a connection somebody else opened, using the Connection<DB> alias.

impl<M: Mode, P> NoteStore<M, P> {
    // A query, on a connection it did not open.
    async fn count_in(conn: &mut Connection<Sqlite>, author: &str) -> Result<i64, Error> {
        sqlx::query_scalar("SELECT count(*) FROM notes WHERE author = ?")
            .bind(author).fetch_one(conn).await.map_err(Error::from)
    }
}

impl<M: Writable> NoteStore<M, User> {
    // The use case, opening one transaction for all of it.
    async fn create_unless_full(&self, body: NoteCreate) -> Result<Note, Error> {
        let author = self.principal().name.clone();
        self.write("notes:write", async |conn| {
            if Self::count_in(conn, &author).await? >= 100 {
                return Err(Error::conflict("Too many notes"));
            }
            // ... the INSERT, on the same conn, in the same transaction ...
        })
        .await
    }
}

Two different stores can take part the same way: both helpers take &mut Connection<Sqlite>, so whichever store opened the transaction lends its connection to the other. What there is no way to do is call two public store methods and have them share a transaction — each one opens its own, by design.

In production: migrations, the pool, a replica

Migrations. sqlx::migrate!() embeds the .sql files of migrations/ in the binary at compile time and applies the ones the database has not seen, which makes “migrate on start” one line (examples/02-notes does it). It needs sqlx’s migrate and macros features:

sqlx::migrate!().run(&pool).await?;

With several instances starting together, sqlx takes a lock so only one applies them. Migrations that must not run on every start (a long backfill) belong in a separate step of the deployment, with sqlx migrate run from sqlx-cli.

The pool. Every store method holds one connection for the length of its transaction, so the pool size is the number of store methods that can run at once, per instance:

let primary = PgPoolOptions::new()
    .max_connections(20)                                 // per instance
    .acquire_timeout(Duration::from_secs(3))            // then the request answers 500
    .connect(&database_url)
    .await?;
  • Instances × max_connections must stay under the database’s own limit (Postgres: 100 by default), with room left for migrations and your own psql.
  • acquire_timeout is how long a request waits for a free connection before failing with a 500 (logged, PoolTimedOut). sqlx’s default is 30 s, longer than most clients wait: pick a few seconds, and keep it below App::timeout if you set one.
  • On AWS Lambda one instance serves one request at a time: max_connections(1) or (2), and a proxy (RDS Proxy) in front of the database, because instances come and go by the hundred.

A read replica. Db::new(primary).with_replica(replica) sends read transactions to the replica and write ones to the primary. The replica lags: a read right after a write may not see it, which is why a decision that leads to a write must be made inside the write (“Never read on the replica to decide a write”, above). Size the replica’s pool like the primary’s.

Testing

Stores are plain values: NoteStore(Store::new(db, Public)) or Store::new(db, some_user) builds one by hand, without HTTP. For end-to-end tests use oneshot as in chapter 12. With SQLite in memory keep one connection in the pool: every connection to sqlite::memory: is a separate database.

let pool = SqlitePoolOptions::new().max_connections(1).connect("sqlite::memory:").await?;

Recap

  • Db<DB> in the state, reachable through FromRef.
  • Implement Authenticated once; Public is built in.
  • #[derive(Store)] on a newtype; queries as methods that call self.read(requirement, ..) or self.write(requirement, ..).
  • ReadOnly / ReadWrite and Anyone / "permission" are checked by the compiler where possible and before the transaction otherwise.
  • lesto::db::Error maps database errors to 403, 404, 409, 500 and documents them.
  • transaction_settings on the principal publishes the caller’s identity to every transaction (SET LOCAL, Postgres only), for row level security policies.
  • A check that decides a write needs a unique constraint or Isolation::Serializable through read_with / write_with; the resulting conflict is a 409 with Retry-After.
  • One store method per use case, and the check that decides a write lives inside that write’s closure — never in a separate read, which would run on the replica.
  • #[store(read = "..", write = "..")] declares the permission pair on the type; the generated read/write then take no requirement argument.
  • read_with/write_with retry a conflict Db::with_conflict_retries times before the 409, which is why their closure is an AsyncFn.

Next: Deploying to AWS Lambda.

14. Deploying to AWS Lambda

The lambda feature runs a lesto application as a Lambda function behind API Gateway, a Function URL or an Application Load Balancer. The same binary keeps working locally, under lesto dev, and in tests. The code of this chapter is examples/03-lambda.

Setup

[dependencies]
lesto = { version = "0.3", features = ["lambda"] }

Change one line in main:

#[lesto::main]
async fn main() -> Result<(), lesto::lambda::Error> {
    lesto::lambda::serve(build_app().with_state(Notes::default())).await
}

lesto::lambda::serve looks at the environment: inside Lambda (the runtime sets AWS_LAMBDA_RUNTIME_API) it starts the Lambda runtime and answers events; anywhere else it behaves exactly like App::serve (LESTO_HOST/LESTO_PORT, socket handover from lesto dev included). serve_at(app, addr) takes an explicit local address. No cfg, no second binary.

What is different in Lambda

Events, not sockets. API Gateway turns each HTTP request into a JSON event and the runtime turns it back into an HTTP request for your router. lesto::lambda accepts the three formats you can meet: REST API (payload 1.0), HTTP API and Function URL (payload 2.0), ALB. Handlers, extractors, validation, RFC 9457 errors and the documentation pages behave as they do locally.

The stage prefix. A REST API is deployed to a stage (prod, v1), and the stage is part of the public URL: https://abc.execute-api.eu-west-1.amazonaws.com/prod/notes. By default lesto::lambda removes the stage from the path before routing, so your routes stay /notes and instance in error responses is /notes/99, not /prod/notes/99. If you want the stage to be part of your routes, use serve_with and Options::default().keep_stage(true). HTTP APIs with the $default stage and Function URLs have no prefix.

Documentation pages. /docs and /swagger link openapi.json with a relative URL, so https://.../prod/docs loads https://.../prod/openapi.json without configuration. If you set App::server("https://api.example.com/prod"), the “Try it out” button uses that base URL.

Logs. Lambda sends stdout to CloudWatch Logs, so the logs of chapter 11 need nothing: one line per request and every tracing event. Set the function’s log format to JSON and Lambda sets AWS_LAMBDA_LOG_FORMAT=JSON, which makes lesto write JSON too (LESTO_LOG still wins), so Logs Insights can query http.route or http.response.status_code directly.

State. A Lambda instance handles one request at a time and may be frozen or discarded at any moment. Keep in-memory state (like the Mutex<Vec<Note>> of the example) for caches only; real data goes to a database or another service. With lesto::db, build the pool once in main and reuse it across invocations, as the example does with its state.

Building and deploying

Lambda wants a Linux executable named bootstrap. The easiest way is cargo-lambda:

cargo install cargo-lambda
cargo lambda build --release --arm64 -p lambda      # cross-compiles for Graviton
cargo lambda deploy lambda --enable-function-url    # creates the function and a Function URL

cargo lambda deploy prints the Function URL; curl $URL/notes answers, and $URL/docs shows the API reference. For API Gateway create an HTTP API with a $default route pointing at the function (payload format 2.0), or a REST API with an ANY /{proxy+} method and a stage. Infrastructure tools (SAM, CDK, Terraform) work with the same bootstrap zip; cargo lambda build --output-format zip produces it.

Environment variables (DATABASE_URL, tokens) are set on the function; LESTO_HOST and LESTO_PORT are ignored in Lambda.

Cold starts and binary size

A Rust Lambda cold-starts in tens of milliseconds, but a smaller binary loads faster. Build in release mode, prefer arm64, and consider in Cargo.toml:

[profile.release]
lto = true
codegen-units = 1
strip = true

The lambda feature enables only the API Gateway and ALB event formats of the runtime; websockets are not included.

Testing with event fixtures

lesto::lambda::test::invoke feeds a raw event (the JSON API Gateway sends, which you can copy from the Lambda console or from the AWS documentation) to your router and returns the HTTP response, so you can test the Lambda path without deploying:

let router = build_app().with_state(Notes::default()).into_router();
let res = lesto::lambda::test::invoke(&router, &event("GET", "/notes/9", None)).await?;
assert_eq!(res.status(), StatusCode::NOT_FOUND);
assert_eq!(res.headers()["content-type"], "application/problem+json");

The response body is already collected (Bytes), ready for serde_json::from_slice. invoke_with(&router, event, Options::default().keep_stage(true)) tests the other stage setting; the option belongs to the router you pass, so both settings can share a test file.

Recap

  • Replace app.serve() with lesto::lambda::serve(app): Lambda when in Lambda, a plain server otherwise.
  • REST API stages are stripped from the path by default (Options::keep_stage to keep them).
  • Documentation pages work behind the stage because they link openapi.json relatively.
  • Logs go to CloudWatch through stdout, as JSON when the function’s log format is JSON.
  • Build with cargo lambda build --release --arm64, deploy with cargo lambda deploy or your infrastructure tool.
  • Test the Lambda path in-process with lesto::lambda::test::invoke and event fixtures.

Next: Observability: tracing and OpenTelemetry.

15. Observability: tracing and OpenTelemetry

Every request a lesto router handles runs inside a tracing span whose fields are the OpenTelemetry semantic conventions for HTTP servers, and every store transaction (chapter 13) inside a client span with the database conventions. Nothing to add to a handler. With the otel feature, nothing to add to main either: the OTEL_* environment variables decide where the spans, the log events and the metrics go.

Three signals, then. Traces: a span per request and per store transaction. Logs: every tracing event, tied to the span it happened in. Metrics: http.server.request.duration, the request-duration histogram of the HTTP conventions, by method, route and status — the request rate, error rate and latency percentiles a dashboard or an alert is built on — plus any instrument of your own on opentelemetry::global::meter(..).

The runnable version of this chapter is examples/04-opentelemetry: an API plus, in Docker, either Jaeger (traces, no configuration at all) or OpenObserve (traces and logs), where you can watch them arrive. ./start.sh (or ./start.sh jaeger) in that folder starts the backend and the API wired to it.

Sending traces and logs, in two lines of configuration

[dependencies]
lesto = { version = "0.3", features = ["otel"] }
#[lesto::main]
async fn main() -> std::io::Result<()> {
    build_app().serve().await          // unchanged
}
export OTEL_SERVICE_NAME=notes
export OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318   # an OTLP collector, or Jaeger
cargo run

A backend that takes traces but not logs or metrics (Jaeger answers 404 on /v1/logs and /v1/metrics) needs two more lines, or every batch of those fails:

export OTEL_LOGS_EXPORTER=none
export OTEL_METRICS_EXPORTER=none

App::serve sees the endpoint, installs a subscriber that prints to the console (the logs of chapter 11, in the format LESTO_LOG picks) and exports over OTLP — spans as traces, tracing events as log records, the duration histogram as metrics (every 60 s, or OTEL_METRIC_EXPORT_INTERVAL milliseconds) — and flushes what is buffered when the server shuts down. Without the endpoint nothing is exported, which is what you want in tests and under lesto dev.

variablemeaning
OTEL_EXPORTER_OTLP_ENDPOINTwhere to send telemetry; /v1/traces, /v1/logs and /v1/metrics are appended. Unset: no export
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, ..._LOGS_ENDPOINT, ..._METRICS_ENDPOINTthe same per signal, used exactly as given
OTEL_EXPORTER_OTLP_HEADERSkey1=value1,key2=value2, for a backend that wants an Authorization
OTEL_SERVICE_NAMEservice.name; defaults to the title of your OpenAPI document
OTEL_TRACES_EXPORTER=nonekeep the logs, stop exporting spans
OTEL_LOGS_EXPORTER=nonekeep the spans, stop exporting logs
OTEL_METRICS_EXPORTER=nonestop exporting metrics
OTEL_SDK_DISABLED=truekeep the console, stop every export
RUST_LOGconsole and export filter, info by default
LESTO_LOGthe console only: text, json or off (chapter 11); the export is the same

Logs, next to the trace they belong to

Every tracing event becomes an OTLP log record carrying the trace and span it happened in, so a backend shows the logs of a request under its trace and RUST_LOG filters both:

tracing::info!(author = %store.principal().name, "creating a note");
body      "creating a note"
severity  INFO
trace_id  c4278fbb…      span_id  b1a587ce…      author "ada"

Errors lesto logs itself (store method failed, handler panicked) arrive the same way, with the cause as an attribute — the response body still says nothing about it. So does the line per request (lesto::access), one log record per request: RUST_LOG=info,lesto::access=off keeps it out of the backend and off the console, where the trace already says the same.

The telemetry stack is kept out of its own pipeline: records from opentelemetry*, reqwest, hyper and friends are printed on the console but never exported, or an export failure would be logged, exported, and fail again.

The export is OTLP over HTTP/protobuf, to http:// or https:// endpoints alike (rustls, with the system’s certificates), so a managed backend is one endpoint and one OTEL_EXPORTER_OTLP_HEADERS away. Two things lesto deliberately does not do: it never touches an application that has already installed a tracing subscriber, and it never exports without an endpoint. So a pipeline of your own (gRPC, a custom sampler, spans and logs) stays possible: build your subscriber before serve, and lesto stands aside.

Where serve is not the entry point — lesto::lambda::serve, a worker, a test — call it yourself and keep the guard alive:

let telemetry = lesto::otel::init_named("notes");
// ... run ...
telemetry.shutdown();          // or let it drop

What a request span contains

http.server.request
  otel.name                 "GET /users/{id}"       the span name a collector shows
  otel.kind                 "server"
  otel.status_code          "ERROR" on 5xx, unset otherwise
  http.request.method       "GET" (or "_OTHER" plus http.request.method_original)
  http.route                "/users/{id}"           the route, never the concrete path
  http.response.status_code 200
  url.path                  "/users/7"
  url.scheme                "http"
  server.address/port       from X-Forwarded-Host, :authority or Host
  client.address            the peer, or X-Forwarded-For when trusted
  network.peer.address/port, network.protocol.version, user_agent.original
  error.type                "500" when the server failed

The name is {method} {route}, which keeps the cardinality of a trace backend sane: /users/7 and /users/8 are the same operation. A request that matched no route has no http.route, and its name is the method alone.

A 4xx is the client’s fault, so the span status stays unset; a 5xx sets error.type and otel.status_code. Panics answer 500, so they show up the same way.

Traces that span services

With the otel feature the traceparent of an incoming request becomes the parent of the request span, so a call from another service continues the same trace:

curl localhost:8000/notes \
  -H 'traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'

The span lands in trace 4bf92f35… with 00f067aa0ba902b7 as its parent. lesto installs the W3C propagator; for B3 or another format, set yours with opentelemetry::global::set_text_map_propagator after init.

Reading the header is switched on by init (and by App::serve), not by the feature: until then lesto never asks the global propagator, which by default is a no-op with a lookup and a header walk to answer “no parent” on every request. An application that installs its own subscriber — the escape hatch below — installs its own propagator too, and has to say so:

opentelemetry::global::set_text_map_propagator(
    opentelemetry_sdk::propagation::TraceContextPropagator::new(),
);
lesto::otel::enable_propagation();

Store spans

With the db feature, read, write, read_with and write_with open a client span around the transaction, as a child of the request span:

GET /notes
└── NoteStore::list
      otel.kind            "client"
      db.system.name       "sqlite"
      error.type           on failure: "23505", "1", "Forbidden", ...
      db.response.status_code  the database's own code, on failure

The span is named after the store method that opened the transaction, which is what a trace view has to show — BEGIN is a statement, not an operation. lesto reads the name from the type of the closure you passed, which the compiler spells with the path of the function it was written in; a closure built elsewhere and handed in falls back to read or write.

Three fields on success, and no more: a span with twenty attributes is a span nobody reads. There is no db.operation.name (the conventions ask for it only when it is readily available, and lesto does not see your statements) and no db.query.text.

The span starts at the BEGIN and ends at the commit or the rollback, so its duration is the time the request really spent holding a transaction — including the retries read_with and write_with make, which appear as transaction conflicted, retrying events inside it.

A requirement that fails has no span at all: a 403 never reaches the database.

The statement lesto builds carries the principal’s transaction_settings, which is identity data, so it is never recorded. The queries are yours: RUST_LOG=sqlx::query=debug puts each one, with its timing, inside the span of the method that ran it.

Configuring what is recorded

use lesto::Trace;

App::new()
    .routes(routes![/* .. */])
    .trace(Trace::new().query(true).forwarded(true))
  • query(true) records url.query. Off by default because a query string is application data: tokens, emails, filters. The values of the keys the conventions call out (sig, X-Amz-Signature, X-Amz-Credential, X-Amz-Security-Token, X-Goog-Signature) are replaced by REDACTED even when it is on.
  • forwarded(true) trusts X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host for client.address, url.scheme and server.address. Turn it on when a proxy you control rewrites those headers — anybody can send them.
  • Trace::off() records no span at all. It is a switch on what is recorded, not a faster path: the layer is installed either way and the branch costs about 13 ns. Reach for it when the span itself is unwanted — a test with a noisy subscriber, an application whose subscriber is built elsewhere, a service that wants the logs but not one SERVER span per request.

client.address without a proxy comes from the peer address, which axum only knows when the app is served with into_make_service_with_connect_info; App::serve does not, so behind a proxy forwarded(true) is the useful setting.

Your own spans and events

Inside a handler tracing works as usual, and everything lands under the request span:

#[lesto::post("/notes")]
async fn create_note(store: NoteStore<ReadWrite, User>, Json(body): Json<NoteCreate>)
    -> Result<Json<Note>, lesto::db::Error>
{
    tracing::info!(author = %store.principal().name, "creating a note");
    Ok(Json(store.create(body).await?))
}

Events become span events and fields become attributes, so tracing::info! is enough — no OpenTelemetry API in your code. To time a helper, put #[tracing::instrument] on it.

In Lambda

The spans are the same, and traceparent from API Gateway is picked up as usual, but lesto::lambda::serve sets up only the console of chapter 11 (stdout goes to CloudWatch Logs): a Lambda instance is frozen between invocations, so a batching exporter loses spans. Call lesto::otel::init() in main and flush before returning, or send to the OpenTelemetry Lambda layer, which does that for you.

Recap

  • Request spans are on by default, named {method} {http.route}, with the HTTP semantic conventions as fields; 5xx marks the span as an error, 4xx does not.
  • Store transactions get a client span with the database conventions, as a child of the request.
  • The otel feature plus OTEL_EXPORTER_OTLP_ENDPOINT is the whole setup: App::serve installs the console of chapter 11 and the OTLP export of spans and logs, and flushes on shutdown.
  • Log records carry their trace_id and span_id, so logs and traces line up in the backend.
  • An incoming traceparent continues the trace here.
  • App::trace(Trace::new()...) opts url.query in, trusts forwarding headers, or turns the span off.
  • examples/04-opentelemetry runs the whole thing against a local Jaeger or OpenObserve.

Next: MCP: tools, resources and prompts for agents.

16. MCP: tools, resources and prompts for agents

The Model Context Protocol (MCP) is how AI agents (Claude, ChatGPT, IDE assistants) discover and call tools, read context and pick prompts. With the mcp feature, a route becomes one of these with one attribute option:

  • a tool is something the model decides to call: create a note, search;
  • a resource is data the user or the client attaches as context: a note’s text;
  • a prompt is a conversation template the user picks, often from a slash menu: “plan this note”.

lesto builds the description from what it already knows about the route, and every MCP request runs the route itself. The code of this chapter is in examples/99-tutorial/src/mcp.rs; examples/02-notes serves its whole API too, bearer tokens and permissions included, except DELETE.

Setup

[dependencies]
lesto = { version = "0.3", features = ["mcp"] }

Mark the operations agents may call, then turn the endpoint on:

use lesto::mcp::Mcp;

/// Create a note.
///
/// Tags are free text; the same tag can be used on many notes.
#[lesto::post("/notes", status = 201, tag = "notes", mcp = "tool")]
async fn create_note(Json(new): Json<NoteCreate>) -> Json<Note> { .. }

/// Fetch a note by id.
#[lesto::get("/notes/{id}", tag = "notes", responses(404), mcp = "tool")]
async fn get_note(Path(id): Path<u64>) -> Result<Json<Note>, HttpError> { .. }

/// Search notes by title.
#[lesto::get("/notes/search", tag = "notes", mcp(tool, name = "search_notes"))]
async fn search(Query(search): Query<Search>) -> Json<Vec<Note>> { .. }

/// Delete a note: not exposed to agents.
#[lesto::delete("/notes/{id}", status = 204, tag = "notes")]
async fn delete_note(Path(_id): Path<u64>) {}

pub fn mcp_app() -> App {
    App::new()
        .title("Notes")
        .routes(routes![create_note, get_note, search, delete_note])
        .mcp(Mcp::new().instructions("Notes of one user. Search before creating a duplicate."))
}

The endpoint is POST /mcp. Only routes with an mcp option are served, so delete_note stays out of reach: turning MCP on never exposes a route by accident.

What an agent sees

tools/list describes every tool from the route:

  • name: the handler’s name (create_note), or mcp(tool, name = ".."). If two exposed handlers share a name (two list functions in different modules), both use their operationId instead (list_notes_get), which is unique.
  • title and description: the doc comment, as in the OpenAPI document.
  • inputSchema: one object built from the path parameters, the query parameters and the properties of the JSON body, side by side. For create_note that is title (required) and tags. When the body is not an object, or one of its fields has the name of a parameter, the body goes under a body argument instead.
  • outputSchema: the schema of the success response.
  • annotations: hints from the HTTP method. GET is read-only; POST only adds; PUT, PATCH and DELETE are destructive, and PUT and DELETE are idempotent.

The schemas carry the garde rules, as in the OpenAPI document (minLength: 1 on title), so the model knows the constraints before it calls. Two details differ from the OpenAPI document, for the sake of clients that are stricter than JSON Schema: an Option<T> field is written as anyOf a T or null rather than "type": [.., "null"], and the output schema’s root is written out in full rather than as a $ref.

What a call does

A tool call becomes an HTTP request to the route: create_note with {"title": "Call Ann", "tags": ["work"]} is a POST /notes with that JSON body. It goes through the same extractors, validation, layers and tracing as a request from any other client. The response becomes the result:

  • success: the JSON body as structuredContent, and as text for clients that do not read structured content;
  • error: the RFC 9457 problem as text, with isError: true. A 422 lists every failed check, so the model can fix its arguments and try again:
let result = result(call_tool("create_note", json!({"title": ""}))).await;
assert_eq!(result["isError"], true);
let problem: Value =
    serde_json::from_str(result["content"][0]["text"].as_str().unwrap()).unwrap();
assert_eq!(problem["status"], 422);
assert_eq!(problem["errors"][0]["pointer"], "/title");

A handler can tell that an agent called it: the request carries an McpCall extension with the tool’s name.

async fn get_note(
    Path(id): Path<u64>,
    call: Option<Extension<McpCall>>,
) -> Result<Json<Note>, HttpError> {
    if let Some(Extension(call)) = call {
        lesto::tracing::info!(tool = call.name(), "called by an agent");
    }
    ..
}

Resources

A GET route marked mcp = "resource" is data a client can read by URI. The URI is the route’s path under lesto://{title}, the app’s title as a slug: GET /notes/{id}/text of the app “Notes” is lesto://notes/notes/{id}/text. A path with parameters is listed as a resource template (resources/templates/list), one without as a resource (resources/list).

/// The text of a note, as Markdown.
#[lesto::get("/notes/{id}/text", tag = "notes", mcp(resource, name = "note_text"))]
async fn note_text(Path(id): Path<u64>) -> Result<(HeaderMap, String), HttpError> {
    if id != 1 {
        return Err(HttpError::not_found(format!("note {id} not found")));
    }
    let mut headers = HeaderMap::new();
    headers.insert(header::CONTENT_TYPE, "text/markdown".parse().unwrap());
    headers.insert(header::CACHE_CONTROL, "private, max-age=60".parse().unwrap());
    Ok((headers, "# Groceries\n\n- milk".into()))
}

Reading lesto://notes/notes/1/text is a GET /notes/1/text with the MCP request’s headers. The body comes back as the resource’s contents, with the response’s Content-Type as mimeType: text for JSON and text/*, base64 for anything else. A 404 is “resource not found”.

Clients may cache what they read in MCP 2026-07-28, and the route says for how long the HTTP way: Cache-Control: max-age=60 is ttlMs: 60000, public or private is the cacheScope. Without the header nothing is cached, because the data may change and may depend on the caller.

Mcp::new().resource_base("notes://") changes the prefix. A resource cannot have a required query parameter, since its URI has no way to pass one: the app panics at startup and says so.

Prompts

A GET route marked mcp = "prompt" returns a lesto::mcp::Prompt: the messages the conversation starts with. Its path and query parameters are the prompt’s arguments, with their doc comments as descriptions.

#[lesto::model]
pub struct Plan {
    /// When the plan is for, e.g. `this week`.
    #[garde(length(min = 1))]
    when: Option<String>,
}

/// Plan the tasks of a note.
#[lesto::get("/prompts/plan/{id}", tag = "prompts", mcp = "prompt")]
async fn plan(Path(id): Path<u64>, Query(plan): Query<Plan>) -> Result<Prompt, HttpError> {
    if id != 1 {
        return Err(HttpError::not_found(format!("note {id} not found")));
    }
    let when = plan.when.unwrap_or_else(|| "today".into());
    Ok(Prompt::new()
        .user(format!("Turn this note into a plan for {when}:\n\n# Groceries\n\n- milk"))
        .assistant("Here is a plan, one step per line:"))
}

prompts/get with {"id": "1", "when": "this week"} is a GET /prompts/plan/1?when=this+week. A 4xx from the route (a missing argument, a failed rule, a 404) is an invalid-params error carrying the problem, so the client can show what to fix. Over plain HTTP the same route answers the messages as JSON. A prompt route that returns anything but Prompt does not compile.

Authentication

The headers of the MCP request are forwarded to the route: Authorization, API keys, cookies, traceparent. A route that takes Bearer, Jwt or a store with an Authenticated principal authenticates the agent exactly as it authenticates any other client.

This holds for resources and prompts too. When the route answers 401 (or 403 asking for more scope), the MCP response is that same 401, with its WWW-Authenticate header. That is the signal MCP clients wait for to start their OAuth flow. With App::protect, the endpoint is protected like every other route, so a client needs a token before it can even list the tools.

Protocol versions

MCP 2026-07-28 is stateless: no handshake, every request carries its protocol version. lesto speaks it, and also 2025-11-25 and 2025-06-18 for clients that still open with initialize. lesto keeps no session in either case, so the endpoint works behind a load balancer and on AWS Lambda (chapter 14) with no change. Mcp::new().legacy(false) answers only 2026-07-28 clients.

Connecting a client

Run the app (lesto dev, or LESTO_PORT=8000 cargo run), then point a client at the endpoint:

claude mcp add --transport http notes http://127.0.0.1:8000/mcp   # Claude Code
npx @modelcontextprotocol/inspector@latest --server-url http://127.0.0.1:8000/mcp --transport http

The second command opens the MCP Inspector in the browser (@latest: an older copy cached by npx may be the deprecated v1). Its command line mode is handy in scripts, and --strict reports tool schemas that some model providers would reject:

npx @modelcontextprotocol/inspector@latest --cli http://127.0.0.1:8000/mcp \
    --method tools/list --strict
npx @modelcontextprotocol/inspector@latest --cli http://127.0.0.1:8000/mcp \
    --header "Authorization: Bearer bob-token" \
    --method tools/call --tool-name create_note --tool-arg text=hello
npx @modelcontextprotocol/inspector@latest --cli http://127.0.0.1:8000/mcp \
    --method resources/read --uri lesto://notes/notes/1/text
npx @modelcontextprotocol/inspector@latest --cli http://127.0.0.1:8000/mcp \
    --method prompts/get --prompt-name tidy_note --prompt-args id=1 "audience=the team"

The Inspector speaks the 2025 protocol unless told otherwise (--protocol-era modern); lesto answers both.

Browser pages are held back: a request with an Origin header is refused with 403 unless the page and the server are both on this machine (localhost, 127.0.0.1, [::1]), or the origin is listed with Mcp::allowed_origins(["https://app.example.com"]). That protects a local server against DNS rebinding, where a hostile page reaches 127.0.0.1 under its own name. Agents and command line clients send no Origin and are not affected.

Options

Mcp::new()
    .path("/agents")                    // default /mcp
    .instructions("How to use these tools")
    .allowed_origins(["https://app.example.com"])
    .resource_base("notes://")          // resource URIs; default lesto://{title}
    .list_ttl(Duration::from_secs(60))  // how long clients may cache the lists (default 5 min)
    .legacy(false)                      // 2026-07-28 clients only

The endpoint is not part of the OpenAPI document. In a nested app (chapter 10), the tools, resources and prompts of every nested app are included, and only the mcp(..) of the app you serve counts.

Testing

A tool call is a JSON-RPC request, so oneshot tests it like any other route (chapter 12). In 2026-07-28 the protocol version goes in _meta, and the method and the tool name are repeated in headers:

fn call_tool(name: &str, arguments: Value) -> Request<Body> {
    let body = json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/call",
        "params": {
            "name": name,
            "arguments": arguments,
            "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28"},
        },
    });
    Request::post("/mcp")
        .header("content-type", "application/json")
        .header("mcp-protocol-version", "2026-07-28")
        .header("mcp-method", "tools/call")
        .header("mcp-name", name)
        .body(Body::from(body.to_string()))
        .unwrap()
}

Not there yet

The endpoint answers with one JSON response per request: no streamed progress, no resource subscriptions and no server-initiated requests. There is no stdio transport, because the clients above connect over HTTP. RFC 9728’s protected resource metadata, which lets a client find the authorization server on its own, is planned with oidc.

Recap

  • mcp = "tool", "resource" or "prompt" on a route, App::mcp(Mcp::new()) on the app, feature mcp.
  • A tool is described from the route: doc comment, parameters and body as arguments, the response as the output, hints from the method.
  • A resource is a GET route read by URI (lesto://{title}{path}), cached as its Cache-Control says; a prompt is a GET route returning Prompt, its parameters as arguments.
  • Every request runs the route: validation errors reach the model as isError results (tools) or invalid-params errors (prompts) with the problem, a 401 reaches the client as a 401.
  • MCP 2026-07-28 plus the two previous revisions, with no session.

Appendices: From FastAPI to lesto, Common problems, Why axum and not actix-web.

A. From FastAPI to lesto

FastAPIlesto
app = FastAPI(title="X", version="1.0")App::new().title("X").version("1.0")
@app.get("/items/{id}")#[lesto::get("/items/{id}")]
status_code=201status = 201
tags=["items"]tag = "items" / tags("a", "b")
summary=, description=doc comment /// (or summary = "...")
deprecated=Truedeprecated
responses={404: {...}}responses(404)
operation_id=operation_id = "..." (default: {function}_{path}_{method}, as in FastAPI)
item_id: int (path)Path(item_id): Path<u64>
q: str | None = None (query)struct with q: Option<String> in Query<T>
limit: int = Query(10, ge=1, le=100)#[serde(default = ...)] #[garde(range(min = 1, max = 100))]
item: Item (body, pydantic)Json(item): Json<Item> (serde + garde)
class Item(BaseModel)#[derive(Deserialize, JsonSchema, Validate)] struct Item
Field(min_length=1)#[garde(length(min = 1))]
EmailStrString + #[garde(email)]
response_model=Outreturn type Json<Out>
class NoteCreate(NoteBase), class Note(NoteBase)#[lesto::views(Create(author, text), Update(text?))] on the model
raise HTTPException(404, "...")Err(HttpError::not_found("..."))
Depends(get_db)State<Db>, or a lesto::db store: NoteStore<ReadOnly, Public> (chapter 13)
Depends(get_current_user)custom extractor (FromRequestParts), or impl Authenticated for User + NoteStore<_, User> (chapter 13)
Depends(require_scope("notes:write"))self.write("notes:write", ..) in the store method (chapter 13)
db.begin() / db.commit()implicit: one transaction per store method
Depends(OAuth2PasswordBearer(...))Bearer / Bearer<MyOAuth2>
Security(APIKeyHeader(name="X-Key"))ApiKey<MyKey>
Depends(HTTPBasic())Basic
jwt.decode(token, jwks_key, audience=..., issuer=...) in a dependencyJwt<Claims> with App::oidc(Oidc::discover(url).audiences([..]).await?) (feature oidc, chapter 9)
Security(dep, scopes=["read"])AuthScheme::scopes() on the marker
APIRouter(prefix="/users") + include_routerApp::new().routes(...) + nest("/users", app)
app.add_middleware(CORSMiddleware, ...).layer(CorsLayer::...)
@app.middleware("http").layer(middleware::from_fn(f))
TestClient(app)app.into_router().oneshot(req)
/docs, /redoc, /openapi.json/docs (Scalar), /swagger (Swagger UI), /openapi.json
docs_url=None.docs_url(None)
uvicorn.run(app, host="0.0.0.0", port=8000).serve().await with LESTO_HOST=0.0.0.0 LESTO_PORT=8000 (or .serve_at("0.0.0.0:8000"))
{"detail": ...} errorsRFC 9457 application/problem+json

Attribute options

Every option of the route attributes (get, post, put, patch, delete, head, options):

#[lesto::get("/path", status = 200, tag = "x", tags("a", "b"), summary = "...",
             description = "...", operation_id = "...", deprecated, responses(404, 409),
             security("bearerAuth"), public, state = AppState,
             mcp = "tool" | "resource" | "prompt")]
  • status = N rewrites every 200 of the handler; to put the status in the type instead, return Created<T> (201), Accepted<T> (202) or NoContent (204), documented under that status.
  • state = T only affects the compile-time checks the macro emits; otherwise the state comes from a State<T> argument or from the App<S> the routes![] set is added to (see Common problems).
  • The default operation_id is {function}_{path}_{method} (get_user_users__id__get), unique per route as in FastAPI.
  • mcp = "tool" (feature mcp) exposes the operation as an MCP tool named after the function; mcp(tool, name = "search_notes") names it. mcp = "resource" (a GET route) serves it as a resource at lesto://{title}{path}, mcp = "prompt" (a GET route returning lesto::mcp::Prompt) as a prompt whose arguments are the path and query parameters; both take name = ".." too. It takes effect once the app calls App::mcp (chapter 16).

Differences to keep in mind

  • Types are checked at compile time. A State<Db> without with_state(Db), a body without Validate, a handler with the wrong signature: all of it fails at compile time, not on the first request.
  • The body goes last among the handler’s arguments (an axum constraint).
  • Every validated field needs a garde attribute, even just skip, or allow_unvalidated on the struct. Pydantic validates everything by default; garde is explicit.
  • Dependencies are not cached per request as in FastAPI: if two extractors derive the same thing, it is computed twice. If that is expensive, compute it once in a middleware and pass the result with Extension<T>.
  • Credential verification is yours, except for OpenID Connect JWTs: security extractors extract and document, Jwt<C> (feature oidc, chapter 9) also verifies.

B. Common problems

cannot find garde in the crate root / use of unresolved module or unlinked crate schemars``

The plain derives (#[derive(Validate)], #[derive(JsonSchema)], #[derive(Serialize)]) generate code that refers to ::garde, ::schemars and ::serde, which only exist when your crate depends on them. Use #[lesto::model] instead: it derives the same traits through lesto’s re-exports. If you do want the plain derives, add the three crates to your manifest, at the versions lesto uses (garde 0.23, schemars 1, serde 1).

warning: axum::Json<T> / axum::extract::Query<T> does not run T’s garde rules

The handler takes axum’s Json or Query, not lesto’s, and T has garde rules: the payload reaches the handler without being validated. Import lesto::Json / lesto::Query (they are in lesto::prelude). If skipping validation is what you want, put #[allow(deprecated)] on the handler: lesto reports this as a deprecation because that is the one warning a macro can raise.

field x has no validation rule

garde requires an attribute on every field. Add #[garde(skip)] to the field or #[garde(allow_unvalidated)] to the struct.

the trait bound Foo: Validate is not satisfied

You are using Json<Foo> or Query<Foo> with a type lacking #[derive(Validate)]. Add it, or if Foo is an external type use lesto::axum::Json<Foo> (documented but without garde validation).

the trait bound Foo: JsonSchema is not satisfied

#[derive(JsonSchema)] is missing on a type that goes through the API (body, response, query, path). For external types that do not implement it, wrap them in a newtype or use #[schemars(with = "...")].

X cannot be a handler argument in this position

A body-consuming extractor (Json<T>, String, Bytes, Request) is not the last argument. Move it to the end. The error points at the offending argument.

X is not an extractor (state type S)

The last argument is not usable as an extractor with that state. Typical causes:

  1. The payload type lacks a derive: Deserialize and JsonSchema for Path/Query/Json, plus Validate for Json/Query.
  2. The extractor only works with a specific state and the handler is registered on an App<S> with another one. The checks take the state from a State<T> argument if there is one, else from the App<S> the routes![] set is added to. #[lesto::get("/path", state = AppState)] pins it explicitly, which moves the error onto the offending argument.

lesto cannot document X as a handler argument / response

The type works at runtime but lesto does not know how to describe it in OpenAPI. For a response type this is always an error; for an argument only with lesto’s strict-docs feature on (or when registering through RouteSet::add instead of routes![]). For payloads, derive JsonSchema (#[lesto::model]) and use Json<T>/Query<T>/Path<T>; for a custom extractor or response type, add impl lesto::OperationInput for X {} / impl lesto::OperationOutput for X {} (an empty impl documents nothing) or delegate to the wrapped extractor (chapter 8).

the trait bound fn(…) {handler}: Handler<_, _> is not satisfied

axum’s own error; lesto’s per-argument checks usually report the real cause just above it. If it appears alone, check: the state in State<A> matches with_state(B); every handler in the same routes![] uses the same state; the handler is async and Send (no std MutexGuard held across an .await).

type annotations needed for App<_>``

The state type cannot be inferred (for instance an App with no State handlers, on which .openapi() is called before with_state). Write App::<()>::new() or App::<AppState>::new().

routes![list]: cannot find type list in this scope

Older compilers say “expected type, found function list”. The handler is missing its #[lesto::get(...)] attribute: routes! looks for the metadata type the attribute generates, and finds only the function. Add the attribute.

routes![users::list] cannot find the function

The handler must be pub (the generated type has the same visibility as the function) and the module must expose it.

Swagger UI says “Could not render responses”

A schema is not an object. Recent lesto versions convert boolean schemas; if it happens with a type of yours that implements JsonSchema by hand, return an object ({} instead of true).

The port is already in use

Address already in use: another process is listening on the same port. Change port or find the process with lsof -i :8000.

Fields in the documentation are not in the struct’s order

They are: lesto guarantees declaration order for JSON keys, in the OpenAPI document and in response bodies (struct fields, problem extensions, a serde_json::Map filled in order). It does so by enabling serde_json and schemars’ preserve_order feature. Cargo unifies features across a build, so every serde_json::Map in your application is insertion-ordered too, not sorted by key. If a field is out of order, it is almost always a HashMap, which has no order to keep: use indexmap::IndexMap (or BTreeMap for sorted keys).

/docs is blank behind a firewall

The documentation pages load Scalar and Swagger UI from jsDelivr (pinned versions, integrity hashes). Where the CDN is unreachable, host the files yourself and point the pages at them: App::scalar_script_url("https://cdn.internal/scalar.js") and App::swagger_ui_base_url("https://cdn.internal/swagger-ui-dist") (the directory that holds swagger-ui.css and swagger-ui-bundle.js).

Client generators complain about duplicate operationIds

Every operation gets {function}_{path}_{method} by default, which is unique per route. If you set operation_id = "..." by hand, the values must be unique across the whole document, nested apps included.

A Jwt route answers 500

The log says a `Jwt` argument needs a verifier: the app serving the route has no App::oidc(..) (or App::protect(..)). Call .oidc(auth) on the app you serve; it covers the nested apps too (chapter 9).

Every token answers 401 “The token is meant for another audience” (or “another issuer”)

The provider puts in aud (or iss) something other than what lesto expects. Decode one token (the middle segment is base64 JSON) and compare: audiences([..]) must list a value of its aud; iss must equal the issuer of the discovery document, which is why the discovery URL has to be the provider’s own (behind a proxy, the provider must announce the public URL). The reason is logged at debug by lesto: RUST_LOG=info,lesto=debug shows it (chapter 11).

an MCP resource must be a GET route (or prompt)

Resources and prompts are read, so only a GET route can be one. An operation that creates or changes something is a tool: mcp = "tool" (chapter 16).

`X` cannot be the response of an MCP prompt

A route marked mcp = "prompt" builds the prompt’s messages: it returns lesto::mcp::Prompt (or Result<Prompt, E>), for example Prompt::new().user(".."). A route that returns data for the model to read is a resource: mcp = "resource".

panicked at 'two routes are exposed as the MCP tool x' (or resource, prompt)

Two routes of the same kind carry the same name = "x". Names must be unique per kind across the app, nested apps included: rename one. Default names (the handler’s) never collide: when two exposed handlers of a kind share a name, both use their operationId.

panicked at 'GET /path cannot be an MCP resource: its query parameter q is required'

A resource is read by its URI alone, so a required query parameter could never be filled. Make it optional (Option<T>), or expose the route as a tool, whose arguments can carry it.

An MCP client says the server rejected the request with 403

The request came from a browser page whose origin is not on this machine, possibly your own public domain. The endpoint refuses such Origins against DNS rebinding; list the page’s origin in Mcp::allowed_origins([..]).

C. Why axum and not actix-web

lesto is built on axum. The other natural candidate was actix-web, the longest-lived Rust framework and often at the top of the benchmarks. Both are mature, fast and in production at many companies: the choice is not “one is better” but “one fits what lesto wants to be”. Here are the reasons, heaviest first.

1. State is checked by the compiler

In axum, shared state is a type parameter of the router: Router<S> and State<S> must match, otherwise the program does not compile. In actix-web, state is registered with app_data(web::Data::new(...)) and extracted with web::Data<T>: forget the registration, or register a different type, and you find out at runtime with a 500 on the first request.

lesto promises that configuration mistakes surface at compile time, like body and response types do. axum’s typed state makes that promise complete; with actix-web it would have been an exception to document.

2. tower: middleware shared with the whole ecosystem

axum has no middleware system of its own: it uses tower. A layer written for tower works with axum, with hyper, with tonic (gRPC), with any service. tower-http already provides CORS, compression, timeouts, tracing, authentication, size limits, static files.

actix-web has its own middleware system, well made but closed: middleware is written for actix-web and stays there. lesto wants to be a thin layer that does not isolate you from the ecosystem, and tower is that ecosystem.

3. A router is a Service

App::into_router() returns an axum::Router, which is a tower::Service. You can serve it with axum::serve, with hyper directly, with axum-server for TLS, inside a Lambda with lambda_http, merged with a gRPC router. lesto does not have to care how the service is run: it leaves that to axum, which leaves it to tower. In actix-web the server, the runtime and the router are one piece.

4. The runtime is tokio, full stop

axum runs on tokio, the runtime used by sqlx, reqwest, tonic, redis, almost everything. A handler is an ordinary Send future and combines with any async library without a second thought.

actix-web uses actix-rt, built on tokio but with a single-threaded worker model: every worker has its own runtime and handlers may be !Send. It is a choice with real advantages, for instance using Rc and RefCell inside a handler, but it introduces rules that beginners meet as surprises: tokio::spawn inside a handler needs care, some libraries must be initialized per worker, tests must use #[actix_web::test] instead of #[lesto::test]. For a framework aimed at beginners, the road without exceptions matters more than the performance margin.

5. The extractor model is FastAPI’s

Both frameworks extract handler arguments from types (FromRequest in actix-web, FromRequestParts / FromRequest in axum). In axum, however, the Handler<T, S> trait exposes the tuple T of argument types as a generic parameter: lesto intercepts it and asks each type to describe itself in OpenAPI (OperationInput), without a macro having to read the function signature. That mechanism is what makes routes![handler] enough to document everything.

The same can be achieved in actix-web, but with more code in the macro and less in the type system.

6. The things that did not weigh

  • Performance. actix-web is almost always ahead in synthetic benchmarks; axum is close. In a real API the time goes to the database and the network: the difference between the two frameworks is in the order of microseconds per request and disappears into the noise.
  • Path syntax. Both use {name}, which matches OpenAPI.
  • Maturity and community. Both are active and stable. axum lives in the tokio-rs organization; actix-web has an independent, established team.
  • Existing OpenAPI support. utoipa supports both; aide only axum. lesto generates its own document and depends on neither.

7. The costs of the choice

  • Handlers must be Send: no Rc/RefCell across an .await. In practice you use Arc and Mutex/RwLock, or better, types that are already thread-safe such as connection pools.
  • Middleware written for actix-web cannot be reused.
  • In pure benchmarks a few percentage points are left on the table.

Could lesto support actix-web too?

lesto’s core is framework-independent: the OpenAPI model, the OperationInput / OperationOutput traits, HttpError and the RFC 9457 format, the conversion of garde reports. The layer binding them to axum, that is the extractors, RouteSet and App, is about a third of the code. An actix-web backend would therefore be possible, with its own extractors and its own App. It is not planned: lesto’s value lies in being one simple way of building APIs in Rust, and two backends would double the surface to learn and to maintain.

D. Leaving lesto

lesto is a layer on axum, not a replacement for it, so leaving is a sequence of small steps, each of which leaves a working application behind. Nobody is locked in; this appendix shows the way out so that you can judge the way in.

1. Take the router

into_router() turns an App into an ordinary axum::Router, with the documentation pages, the problem fallbacks and every layer already installed. Serve it with axum:

let router: lesto::axum::Router = build_app(state).into_router();
let listener = lesto::tokio::net::TcpListener::bind("0.0.0.0:8000").await?;
lesto::axum::serve(listener, router)
    .with_graceful_shutdown(lesto::shutdown_signal())
    .await?;

From here on, new code can be plain axum: router.merge(..), router.nest(..), any tower layer.

2. Write handlers without the route attribute

A handler without #[lesto::post] is an axum handler. lesto’s extractors and errors still work in it, because they are ordinary FromRequest and IntoResponse types:

/// A plain axum handler: no route attribute, still lesto's validating `Json` and `HttpError`.
async fn plain_create_item(Json(item): Json<Item>) -> Result<Json<ItemOut>, HttpError> {
    if item.name == "forbidden" {
        return Err(HttpError::forbidden("that name is taken"));
    }
    Ok(Json(ItemOut {
        name: item.name,
        price_with_tax: item.price + item.tax.unwrap_or(0.0),
    }))
}

What it loses is the documentation: axum does not keep a handler’s types, so the operation is missing from openapi.json. Freeze the document before you go (lesto openapi -o openapi.json) and serve the file, or keep the documented part of the API on lesto while the rest moves.

3. Keep the layers

A router that App never built can keep lesto’s runtime behavior with the public layers:

pub fn plain_router() -> lesto::axum::Router {
    use lesto::layers::{CatchPanicLayer, ProblemLayer, RequestSpanLayer, TimeoutLayer};
    use std::time::Duration;

    lesto::axum::Router::new()
        .route("/items", lesto::axum::routing::post(plain_create_item))
        .layer(TimeoutLayer::new(Duration::from_secs(10)))
        .layer(CatchPanicLayer)
        .layer(ProblemLayer)
        .layer(RequestSpanLayer::new())
}
  • ProblemLayer fills in instance on every RFC 9457 response.
  • CatchPanicLayer answers a panic with a 500 problem instead of a dropped connection.
  • RequestSpanLayer opens the request span of chapter 15.
  • TimeoutLayer answers 503 when a request runs too long.
  • CORS, compression and request ids are tower-http’s own layers (CorsLayer, CompressionLayer, SetRequestIdLayer + PropagateRequestIdLayer): App::cors and friends only install them.

The order is the one App uses: the last layer added is the outermost.

4. Replace the derives

#[lesto::model] expands to four derives through lesto’s copies of serde, schemars and garde (chapter 4). Write them out and add the three crates to your manifest:

#[derive(serde::Serialize, serde::Deserialize, schemars::JsonSchema, garde::Validate)]
struct Item { /* the fields, unchanged */ }

A model that had no garde rule needs #[garde(allow_unvalidated)], which #[lesto::model] added for you. #[lesto::views] keeps working on plain derives, or its output can be written by hand.

5. The last pieces

  • #[lesto::main] / #[lesto::test] become #[tokio::main] / #[tokio::test] with tokio in the manifest.
  • lesto::Json / lesto::Query validate with garde and answer RFC 9457 problems. Keep them (they have no dependency on App), or replace them with axum::Json and a call to .validate() in the handler.
  • lesto::db stores are plain structs around a sqlx pool; the closure bodies are sqlx code and move as they are.

At that point lesto can leave Cargo.toml.

E. Performance

Per request

What lesto costs over the axum::Router it builds on, per request, measured through tower::Service calls with no networking (benches/overhead.rs, cargo bench -p lesto; an Apple laptop, best of five rounds of 200,000 requests):

Requestaxum 0.8lesto
GET /hello616 ns911 ns
POST /items, JSON body validated, status = 2011,187 ns1,498 ns
GET /missing → 404 problem+json388 ns1,260 ns
GET a handler returning HttpError::not_found805 ns1,215 ns

The difference is what lesto adds: the request span, the panic catcher, and an RFC 9457 body where axum answers with none. A 422 costs more again (about 3.0 µs) because it also runs garde and reports every failed check with its location — plain axum does not validate at all, so there is nothing to compare it with.

Trace::off() is not on that list on purpose: it saves about 13 ns, because it is a branch inside a layer that is installed anyway. Use it when you do not want a request span, not when you want speed. scripts/bench-http.sh runs the same application behind oha over a real socket, where the numbers above are lost in the noise of the network.

Build time

Build time is the other cost, and the reason otel is not a default feature. examples/02-notes (sqlite), measured 2026-09-23 on an Apple Silicon laptop with the workspace’s dev profile (dependencies at opt-level = 3):

cratesclean buildrebuild after editing the app
without otel17794 s1.0 s
with otel210118 s1.1 s

The OpenTelemetry SDK, the OTLP exporter and its HTTP client add 33 crates and about a quarter to a clean build; a rebuild of your own code does not notice. Turn it on where telemetry is exported, typically in the deployed build.