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.