Controllers & routes
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. 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.
Run it:
$ nestrs run dev…2026-06-03T10:14:22Z INFO nest_rs::http: bound 1 route on 0.0.0.0:3000 GET / → HelloController::hello
$ curl http://localhost:3000Hello WorldAdding a path parameter
Section titled “Adding a path parameter”use 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.
$ curl http://localhost:3000/AdaHello, Ada!Reading query parameters
Section titled “Reading query parameters”use poem::web::Query;use serde::Deserialize;
#[derive(Deserialize)]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::{Valid, input};use poem::web::Json;use poem::Result;use serde::Serialize;use validator::Validate;
#[input]struct GreetInput { #[validate(length(min = 1))] name: String,}
#[derive(Serialize)]struct GreetReply { greeting: String,}
#[post("/")]async fn shout(&self, Valid(Json(input)): Valid<Json<GreetInput>>) -> Result<Json<GreetReply>> { Ok(Json(GreetReply { greeting: format!("HELLO, {}!", input.name.to_uppercase()), }))}#[input]is the shorthand for input DTOs. It appends#[derive(::serde::Deserialize, ::validator::Validate)]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. Don’t use#[input]together with a manual#[derive(Deserialize, Validate)]— pick one (the macro errors on a double-derive otherwise). Need a customDeserializeshape (e.g. untagged enums)? Skip#[input]and derive 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":""}'{"error":"validation","fields":{"name":["length"]}}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,#[http_code],#[response_header],#[redirect], how multiple shapers compose. - Errors —
ResponseError, RFC 9457ProblemDetails, mapping a feature error enum to a wire response. - Extractors — the full extractor surface:
Json<T>,RawBody,ClientIp,Scoped<T>(request-scoped providers),Ctx<T>(context attached by a guard),Reflector+#[meta(...)](route-level metadata read by guards/interceptors). - Versioning —
#[controller(version = "1")], multi-version controllers, the boot path. - Configuration —
HttpConfig, TLS, CORS, the framework header. - Security — bind an
AuthGuardand anAbilityGuardto attach the principal and gate actions per row. - OpenAPI — every route documents itself; nothing to hand-maintain.