Skip to content

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.

crates/features/src/hello/http/controller.rs
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 shared Arc<HelloService> when it builds the controller.

  • #[routes] generates the route table; one entry per verb attribute.

  • async fn hello(&self) -> String — any type that implements IntoResponse is 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 to Default. 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:

Terminal window
$ nestrs run dev
…
2026-06-03T10:14:22Z INFO nest_rs::routes: mounted route controller="HelloController" method="GET" path="/" handler="hello"
$ curl http://localhost:3000
Hello World

The 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.

crates/features/src/hello/http/controller.rs
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.

Terminal window
$ curl http://localhost:3000/Ada
Hello, Ada!
crates/features/src/hello/http/controller.rs
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(),
}
}
Terminal window
$ curl 'http://localhost:3000/?lang=fr'
Bonjour le monde
crates/features/src/hello/http/controller.rs
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 with 400 at parse time instead of silently dropped. Serialize is in that list on purpose: it is what lets the same attribute carry a response type such as GreetReply below, returned as Json<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 custom Deserialize shape (e.g. untagged enums)? Skip #[input] and derive all four by hand.
  • Json<T> parses the request body into T. Malformed JSON is 400.
  • Valid<E> runs validator on the extracted value. A failure returns 400 with the structured error list — no manual checks in the handler. Valid is the ergonomic form of the more general Piped<P, E> covered in extractors.
Terminal window
$ 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}}]}}

Json<T> works as a return type as well — the framework serializes T and sets Content-Type: application/json:

crates/features/src/hello/http/controller.rs
#[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.

  • Responses — Json<T>, status + body tuples, and how several shapers compose.
  • Extractors — the full extractor surface a handler parameter can take.
  • Errors — ResponseError on a feature error enum, and RFC 9457 problem responses.
  • Security — binding a guard so the handler runs with a principal and an ambient ability.