Responses
A handler picks a return type, and the framework turns it into the
response. String, &'static str, Json<T>, a (StatusCode, Body)
tuple — anything that implements poem’s
IntoResponse is a valid return. When the status, a header, or a
redirect is the only thing changing, three attribute macros sit beside
the verb attribute and shape the response without touching the handler
body.
Returning typed JSON
Section titled “Returning typed JSON”Json<T> is the everyday case — serializes T, sets
Content-Type: application/json, and feeds the OpenAPI document with
the same schemars::JsonSchema:
use poem::web::Json;use serde::Serialize;
#[derive(Serialize, schemars::JsonSchema)]struct GreetReply { greeting: String,}
#[get("/me")]async fn me(&self) -> Json<GreetReply> { Json(GreetReply { greeting: self.svc.greeting() })}Status + body tuples
Section titled “Status + body tuples”Tuples up to (StatusCode, Headers, Body) are valid responses — useful
when one route returns 202 Accepted and another stays on the default
200:
use poem::http::StatusCode;
#[post("/queue")]async fn enqueue(&self) -> (StatusCode, &'static str) { (StatusCode::ACCEPTED, "queued")}For anything richer than a static body, prefer Json<T> so the OpenAPI
document picks up the shape.
Shape a response declaratively
Section titled “Shape a response declaratively”When the handler body would otherwise return Ok(value) and the only
thing changing is the status, a header, or a redirect, three attribute
macros sit beside the verb attribute:
use nest_rs_http::{http_code, response_header, redirect};use poem::web::Json;
#[post("/posts")]#[http_code(201)]#[response_header("x-trace-id", "abc-123")]async fn create(&self, body: Valid<Json<CreatePost>>) -> Json<Post> { Json(self.svc.create(body.into_inner()))}
#[get("/old-path")]#[redirect("/new-path", 301)]async fn moved(&self) {}#[http_code(N)]
Section titled “#[http_code(N)]”Overrides the response status on the success path. N must be in
100..=999 and is validated at compile time. When the handler returns a
Result and yields Err, the shaper short-circuits: the error’s own
ResponseError status stands and #[http_code] never touches it — so a
201 handler that errors still returns the error’s 4xx/5xx, not
201. Mutually exclusive with #[redirect] — the two macros cannot both
decorate the same handler.
#[response_header("name", "value")]
Section titled “#[response_header("name", "value")]”Appends a header to the response. Repeatable; stack as many as the route needs. Two checks run at compile time:
- the header name must be lowercase ASCII (the HTTP/2 / HTTP/3 grammar);
- the value must be printable ASCII (no CR, LF, or NUL).
A typo (Cache-Control instead of cache-control, a stray newline in
the value) is caught by cargo build, never at runtime.
#[redirect("url"[, status])]
Section titled “#[redirect("url"[, status])]”Sends a Location: <url> response without calling the handler body.
Status defaults to 307 and must be in 300..=399. The method’s
extractors still resolve (they are part of the request pipeline), but the
body is never executed — so the handler must have an empty body
(async fn moved(&self) {}). A non-empty body is a compile error, because
any side-effect work there would silently vanish; move such work into a
service the user is redirected to.
How shapers compose
Section titled “How shapers compose”Multiple shapers stack on the same handler. They apply in the order they appear in the source, but each one targets a different facet of the response (status, headers, body), so the order rarely matters in practice:
#[post("/posts")]#[http_code(201)]#[response_header("x-trace-id", "abc-123")]#[response_header("link", "</posts>; rel=collection")]async fn create(&self, body: Valid<Json<CreatePost>>) -> Json<Post> { Json(self.svc.create(body.into_inner()))}The handler returns Json<Post>; #[http_code(201)] rewrites the status
from 200 to 201; the two #[response_header] attributes append their
headers. #[redirect] cannot stack with #[http_code] — they decide the
same facet (the response status) two different ways.
A handler that returns a Result keeps its error contract intact: on
Err, the shaper short-circuits before #[http_code] runs, so the
error’s ResponseError status and body survive unchanged. #[http_code]
only ever rewrites the success status.
When not to reach for a shaper
Section titled “When not to reach for a shaper”The three macros are declarative shortcuts for the common cases. When the response wiring is genuinely conditional — the status depends on whether an entity already existed, the headers depend on the resolved user — go back to a plain tuple return and let the handler decide:
use poem::http::StatusCode;use poem::IntoResponse;
#[post("/posts")]async fn upsert(&self, body: Valid<Json<CreatePost>>) -> Result<impl IntoResponse> { let (post, created) = self.svc.upsert(body.into_inner()).await?; let status = if created { StatusCode::CREATED } else { StatusCode::OK }; Ok((status, Json(post)))}The shapers are best when the value is the same on every call — that’s why they live on the attribute line, not in the handler body.
Going further
Section titled “Going further”- Errors — when the handler returns
Err(...):ResponseError,ProblemDetails, RFC 9457. - Controllers & routes — back to the route table.
- OpenAPI —
Json<T>and the response shape feed the generated spec automatically.
Built by YV17labs