# oaisp
A code-first **OpenAPI 3.1** generator for [Wisp](https://gleam.run/wisp/)
applications on the BEAM. You declare your API as a list of **routes** — each
binding a path + method to a handler and carrying its OpenAPI annotations — and
that single list drives both your running server *and* the emitted document. One
CLI command writes a truthful OpenAPI 3.1 document at build time.
The schemas come from the compiler's own resolved type information
(`gleam export package-interface`), so the document can't drift from your types.
No spec-first scaffolding, no runtime reflection of the router, no source
re-parsing.
## Single source of truth
The thing that usually rots — the doc drifting from the routes — can't happen
here, because there is only one list:
```gleam
import oaisp
import oaisp/param
import oaisp/route.{type Route, OpenApi, ResponseBody}
pub fn routes() -> List(Route(Handler)) {
[
route.get("/todos/{id}", get_todo)
|> route.with_openapi(OpenApi(
..route.openapi(),
summary: Some("Get a todo"),
tags: ["todos"],
path: [#("id", param.string())],
responses: [ResponseBody(200, oaisp.type_ref("myapp/types", "Todo"))],
)),
]
}
```
`route.match(routes(), method, segments)` dispatches a request to `get_todo`;
`oaisp.add_openapi(routes(), info)` documents the same list. `Route` is generic
in the handler — oaisp never inspects it — so oaisp has **no dependency on wisp,
mist, or any server library**.
## Requirements
- **Erlang/OTP 27+** — `gleam_json` 3.x uses the OTP 27 `json` module.
- **Gleam 1.11+**.
## How it works
```
your Gleam types ──► gleam export package-interface ─┐
├──► oaisp/cli merge ──► openapi.json
your routes() ──► --emit-endpoints ────────────────┘
```
1. You write `routes()` — each route binds a handler and carries an `OpenApi`
annotation (request/response **types** by reference, params, summary, tags).
2. You dispatch with `route.match` and wire `oaisp.add_openapi(routes(), info)`
into your `main` (a one-line, generic pass-through; under `--emit-endpoints`
it prints the declarations and exits).
3. `gleam run -m oaisp/cli generate` runs the package-interface export, collects
the declarations, resolves every `type_ref` against the resolved type
information, and writes `openapi.json`.
**Soundness over completeness:** the document never claims something the server
won't honor. Routes you don't put in `routes()` (streaming, websockets, …) are
served by your fallback and simply left undocumented; a type whose JSON shape
oaisp can't derive is described permissively.
## Quickstart
### 1. Your types are just types
```gleam
// src/myapp/types.gleam
pub type Todo {
Todo(id: String, title: String, done: Bool)
}
```
Doc-comments on types become schema descriptions.
### 2. Bind routes to handlers, annotate them
```gleam
// src/myapp/api.gleam
import gleam/http
import gleam/string
import oaisp
import oaisp/route.{type Route, OpenApi, ResponseBody}
import wisp
pub type Handler =
fn(wisp.Request, List(#(String, String))) -> wisp.Response
pub fn routes() -> List(Route(Handler)) {
[
// Simple: `documented` sets summary, tags, path, and responses in one call.
route.get("/todos/{id}", get_todo)
|> route.documented(
summary: "Get a todo by id",
tags: ["todos"],
path: [#("id", param.string())],
responses: [ResponseBody(200, oaisp.type_ref("myapp/types", "Todo"))],
),
// Full: the `OpenApi` record carries the rest — request body, query record,
// operationId, description.
route.post("/todos", create_todo)
|> route.with_openapi(OpenApi(
..route.openapi(),
summary: Some("Create a todo"),
request_body: Some(oaisp.type_ref("myapp/types", "NewTodo")),
responses: [ResponseBody(201, oaisp.type_ref("myapp/types", "Todo"))],
)),
]
}
pub fn handle(req: wisp.Request) -> wisp.Response {
let method = string.lowercase(http.method_to_string(req.method))
case route.match(routes(), method, wisp.path_segments(req)) {
Ok(route.Matched(handler, params)) -> handler(req, params)
Error(Nil) -> wisp.not_found()
}
}
fn get_todo(_req, params) -> wisp.Response {
// … build and return a Todo response …
}
```
### 3. One pipeline in `main`
```gleam
wisp_mist.handler(api.handle, secret_key_base)
|> mist.new
|> oaisp.add_openapi(api.routes(), info)
|> mist.port(8080)
|> mist.start
```
`add_openapi` adds nothing at runtime beyond an `argv` peek at startup.
### 4. Generate
```sh
gleam run -m oaisp/cli generate # → ./openapi.json
```
## CLI
```
gleam run -m oaisp/cli <command> [options]
```
| Command | What it does |
|---|---|
| `generate` | Emit the OpenAPI 3.1 document. |
Options: `-o, --out <PATH>` (`-` for stdout), `--package-interface <PATH>`,
`--quiet`. Writes are atomic; status goes to stderr.
## What maps to what
| Gleam | OpenAPI 3.1 schema |
|---|---|
| record (one constructor, labelled fields) | `object` with `properties` + `required` |
| `Option(T)` field | not required, type allows `null` |
| `List(T)` | `array` of `T` |
| `Dict(String, V)` | `object` with `additionalProperties: V` |
| union of fieldless variants | `string` `enum` |
| `String` / `Int` / `Float` / `Bool` | `string` / `integer` / `number` / `boolean` |
| `String` field with a `@format` directive | `string` with that `format` |
| `gleam/time/timestamp.Timestamp` | `string`, `format: date-time` (RFC 3339) |
| reference to another public type | `$ref` (collected transitively) |
| opaque type, generic, or union with payloads | permissively under-described |
`Float` additionally carries `format: double` (it is an IEEE-754 double on the
BEAM). `Int` is intentionally left without an `int32`/`int64` format: a Gleam
`Int` is an arbitrary-precision bignum, so claiming a fixed width would be
unsound.
String `format`s are type-driven: a `gleam/time/timestamp.Timestamp` field
becomes `format: date-time`. oaisp recognises it by name in the package interface
and takes no dependency on `gleam_time`, so the format rides on the standard type
without forcing an oaisp-owned type on you — the same way the F# generator derives
`date-time` from `DateTimeOffset`.
### `@format` — formats for plain string fields
When a field is a plain `String` (not a dedicated type), request a `format` with
a `@format <field>: <format>` directive in the type's **doc comment**. Gleam has
no metaprogramming, so a doc comment is the one place metadata can sit next to a
type and still reach the generator — the nearest equivalent to an F#
`[DataType(DataType.EmailAddress)]` attribute.
```gleam
/// A user account.
/// @format email: email
/// @format website: uri
pub type User {
User(id: String, email: String, website: String)
}
```
`email` and `website` stay `String` in your code — the directive is pure
metadata. In the document they become `{ "type": "string", "format": "email" }`
and `{ … "format": "uri" }`. The directive lines never appear in the schema
`description`. It applies to a `String` or an `Option(String)` field; on any
other field it is ignored.
Because the directive is pure text, a directive oaisp can't honour — naming an
unknown field, a non-string field, or a malformed line — is simply dropped, so
the document always stays sound. Any format string is allowed and rides through
to the document as-is (`email`, `uri`, `uuid`, `date`, `ipv4`, …).
## Query parameters
Declare query parameters either way:
- **Explicitly** — `query: [QueryParam("q", param.string(), False), …]`.
- **Reflected from a record** — `query_record: Some(type_ref("myapp/types", "TodoQuery"))`.
Each scalar field of the record becomes a query parameter (an `Option` field
is optional, the rest required; a `List(scalar)` field becomes an array
parameter; non-scalar fields are soundly omitted). Write the record, get the
parameters — mirroring F#'s `addQueryParameters<'T>`.
## Caveats
- **Soundness, not completeness.** Undeclared routes are served by your fallback
and left out of the doc; that's intentional (streaming, websockets, …).
- **Schemas follow type structure** — oaisp assumes your handler reads and
writes a type with its field labels as JSON keys, so your encoders and
decoders must follow the same shape.
- **Public types only.** A `type_ref` is resolved against the package interface,
so it must name a public type.
- **Erlang target only.**
## Example
A complete Wisp/mist Todo API is in [`example/`](example/), exercising every
shape oaisp models. Its end-to-end check ([`example/e2e/`](example/e2e/))
generates the document, validates it as OpenAPI 3.1 with redocly, type-checks an
`openapi-fetch` client against it, and runs that client against the live server —
all in CI ([`.github/workflows/e2e.yml`](.github/workflows/e2e.yml)).
## License
Apache-2.0