Skip to content

Extractors

The full HTTP extractor surface — JSON, raw body, client IP, request-scoped providers, guard-attached context, route metadata.

An extractor pulls a typed value out of the request before the handler runs. Most of the surface is poem’s — Path<T>, Query<T>, Json<T>, Form<T> cover the request line, query string, JSON body, and form body. NestRS adds the framework-specific extractors: the validated Valid<E>, the typed header reader Header<T>, the raw-body reader RawBody, the spoofable but observation-grade ClientIp, the request-scoped DI resolver Scoped<T>, the guard-attached context reader Ctx<T>, and the route metadata reader Reflector paired with #[meta(...)].

This page is the reference: one extractor, one example, one rule. The Guards for Ctx<T> and Providers for Scoped<T> — tell the story.

Parses the request body into T; rejects malformed JSON with 400. Works as a return type too — serializes T, sets Content-Type: application/json, and feeds the OpenAPI document with the same schema:

crates/features/src/posts/http/controller.rs
use nest_rs::http::poem::web::Json;
use nest_rs::http::input;
#[input]
struct CreatePost { title: String }
#[input]
struct Post { id: Uuid, title: String }
#[post("/posts")]
async fn create(&self, Json(body): Json<CreatePost>) -> Json<Post> {
Json(self.svc.create(body.title))
}

For validated bodies, wrap it in Valid<Json<T>> (covered next).

Valid<E> and Piped<P, E> — validate or transform at the edge

Section titled “Valid<E> and Piped<P, E> — validate or transform at the edge”

Valid<E> runs validator on whatever the inner extractor produced. The E is usually Json<T>, but Path<T> and Query<T> work the same way — every poem extractor that exposes IntoInner is composable:

crates/features/src/users/http/controller.rs
use nest_rs::http::{Valid, input};
use nest_rs::http::poem::web::Json;
#[input]
struct CreateUser {
#[validate(length(min = 1))]
name: String,
#[validate(email)]
email: String,
}

#[input] carries Serialize, Deserialize, Validate and JsonSchema, so a DTO behind it needs no second derive — not for validation, not for the OpenAPI document, and not to come back out as Json<T>. A DTO derived by hand carries all four itself; adding one of them next to #[input] is a conflicting impl (E0119).

crates/features/src/users/http/controller.rs
#[post("/users")]
async fn create(&self, Valid(body): Valid<Json<CreateUser>>) -> Json<User> {
Json(self.svc.create(body))
}

A validation failure returns 400 with a structured field-level error list — no manual checks in the handler.

Valid<E> is the ergonomic shortcut for the more general Piped<P, E>: apply any transport-agnostic pipe to whatever the inner extractor produced. Valid<Json<T>> is exactly Piped<ValidationPipe<T>, Json<T>>:

crates/features/src/posts/http/controller.rs
use nest_rs::http::Piped;
use nest_rs::pipes::ParseUuid;
use nest_rs::http::poem::web::Path;
use uuid::Uuid;
#[get("/posts/:id")]
async fn show(&self, id: Piped<ParseUuid, Path<String>>) -> Json<Post> {
let id: Uuid = id.into_inner();
Json(self.svc.find(id))
}

Reach for Piped when a reusable pipe exists for the job; Valid when the job is validator::Validate.

The header-map twin of Query<T>: one struct field per header, #[serde(rename = "…")] spelling the wire name, and an Option<_> field marking the header optional. Lookup is case-insensitive, as HTTP header names are, and values are parsed into the field’s type — a u32 field is an integer, not a string you parse in the handler.

crates/features/src/notify/http/controller.rs
use nest_rs::http::{Header, SseStream};
use serde::Deserialize;
use schemars::JsonSchema;
#[derive(Deserialize, JsonSchema)]
struct StreamResume {
#[serde(rename = "Last-Event-ID")]
last_event_id: Option<u32>,
}
#[sse("/events")]
async fn events(&self, resume: Header<StreamResume>) -> SseStream {
self.svc.stream_from(resume.into_inner().last_event_id)
}

A missing required header, or a value that does not parse, is rejected with the same RFC 9457 application/problem+json 400 a validation failure carries. The rejection names the header and never quotes its value — a header is where credentials travel, and a 400 body is logged, cached and proxied.

Each property becomes an in: header parameter in the OpenAPI document, required following the schema. It composes like every other extractor: Valid<Header<T>> validates the DTO, Piped<P, Header<T>> pipes it.

Some handlers — webhook signatures (Stripe, GitHub), proxied protobuf bodies — need the exact byte string before any parsing. RawBody reads the whole body into Bytes, capped at RawBody::DEFAULT_LIMIT (2 MiB). Past the cap the extractor returns 413 Payload Too Large — never silently truncates, never buffers unbounded memory.

crates/features/src/notify/http/controller.rs
use nest_rs::http::{ClientIp, RawBody};
use nest_rs::http::poem::Result;
#[post("/webhooks/stripe")]
async fn stripe(&self, body: RawBody, ip: ClientIp) -> Result<&'static str> {
verify_stripe_signature(&body, /* ... */)?;
self.svc.handle_event(&body).await?;
Ok("ok")
}

For a tighter cap on a specific route, take the body manually:

crates/features/src/notify/http/controller.rs
use nest_rs::http::RawBody;
use nest_rs::http::poem::{Result, RequestBody};
#[post("/webhooks/small")]
async fn small(&self, mut body: RequestBody) -> Result<&'static str> {
let raw = RawBody::extract_with_limit(&mut body, 16 * 1024).await?;
self.svc.handle_small(&raw).await?;
Ok("ok")
}

Anything that can deserialize through Json<T> should use that instead — RawBody is for handlers that genuinely care about the bytes.

ClientIp — best-effort, observation-grade

Section titled “ClientIp — best-effort, observation-grade”
crates/features/src/users/http/controller.rs
use nest_rs::http::ClientIp;
#[get("/whoami")]
async fn whoami(&self, ip: ClientIp) -> String {
format!("you look like {} (forwarded={})", ip.ip, ip.forwarded)
}

ClientIp resolves against the transport peer, and only believes a forwarding header when that peer is a proxy you listed:

  1. No peer address at all (a unix socket, or a proxy that hides it) ⇒ 0.0.0.0, forwarded = false. The extractor never fails.
  2. The peer is not in NESTRS_HTTP__TRUSTED_PROXIES ⇒ that peer is the client, forwarded = false, and the headers are not read.
  3. The peer is a trusted proxy ⇒ the client is the rightmost hop that is not itself a trusted proxy, read from Forwarded (RFC 7239) first, then X-Forwarded-For, then X-Real-IP; forwarded = true. Hops parse as a bare IP, IP:port, or a bracketed IPv6 ([ip], [ip]:port); a Forwarded node of unknown or an obfuscated _id names no address and is skipped.

With no trusted proxy configured — the default — step 2 always wins, so forwarded stays false and a header a caller sets changes nothing.

Terminal window
# The deployment's ingress, named once. Read by ClientIp *and* the throttler.
NESTRS_HTTP__TRUSTED_PROXIES=10.0.0.1,10.0.0.2

Rightmost, not leftmost. A proxy appends the address it received the request from to the right of the chain, so the genuine client is the last hop your infrastructure wrote; a caller can only prepend, and a prepended entry lands to the left of it. Keying on the leftmost hop is the spoofable rule.

Scoped<T> — resolve a request-scoped provider

Section titled “Scoped<T> — resolve a request-scoped provider”

A #[injectable(scope = request)] provider is built once per request — useful for anything that should not outlive the response (a SQL transaction handle, a per-request audit batch, a user-specific cache). Scoped<T> is how a handler reads it back:

crates/features/src/posts/http/controller.rs
use nest_rs::core::injectable;
use nest_rs::http::Scoped;
#[injectable(scope = request)]
pub struct PerRequestAudit { /* ... */ }
#[get("/posts")]
async fn list(&self, audit: Scoped<PerRequestAudit>) -> Json<Vec<Post>> {
audit.note("list_posts");
Json(self.svc.list())
}

Two contracts hold:

  • Scoped<T> falls back to a singleton if no scoped factory exists for T — convenient, but a singleton should still be reached via plain #[inject] on the controller. Reserve Scoped<T> for genuinely request-scoped state.
  • The framework installs the request scope as the outermost HTTP wrap (before guards and interceptors). A missing scope is a transport wiring bug, surfaced as 500 with a diagnostic naming the missing provider.

See Providers for the scope rules — a request-scoped provider may inject singletons, never the reverse, never another request-scoped one.

A guard runs before the handler, can short-circuit with a response, and can attach a typed value to the request. Ctx<T> is how the handler reads that value back:

crates/features/src/posts/http/controller.rs
use nest_rs::http::Ctx;
use crate::Claims;
#[post("/posts")]
async fn create(
&self,
auth: Ctx<Claims>,
body: Valid<Json<CreatePost>>,
) -> Json<Post> {
Json(self.svc.create_in_org(body.into_inner(), auth.org_id))
}

Ctx<T> rejects with 500 if T is absent — a missing context means the guard that should have set it never ran on this route (a wiring bug, not a client error). The value is cloned out of the request’s extensions; store an Arc<_> if the value is large.

The canonical use is the authenticated principal: AuthnGuard runs the strategy, then inserts a Claims (or whatever the resource server’s principal is) into the request. Every authenticated handler reads it as auth: Ctx<Claims>.

See Guards for how to attach a value, and for the design rule (guards gate and attach; handlers read).

Reflector + #[meta(EXPR)] — typed route metadata

Section titled “Reflector + #[meta(EXPR)] — typed route metadata”

A route can carry typed metadata a guard reads at decision time — the cleanest case is a rate-limit guard reading the route’s quota. The decorator attaches; the reader picks it up by type.

Attach metadata on the route:

crates/features/src/authn/http/controller.rs
use nest_rs::throttler::{Throttle, ThrottlerGuard};
#[post("/login")]
#[use_guards(ThrottlerGuard)]
#[meta(Throttle::per_minute(10))]
async fn login(&self, body: Valid<Json<LoginDto>>) -> Result<Json<AccessTokenDto>> {
Ok(Json(self.issuer.grant_password(&body.email, &body.password).await?))
}

Everything below is for the guard author, not the handler — a handler only attaches metadata; the guard reads it back at decision time:

crates/features/src/authn/guard.rs
use nest_rs::http::Reflector;
use nest_rs::http::poem::Request;
async fn check_http(&self, req: &mut Request) -> Result<(), Denial> {
let throttle = Reflector::new(req)
.get::<Throttle>()
.copied()
.unwrap_or(self.default_throttle);
// ...
}

Two rules pin the contract:

  • The value type must be Clone + Send + Sync + 'static. The macro attaches it to the request extensions; the reader resolves by TypeId. Wrap multiple values of the same underlying shape in distinct newtypes when they need to coexist.
  • Scope does not change what a guard reads. The pool executes post-routing at the RouteShaper, inside the metadata wrap, so a guard bound with use_guards_global reads the route’s #[meta] exactly as a #[use_guards] one does — see the request lifecycle.

Reflector implements the framework-wide HandlerMetadata trait. The same guard can read Reflector on HTTP, the per-message metadata reader on WS, and the per-call reader on MCP — one trait, one shape, three transports.

#[public] is shorthand for #[meta(Public)] — it attaches the framework’s only universal route marker. Guards read it through HandlerMetadata::is_public() and decide whether to honor it. The framework itself does not act on the marker — AuthnGuard, for example, still runs the strategy on a #[public] route, but lets an anonymous request through instead of returning 401.

crates/features/src/authn/http/controller.rs
#[post("/login")]
#[public]
#[use_guards(ThrottlerGuard)]
async fn login(&self, body: Valid<Json<LoginDto>>) -> Result<Json<AccessTokenDto>> {
// ...
}
  • Guards — write a guard that attaches Ctx<T>.
  • Providers — request-scoped providers and the rules Scoped<T> reads.
  • Pipes — write a pipe and apply it through Piped<P, E>.
  • Responses — turn the value back into a response.