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("/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) {}

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.

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.

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.

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.

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.

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

Built by YV17labs