Controllers
A controller receives the HTTP requests for a set of routes and returns the response. In Corvis a controller is made of two pieces:
- a
structmarked with#[controller], whose fields are its dependencies, and - an
implblock marked with#[routes], where each method with a verb attribute (#[get],#[post], ...) is a handler.
use corvis::prelude::*;
#[controller("cats")]
pub struct CatsController {
cats_service: Arc<CatsService>,
}
#[routes]
impl CatsController {
#[get]
fn find_all(&self) -> Vec<Cat> {
self.cats_service.find_all()
}
#[get(":id")]
fn find_one(&self, #[param("id", ParseIntPipe)] id: u32) -> HttpResult<Cat> {
self.cats_service.find_one(id)
}
}For the router to know about it, the controller is declared in the module that contains it:
#[module(controllers = [CatsController], providers = [CatsService])]
pub struct CatsModule;#[controller] also turns the struct into a provider: its Arc<T> fields are injected just like in an #[injectable] (see Providers). The controller is created once and shared across all requests, so any mutable state it holds must be protected (Mutex, RwLock, atomics).
Routes
Controller prefix
| Form | Effect |
|---|---|
#[controller] | No prefix: routes hang from /. |
#[controller("cats")] | Prefix /cats. |
#[controller(["cats", "gatos"])] | Several prefixes: each route is mounted under all of them. |
#[controller(path = "cats", version = "1", host = "admin.example.com")] | Named form. version and host are explained below. |
If the application uses app.set_global_prefix("api"), that prefix is prepended to all routes (see Global prefix).
Verbs
Verb attributes go on the methods of the #[routes] block:
| Attribute | HTTP method |
|---|---|
#[get], #[get(":id")] | GET |
#[post] | POST |
#[put] | PUT |
#[patch] | PATCH |
#[delete] | DELETE |
#[options] | OPTIONS |
#[head] | HEAD |
#[search] | SEARCH |
#[all] | Any method |
#[sse("stream")] | GET that responds with a Server-Sent Events stream (see Server-Sent Events) |
Without an argument, the route is the controller's own. It also accepts several routes at once: #[get(["a", "b"])].
All handlers receive &self as their first parameter (the macro rejects those that don't have it). They can be fn or async fn.
Route patterns
:namecaptures one segment:#[get(":id")],#[get(":room/messages")].*,*name, or{*name}at the end capture the rest of the path.- Routes are case-insensitive (
/CATS=/cats) and accept a single optional trailing slash (/cats/works,/cats//does not), like the Express 5 router. - Static segments take priority over parameters regardless of declaration order:
#[get("count")]and#[get(":id")]can coexist in either order. - Parameters are decoded with
decodeURIComponentsemantics; a badly encoded one responds400 Failed to decode param '...'.
Host-based routing
#[controller(host = "admin.example.com")] makes the controller's routes respond only when the request's Host matches (case-insensitive). The value is a fixed name: there are no host parameters.
Handler parameters
Each parameter carries an attribute that says where its value comes from. The value is extracted as JSON, goes through the pipes, and is finally deserialized into the declared type with serde.
| Attribute | Value |
|---|---|
#[body] dto: T | The whole body |
#[body("name")] name: String | One property of the body |
#[param("id")] id: u32 | A route parameter |
#[param] params: HashMap<String, String> | All route parameters |
#[query] q: T | The whole query string as an object |
#[query("page")] page: Option<u32> | One key of the query string |
#[headers("authorization")] auth: Option<String> | One header (also #[header("...")]); the name is normalized to lowercase |
#[headers] h: HashMap<String, String> | All headers |
#[ip] ip: String | The client IP (honors set_trust_proxy) |
#[req] req: Request | The request (also #[request]) |
#[res] res: Res | The Express-style response (see below) |
#[uploaded_file], #[uploaded_files] | Uploaded files (see File upload) |
After the name you can list pipes, by type or as an expression: #[param("id", ParseIntPipe)], #[query("page", DefaultValuePipe::new(1), ParseIntPipe)].
#[get(":room/messages")]
async fn messages(&self, #[param("room")] room: String, #[query] q: HistoryQuery) -> HttpResult<Vec<ChatMessage>> {
self.find(&room).await?;
Ok(self.rooms.history(&room, q.limit.unwrap_or(50).clamp(1, 200)).await?)
}(examples/tertulia/src/rooms/rooms_controller.rs)
Type conversion. Route, query, and header values arrive as text; when deserializing them, Corvis converts the strings to numbers or booleans when the type asks for it (#[query("page")] page: Option<u32> with ?page=2 gives Some(2)). The body is deserialized strictly. If deserialization fails, the response is 400 Bad Request with the serde error.
Parameters without an attribute. A parameter without an attribute is resolved with the FromContext trait. This is how you write your own parameter decorators, for example the authenticated user:
#[get("me")]
#[use_guards(AuthGuard)]
fn me(&self, user: ChatUser) -> ChatUser {
user
}ChatUser implements FromContext in examples/tertulia/src/auth/user.rs. See Custom decorators. Corvis already implements FromContext for Request, Res, ExecutionContext, ModuleRef, and Option<T>.
Responses
The handler's return value is converted into the response:
| Returned type | Response |
|---|---|
Any T: Serialize (structs, Vec, Value, ...) | JSON |
String, &'static str | Text with Content-Type: text/html; charset=utf-8, like res.send(string) in Express |
() | Empty body |
Json(v), Html(s) | Explicit JSON or HTML |
Redirect::to(url), Redirect::permanent(url) | Redirect |
Response | Full control: Response::json(..), Response::text(..), Response::html(..), Response::bytes(data, type), Response::stream(..), Response::redirect(url, code), with .with_status(..), .with_header(..), .with_cookie(..) |
StreamableFile | A file or byte stream (see Streaming responses) |
Sse | Server-Sent Events |
(StatusCode, T) or (u16, T) | T with a different status |
Bytes, Vec<u8> | Raw bytes |
Option<T>, Result<T, E> | Depends on the content |
Text and JSON bodies get ETag, Content-Length, and the 304 response to conditional requests, just like in Express.
Status code
By default the response is 200, except for POST, which responds 201. To change it:
#[delete(":id")]
#[http_code(HttpStatus::NO_CONTENT)]
fn remove(&self, #[param("id", ParseIntPipe)] id: u32) -> HttpResult<()> {
self.cats_service.remove(id)
}#[http_code(...)] accepts a number or an HttpStatus constant (HttpStatus::CREATED, HttpStatus::NOT_FOUND, ...). For a status that depends on logic, return a (code, value) tuple or a Response with .with_status(...).
Headers
#[get("whoami/role")]
#[header("Cache-Control", "none")]
async fn whoami(&self, role: CurrentRole) -> String {
role.0
}A header that the handler already set on its Response takes priority over #[header]. If the header is a text Content-Type, charset=utf-8 is appended.
Redirects
#[get("legacy/redirect")]
#[redirect("https://example.com", 301)]
fn legacy(&self) {}The code is optional (302 by default). If the handler returns a JSON object with url (and optionally statusCode), those values replace the ones from the attribute.
Errors
A handler that returns HttpResult<T> (an alias of Result<T, HttpException>) responds with the error as JSON with its status:
fn find_one(&self, #[param("id", ParseIntPipe)] id: u32) -> HttpResult<Cat> {
self.cats_service.find_one(id).ok_or_else(|| NotFoundException::new(format!("Cat #{id} not found")))
}{ "message": "Cat #42 not found", "error": "Not Found", "statusCode": 404 }Any Result whose error converts into HttpException or anyhow::Error works; an error that is not an HttpException produces a generic 500 and is recorded in the log. The list of exceptions and how to customize them is in Exception filters.
Async handlers
An async fn handler can await any future. Corvis runs it on the Tokio runtime:
#[get]
async fn list(&self) -> HttpResult<Value> {
let rooms = self.rooms.list().await?;
Ok(self.chat.with_members(rooms))
}The response object (#[res])
If a handler needs to manage the response the Express way, declare a #[res] res: Res parameter:
#[get("magic")]
async fn magic(&self, #[query("token")] token: Option<String>, #[res] res: Res) {
if let Some(s) = self.auth.redeem(token).await {
res.set_header("Set-Cookie", format!("sid={s}; Path=/; HttpOnly"));
}
res.redirect_with(302, "/");
}- With
#[res]the handler owns the response: the return value is ignored and the request stays open until it callssend,json,end,end_empty,redirect,redirect_with, orsend_status. - With
#[res(passthrough)](or#[res(passthrough = true)]) Corvis keeps serializing the return value and preserves the status, headers, and cookies the handler set onres. It is the convenient way to set a cookie without giving up the rest:
#[post("login")]
#[http_code(204)]
async fn login(&self, #[body] b: Value, #[res(passthrough)] res: Res) -> HttpResult<()> {
res.cookie("sid", &self.auth.create().await, CookieOptions::new().http_only(true).same_site("lax"));
Ok(())
}Res has the Express methods in snake_case: status, status_code, send_status, set, header, set_header, append, get, get_header, get_headers, has_header, remove_header, type_, content_type, vary, links, attachment, location, cookie, cookie_json, clear_cookie, redirect, redirect_with, send, json, end, end_empty, headers_sent, and writable_ended.
WARNING
Prefer returning values. With #[res] in full mode you lose the automatic conversion of the return value, and whatever an interceptor does with the response does not reach the client: what the handler wrote to res is what gets sent.
Versioning
#[version("2")] on a handler, or version = "1" in #[controller(...)], assigns versions when the application enables versioning:
#[get(":id")]
#[version("2")]
fn find_one_v2(&self, #[param("id", ParseIntPipe)] id: u32) -> HttpResult<Value> {
let cat = self.cats_service.find_one(id)?;
Ok(json!({ "cat": cat, "apiVersion": 2 }))
}#[version(VERSION_NEUTRAL)] marks a route that responds to any version. The details are in Versioning.
Class attributes
Below #[controller] you can put attributes that apply to all of its routes:
#[controller("cats")]
#[use_interceptors(TimingInterceptor)]
pub struct CatsController {
cats_service: Arc<CatsService>,
}The available ones are #[use_guards], #[use_interceptors], #[use_pipes], #[use_filters], #[set_metadata], #[version], #[apply_decorators], and the OpenAPI #[api_*] attributes.
Attribute order
Class attributes must go below #[controller]. Rust expands attributes from top to bottom, and #[controller] only sees the ones that come after it.
A complete example
The REST controller in examples/tertulia combines injection, guards, metadata, parameters, and a custom parameter decorator:
#[controller("rooms")]
pub struct RoomsController {
rooms: Arc<RoomsService>,
chat: Arc<ChatGateway>,
}
#[routes]
impl RoomsController {
#[get]
async fn list(&self) -> HttpResult<Value> {
let rooms = self.rooms.list().await?;
Ok(self.chat.with_members(rooms))
}
#[post]
#[use_guards(AuthGuard)]
async fn create(&self, #[body] dto: CreateRoomDto, user: ChatUser) -> HttpResult<Room> {
Ok(self.rooms.create(dto, &user.username).await?)
}
#[post(":room/announcements")]
#[set_metadata(ROLES_KEY, [MODERATOR])]
#[use_guards(AuthGuard, RolesGuard)]
async fn announce(&self, #[param("room")] room: String, #[body] dto: AnnouncementDto, user: ChatUser) -> HttpResult<Value> {
self.find(&room).await?;
let delivered = self.chat.announce(&room, &user, &dto.text);
Ok(json!({ "room": room, "delivered": delivered }))
}
}Methods outside the #[routes] block (like find in the same file) are regular Rust methods: they don't generate routes.