Skip to content

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.

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.

Run it:

Terminal window
$ 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:3000
Hello World
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.

Terminal window
$ curl http://localhost:3000/Ada
Hello, Ada!
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(),
}
}
Terminal window
$ curl 'http://localhost:3000/?lang=fr'
Bonjour le monde
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 with 400 at 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 custom Deserialize shape (e.g. untagged enums)? Skip #[input] and derive 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":""}'
{"error":"validation","fields":{"name":["length"]}}

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.

  • ResponsesJson<T>, status + body tuples, #[http_code], #[response_header], #[redirect], how multiple shapers compose.
  • ErrorsResponseError, RFC 9457 ProblemDetails, 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.
  • ConfigurationHttpConfig, TLS, CORS, the framework header.
  • Security — bind an AuthGuard and an AbilityGuard to attach the principal and gate actions per row.
  • OpenAPI — every route documents itself; nothing to hand-maintain.