OpenAPI
An OpenAPI 3.1 document generated from your routes at boot, a docs UI, exports for typed clients, and API resources.
On this page
Featureapi Every Navree route already knows its method, path, access rules and, if you tell it, its request and response types. navree-api turns that into an OpenAPI 3.1 document when the app boots, serves it with an interactive docs page, and writes it to a file so front ends can generate typed clients from it. There is no separate spec file to keep in sync: the document comes from the code.
Setup
Add ApiModule to your app:
use navree::prelude::*;
pub fn app(sources: ConfigSources) -> AppBuilder {
Navree::app()
.module(ConfigModule::for_root(sources))
.module(HttpModule::from_config())
.module(ApiModule::from_config()) // section `api`
}You now have two public routes under the API prefix (http.api_prefix, /api by default):
| Route | What it serves |
|---|---|
GET /api/openapi.json | The document, pretty-printed JSON. |
GET /api/docs | The Scalar docs UI, reading that document. |
Neither appears in the document itself. The api section is optional:
[api]
title = "Navree CRM API"
version = "1.0.0"
servers = ["https://crm.example.com"]
docs_ui = true| Key | Default | What it does |
|---|---|---|
api.title | Navree API | info.title, and the docs page's title. |
api.version | 1.0.0 | info.version, your API's version (not Navree's). |
api.servers | [] | Server URLs listed in the document. Without any, clients use the URL they loaded the document from. From the environment, comma-separated: APP_API__SERVERS=https://a.example,https://b.example. |
api.docs_ui | true | Serve the docs UI at {api_prefix}/docs. |
Or configure it in code:
use navree::api::ApiOptions;
.module(ApiModule::for_root(ApiOptions {
title: "CRM API".to_owned(),
version: "2026.10".to_owned(),
servers: vec!["https://crm.example.com".to_owned()],
..ApiOptions::default()
}))ApiModule imports HttpModule, so the document covers every route of the app, framework routes included (authentication, notifications, health checks and so on).
Describing routes
Without any annotation, a route is documented with its path, path parameters, security and access-related error responses. To add the rest, put #[api(...)] on the route. This is the CRM's deals controller:
#[controller("/deals", version = "1")]
impl DealsController {
#[get("/")]
#[can(Read, Deal)]
#[api(
summary = "List deals",
tag = "Deals",
response = DealPage,
responses(400, 401, 403)
)]
async fn index(
&self,
uow: Uow,
scope: Accessible<Deal, Read>,
Valid(Query(filter)): Valid<Query<DealFilter>>,
page: PageQuery,
) -> Result<Page<DealResource>> {
self.deals.list(&uow, scope, filter, page).await
}
#[post("/")]
#[can(Create, Deal)]
#[api(
summary = "Create a deal",
tag = "Deals",
request = CreateDeal,
response = DealResource,
status = 201,
responses(400, 401, 403)
)]
async fn store(
&self,
uow: Uow,
CurrentUser(user): CurrentUser,
Abilities(ability): Abilities,
Valid(Json(input)): Valid<Json<CreateDeal>>,
) -> Result<Created<DealResource>> {
let deal = self.deals.create(&uow, &ability, user.id, input).await?;
Ok(Created(format!("/api/v1/deals/{}", deal.id), deal))
}
}| Key | What it documents |
|---|---|
summary = "..." | The one-line summary. |
description = "..." | A longer description (Markdown). |
tag = "...", tags("A", "B") | The groups the docs UI shows the route under. |
request = T | The JSON request body (required), from T's schema. |
response = T | The success response's JSON body. |
status = 201 | The success status (default 200). |
responses(400, 404) | Error statuses the route can answer, documented with the error schema. |
Give a route a name with #[name("deals.index")] to set its operationId; routes without a name have none. Routes written by hand describe themselves with RouteDef::api(ApiMeta { .. }), where request and response are Some(schema_fn::<T>) (both from navree::http).
What is generated for you
- Paths are the mounted paths, prefix and version included, in OpenAPI form (
{*rest}becomes{rest}). Every{param}is a required path parameter of typestring, whatever its Rust type. - Security: every route that isn't public lists the
bearer(JWT access token) andcookie(session) security schemes and documents401. Routes checked by the route authoriser (#[roles],#[permissions],#[can]) also document403. - Errors: every documented error status uses the
HttpErrorschema, the NestJS error format (statusCode,message,error, anderrorsfor validation failures). - Tags are listed in the order routes first use them.
The cookie scheme names the session cookie from session.cookie (default navree_session). It is listed even in apps that only use bearer tokens, such as the CRM.
Schemas
Request and response types describe themselves with utoipa's ToSchema derive. Add utoipa to your app's dependencies (the generated app doesn't include it):
utoipa = { version = "6", features = ["chrono", "uuid"] }Then derive ToSchema next to serde. Doc comments become descriptions, and #[schema(as = Name)] sets the component name the document uses:
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
/// A note on a deal.
#[derive(Clone, Debug, Serialize, ToSchema)]
#[schema(as = Note)]
pub struct NoteResource {
/// Always `"Note"` (the CASL subject type).
#[serde(rename = "__type")]
#[schema(example = "Note")]
pub resource_type: &'static str,
pub id: Uuid,
/// The text.
pub body: String,
pub created_at: DateTime<Utc>,
}
/// What `POST /api/v1/notes` takes.
#[derive(Debug, Deserialize, ToSchema)]
pub struct CreateNote {
/// The text, 1 to 2000 characters.
pub body: String,
}The routes then name these types in #[api(..)]:
#[injectable]
pub struct NotesController;
#[controller("/notes", version = "1")]
impl NotesController {
#[post("/")]
#[authenticated]
#[name("notes.store")]
#[api(
summary = "Create a note",
description = "The note belongs to the signed-in user.",
tag = "Notes",
request = CreateNote,
response = NoteResource,
status = 201,
responses(400)
)]
async fn store(&self, Json(input): Json<CreateNote>) -> Result<Created<NoteResource>> {
let note = NoteResource {
resource_type: "Note",
id: Uuid::now_v7(),
body: input.body,
created_at: Utc::now(),
};
Ok(Created(format!("/api/v1/notes/{}", note.id), note))
}
}
#[module(controllers = [NotesController])]
pub struct NotesModule;The request and response schemas of every route, and every schema they refer to, become components.schemas. Two kinds of mistakes stop the boot, so a broken document never ships:
- Two different schemas with the same name (
ApiError::SchemaConflict): the error names both routes; rename one type with#[schema(as = OtherName)]. - A reference to a schema nothing registered (
ApiError::MissingSchemas): utoipa doesn't collect the schemas of generic type arguments, so a response such as your own genericPage<Note>refers to aNotecomponent that nothing provides. Register it in its module withm.api_schema::<T>().
Two different body schemas with the same name are not an error: the first becomes the component and the others are inlined where they are used.
m.api_schema::<T>() (from the ModuleBuilderApiExt trait, in the prelude) adds any type to the components, with the schemas it refers to. The CRM uses it for its list filters, which no route body names:
impl CompaniesModule {
/// Schemas the routes' documents refer to only through query strings.
fn wire(&self, m: &mut ModuleBuilder) {
m.api_schema::<CompanySort>().api_schema::<CompanyFilters>();
}
}Paginated responses
navree-db's Page<T> doesn't derive ToSchema, so #[api(response = ..)] can't name it. Describe each list with its own named type, as the CRM does, while the handler keeps returning Page<T> (pagination):
/// A page of deals (the OpenAPI shape of `GET /api/v1/deals`).
#[derive(Clone, Debug, Serialize, ToSchema)]
pub struct DealPage {
/// The deals.
pub data: Vec<DealResource>,
/// Where the page is.
#[schema(inline)]
pub meta: PageMetaSchema,
}The route then says #[api(response = DealPage)], as in the controller above.
Limitations
The document is honest about what it knows, and there are things it can't express yet:
- Query parameters aren't documented.
ApiMetahas no field for them, so list filters, sorting and paging parameters are missing. Describe them in the route'sdescriptionand, as the CRM does, register the filter type withm.api_schema::<T>()so its fields appear among the schemas. Generated clients then need these parameters added by hand (see below). - Bodies are JSON only. A non-JSON response, such as the CRM's CSV export at
GET /api/v1/contacts/export, has no response schema, and multipart request bodies (uploads) can't be described. - Path parameters are always strings, even when the handler parses a UUID or a number.
- Some framework routes have no schemas:
/api/me/abilitiesand/api/access/*(navree-access),/broadcasting/authand/app/{key}(real time),/health/*and/metrics. - User resource extensions aren't reflected.
GET /api/auth/meand the login response'suserare documented asUserResource, without the fields aUserResourceExtensionadds (the CRM'sroles). - No shared page metadata component. Every page schema inlines its
meta.
These are tracked in the framework's open issues and on the roadmap.
Exporting the document
The openapi:export command writes the document to a file, openapi.json by default, creating missing directories:
navree openapi:export web/openapi.jsonOpenAPI document written to web/openapi.jsonIt writes exactly what GET /api/openapi.json serves. Commit the file if your front end builds from it, and re-export after changing routes or types.
Typed clients from the document
The CRM's front end, apps/crm-web, generates its TypeScript types from the exported document with openapi-typescript and calls the API with openapi-fetch:
cd apps/crm-api
cargo run -p crm-api -- openapi:export ../crm-web/openapi.json
cd ../crm-web
pnpm gen:api # openapi-typescript ./openapi.json -o src/lib/api/schema.d.tsThe app never imports schema.d.ts directly. src/lib/api/types.ts gives the schemas friendly names, so a renamed schema only needs that file to change, and adds the list endpoints' query parameters that the document can't describe yet:
import type { components, paths as GeneratedPaths } from "./schema";
type Schemas = components["schemas"];
/** NestJS error body: `{statusCode, message, error, errors?}`. */
export type ApiErrorBody = Schemas["HttpError"];
export type User = Schemas["User"];
export type TokenPair = Schemas["TokenPair"];API resources
Front ends that check permissions with CASL need to know what type each JSON object is. Navree's convention is a __type field holding the subject type ("Deal"), which CASL reads with detectSubjectType: (o) => o.__type. The CRM puts it in its resources as a renamed field, so the schema documents it (see NoteResource above).
For resources that don't carry the field, navree::api has helpers:
use navree::api::{ApiResource, mask_fields, resource_json};
use serde::Serialize;
#[derive(Serialize)]
pub struct DealSummary {
pub id: u64,
pub title: String,
pub amount_cents: i64,
}
impl ApiResource for DealSummary {
const TYPE: &'static str = "Deal";
}
/// `{"id", "title", "amount_cents", "__type": "Deal"}`, keeping only the
/// fields the user may read (plus `id` and `__type`).
pub fn summary_json(deal: &DealSummary, readable: Option<&[String]>) -> serde_json::Value {
mask_fields(resource_json(deal), readable)
}resource_json(&value)serializes a resource and adds"__type": T::TYPE.mask_fields(value, Some(&fields))keeps only the listed top-level fields, plusidand__type, in an object or in each object of an array;Nonekeeps everything. Pass the fields an ability permits for field-level access control; see Authorization.
See Responses and API resources for how handlers return them.
Testing
The document is built at boot, so a schema conflict or a missing schema already fails any test that boots the app. To check what a route documents, fetch the document like a client would:
use navree::prelude::*;
use navree::testing::TestApp;
use serde_json::json;
#[tokio::test]
async fn notes_are_documented() {
let app = TestApp::builder()
.module(ApiModule::from_config())
.module(NotesModule)
.build()
.await;
let document = app
.client()
.get("/api/openapi.json")
.send()
.await
.assert_status(200)
.json_value();
let store = &document["paths"]["/api/v1/notes"]["post"];
assert_eq!(store["operationId"], "notes.store");
assert_eq!(
store["responses"]["201"]["content"]["application/json"]["schema"]["$ref"],
json!("#/components/schemas/Note")
);
app.shutdown().await.unwrap();
}Each CRM feature crate has a test like this (for example crates/crm-deals/tests/deals/openapi.rs), checking that its routes refer to the schema names apps/crm-web imports.