oaspec

Hex Hex Downloads CI

Generate usable Gleam code from OpenAPI 3.x specifications.

oaspec is aimed at practical, typed code generation rather than a feature checklist. It handles the OpenAPI cases that tend to break real projects, such as $ref resolution, allOf, oneOf and anyOf, deepObject query parameters, form bodies, multipart bodies, and multiple security schemes, while failing fast when a spec goes outside the supported subset.

Why oaspec?

oaspec is the OpenAPI code generator built for Gleam. Generated code is regular Gleam: no templates, no runtime magic, type-safe end to end.

oaspecGeneric multi-language generators (e.g. openapi-generator)Single-language generators for other targets (e.g. oapi-codegen for Go)
First-class Gleam outputYesNoNo
Idiomatic types, decoders, encoders, and request/response recordsYesTemplated, not always idiomaticLanguage-specific
Refuses to emit broken code on unsupported spec patternsYesSometimesPartial

What you get

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

gen/my_api/
  types.gleam
  decode.gleam
  encode.gleam
  request_types.gleam
  response_types.gleam
  guards.gleam          (only if schemas have validation constraints)
  handlers.gleam        (user-owned: written once with panic stubs, skipped on regeneration)
  handlers_generated.gleam (sealed delegator; router imports this)
  router.gleam

gen_client/my_api/
  types.gleam
  decode.gleam
  encode.gleam
  request_types.gleam
  response_types.gleam
  guards.gleam          (only if schemas have validation constraints)
  client.gleam

Example generated code:

/// A pet in the store
pub type Pet {
  Pet(
    id: Int,
    name: String,
    status: PetStatus,
    tag: Option(String),
  )
}

pub type PetStatus {
  PetStatusAvailable
  PetStatusPending
  PetStatusSold
}

pub fn create_pet(config: ClientConfig, body: types.CreatePetRequest)
  -> Result(response_types.CreatePetResponse, ClientError) {
  // ...
}

pub fn list_pets(req: request_types.ListPetsRequest)
  -> response_types.ListPetsResponse {
  let _ = req
  panic as "unimplemented: list_pets"
}

Is oaspec right for your spec?

A one-minute check before you paste in your OpenAPI document — if your spec stays inside the green list below, oaspec will generate code; if it relies on anything in the red list, generation stops with a clear diagnostic instead of producing broken output.

Generates code for:

Stops with a diagnostic for:

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

See Current Boundaries for the full list, including server-mode restrictions and normalization rules. The boundaries are kept in sync with the capability registry at src/oaspec/capability.gleam by a drift-detection test.

Quickstart

Install from GitHub release (Linux / macOS)

Requires Erlang/OTP 27+. The release binary is an Erlang escript that runs on any platform with Erlang installed.

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.

Build from source (all platforms)

Requires Gleam 1.15+, Erlang/OTP 27+, and rebar3. Works on Linux, macOS, and Windows.

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

On Linux/macOS, move the binary into your PATH:

sudo mv oaspec /usr/local/bin/

On Windows, move oaspec to a directory on your PATH and run it with escript oaspec <command>.

Generate code

  1. Create a config file.
oaspec init
  1. Edit oaspec.yaml.
input: openapi.yaml
package: my_api
output:
  dir: ./gen
  1. Run the generator.
oaspec generate --config=oaspec.yaml

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

Runnable examples

Working examples live under 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
validatenofalseEnable 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

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

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
--validatefalseEnable guard validation in generated server/client code

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.

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:

Current Boundaries

These boundaries are generated from the capability registry in src/oaspec/capability.gleam.

These are the most important limitations today:

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 — each operation forwards to handlers.<op_name>(req), so router/handler wiring stays in sync with the spec without ever touching your code.

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

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
  }
  Error(generate.ValidationErrors(errors:)) -> {
    // errors: List(Diagnostic) — blocking validation errors
  }
}

Example: validate without generating

case generate.validate_only(spec, cfg) {
  Ok(summary) -> // spec is valid; summary.warnings may be non-empty
  Error(generate.ValidationErrors(errors:)) -> // spec has errors
}

Key modules

ModulePurpose
oaspec/openapi/parserParse YAML/JSON spec into OpenApiSpec(Unresolved)
oaspec/configLoad config from YAML or construct programmatically
oaspec/generatePure generation pipeline (parse → codegen)
oaspec/codegen/writerWrite generated files to disk
oaspec/openapi/diagnosticStructured warnings and errors

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