oaspec

Hex package HexDocs License CI

Generate Gleam client and server modules from OpenAPI 3.x specs.

OpenAPI in → typed Gleam client and server out, with no per-operation glue code to write or maintain. The generator owns request and response types, encoders, decoders, validation guards, and the router; you wire credentials and a transport adapter, then call typed operation functions:

import api/client
import oaspec/httpc
import oaspec/transport

let send =
  httpc.send
  |> transport.with_base_url(client.default_base_url())

let assert Ok(pets) = client.list_pets(send, limit: Some(10), offset: None)

oaspec focuses on the parts of OpenAPI that affect generated code in real projects: $ref, allOf, oneOf, anyOf, typed request and response bodies, deepObject query parameters, form bodies, multipart bodies, and security schemes. When a spec falls outside the supported subset, generation stops with a diagnostic instead of emitting partial code.

API reference: https://hexdocs.pm/oaspec/

Install

oaspec ships in two flavors:

Most users want both: gleam add oaspec gleam_json in the project that consumes the generated code, and the CLI installed system-wide to run oaspec generate.

Library (Hex)

gleam add oaspec gleam_json

This pulls the published hex.pm package and gives you the public modules under oaspec/transport, oaspec/mock, oaspec/config, oaspec/generate, oaspec/openapi/parser, and oaspec/openapi/diagnostic. See Library API below for the full module list.

gleam_json is added in the same step because the generated decode.gleam, encode.gleam, guards.gleam, and router.gleam modules import gleam/json directly. Without gleam_json listed as a direct dependency of the consumer project, gleam check prints a “Transitive dependency imported” warning for each generated file, and a future Gleam release will turn the warning into a compile error. Adding it up front avoids both.

CLI — GitHub release

Requires Erlang/OTP 27+. The release artifact is an Erlang escript, so the same binary runs anywhere Erlang is available.

curl -fSL -o oaspec https://github.com/nao1215/oaspec/releases/latest/download/oaspec
chmod +x oaspec
sudo mv oaspec /usr/local/bin/

On Windows, download oaspec from the latest release and run it with escript oaspec <command>. Erlang/OTP 27+ must be on your PATH.

CLI — build from source

Requires Gleam 1.15+, Erlang/OTP 27+, and rebar3.

git clone https://github.com/nao1215/oaspec.git
cd oaspec
gleam deps download
gleam run -m gleescript

On Linux and macOS, move the built oaspec binary into your PATH with sudo mv oaspec /usr/local/bin/. On Windows, move oaspec to a directory on your PATH and run it with escript oaspec <command>.

Quickstart

If you already have an OpenAPI 3.x spec on disk, skip step 1 and point input: at it. Otherwise, fetch a tiny sample to try the generator end-to-end:

  1. Fetch a sample spec (skip this step if you have your own).
curl -fSL -o openapi.yaml https://raw.githubusercontent.com/nao1215/oaspec/main/test/fixtures/petstore.yaml
  1. Generate a starter oaspec.yaml.
oaspec init

oaspec init writes a fully-commented template — package: api is the only uncommented field, with input, mode, validate, and output: all present as commented examples. Open the file and at minimum uncomment input: and point it at your spec (or set input: openapi.yaml if you followed step 1).

  1. Run the generator.
oaspec generate --config=oaspec.yaml

You can also run gleam run -- generate --config=oaspec.yaml.

Important: all path-valued config fields (input, output.dir, output.server, output.client) are resolved relative to the current working directory when oaspec runs, not relative to the config file location. If oaspec.yaml lives in a subdirectory, either invoke oaspec from that directory or write paths relative to the directory you run the command from.

Generated files

Given one OpenAPI spec, oaspec writes modules you can keep in your repository:

gen/my_api/
  types.gleam
  decode.gleam
  encode.gleam
  request_types.gleam
  response_types.gleam
  guards.gleam
  handlers.gleam
  handlers_generated.gleam
  router.gleam

gen/my_api_client/
  types.gleam
  decode.gleam
  encode.gleam
  request_types.gleam
  response_types.gleam
  guards.gleam
  client.gleam

Supported input

oaspec handles the following OpenAPI shapes today:

Generation stops with a diagnostic for:

Parsed but not yet turned into code: callbacks, webhooks, externalDocs, tags, examples, links, and encoding metadata.

See Current Boundaries for the full list, including server-mode restrictions and normalization rules. That section stays in sync with the capability registry at src/oaspec/internal/capability.gleam.

Runnable examples

Working examples live under examples/:

Client transport

Generated clients depend on a tiny pure runtime (oaspec/transport) instead of any specific HTTP library. Operations expose both synchronous transport.Send entry points and asynchronous transport.AsyncSend variants, so the same generated code runs against real HTTP, fakes, or any future runtime:

import api/client
import oaspec/httpc          // BEAM adapter (sibling package)
import oaspec/transport

let send =
  httpc.send
  |> transport.with_base_url(client.default_base_url())
  |> transport.with_security(
    transport.credentials()
    |> transport.with_bearer_token("BearerAuth", token),
  )

let result = client.list_pets(send, limit: Some(10), offset: None)

On the JavaScript target, use the async variant with the first-party fetch adapter:

import api/client
import oaspec/fetch
import oaspec/transport

let send =
  fetch.send
  |> transport.with_base_url(client.default_base_url())

client.list_pets_async(send, limit: Some(10), offset: None)
|> transport.run(fn(result) {
  let _ = result
  Nil
})

Each operation also exposes build_<op>_request and decode_<op>_response helpers, plus request-object wrappers for both sync and async call paths, so callers can drive the request and response halves independently — useful for retry middleware, logging, or testing decoding in isolation.

For tests, swap in oaspec/mock:

import oaspec/mock

let send = mock.text(200, "[{\"id\": 1, \"name\": \"Fido\"}]")
let assert Ok(_) = client.list_pets(send, limit: None, offset: None)

The pure runtime supplies middleware for base URL override, default headers, and OpenAPI security (with_security walks the request’s declared OR-of-AND alternatives and applies the first one whose required schemes have credentials). The same with_* middleware works for both transport.Send and transport.AsyncSend.

Adapters that bridge transport.Send / transport.AsyncSend to a real runtime live as sibling Gleam packages under adapters/, so the root oaspec package never depends on gleam_httpc or any specific HTTP runtime:

Both adapters are published to Hex from this repository on tag push: oaspec_httpc-v* for the BEAM adapter and oaspec_fetch-v* for the JavaScript adapter, separately from the main oaspec release tag (v*). The publishing workflow swaps each adapter’s parent dep (oaspec = { path = "../.." } in-tree, for monorepo development) to a Hex version constraint just before publishing, so consumers install with the usual gleam add flow:

gleam add oaspec_httpc   # BEAM
gleam add oaspec_fetch   # JavaScript

If gleam add oaspec_httpc reports package not found, no adapter release has been cut yet — depend on the adapter via a path dependency to a local checkout of the oaspec repository until the first tag push:

[dependencies]
oaspec = "..."
oaspec_fetch = { path = "../oaspec/adapters/fetch" }

A pure git = "..." dependency is not a workaround in that interim state: each adapter lives in a subdirectory of the oaspec repo (adapters/httpc/, adapters/fetch/), and Gleam’s gleam.toml parser does not support a subpath field on git dependencies as of Gleam 1.16, so the build tool cannot locate the adapter’s gleam.toml inside the larger repository.

See examples/petstore_client_fetch/gleam.toml for the canonical path-dependency layout used in the bundled examples.

Configuration

Generated server code is written to <dir>/<package> and generated client code is written to <dir>/<package>_client. Both default paths land inside the same <dir>, so a single gleam build rooted at <dir> (e.g. when <dir> is the project’s src/) picks up both. The basename of each output directory must match the package name so imports such as import my_api/types (server) and import my_api_client/types (client) resolve correctly. To split server and client into separate Gleam projects, set output.server and/or output.client explicitly.

FieldRequiredDefaultDescription
inputyes-Path to an OpenAPI 3.x spec in YAML or JSON
packagenoapiGleam module namespace prefix
modenobothserver, client, or both
validatenomode-dependent (true for server / both, false for client)Enable guard validation in generated server/client code
output.dirno./genBase output directory
output.serverno<dir>/<package>Server output path
output.clientno<dir>/<package>_clientClient output path
include.tagsno[]Operation tag allowlist (filter)
include.pathsno[]Operation path allowlist (filter, supports /foo/** glob)
targetsno-Array of per-target overrides (multi-target codegen)

Filtering operations with include:

To generate code for a subset of a large spec without modifying the spec file, set include.tags and / or include.paths:

input: github.yaml
package: github
mode: client
include:
  tags: [issues, repos]
  paths:
    - "/users/{username}"
    - "/repos/**"

Both lists are optional; omitting one means there is no constraint on that axis, and omitting both leaves the filter inactive. An operation is kept when its tag list intersects include.tags or its path matches one of include.paths; the two lists are unioned rather than intersected, so adding entries to either list widens the result.

Path patterns ending in /** match any path that extends the prefix with a /<rest> segment, so "/repos/**" matches /repos/foo and /repos/foo/bar but does not match the bare /repos — list /repos explicitly when you also need it. Other patterns are compared by exact equality.

Splitting one spec into multiple packages with targets:

targets: is an array of per-target overrides. The same input spec is generated once per entry, each with its own package, output, and include. The top-level input, mode, and validate are shared across every target.

input: github.yaml
mode: client
targets:
  - package: dco_check/github/issues
    output: { dir: ./src }
    include:
      tags: [issues]
  - package: dco_check/github/repos
    output: { dir: ./src }
    include:
      paths: ["/repos/**"]

The example above produces two packages from one oaspec generate run, at ./src/dco_check/github/issues/... and ./src/dco_check/github/repos/.... Callers consume them as import dco_check/github/issues/client and import dco_check/github/repos/client.

Each target must declare its own package; there is no fallback default for multi-target configs because two targets sharing the same default would overwrite each other. The CLI rejects configs whose targets resolve to overlapping output directories before writing any file. The --output CLI flag is also rejected with multi-target configs because each target already declares its own per-package output directory; use per-target output: blocks instead.

Configuration paths

All path-valued fields — input, output.dir, output.server, output.client — are resolved relative to the current working directory when oaspec runs, not the directory the config file lives in.

A config at the repo root that refers to a sibling spec works with no prefix:

myproject/
├── oaspec.yaml   # input: openapi.yaml
└── openapi.yaml
cd myproject
oaspec generate --config=oaspec.yaml   # resolves ./openapi.yaml

If the config lives in a subdirectory, its input must be reachable from where the command is run, so either use a path relative to that CWD or keep invoking oaspec from the config’s own directory:

myproject/
├── api/
│   ├── oaspec.yaml    # input: openapi.yaml
│   └── openapi.yaml
└── (other code)
cd myproject/api
oaspec generate --config=oaspec.yaml   # resolves ./openapi.yaml

# or, from the repo root:
oaspec generate --config=api/oaspec.yaml   # needs input: api/openapi.yaml

Output directories (output.dir, output.server, output.client) are created automatically if they do not exist; existing files in the target directories are overwritten by the newly generated code.

If the input spec or the config file itself cannot be opened, oaspec exits with a Config file not found / parse_file diagnostic that includes the path it attempted to read.

CLI commands

CommandDescription
oaspec generateGenerate Gleam code from an OpenAPI specification
oaspec validateValidate an OpenAPI specification without generating code
oaspec initCreate a default oaspec.yaml config file
oaspec versionPrint the installed oaspec version (also available as --version)

CLI options for init

FlagDefaultDescription
--output=<path>./oaspec.yamlOutput path for the generated config file

CLI options for generate

FlagDefaultDescription
--config=<path>./oaspec.yamlPath to config file
--mode=<mode>bothserver, client, or both (overrides config)
--output=<path>-Override output base directory
--checkfalseCheck that generated code matches existing files without writing
--fail-on-warningsfalseTreat warnings as errors
--validatefalseForce-enable guard validation in generated server/client code. One-way override — passing this flag turns validation on, but it cannot turn it off. To disable validation when the config sets validate: true (the default for server / both modes), edit validate: false in oaspec.yaml.

CLI options for validate

FlagDefaultDescription
--config=<path>./oaspec.yamlPath to config file
--mode=<mode>bothserver, client, or both (overrides config)

Validate

Check a spec for unsupported patterns without generating code:

oaspec validate --config=oaspec.yaml

Guard validation

By default, generated code does not validate request bodies at runtime. Enable validate in the config file or pass --validate to generate to add schema-constraint checks:

validate: true
oaspec generate --config=oaspec.yaml --validate

When enabled, generated routers validate request bodies against schema constraints and return 422 on failure. Generated clients validate request bodies before sending.

The 422 response body is a JSON array of ValidationFailure objects with the violating field, the JSON Schema keyword that failed, and a human-readable message:

[
  {"field": "name", "code": "minLength", "message": "must be at least 1 character"},
  {"field": "age", "code": "maximum", "message": "must be at most 150"}
]

Generated clients surface the same failures via ClientError.ValidationError(errors: List(guards.ValidationFailure)).

CI integration

Use --check and --fail-on-warnings to verify generated code stays in sync:

# Fail if generated code would differ from what's committed
oaspec generate --config=oaspec.yaml --check --fail-on-warnings

Best For

OpenAPI Support

oaspec supports OpenAPI 3.0.x and a practical subset of OpenAPI 3.1.x in YAML or JSON. For compatibility, the parser also accepts the two-segment forms 3.0 / 3.1, including YAML numeric values such as openapi: 3.0 that arrive as the float 3.0. Any other openapi value — for example 2.0, 4.0.0, a bare 3, or a malformed 3.0.foo — is rejected with an invalid_value diagnostic so unsupported versions fail fast instead of producing plausible-looking but meaningless output.

operationId uniqueness

Every operation must carry a unique operationId. oaspec validates this as a hard error with the offending METHOD /path sites listed, because silently renaming the second occurrence (as some generators do) would mutate the generated function/type names without telling the user. The check also catches IDs that only differ in casing — listItems and list_items both collapse to the same generated list_items function, so the spec is rejected.

Coverage is strongest in these areas:

format: byte and format: binary

The OpenAPI format keyword on a string schema is passed through as metadata only in the current release. Generated fields keep the Gleam type String; the encoded contract (format: byte = base64 per OAS 3.0 §4.7.4 / OAS 3.1 alignment with JSON Schema, format: binary = raw bytes) is not enforced or materialised by the generator.

Practical implications:

A future release may auto-decode format: byte to BitArray or emit a format docstring on the generated field; tracking issue #338.

Current Boundaries

This section stays in sync with src/oaspec/internal/capability.gleam.

Mode-Specific Support

oaspec generates different files depending on the --mode flag. Some features have mode-specific restrictions enforced at validation time.

Generated files

Fileserverclient
types.gleamyesyes
decode.gleamyesyes
encode.gleamyesyes
request_types.gleamyesyes
response_types.gleamyesyes
guards.gleamyesyes
handlers.gleamyes (once)-
handlers_generated.gleamyes-
router.gleamyes-
client.gleam-yes

handlers.gleam is user-owned. The generator writes panic stubs on the first run and skips the file on every subsequent run, so your implementations survive regeneration. handlers_generated.gleam is the sealed delegator the router imports, and each operation forwards to handlers.<op_name>(req).

Feature restrictions by mode

FeatureserverclientNotes
JSON request/response bodiesyesyes
Path / query / header / cookie parametersyesyes
style: deepObject parametersrestrictedyesServer: only primitive scalars and primitive arrays
Array query parametersrestrictedyesServer: only inline primitive item schemas
style: pipeDelimited / style: spaceDelimited query arraysyesyesQuery array parameters only; primitive item types. Non-exploded joins with | / %20, exploded degenerates to form-style name=a&name=b.
application/x-www-form-urlencodedrestrictedyesServer: must be sole content type; only primitive fields and shallow nested objects
multipart/form-datarestrictedyesServer: must be sole content type; only primitive scalar fields or arrays of primitive scalars
text/plain request bodyyesyesTreated as a single String field on the request
application/octet-stream request bodyyesyesTreated as raw BitArray/binary on the request
Security (apiKey, HTTP, OAuth2, OpenID Connect)yesyesClient attaches credentials via config; OAuth2/OpenID Connect: bearer token only

Library API

oaspec can be used as a Gleam library, not just a CLI tool. The generation pipeline is pure (no IO) and split into composable steps.

Public modules at a glance

ModulePurpose
oaspec/transportRuntime contract for generated clients (Send / AsyncSend types, with_base_url, with_default_headers, with_security)
oaspec/mockIn-memory transport adapter for tests — no network, no FFI
oaspec/configLoad config from YAML (config.load/1 / config.load_all/1) or build a Config in code (config.new/6)
oaspec/generatePure generation pipeline (generate.generate/2, generate.validate_only/2) — no IO
oaspec/openapi/parserParse YAML/JSON spec text into an OpenApiSpec(Unresolved)
oaspec/openapi/diagnosticStructured warnings and errors used throughout the pipeline
oaspec/codegen/writerWrite a List(GeneratedFile) to disk under output.server / output.client

If you only consume generated clients, you only need oaspec/transport and oaspec/mock. Tools that drive generation in-process (CI checks, custom build steps, doctests) reach for oaspec/openapi/parseroaspec/generateoaspec/codegen/writer.

Pipeline overview

parse → normalize → resolve → capability check → hoist → dedup → validate → codegen

The oaspec/generate module wraps this pipeline into two entry points:

Example: generate files from a parsed spec

import oaspec/config
import oaspec/generate
import oaspec/openapi/parser

let assert Ok(spec) = parser.parse_file("openapi.yaml")
let cfg = config.new(
  input: "openapi.yaml",
  output_server: "./gen/my_api",
  output_client: "./gen/my_api_client",
  package: "my_api",
  mode: config.Both,
  validate: False,
)

case generate.generate(spec, cfg) {
  Ok(summary) -> {
    // summary.files: List(GeneratedFile) — path and content for each file
    // summary.warnings: List(Diagnostic) — non-blocking warnings
    // summary.spec_title: String
    Nil
  }
  Error(generate.ValidationErrors(errors:)) -> {
    // errors: List(Diagnostic) — blocking validation errors
    Nil
  }
}

Example: validate without generating

case generate.validate_only(spec, cfg) {
  Ok(_summary) -> Nil
  // spec has errors; surface `errors` to the user
  Error(generate.ValidationErrors(errors: _errors)) -> Nil
}

Development

This project uses mise for tool versions and just as a task runner.

mise install
just check
just shellspec
just integration

Test structure:

CommandToolWhat it tests
just testgleeunitParser, validator, naming, config, collision detection
just shellspecShellSpecCLI behaviour, file generation, content, unsupported feature detection
just integrationgleeunitGenerated code compiles and the generated modules work together

License

MIT

Search Document