Skip to content

OpenAPI and Typed Clients

OpenAPI & Typed Clients

An application already knows its verbs, its paths, its middleware and the rules it validates with. What it cannot know — without reflection, which Maat does not use anywhere — is what a handler returns, because every handler returns Object?.

So a route says it, once, next to its name and its middleware:

Route.post('/books', books.store)
.name('books.store')
.middleware(['auth', 'ability:books:write'])
.summary('Add a book')
.body(StoreBookRequest())
.responds(bookSchema, status: 201);

From that, two commands render two views of the same description:

Terminal window
sesh api:openapi # openapi.json — an OpenAPI 3.1 document
sesh api:client # lib/api/api_client.dart — a typed Dart client

Both read one in-memory document, so the spec and the client cannot disagree about the same endpoint.

Declaring a Contract

Every method returns the route, so they chain in any order.

Method What it declares
.summary('...') The one-line title, and the client method’s doc comment
.describe('...') The longer prose under it
.tags(['Books']) Grouping for documentation tools
.operation('listBooks') An explicit operation id — the client’s method name
.params({'id': 'integer'}) Types a {placeholder} in the URI
.query({'q': 'string'}) Query string rules
.body(rules) The JSON request body: a rule map or a FormRequest
.responds(schema, status: 200) One response. Call it once per status
.deprecate() Marks the operation deprecated in both outputs

A route with no contract is not exported. That is what keeps HTML routes out of an API document without guessing from a URL prefix, and it makes the export opt-in and reviewable.

When a contract does not declare a successful response, Maat adds an unknown 200 response. The generated method returns Object? until .responds() provides a concrete schema.

What You Do Not Have to Declare

The generator reads these off the route itself:

  • Verbs and paths — HEAD is dropped, since every OpenAPI consumer derives it from GET, and so is OPTIONS.
  • Path parameters — from the URI. Untyped, they are strings, which is what a URL actually carries. .params() narrows them.
  • Authentication — a string middleware of auth or auth:guard requires the bearerAuth scheme; ability:books:write and abilities:a,b become x-maat-abilities metadata on the operation. OpenAPI reserves security requirement scopes for OAuth2 and OpenID Connect, so HTTP bearer entries keep the required empty array. Only string aliases are read: a middleware instance carries no declarative name to document.
  • Error responses — a secured operation gets a 401 and an operation with body or query rules gets a 422, both describing the bodies ExceptionHandler really renders. A .responds() for the same status wins.
  • Operation ids — from the route name (books.store → booksStore), or from the verb and path when a route has no name. .operation() overrides it, which is worth doing when a Route.prefix('api').name('api.') group would otherwise give you apiBooksStore.

Rules Become Schemas

.body() and .query() take the rule maps the handler already validates with, so the document describes what the server enforces rather than a second description of it that can rot:

Rule Schema
integer / numeric / boolean / array integer / number / boolean / array
string (or any rule implying text) string
email / url / uuid / date string with format
min / max / between / size minimum/maximum on a number, minLength/maxLength on a string, minItems/maxItems on an array
in:draft,published enum
regex: pattern
digits: / digits_between: length bounds and ^\d+$
required listed in the object’s required
sometimes the key may be absent
nullable the value may be null

Which bound a min becomes follows the validator exactly: sizeOf measures a non-numeric value by its length, so max:120 on a string is maxLength. Rules the mapping does not know are ignored rather than refused, so a unique:books on the server never breaks the export.

Dotted and wildcard keys nest the way the validator reads them:

.body({
'title': 'required|string|max:120',
'author.name': 'required|string', // an object
'tags.*': 'required|string|max:20', // an array of strings
})

Pass a FormRequest instead of a map and the same object is what the handler enforces and what the document describes:

Route.post('/books', books.store).body(StoreBookRequest());

Response Schemas

Nothing can read a JsonResource’s toJson, so responses are described with ApiSchema. Named objects are the unit of reuse: each becomes a components/schemas entry and one generated DTO class.

final bookSchema = ApiSchema.object({
'id': ApiSchema.integer(),
'title': ApiSchema.string(),
'published_at': ApiSchema.string(format: 'date-time').nullable,
'tags': ApiSchema.array(ApiSchema.string()),
}, name: 'Book');
final bookPageSchema = ApiSchema.object({
'data': ApiSchema.array(bookSchema),
'total': ApiSchema.integer(),
}, name: 'BookPage');

ApiSchema.none() is an empty body — a 204, or a 304.

.nullable and .optional are different things, and OpenAPI 3.1 puts them in different places: .nullable makes the value ["string", "null"], .optional leaves the key out of the parent’s required. Dart collapses both to T?.

Because .nullable composes a new schema, a schema that uses it is final rather than const.

The Generated Client

The client is built on horus_client, which owns the transport. The generated file only has to get the types right:

final class BookDto {
const BookDto({required this.id, required this.title, this.publishedAt});
factory BookDto.fromJson(Map<String, Object?> json) => /* ... */;
final int id;
final String title;
final DateTime? publishedAt;
Map<String, Object?> toJson() => /* ... */;
}
final class CatalogApi {
const CatalogApi(this._client);
/// Add a book
///
/// `POST /api/books` — requires a bearer token with books:write.
Future<BookDto> booksStore({required BooksStoreRequestDto body}) =>
_client.send(_booksStore, body: body.toJson());
}

Using it:

final transport = HorusClient(
Uri.parse('https://catalog.test'),
headers: {'authorization': 'Bearer $token'},
);
final api = CatalogApi(transport);
final page = await api.booksIndex(q: 'ptahhotep', limit: 5);

Details worth knowing:

  • Every parameter is named — path, query and body alike — so adding a query parameter later can never silently reorder an existing call.
  • T? for anything absent or nullable, and a toJson that leaves an optional key out entirely when it is null. That is the difference sometimes makes on the server: sending an explicit null instead would fail a rule the caller never broke.
  • format: date-time becomes DateTime, parsed on the way in and written back as ISO-8601.
  • number becomes num, not double — JSON gives an int back for a whole number, and a double cast on one throws.
  • No class for error bodies. A non-2xx response throws HorusClientException carrying the status and the decoded body, so a DTO for it would be unreachable code.
  • An operation with no response body returns Future<void>.

Configuration

info and servers come from config('api.*'):

config/api.dart
Map<String, dynamic> get api => {
'title': 'Catalog API',
'version': '1.0.0',
'description': 'A small book catalogue.',
'servers': [
'https://catalog.test',
{'url': env('APP_URL', 'http://localhost:8000')!, 'description': 'Local'},
],
};

A server may be a bare URL or a {url, description} map. Without an api config the title falls back to config('app.name') and the version to 0.1.0.

Commands

Terminal window
sesh api:openapi # writes openapi.json
sesh api:openapi --output=public/openapi.json
sesh api:openapi --output=- # writes to stdout
sesh api:client # writes lib/api/api_client.dart
sesh api:client --output=lib/api/catalog_client.dart --class=CatalogApi

Both fail with an explanation when no route declares a contract: an empty document is nearly always a forgotten .responds() rather than an API with no routes.

The client is written through the same path as make:controller, so what lands in your repository is already dart format clean.

Keeping It Honest in CI

The output is deterministic by construction — paths sorted, verbs in OpenAPI’s own order, schemas sorted by name, no timestamps or absolute paths anywhere — so a regeneration is a real check rather than a source of noise:

Terminal window
sesh api:openapi
git diff --exit-code openapi.json

--output=- does the same without a temporary file. The catalog example goes one step further: one test regenerates the document and compares it to the committed openapi.json, and another boots the application on a real port and drives every operation through the generated client, so the spec, the client and the server are checked against each other on every run.

Not Covered Yet

  • Client generation for other languages. The document is a normal OpenAPI 3.1 file, so an external generator can read it today.
  • WebSocket and broadcast channels. See WebSockets with Thoth.
  • multipart/form-data and file uploads.
  • Optional path parameters. A route declared /books/{id?} is documented as /books/{id} with a required parameter, because OpenAPI has no way to say otherwise.
  • Reading an existing openapi.json as input. Both renderers read the in-memory document, which is exactly why the spec and the client cannot disagree.