navree

Responses and API resources

Return JSON, status codes and headers from handlers, shape entities into API resources, and send pages, downloads and streams.

On this page

A handler returns anything that implements Axum's IntoResponse. Navree adds a few response types of its own (Created, NoContent, Page<T>) and a pattern for API resources: structs that give your entities a stable, documented JSON shape. This page covers all of them, plus headers, file downloads and streamed bodies.

Returning JSON

Wrap any Serialize value in Json. Most handlers return Result<Json<T>>, where Result is the prelude's navree::Result, whose error type is AppError:

src/companies/controller.rs

The response is 200 OK with Content-Type: application/json. Json is Axum's, re-exported by navree::http and the prelude.

For a one-off shape, serde_json::json! is enough:

Rust

Other types work too: a String or &'static str is a 200 with text/plain; charset=utf-8, and a bare StatusCode is an empty response with that status (an error status gets the error format body).

Responses are compressed with gzip or brotli when the client accepts it (Accept-Encoding), except text/event-stream, images, gRPC and bodies under 32 bytes (tower-http's defaults). Turn compression off with http.compression = false.

Status codes

Created and no content

Created(location, body) answers 201 Created with a Location header and a JSON body. NoContent answers 204 No Content. The CRM's deals controller uses both:

crm-deals/src/controller.rs

A Location that isn't a valid header value turns the response into a 500.

Any other status

Return a tuple of a StatusCode and the body:

Rust

Error statuses come from AppError, so they share the NestJS error format; see errors. To document a route's status in the OpenAPI document, add status = 201 to its #[api(...)] attribute (OpenAPI).

Headers

Put an array of header pairs before the body:

Rust

A HeaderMap works in the same place when the headers are built at run time.

Navree adds its own headers to every response: X-Request-Id, the security headers and, for allowed origins, the CORS headers. A security header you set yourself replaces the default one. The full response of the example above looks like this:

HTTP

API resources

Returning a database model as JSON ties your API to your table: a renamed column or a new private field changes what clients see. An API resource is a separate struct that decides the shape. Laravel calls these resources; NestJS calls them response DTOs.

The resource pattern

The CRM gives every entity a *Resource struct, builds it from the model, and returns it from every route of that entity:

src/companies/resource.rs
GET /api/v1/companies/0192f0c1-...

The pieces of the pattern:

  • __type carries the subject type, so a front end using CASL can check permissions on plain JSON objects (detectSubjectType: (o) => o.__type). The name matches the subject your access rules use.
  • ToSchema with #[schema(as = Company)] puts the resource in the OpenAPI document under a clean name; reference it with #[api(response = CompanyResource)].
  • Embeds. The CRM's DealResource embeds company, contact and owner objects. Its load_many reads them with one query per kind of embed for a whole page of deals, never one per row.
  • Derived fields. DealResource adds probability, computed from the stage, which the table doesn't store.

Resource helpers

navree::api has three helpers for resources built at run time:

Rust
  • resource_json(&resource) serializes the resource and adds "__type": TYPE.
  • mask_fields(value, Some(&fields)) keeps only the listed top-level fields, plus id and __type, in an object or in every object of an array. None keeps everything. Pass the fields the user may read, for example from the ability's permitted_fields.

With readable set to ["name"], project_json returns {"__type": "Project", "id": "...", "name": "Apollo"}.

The CRM uses typed resource structs rather than these helpers, because typed structs also give it OpenAPI schemas.

Paginated responses

Page<T> is a page of results. Return it directly; it serializes as data plus meta:

Rust
GET /api/v1/companies?page=2&per_page=15

PageQuery reads ?page= and ?per_page= (defaults 1 and 15; per_page is capped at 100) and Page::map turns models into resources without touching meta. Cursor pagination returns a CursorPage<T> instead: {"data": [...], "next_cursor": "..."}. Both are covered on the pagination page.

File downloads

A download is a body with a Content-Disposition header. The CRM's contact export builds a CSV in memory:

crm-companies/src/contacts/export.rs

The CORS configuration exposes Content-Disposition, so a browser front end on another origin can read the file name.

Streaming a file

For large files, stream the body instead of loading it. A storage disk hands you a stream that Body::from_stream sends chunk by chunk:

src/invoices/controller.rs

With StorageModule in the app, a missing file is a 404 and an invalid path a 400. To let a browser download a file without your API in between, give it a signed temporary URL instead; see file storage.

Streaming responses

Body::from_stream takes any stream of Result<Bytes, E>, so you can stream generated data the same way. Things to know:

  • The request timeout (http.timeout_secs, 30 seconds by default) covers the time until your handler returns the response, not the time spent sending a streamed body.
  • Server-sent events work with Axum's Sse type (navree::axum::response::sse). Navree doesn't compress text/event-stream responses, so events reach the client as they're sent. Navree has no SSE helpers of its own.
  • For pushing events to browsers, Navree's main tool is broadcasting over WebSockets.

Testing

The test client reads statuses, headers and JSON paths:

tests/companies.rs

See HTTP tests.

Popular pages

  • Installation
  • Your first app
  • Routing and controllers
  • Validation
  • Authentication
  • Benchmarks