Skip to content

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.

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() })
}

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.

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("/items")]
#[http_code(201)]
#[response_header("x-trace-id", "abc-123")]
async fn create(&self, body: Valid<Json<NewItem>>) -> Json<Item> {
Json(self.svc.create(body.into_inner()))
}
#[get("/old-path")]
#[redirect("/new-path", 301)]
async fn moved(&self) {}
#[post("/sse")]
#[response_header("cache-control", "no-store")]
#[response_header("x-accel-buffering", "no")]
async fn stream(&self) -> EventStream {
self.svc.subscribe()
}

Overrides the response status. N must be in 100..=999 and is validated at compile time. Mutually exclusive with #[redirect] — the two macros cannot both decorate the same handler.

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.

Discards the handler payload and returns a Location: <url> response. Status defaults to 307 and must be in 300..=399. The handler still runs (its extractors fire, its side effects happen), but its return value is dropped — write the body as async fn moved(&self) {} for clarity.

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("/items")]
#[http_code(201)]
#[response_header("x-trace-id", "abc-123")]
#[response_header("link", "</items>; rel=collection")]
async fn create(&self, body: Valid<Json<NewItem>>) -> Json<Item> {
Json(self.svc.create(body.into_inner()))
}

The handler returns Json<Item>; #[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.

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("/items")]
async fn upsert(&self, body: Valid<Json<NewItem>>) -> Result<impl IntoResponse> {
let (item, created) = self.svc.upsert(body.into_inner()).await?;
let status = if created { StatusCode::CREATED } else { StatusCode::OK };
Ok((status, Json(item)))
}

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.

  • Errors — when the handler returns Err(...): ResponseError, ProblemDetails, RFC 9457.
  • Controllers & routes — back to the route table.
  • OpenAPIJson<T> and the response shape feed the generated spec automatically.