navree
Documentation

API-first

Treat the API as the product: versioned routes, validated DTOs, one JSON error shape, pagination and an OpenAPI 3.1 document generated from your code.

On this page

Introduction

An API-first backend treats its HTTP API as the product. A web app, mobile apps, partners, other services and AI agents all build against the same contract, so the contract has to be explicit, stable and documented before anyone writes a client.

Navree is built for this. The code is the single source of the contract:

  • Routes declare their path, version and access rules in one place.
  • Inputs are typed structs with validation rules, rejected with field-level errors before your handler runs.
  • Outputs are typed resources, separate from your database rows.
  • Errors share one JSON shape, from a missing route to a panic.
  • The document: an OpenAPI 3.1 document is generated from all of the above at boot, served with a docs UI and exported for typed clients.

There is no spec file to keep in sync by hand. Change a DTO and the document, the validation and the generated client types change with it.

API-firstValidated DTOs (garde rules) feed versioned controllers under /api/v1. Navree generates an OpenAPI 3.1 document from both when the app boots and serves it at /api/openapi.json. openapi:export writes the document to a file, and typed clients are generated from it: the web app in TypeScript, mobile apps in Swift or Kotlin, partners and agents in any language. Validation failures (400) and refusals from controllers (401, 403, 404) share one JSON error shape: statusCode, message and error.Validated DTOsgarde rulesControllersversioned: /api/v1OpenAPI 3.1built at bootOne error shapestatusCode, message, errorWeb appTypeScriptMobile appsSwift · KotlinPartners, agentsany languageinput#[api]openapi:export
API-first
  • generated from

The CRM API in apps/crm-api is built this way, and apps/crm-web is a client generated from its document. The examples on this page come from the deals API.

The workflow

  1. Model the resources and inputs

    Write the response types (DealResource) and the request bodies (CreateDeal, UpdateDeal) first. They are the contract; everything else serves them.

  2. Write the controller

    Declare each route with its version, its access rule and an #[api(...)] description that names the request and response types.

  3. Review the document

    Open /api/docs and read the API the way a client will. Review changes to the document like changes to code.

  4. Export it and generate clients

    openapi:export writes the document to a file; client generators turn it into typed code.

  5. Lock it with tests

    Contract tests assert the routes, the schema names and the error responses, so a change that breaks clients fails CI.

Resources: what clients receive

A resource is the shape a client sees. Keep it separate from the entity: the table can gain a column, rename one or split in two without the API noticing. The CRM's deal resource adds the computed probability and embeds the company, contact and owner, so a client draws a deal card from one response:

crates/crm-deals/src/resource.rs

A few conventions keep resources predictable across an API:

  • __type names the subject type ("Deal"), so a front end can check permissions on plain JSON objects with the rules from GET /api/me/abilities. navree::api has ApiResource and resource_json for resources that don't declare the field themselves.
  • #[schema(as = Deal)] sets the public schema name. The Rust type can be DealResource while clients see Deal.
  • Money is an integer in minor units with a currency code, never a float.
  • Ids are UUID v7 strings and timestamps are RFC 3339 in UTC.
  • Embeds are loaded in batches (DealResource::load_many runs one query per kind of embed), so a page of 100 deals costs a handful of queries, not 300.

See Responses and API resources.

Inputs: validated DTOs

Every request body is a struct with its rules beside its fields. The handler takes it through Valid<Json<T>>, so invalid input never reaches your code:

crates/crm-deals/src/dto.rs

Required fields are Options with garde's required rule rather than plain fields. A missing plain field stops deserialization at the first one; with required, a client that sends {} learns about every missing field in one round trip:

400 Bad Request

#[schema(required = true)] keeps the document honest: the field is required on the wire even though it is an Option in Rust. Rules that need the database (a unique email, a company that exists) run with ValidAsync, or in the service, and report through the same errors map. See Validation.

One error shape

Every error a Navree app sends has the same JSON body: a 404 from a missing route, a 400 from validation, a 403 from an access rule, a 429 from the rate limiter, a 500 from a panic.

404 Not Found
MemberAlwaysHolds
statusCodeYesThe HTTP status.
messageYesThe detail, or, for validation errors, every field message as a list.
errorYesThe status's reason phrase.
errorsValidation onlyField paths mapped to their messages.

The request id travels in the X-Request-Id header, never in the body, and internal details go to the log, never to the client. Input errors are always 400, never 422.

One shape means one piece of client code handles every failure. The CRM's front end maps errors straight onto form fields:

apps/crm-web/src/lib/api/form-errors.ts

In handlers, return AppError (AppError::bad_request, AppError::forbidden().with_detail(..)) or let ? convert database errors: a missing record is a 404, a unique or foreign-key violation a 409. See Errors.

Versioning

#[controller("/deals", version = "1")] mounts the controller's routes under /api/v1/deals. The version is part of the path, visible in logs, metrics and the document.

Change an existing version only in ways old clients don't notice:

  • add a route;
  • add a field to a response;
  • add an optional field to a request.

Anything else is breaking: removing or renaming a field, changing a type or a default, making an optional field required, changing what a status code means. Ship it as a new version, next to the old one. To serve two versions, write two controllers over the same service. Here a new crate adds a version 2 of the deal route, built on the CRM's deals module:

crates/crm-deals-v2/src/lib.rs

The business rules stay in DealService, which both versions call; version 2 only changes the shape. Access rules apply to both, because they are declared on the routes. Add .module(DealsV2Module) to the app and the document lists /api/v2/deals/{id} beside the version 1 routes.

Retire a version when nobody calls it. Every request is counted in http_requests_total, labeled with the route template, so /api/v1/deals/{id} traffic is visible on your metrics dashboard (Health checks and metrics). When it reaches zero, delete the old controller.

Pagination

Every list is paginated, so a client never downloads a whole table. Take PageQuery and return Page<T>:

crates/crm-deals/src/controller.rs

GET /api/v1/deals?stage=proposal&page=2&per_page=20 answers with the page and where it is:

JSON
  • page starts at 1; per_page defaults to 15 and is capped at 100. Anything that isn't a positive integer is a 400.
  • Accessible<Deal, Read> turns the caller's access rules into SQL, so a page only contains rows the caller may read and total counts only those.
  • Filters are a validated query struct (DealFilter) next to PageQuery.
  • For feeds and infinite scroll, cursor pagination (CursorQuery, cursor_paginate_desc) returns next_cursor instead of page numbers and never shifts when rows are added.

Page<T> is generic, so give each list a named schema for the document. The CRM's DealPage has the same data and meta members, and the route says response = DealPage. See Pagination.

The OpenAPI document

Add ApiModule and the app serves its contract:

src/app.rs
RouteServes
GET /api/openapi.jsonThe OpenAPI 3.1 document.
GET /api/docsAn interactive docs UI reading it.

The document is built at boot from every route: its path with prefix and version, its path parameters, its security (bearer token or session cookie), the 401 and 403 responses its access rule implies, and what #[api(...)] adds: summary, description, tag, request, response, status and the error statuses in responses(...). Error statuses reference one HttpError schema, the error shape above. Name the API in the api section:

config/app.toml

See OpenAPI for every key and schema option.

Typed clients

Export the document and generate a client from it. The CRM's front end uses openapi-typescript and openapi-fetch:

Shell

The front end then gets the path, the parameters and the body types checked by the TypeScript compiler. A renamed field in DealResource becomes a type error in the component that reads it, before anyone opens a browser. src/lib/api/types.ts gives the generated schemas the names the app imports, so a renamed schema only changes that one file:

apps/crm-web/src/lib/api/types.ts

The document is standard OpenAPI 3.1, so any generator works the same way: Swift and Kotlin for mobile apps, Python or Go for partners. Commit the exported file next to the client that builds from it.

Contract tests

Three kinds of tests keep the contract from drifting.

The document names what clients import. Each CRM feature crate fetches the document and asserts its routes, tags and schema names, because apps/crm-web imports those names:

crates/crm-deals/tests/deals/openapi.rs

The committed document matches the code. Export in CI and fail when the file changes, so every contract change shows up in review as a diff of openapi.json:

Shell

Behavior matches the document. HTTP tests assert the statuses and the error shape the document promises. assert_validation_error checks for a 400 with messages for a field, and assert_error checks the status, the content type, the members of the error body and the X-Request-Id header:

crates/crm-deals/tests/deals/crud.rs

See HTTP tests.