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.
| # | Chapter | What you learn |
|---|---|---|
| 1 | First steps | Create the project, the first route, automatic documentation |
| 2 | Path parameters | Path<T>, types, several parameters, what happens when the type doesn’t fit |
| 3 | Query parameters | Query<T>, optionals, defaults, validation |
| 4 | Request body | Json<T>, deserialization and validation with garde |
| 5 | Validation | garde rules, nested structures, custom rules |
| 6 | Responses | Return types, status codes, Result, empty responses |
| 7 | Error handling | HttpError, RFC 9457, documented errors |
| 8 | State and dependencies | State<S>, Extension, custom extractors (lesto’s Depends) |
| 9 | Security | Bearer, API keys, Basic, OAuth2, verifying tokens |
| 10 | Bigger projects | Modules, nest, tags, route order |
| 11 | Middleware and axum | tower layers, CORS, logging, dropping down to axum |
| 12 | Testing | Testing the API without opening a port |
| 13 | Databases | sqlx stores with the db feature: transactions, principals, permissions |
| 14 | AWS Lambda | The same binary behind API Gateway with the lambda feature |
| 15 | Observability | Request and store spans, OpenTelemetry semantic conventions, OTLP |
| 16 | MCP | Routes as tools, resources and prompts for AI agents, both protocol eras, authentication |
| A | From FastAPI to lesto | Correspondence table |
| B | Common problems | Compile errors and how to fix them |
| C | Why axum and not actix-web | The reasons behind the choice of core |
| D | Leaving lesto | The way out, step by step: nobody is locked in |
| E | Performance | What 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),HttpErrorand the serde, garde and schemars derives.#[lesto::get("/hello")]declares aGET /helloroute. There are alsopost,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 istext/plain.App::new()...routes(routes![root])registers the routes.routes![]takes a list of annotated functions..serve()bindsLESTO_HOST:LESTO_PORT(default127.0.0.1:8000;PORTalone also works, for Heroku and Cloud Run) and serves untilCtrl-CorSIGTERM. The shutdown is graceful: the port closes at once, requests already running finish, thenservereturns..serve_at("0.0.0.0:9000")takes an explicit address. Underlesto 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 fnwith#[lesto::get("/path")]. - The doc comment becomes the operation’s description.
- The return type decides response and documentation.
/docs,/swaggerand/openapi.jsoncome 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
Tdoes 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
Deserializereads the values from the query string. With#[serde(default)]a missing field takes its default value; with#[serde(default = "fn")]you pick one.JsonSchemaproduces the documentation: every field becomes anin: queryparameter, with type, default and the description taken from the doc comment.Validate(garde) adds the rules: herelimitmust 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 withQuery<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
422with 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, ordivefor a nested model. That is what catches a forgottendive, 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:
- Checks that
Content-Typeis JSON (application/jsonorapplication/*+json). Otherwise it answers415 Unsupported Media Type. - Reads the body and deserializes it into
T. Missing fields, wrong types or malformed JSON yield a422that names the field. - Runs garde validation. Every violated rule becomes an entry in
errors. - 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>withT: 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.
| Rule | Applies to | Example |
|---|---|---|
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))] |
email | strings | #[garde(email)] |
url | strings | #[garde(url)] |
ip, ipv4, ipv6 | strings | #[garde(ipv4)] |
alphanumeric, ascii | strings | #[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))] |
required | Option<T> must be Some | #[garde(required)] |
inner(...) | applies the rule to the elements | #[garde(inner(length(min = 1)))] |
dive | validates a nested struct | #[garde(dive)] |
custom(fn) | your own function | #[garde(custom(is_even))] |
skip | no 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:
| garde | JSON Schema |
|---|---|
length(min, max) on a string | minLength, maxLength |
length(min, max) on a Vec | minItems, maxItems |
range(min, max) | minimum, maximum |
email | format: email |
url | format: uri |
pattern(r) | pattern |
required | the 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, orallow_unvalidatedon the struct. divefor nested structs,innerfor the elements ofOptionandVec.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
| Type | Response | Documentation |
|---|---|---|
Json<T> | JSON | schema of T |
String, &'static str | text/plain | string |
() | empty body | status only |
Html<T> | text/html | string |
Result<T, E> | T or E | union of both |
(StatusCode, T) | T with the given status | like T (status unknown at compile time) |
(HeaderMap, T) | T with extra headers | like T |
Created<T>, Accepted<T> | T with 201 / 202 | like T, under 201 / 202 |
NoContent | 204, empty body | 204 |
StatusCode, Response, Redirect | as in axum | none |
You can document a type of your own by implementing lesto::OperationOutput.
Recap
Json<T>for JSON,Stringfor text,()for empty bodies.status = Non the attribute changes the success code, at runtime too;Created<T>,Accepted<T>andNoContentput 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:
| Member | Meaning |
|---|---|
type | URI identifying the error class. about:blank when the status is enough. |
title | Short description, the same for every error of that type. With about:blank it is the HTTP reason phrase. |
status | The HTTP code. |
detail | Explanation of this occurrence. |
instance | URI of the request. lesto fills it in for you. |
errors | Extension: 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
| Situation | Status | code in errors |
|---|---|---|
| Path/query/body cannot be converted | 422 | value_error |
| Required field missing | 422 | missing |
| Malformed JSON | 422 | json_invalid |
| garde rule violated | 422 | value_error |
Content-Type not JSON | 415 | – |
Missing credentials (Bearer, ApiKey, Basic) | 401 | – |
| Unknown route | 404 | – |
| Known route, wrong method | 405 | – |
| Handler panicked | 500 | – (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?andFromfor domain errors.- Everything is RFC 9457:
type,title,status,detail,instance, pluserrorsin 422s. with_typeandwith_extensionfor structured errors;with_headerfor 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(+OperationInputfor the documentation). Extractors compose:Bearer,Query,Stateinside 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):
- the algorithm in the token’s header: one the provider announces, and asymmetric.
noneandHS256/HS384/HS512are always refused (anHS*token “signed” with the public key is the classic forgery); - the key: the one of the JWKS that the token’s
kidnames, for that algorithm; - the signature, with that key;
expandnbf, with 30 seconds of leeway for clock skew (.leeway(Duration));iss: the provider’s issuer, as the discovery document states it;audcontains one of theaudiences, when you configured some;- the client, when you configured
clients:azp, thenclient_id(RFC 9068), thenappid(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,
};
| Variable | Meaning |
|---|---|
LESTO_OIDC_DISCOVERY_URL | the discovery document; unset, from_env returns Ok(None) |
LESTO_OIDC_AUDIENCES | accepted audiences, comma separated |
LESTO_OIDC_CLIENTS | accepted clients, comma separated |
LESTO_OIDC_LEEWAY_SECS | leeway 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>(featureoidc): a token verified against an OpenID Connect provider;App::protectrequires one on a whole group of routes.- One marker type per API key or OAuth2 scheme;
Bearer<MyScheme>to use it. - Missing credentials →
401withWWW-Authenticate. security(..),publicandApp::securityfor 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>. pubhandlers referenced by path inroutes![].nest(prefix, app)to mount and prefix;tagto 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 answers503 Service Unavailable(problem+json, withinstance), 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::TimeoutLayeris the same thing for a plain axum router. -
.body_limit(64 * 1024): a larger body answers413 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’sCorsLayer(re-exported aslesto::cors), installed outside everything else, so a404or500problem 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 sendAccept-Encoding: gzip(featurecompression, on by default). -
.request_id(): every request gets anx-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:header value X-Content-Type-OptionsnosniffX-Frame-OptionsDENYReferrer-Policyno-referrerStrict-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 ignoreStrict-Transport-Securityover plainhttp, 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 themax-age;includeSubDomainsandpreloadare never added for you, because they commit domains other than this service’s. AContent-Security-Policyyouinsertreplaces 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());
ProblemLayergives every RFC 9457 response itsinstance(the request path).CatchPanicLayerturns a panic into a500problem.RequestSpanLayeropens 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}
| variable | meaning |
|---|---|
LESTO_LOG | text (the default), json or off |
RUST_LOG | what is printed, info by default: RUST_LOG=info,lesto=debug, RUST_LOG=warn |
NO_COLOR | no 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_untilfor a custom shutdown trigger. - Logs on stdout out of the box, one line per request:
LESTO_LOG=jsonfor a collector,RUST_LOGto filter, a subscriber of your own to replace them. middleware::from_fnfor ad hoc middleware; they can returnHttpError.map_routerandinto_routerfor everything that is pure axum.lesto::layersif 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)
}
}
Credentialis the extractor that reads the raw credential:Bearer,ApiKey<MyKey>,Basicor your own. Its behavior is inherited: the 401 when it is missing, theWWW-Authenticateheader, the security scheme in OpenAPI.authenticateturns the credential into an identity. Here a lookup table; in a real application, a JWT check or a session query. Failing withHttpError::unauthorizedanswers 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 theArc<User>).has_permissionis 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.
readopens a read-only transaction (BEGIN READ ONLYon Postgres,START TRANSACTION READ ONLYon MySQL,BEGIN DEFERREDon SQLite, which has no read-only mode) on the replica when there is one.writeopens a read-write transaction on the primary, and only compiles whenM: Writable, that is, onReadWritestores. Callingwritefrom aReadOnlystore 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 answertruefromhas_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::Default | what the database does on its own. No conflicts to handle. |
Isolation::Snapshot | one stable snapshot for the transaction. Not enough for check-then-act. |
Isolation::Serializable | a 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:
| Cause | Response |
|---|---|
| missing permission | 403, with required_permission |
.or_not_found(..) on a query that found no row | 404 |
| unique or foreign key violation | 409 |
serialization failure or deadlock (40001, 40P01, SQLITE_BUSY) | 409 with Retry-After: 0 |
Error::not_found(..), Error::conflict(..), any HttpError | that error |
other sqlx errors (a bare RowNotFound too), .internal(), anyhow::Error | 500, 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_connectionsmust stay under the database’s own limit (Postgres: 100 by default), with room left for migrations and your own psql. acquire_timeoutis how long a request waits for a free connection before failing with a500(logged,PoolTimedOut). sqlx’s default is 30 s, longer than most clients wait: pick a few seconds, and keep it belowApp::timeoutif 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 throughFromRef.- Implement
Authenticatedonce;Publicis built in. #[derive(Store)]on a newtype; queries as methods that callself.read(requirement, ..)orself.write(requirement, ..).ReadOnly/ReadWriteandAnyone/"permission"are checked by the compiler where possible and before the transaction otherwise.lesto::db::Errormaps database errors to 403, 404, 409, 500 and documents them.transaction_settingson 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::Serializablethroughread_with/write_with; the resulting conflict is a 409 withRetry-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 generatedread/writethen take no requirement argument.read_with/write_withretry a conflictDb::with_conflict_retriestimes before the 409, which is why their closure is anAsyncFn.
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()withlesto::lambda::serve(app): Lambda when in Lambda, a plain server otherwise. - REST API stages are stripped from the path by default (
Options::keep_stageto keep them). - Documentation pages work behind the stage because they link
openapi.jsonrelatively. - Logs go to CloudWatch through stdout, as JSON when the function’s log format is JSON.
- Build with
cargo lambda build --release --arm64, deploy withcargo lambda deployor your infrastructure tool. - Test the Lambda path in-process with
lesto::lambda::test::invokeand 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.
| variable | meaning |
|---|---|
OTEL_EXPORTER_OTLP_ENDPOINT | where to send telemetry; /v1/traces, /v1/logs and /v1/metrics are appended. Unset: no export |
OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, ..._LOGS_ENDPOINT, ..._METRICS_ENDPOINT | the same per signal, used exactly as given |
OTEL_EXPORTER_OTLP_HEADERS | key1=value1,key2=value2, for a backend that wants an Authorization |
OTEL_SERVICE_NAME | service.name; defaults to the title of your OpenAPI document |
OTEL_TRACES_EXPORTER=none | keep the logs, stop exporting spans |
OTEL_LOGS_EXPORTER=none | keep the spans, stop exporting logs |
OTEL_METRICS_EXPORTER=none | stop exporting metrics |
OTEL_SDK_DISABLED=true | keep the console, stop every export |
RUST_LOG | console and export filter, info by default |
LESTO_LOG | the 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)recordsurl.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 byREDACTEDeven when it is on.forwarded(true)trustsX-Forwarded-For,X-Forwarded-ProtoandX-Forwarded-Hostforclient.address,url.schemeandserver.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 oneSERVERspan 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;5xxmarks the span as an error,4xxdoes not. - Store transactions get a client span with the database conventions, as a child of the request.
- The
otelfeature plusOTEL_EXPORTER_OTLP_ENDPOINTis the whole setup:App::serveinstalls the console of chapter 11 and the OTLP export of spans and logs, and flushes on shutdown. - Log records carry their
trace_idandspan_id, so logs and traces line up in the backend. - An incoming
traceparentcontinues the trace here. App::trace(Trace::new()...)optsurl.queryin, trusts forwarding headers, or turns the span off.examples/04-opentelemetryruns 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), ormcp(tool, name = ".."). If two exposed handlers share a name (twolistfunctions in different modules), both use theiroperationIdinstead (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_notethat istitle(required) andtags. When the body is not an object, or one of its fields has the name of a parameter, the body goes under abodyargument instead. - outputSchema: the schema of the success response.
- annotations: hints from the HTTP method.
GETis read-only;POSTonly adds;PUT,PATCHandDELETEare destructive, andPUTandDELETEare 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. A422lists 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, featuremcp.- 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
GETroute read by URI (lesto://{title}{path}), cached as itsCache-Controlsays; a prompt is aGETroute returningPrompt, its parameters as arguments. - Every request runs the route: validation errors reach the model as
isErrorresults (tools) or invalid-params errors (prompts) with the problem, a401reaches the client as a401. - 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
| FastAPI | lesto |
|---|---|
app = FastAPI(title="X", version="1.0") | App::new().title("X").version("1.0") |
@app.get("/items/{id}") | #[lesto::get("/items/{id}")] |
status_code=201 | status = 201 |
tags=["items"] | tag = "items" / tags("a", "b") |
summary=, description= | doc comment /// (or summary = "...") |
deprecated=True | deprecated |
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))] |
EmailStr | String + #[garde(email)] |
response_model=Out | return 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 dependency | Jwt<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_router | App::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": ...} errors | RFC 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 = Nrewrites every200of the handler; to put the status in the type instead, returnCreated<T>(201),Accepted<T>(202) orNoContent(204), documented under that status.state = Tonly affects the compile-time checks the macro emits; otherwise the state comes from aState<T>argument or from theApp<S>theroutes![]set is added to (see Common problems).- The default
operation_idis{function}_{path}_{method}(get_user_users__id__get), unique per route as in FastAPI. mcp = "tool"(featuremcp) exposes the operation as an MCP tool named after the function;mcp(tool, name = "search_notes")names it.mcp = "resource"(aGETroute) serves it as a resource atlesto://{title}{path},mcp = "prompt"(aGETroute returninglesto::mcp::Prompt) as a prompt whose arguments are the path and query parameters; both takename = ".."too. It takes effect once the app callsApp::mcp(chapter 16).
Differences to keep in mind
- Types are checked at compile time. A
State<Db>withoutwith_state(Db), a body withoutValidate, 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, orallow_unvalidatedon 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>(featureoidc, 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:
- The payload type lacks a derive:
DeserializeandJsonSchemaforPath/Query/Json, plusValidateforJson/Query. - 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 aState<T>argument if there is one, else from theApp<S>theroutes![]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.
utoipasupports both;aideonly axum. lesto generates its own document and depends on neither.
7. The costs of the choice
- Handlers must be
Send: noRc/RefCellacross an.await. In practice you useArcandMutex/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())
}
ProblemLayerfills ininstanceon every RFC 9457 response.CatchPanicLayeranswers a panic with a500problem instead of a dropped connection.RequestSpanLayeropens the request span of chapter 15.TimeoutLayeranswers503when a request runs too long.- CORS, compression and request ids are tower-http’s own layers (
CorsLayer,CompressionLayer,SetRequestIdLayer+PropagateRequestIdLayer):App::corsand 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::Queryvalidate with garde and answer RFC 9457 problems. Keep them (they have no dependency onApp), or replace them withaxum::Jsonand a call to.validate()in the handler.lesto::dbstores 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):
| Request | axum 0.8 | lesto |
|---|---|---|
GET /hello | 616 ns | 911 ns |
POST /items, JSON body validated, status = 201 | 1,187 ns | 1,498 ns |
GET /missing → 404 problem+json | 388 ns | 1,260 ns |
GET a handler returning HttpError::not_found | 805 ns | 1,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):
| crates | clean build | rebuild after editing the app | |
|---|---|---|---|
without otel | 177 | 94 s | 1.0 s |
with otel | 210 | 118 s | 1.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.