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

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

// 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.start

add_openapi adds nothing at runtime beyond an argv peek at startup.

4. Generate

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 stringenum
String / Int / Float / Boolstring / integer / number / boolean
String field with a @format directive string with that format
gleam/time/timestamp.Timestampstring, 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:

Caveats

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