Client schemas¶
Your front end’s types come from the same routes as the server: a TypeScript module of Zod schemas for every body, answer and error. Change a type in OCaml, generate again, and the front end’s compiler shows you every place that must change with it.
Setting up the command line¶
Your OCaml project gets a command line of its own: a second executable,
named after the app, built beside the server from the same routes. It never
serves anything; it writes the OpenAPI document and the client schemas into
your front end’s folder – web/src/ below – and exits. Install it:
bin/
├── dune
├── routes.ml the routes, shared by both
├── main.ml the server
└── notes.ml the command line
(executables
(names main notes)
(libraries spindle spindle-cli eio_main)
(preprocess
(pps ppx_wiretype)))
notes.ml makes the app from the routes and hands it to Spindle_cli.run:
let () =
Spindle_cli.run ~name:"notes" ~title:"Notes"
~app:(Spindle.App.make Routes.routes) ()
The app is only described, never started or served. A database app’s routes need nothing to be described: they read their pool as a resource, which the server supplies and the command line never asks for.
| Command | What it does |
|---|---|
notes api openapi [-o FILE] |
Writes the OpenAPI 3.2 document, to stdout without -o |
notes api zod [-o FILE] |
Writes the Zod schemas, to stdout without -o |
notes api check --openapi FILE --zod FILE [--strict] |
Exits 1 when either file is not what the routes make now |
Every command has --help.
Generating the schemas¶
/* Generated from Notes's routes. Do not edit: change the routes, and generate it again. */
import { z } from "zod/mini";
/** One input that is not what it should be. */
export const ProblemSchema = z.object({
/** Where: path.x, query.x, body.x. */
at: z.string(),
code: z.string(),
message: z.string(),
});
export type Problem = z.infer<typeof ProblemSchema>;
/** A refusal: the code a client branches on, and a sentence for a person. */
export const RefusalSchema = z.object({
/** The code. */
error: z.string(),
/** A sentence. */
message: z.string(),
/** For invalid: each input that is not what it should be. */
problems: z.optional(z.array(ProblemSchema)),
});
export type Refusal = z.infer<typeof RefusalSchema>;
export const NoteSchema = z.object({
id: z.int(),
text: z.string(),
});
export type Note = z.infer<typeof NoteSchema>;
/** Every code a route may refuse with. */
export const CodeSchema = z.enum([
"busy",
"internal",
"invalid",
]);
export type Code = z.infer<typeof CodeSchema>;
/** Each route, by method and pattern: the body it reads, and what it answers. */
export const routes = {
"GET /notes/{note_id}": {
body: null,
answer: { status: 200, schema: NoteSchema },
},
} as const;
Each type is a schema with its TypeScript type beside it, the error shapes
(Problem, Refusal) among them; then the codes a client may branch on,
and a table of the routes. Objects strip unknown keys, so a server that gains
a field breaks no client.
Using them in the front end¶
The module imports zod/mini, which is in Zod 4 and later:
Parse each answer with its schema. A refusal has the same shape on every route, so one schema reads all of them:
import { NoteSchema, RefusalSchema } from "./wire.gen";
const res = await fetch("/notes/1");
if (res.ok) {
const note = NoteSchema.parse(await res.json()); // a Note, checked
console.log(note.text);
} else {
const refusal = RefusalSchema.parse(await res.json());
console.log(refusal.error, refusal.message); // "invalid", "..."
}
routes["GET /notes/{note_id}"].answer.schema is the same NoteSchema, for
code that works from the table rather than by name.
Checking them in CI¶
Commit the schemas and the OpenAPI document, and run api check in CI: it
exits 1 when either is not what the routes make now, so a front end is never
typed against an API that changed under it. A Makefile keeps the paths in one place:
NOTES = ./_build/default/bin/notes.exe
OPENAPI = web/src/openapi.gen.json
WIRE = web/src/wire.gen.ts
wire: ## write the API's document and the client's Zod from the routes
dune build
$(NOTES) api openapi -o $(OPENAPI)
$(NOTES) api zod -o $(WIRE)
check-wire: ## fail when either is not what the routes make
dune build
$(NOTES) api check --openapi $(OPENAPI) --zod $(WIRE) --strict
--strict also fails when anything is described loosely: see
the report.
The whole command line, routes included, in one file
(* An application's command line: its API's document and its client's Zod
schemas, written from the routes without serving them.
dune exec examples/api_cli.exe -- api openapi
dune exec examples/api_cli.exe -- api zod -o wire.gen.ts
dune exec examples/api_cli.exe -- api check \
--openapi openapi.json --zod wire.gen.ts *)
open Spindle.Syntax
type note = { id : int; text : string } [@@deriving wiretype]
let note_id = Spindle.Path.int "note_id"
let routes =
[
Spindle.get ~summary:"One note"
Spindle.Path.(s "notes" / note_id)
(Spindle.Returns.json note_json)
(let+ id = Spindle.param note_id in
Ok { id; text = "Buy milk" });
]
(* The app is only described, never started, so a resource its routes read
-- a pool, a client -- need not exist. *)
let () =
Spindle_cli.run ~name:"notes" ~title:"Notes" ~app:(Spindle.App.make routes) ()
Generating them from code¶
The command line calls one function you can call yourself:
It answers a result; its Error names two different types described
under one name, two routes whose paths make one operationId
(/a-b and /a_b), two whose paths differ only in a parameter’s
name (/items/{id} and /items/{item}), and routes told apart by a
parameter’s type that answer in different ways or read different bodies.
Routes told apart by a parameter’s type are one entry, whose body and
answer are either route’s.
That is the tutorial. The guides take each of the rest a page at a time.