oaisp
A code-first OpenAPI 3.1 generator for 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:
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_json3.x uses the OTP 27jsonmodule. - Gleam 1.11+.
How it works
your Gleam types ──► gleam export package-interface ─┐
├──► oaisp/cli merge ──► openapi.json
your routes() ──► --emit-endpoints ────────────────┘-
You write
routes()— each route binds a handler and carries anOpenApiannotation (request/response types by reference, params, summary, tags). -
You dispatch with
route.matchand wireoaisp.add_openapi(routes(), info)into yourmain(a one-line, generic pass-through; under--emit-endpointsit prints the declarations and exits). gleam run -m oaisp/cli generateruns the package-interface export, collects the declarations, resolves everytype_refagainst the resolved type information, and writesopenapi.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
// 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
// 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
wisp_mist.handler(api.handle, secret_key_base)
|> mist.new
|> oaisp.add_openapi(api.routes(), info)
|> mist.port(8080)
|> mist.startadd_openapi adds nothing at runtime beyond an argv peek at startup.
4. Generate
gleam run -m oaisp/cli generate # → ./openapi.jsonCLI
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 | stringenum |
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 formats 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.
/// 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 (anOptionfield is optional, the rest required; aList(scalar)field becomes an array parameter; non-scalar fields are soundly omitted). Write the record, get the parameters — mirroring F#'saddQueryParameters<'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_refis 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/, exercising every
shape oaisp models. Its end-to-end check (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).
License
Apache-2.0