Controllers & routes
Controllers as plain structs, routes from #[routes], path/query/JSON extractors, typed returns — the minimum to ship an HTTP handler.
A controller is a plain struct: inject what it needs, hang verb methods
off its impl block, and #[routes] generates the route table. Verb
attributes (#[get], #[post], #[put], #[delete], #[patch]) bind a
method to a path, and #[sse] binds a GET that
answers an event stream. Extractors, validation and response types all come from
poem — NestRS adds the DI shape, the self-composing
route table, and a few framework-specific extractors covered on the
extractors page.
This page covers the minimum to ship an HTTP handler. Response shaping (responses), error mapping (errors), the extractor surface (extractors) and URI versioning (versioning) each have their own page.
The minimal controller
Section titled “The minimal controller”use std::sync::Arc;use nest_rs::http::{controller, routes};
use crate::hello::HelloService;
#[controller(path = "/")]pub struct HelloController { #[inject] svc: Arc<HelloService>,}
#[routes]impl HelloController { #[get("/")] async fn hello(&self) -> String { self.svc.greeting() }}-
#[controller(path = "/")]mounts the impl block under that path. -
#[inject] svc: Arc<HelloService>declares a dependency — the container hands the controller the sharedArc<HelloService>when it builds the controller. -
#[routes]generates the route table; one entry per verb attribute. -
async fn hello(&self) -> String— any type that implementsIntoResponseis a valid return.String,&'static str,i32,Json<T>,(StatusCode, Body)… all work. -
A controller with no
#[inject]field needs#[derive(Default)]. Injected fields give the container everything it needs to build the struct; with none, it falls back toDefault. rustc names the fix, but the case only shows up on a controller that reaches for nothing:crates/features/src/greetings/http/controller.rs #[controller(path = "/greetings")]#[derive(Default)]pub struct GreetingsController;
Run it:
$ nestrs run dev…2026-06-03T10:14:22Z INFO nest_rs::routes: mounted route controller="HelloController" method="GET" path="/" handler="hello"
$ curl http://localhost:3000Hello WorldThe boot line is authoritative about the served path. One
nest_rs::routes: mounted route event per route, with the full path in the
path field — the transport logs what it actually mounted, prefix and version
folded in.
#[get("/")] on #[controller(path = "/greetings")] serves /greetings, and
/greetings/ reaches the same handler: the transport edge trims the trailing
slash before anything routes on the path, so both spellings run the same
guards, interceptors and filters. The mounted path — the one the event above
prints, the one the OpenAPI document lists — is the trimmed form. Interior
slashes are left alone: /greetings//hello is a different path, not a typo.
When a route seems missing, compare your URL against the path field of that
event rather than against the attribute.
Adding a path parameter
Section titled “Adding a path parameter”use nest_rs::http::poem::web::Path;
#[get("/:name")]async fn greet(&self, Path(name): Path<String>) -> String { format!("Hello, {name}!")}Path<T> extracts a typed path segment. Typed parsing fails with 400
before the handler runs — there is no name.parse::<i32>() to handle
manually.
Destructuring in the parameter list is supported — Path(name),
Query(q), Json(body), Valid(input) — as long as the pattern binds one
name. #[routes] forwards the argument to the handler it generates under that
name, so the pattern stays where you wrote it. A pattern binding two
(Path((a, b))) has no single name to forward and is a compile error naming
the way out: bind the whole extractor and destructure in the body.
$ curl http://localhost:3000/AdaHello, Ada!Reading query parameters
Section titled “Reading query parameters”use nest_rs::http::poem::web::Query;use nest_rs::http::input;
#[input]struct Greet { lang: Option<String>,}
#[get("/")]async fn hello(&self, Query(q): Query<Greet>) -> String { match q.lang.as_deref() { Some("fr") => "Bonjour le monde".to_string(), _ => self.svc.greeting(), }}$ curl 'http://localhost:3000/?lang=fr'Bonjour le mondeAccepting a JSON body, validated
Section titled “Accepting a JSON body, validated”use nest_rs::http::poem::Result;use nest_rs::http::poem::web::Json;use nest_rs::http::{Valid, input};
#[input]struct GreetInput { #[validate(length(min = 1))] name: String,}
#[input]struct GreetReply { greeting: String,}
#[post("/")]async fn shout(&self, Valid(input): Valid<Json<GreetInput>>) -> Result<Json<GreetReply>> { Ok(Json(GreetReply { greeting: format!("HELLO, {}!", input.name.to_uppercase()), }))}#[input]is the shorthand for wire DTOs — the ones crossing the edge in either direction. It appends#[derive(::serde::Serialize, ::serde::Deserialize, ::validator::Validate, ::schemars::JsonSchema)]and#[serde(deny_unknown_fields)]— an unknown field on the wire ({"name":"Ada","is_admin":true}) is rejected with400at parse time instead of silently dropped.Serializeis in that list on purpose: it is what lets the same attribute carry a response type such asGreetReplybelow, returned asJson<T>. Don’t top the list up by hand — a manual#[derive(Serialize)],#[derive(Deserialize)]or#[derive(Validate)]next to#[input]is a conflicting impl (E0119), not an addition. Need a customDeserializeshape (e.g. untagged enums)? Skip#[input]and derive all four by hand.Json<T>parses the request body intoT. Malformed JSON is400.Valid<E>runs validator on the extracted value. A failure returns400with the structured error list — no manual checks in the handler.Validis the ergonomic form of the more generalPiped<P, E>covered in extractors.
$ curl -sX POST http://localhost:3000/ \ -H 'Content-Type: application/json' \ -d '{"name":"ada"}'{"greeting":"HELLO, ADA!"}
$ curl -sX POST http://localhost:3000/ \ -H 'Content-Type: application/json' \ -d '{"name":""}'{"type":"https://www.rfc-editor.org/rfc/rfc9110#status.400","title":"Bad Request", "status":400,"detail":"validation failed", "errors":{"name":[{"code":"length","message":null,"params":{"min":1}}]}}Returning typed JSON
Section titled “Returning typed JSON”Json<T> works as a return type as well — the framework serializes T
and sets Content-Type: application/json:
#[get("/me")]async fn me(&self) -> Json<GreetReply> { Json(GreetReply { greeting: self.svc.greeting() })}That single type drives three things in lockstep: the wire format, the
OpenAPI schema (via schemars::JsonSchema on T), and the response body.
None of them can drift from the others — they share one source.
Need a different status, a redirect, a custom header? See
responses for the response-shaping attributes that
sit beside the verb. Need the handler’s Err(...) to render a structured
JSON body? See errors for ResponseError and
ProblemDetails.
Going further
Section titled “Going further”- Responses —
Json<T>, status + body tuples, and how several shapers compose. - Extractors — the full extractor surface a handler parameter can take.
- Errors —
ResponseErroron a feature error enum, and RFC 9457 problem responses. - Security — binding a guard so the handler runs with a principal and an ambient ability.