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:
sesh api:openapi # openapi.json — an OpenAPI 3.1 documentsesh api:client # lib/api/api_client.dart — a typed Dart clientBoth 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 —
HEADis dropped, since every OpenAPI consumer derives it fromGET, and so isOPTIONS. - Path parameters — from the URI. Untyped, they are strings, which is what
a URL actually carries.
.params()narrows them. - Authentication — a string middleware of
authorauth:guardrequires thebearerAuthscheme;ability:books:writeandabilities:a,bbecomex-maat-abilitiesmetadata 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
401and an operation with body or query rules gets a422, both describing the bodiesExceptionHandlerreally 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 aRoute.prefix('api').name('api.')group would otherwise give youapiBooksStore.
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 atoJsonthat leaves an optional key out entirely when it is null. That is the differencesometimesmakes on the server: sending an explicitnullinstead would fail a rule the caller never broke.format: date-timebecomesDateTime, parsed on the way in and written back as ISO-8601.numberbecomesnum, notdouble— JSON gives anintback for a whole number, and adoublecast on one throws.- No class for error bodies. A non-2xx response throws
HorusClientExceptioncarrying 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.*'):
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
sesh api:openapi # writes openapi.jsonsesh api:openapi --output=public/openapi.jsonsesh api:openapi --output=- # writes to stdout
sesh api:client # writes lib/api/api_client.dartsesh api:client --output=lib/api/catalog_client.dart --class=CatalogApiBoth 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:
sesh api:openapigit 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-dataand 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.jsonas input. Both renderers read the in-memory document, which is exactly why the spec and the client cannot disagree.