{"id":48508931,"url":"https://github.com/nao1215/oaspec","last_synced_at":"2026-05-18T09:08:09.078Z","repository":{"id":349786252,"uuid":"1203808931","full_name":"nao1215/oaspec","owner":"nao1215","description":"Generate strongly typed Gleam server stubs and client SDK from OpenAPI 3.x specifications","archived":false,"fork":false,"pushed_at":"2026-05-16T09:32:18.000Z","size":3110,"stargazers_count":5,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-16T11:30:44.303Z","etag":null,"topics":["api","client","code-generator","codegen","gleam","gleam-lang","openapi","openapi3","server"],"latest_commit_sha":null,"homepage":null,"language":"Gleam","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/nao1215.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":".github/FUNDING.yml","license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":"SECURITY.md","support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":"nao1215"}},"created_at":"2026-04-07T12:01:05.000Z","updated_at":"2026-05-16T09:32:16.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/nao1215/oaspec","commit_stats":null,"previous_names":["nao1215/oaspec","nao1215/gleam-oas"],"tags_count":76,"template":false,"template_full_name":null,"purl":"pkg:github/nao1215/oaspec","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nao1215%2Foaspec","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nao1215%2Foaspec/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nao1215%2Foaspec/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nao1215%2Foaspec/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/nao1215","download_url":"https://codeload.github.com/nao1215/oaspec/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/nao1215%2Foaspec/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33172173,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-18T05:43:36.989Z","status":"ssl_error","status_checked_at":"2026-05-18T05:43:19.133Z","response_time":71,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["api","client","code-generator","codegen","gleam","gleam-lang","openapi","openapi3","server"],"created_at":"2026-04-07T17:00:44.527Z","updated_at":"2026-05-18T09:08:09.065Z","avatar_url":"https://github.com/nao1215.png","language":"Gleam","funding_links":["https://github.com/sponsors/nao1215"],"categories":[],"sub_categories":[],"readme":"# oaspec\n\n[![Hex package](https://img.shields.io/hexpm/v/oaspec)](https://hex.pm/packages/oaspec)\n[![HexDocs](https://img.shields.io/badge/hexdocs-latest-blue)](https://hexdocs.pm/oaspec/)\n[![License](https://img.shields.io/github/license/nao1215/oaspec)](https://github.com/nao1215/oaspec/blob/main/LICENSE)\n[![CI](https://github.com/nao1215/oaspec/actions/workflows/ci.yml/badge.svg)](https://github.com/nao1215/oaspec/actions)\n\nGenerate Gleam client and server modules from OpenAPI 3.x specs.\n\nOpenAPI in → typed Gleam client and server out, with no per-operation glue\ncode to write or maintain. The generator owns request and response types,\nencoders, decoders, validation guards, and the router; you wire credentials\nand a transport adapter, then call typed operation functions:\n\n```gleam\nimport api/client\nimport oaspec/httpc\nimport oaspec/transport\n\nlet send =\n  httpc.send\n  |\u003e transport.with_base_url(client.default_base_url())\n\nlet assert Ok(pets) = client.list_pets(send, limit: Some(10), offset: None)\n```\n\n`oaspec` focuses on the parts of OpenAPI that affect generated code in real\nprojects: `$ref`, `allOf`, `oneOf`, `anyOf`, typed request and response\nbodies, `deepObject` query parameters, form bodies, multipart bodies, and\nsecurity schemes. When a spec falls outside the supported subset, generation\nstops with a diagnostic instead of emitting partial code.\n\n- Generate client and server-side modules from a single spec\n- Produce readable Gleam types, encoders, decoders, request types, and response\n  types\n- Keep unsupported spec shapes explicit and testable\n- Backed by 361 unit tests, ShellSpec CLI tests, 40 integration compile tests,\n  and 261 test fixtures (including 98 OSS-derived edge-case specs)\n\nAPI reference: \u003chttps://hexdocs.pm/oaspec/\u003e\n\n## Install\n\n`oaspec` ships in two flavors:\n\n- Library API (the runtime contract for generated clients, plus the\n  generator itself for in-process use) — install via `gleam add` from Hex.\n- CLI (the `oaspec` binary that drives `init` / `generate` / `validate`\n  on the command line) — install from a GitHub release or build from source.\n\nMost users want both: `gleam add oaspec gleam_json` in the project that\nconsumes the generated code, and the CLI installed system-wide to run\n`oaspec generate`.\n\n### Library (Hex)\n\n```sh\ngleam add oaspec gleam_json\n```\n\nThis pulls the published [hex.pm package](https://hex.pm/packages/oaspec)\nand gives you the public modules under `oaspec/transport`, `oaspec/mock`,\n`oaspec/config`, `oaspec/generate`, `oaspec/openapi/parser`, and\n`oaspec/openapi/diagnostic`. See [Library API](#library-api) below for the\nfull module list.\n\n`gleam_json` is added in the same step because the generated `decode.gleam`,\n`encode.gleam`, `guards.gleam`, and `router.gleam` modules `import gleam/json`\ndirectly. Without `gleam_json` listed as a direct dependency of the consumer\nproject, `gleam check` prints a \"Transitive dependency imported\" warning for\neach generated file, and a future Gleam release will turn the warning into a\ncompile error. Adding it up front avoids both.\n\n### CLI — GitHub release\n\nRequires Erlang/OTP 27+. The release artifact is an Erlang escript, so the\nsame binary runs anywhere Erlang is available.\n\n```sh\ncurl -fSL -o oaspec https://github.com/nao1215/oaspec/releases/latest/download/oaspec\nchmod +x oaspec\nsudo mv oaspec /usr/local/bin/\n```\n\nOn Windows, download `oaspec` from the [latest release](https://github.com/nao1215/oaspec/releases/latest) and run it with `escript oaspec \u003ccommand\u003e`. Erlang/OTP 27+ must be on your `PATH`.\n\n### CLI — build from source\n\nRequires Gleam 1.15+, Erlang/OTP 27+, and `rebar3`.\n\n```sh\ngit clone https://github.com/nao1215/oaspec.git\ncd oaspec\ngleam deps download\ngleam run -m gleescript\n```\n\nOn Linux and macOS, move the built `oaspec` binary into your `PATH` with\n`sudo mv oaspec /usr/local/bin/`. On Windows, move `oaspec` to a directory on\nyour `PATH` and run it with `escript oaspec \u003ccommand\u003e`.\n\n## Quickstart\n\nIf you already have an OpenAPI 3.x spec on disk, skip step 1 and point\n`input:` at it. Otherwise, fetch a tiny sample to try the generator\nend-to-end:\n\n1. Fetch a sample spec (skip this step if you have your own).\n\n```sh\ncurl -fSL -o openapi.yaml https://raw.githubusercontent.com/nao1215/oaspec/main/test/fixtures/petstore.yaml\n```\n\n2. Generate a starter `oaspec.yaml`.\n\n```sh\noaspec init\n```\n\n`oaspec init` writes a fully-commented template — `package: api` is the\nonly uncommented field, with `input`, `mode`, `validate`, and `output:` all\npresent as commented examples. Open the file and at minimum uncomment\n`input:` and point it at your spec (or set `input: openapi.yaml` if you\nfollowed step 1).\n\n3. Run the generator.\n\n```sh\noaspec generate --config=oaspec.yaml\n```\n\nYou can also run `gleam run -- generate --config=oaspec.yaml`.\n\nImportant: all path-valued config fields (`input`, `output.dir`,\n`output.server`, `output.client`) are resolved relative to the current\nworking directory when `oaspec` runs, not relative to the config file\nlocation. If `oaspec.yaml` lives in a subdirectory, either invoke\n`oaspec` from that directory or write paths relative to the directory\nyou run the command from.\n\n## Generated files\n\nGiven one OpenAPI spec, `oaspec` writes modules you can keep in your\nrepository:\n\n```text\ngen/my_api/\n  types.gleam\n  decode.gleam\n  encode.gleam\n  request_types.gleam\n  response_types.gleam\n  guards.gleam\n  handlers.gleam\n  handlers_generated.gleam\n  router.gleam\n\ngen/my_api_client/\n  types.gleam\n  decode.gleam\n  encode.gleam\n  request_types.gleam\n  response_types.gleam\n  guards.gleam\n  client.gleam\n```\n\n## Supported input\n\n`oaspec` handles the following OpenAPI shapes today:\n\n- Schemas: `object`, primitives, arrays, enums, nullable, `allOf`,\n  `oneOf`, `anyOf`, typed `additionalProperties`\n- Local `$ref` (and relative-file external `$ref`) across schemas,\n  parameters, request bodies, responses, and path items. External ref\n  graphs must be acyclic — cycles such as `A.yaml → B.yaml → A.yaml`\n  fail fast with a dedicated diagnostic that shows the visited chain.\n- Parameters: path, query, header, cookie, plus array styles (`form`,\n  `pipeDelimited`, `spaceDelimited`) and objects via `deepObject`\n- Request bodies: `application/json`, `text/plain`,\n  `application/octet-stream`, `application/x-www-form-urlencoded`,\n  `multipart/form-data`\n- Typed response variants, typed response headers, and `$ref` /\n  `default` responses\n- Security: `apiKey`, HTTP (bearer/basic/digest), OAuth2, OpenID Connect\n  (bearer token attachment on the client; **parsed but not enforced** on\n  the server — see [Server security model](#server-security-model))\n\nGeneration stops with a diagnostic for:\n\n- JSON Schema 2020 keywords: `$defs`, `prefixItems`, `if/then/else`,\n  `dependentSchemas`, `not`, `unevaluatedProperties` /\n  `unevaluatedItems`, `contentEncoding` / `contentMediaType` /\n  `contentSchema`\n- XML request/response bodies with structural decoding, `xml`\n  annotations, and `mutualTLS` security\n\nParsed but not yet turned into code: callbacks, webhooks, `externalDocs`,\ntags, examples, links, and `encoding` metadata.\n\nSee [Current Boundaries](#current-boundaries) for the full list, including\nserver-mode restrictions and normalization rules. That section stays in sync\nwith the capability registry at\n[`src/oaspec/internal/capability.gleam`](src/oaspec/internal/capability.gleam).\n\n### Runnable examples\n\nWorking examples live under [`examples/`](./examples):\n\n- [`examples/petstore_client`](./examples/petstore_client) — generated client / decoder roundtrip demo against a stub transport (no network). The example's README shows the one-liner swap to `oaspec_httpc` for real BEAM HTTP. Run it from the repo root with `just example-petstore`.\n- [`examples/petstore_client_fetch`](./examples/petstore_client_fetch) — JavaScript-target client usage through the first-party fetch adapter. Run it from the repo root with `just example-petstore-fetch`.\n- [`examples/server_adapter`](./examples/server_adapter) — wires the generated `router.route/6` to a framework-free adapter. Run it from the repo root with `just example-server-adapter`.\n\n### Client transport\n\nGenerated clients depend on a tiny pure runtime (`oaspec/transport`)\ninstead of any specific HTTP library. Operations expose both synchronous\n`transport.Send` entry points and asynchronous `transport.AsyncSend`\nvariants, so the same generated code runs against real HTTP, fakes,\nor any future runtime:\n\n```gleam\nimport api/client\nimport oaspec/httpc          // BEAM adapter (sibling package)\nimport oaspec/transport\n\nlet send =\n  httpc.send\n  |\u003e transport.with_base_url(client.default_base_url())\n  |\u003e transport.with_security(\n    transport.credentials()\n    |\u003e transport.with_bearer_token(\"BearerAuth\", token),\n  )\n\nlet result = client.list_pets(send, limit: Some(10), offset: None)\n```\n\nOn the JavaScript target, use the async variant with the first-party\nfetch adapter:\n\n```gleam\nimport api/client\nimport oaspec/fetch\nimport oaspec/transport\n\nlet send =\n  fetch.send\n  |\u003e transport.with_base_url(client.default_base_url())\n\nclient.list_pets_async(send, limit: Some(10), offset: None)\n|\u003e transport.run(fn(result) {\n  let _ = result\n  Nil\n})\n```\n\nEach operation also exposes `build_\u003cop\u003e_request` and\n`decode_\u003cop\u003e_response` helpers, plus request-object wrappers for both\nsync and async call paths, so callers can drive the request and\nresponse halves independently — useful for retry middleware, logging,\nor testing decoding in isolation.\n\nFor tests, swap in `oaspec/mock`:\n\n```gleam\nimport oaspec/mock\n\nlet send = mock.text(200, \"[{\\\"id\\\": 1, \\\"name\\\": \\\"Fido\\\"}]\")\nlet assert Ok(_) = client.list_pets(send, limit: None, offset: None)\n```\n\nThe pure runtime supplies middleware for base URL override, default\nheaders, and OpenAPI security (`with_security` walks the request's\ndeclared OR-of-AND alternatives and applies the first one whose\nrequired schemes have credentials). The same `with_*` middleware works\nfor both `transport.Send` and `transport.AsyncSend`.\n\nAdapters that bridge `transport.Send` / `transport.AsyncSend` to a real\nruntime live as\nsibling Gleam packages under [`adapters/`](./adapters), so the root\n`oaspec` package never depends on `gleam_httpc` or any specific\nHTTP runtime:\n\n- `oaspec_httpc` (`adapters/httpc/`) — BEAM adapter backed by\n  `gleam_httpc`.\n- `oaspec_fetch` (`adapters/fetch/`) — JavaScript adapter backed by\n  `gleam_fetch`, with helpers to bridge `transport.Async` and native\n  JavaScript promises.\n\nBoth adapters are published to Hex from this repository on tag push:\n`oaspec_httpc-v*` for the BEAM adapter and `oaspec_fetch-v*` for the\nJavaScript adapter, separately from the main `oaspec` release tag\n(`v*`). The publishing workflow swaps each adapter's parent dep\n(`oaspec = { path = \"../..\" }` in-tree, for monorepo development)\nto a Hex version constraint just before publishing, so consumers\ninstall with the usual `gleam add` flow:\n\n```sh\ngleam add oaspec_httpc   # BEAM\ngleam add oaspec_fetch   # JavaScript\n```\n\nIf `gleam add oaspec_httpc` reports `package not found`, no adapter\nrelease has been cut yet — depend on the adapter via a path\ndependency to a local checkout of the oaspec repository until the\nfirst tag push:\n\n```toml\n[dependencies]\noaspec = \"...\"\noaspec_fetch = { path = \"../oaspec/adapters/fetch\" }\n```\n\nA pure `git = \"...\"` dependency is not a workaround in that interim\nstate: each adapter lives in a subdirectory of the oaspec repo\n(`adapters/httpc/`, `adapters/fetch/`), and Gleam's `gleam.toml`\nparser does not support a `subpath` field on git dependencies as of\nGleam 1.16, so the build tool cannot locate the adapter's\n`gleam.toml` inside the larger repository.\n\nSee [`examples/petstore_client_fetch/gleam.toml`](./examples/petstore_client_fetch/gleam.toml)\nfor the canonical path-dependency layout used in the bundled\nexamples.\n\n### Server transport\n\nGenerated server code is the dual of the client side: the codegen\nemits a single pure router function, and adapters bridge it to a real\nHTTP framework. `api/router.route/6` takes the primitive pieces of a\nrequest — `state`, `method`, `path`, `query`, `headers`, `body` — and\nreturns a `ServerResponse` whose `body` is a sum\n(`TextBody(String)`, `BytesBody(BitArray)`, `EmptyBody`), so binary\nendpoints carry real bytes through without a String round-trip.\n\nA canonical [`mist`](https://hexdocs.pm/mist/) adapter looks like\nthis:\n\n```gleam\nimport api/handlers\nimport api/router as oas_router\nimport gleam/bit_array\nimport gleam/bytes_tree\nimport gleam/dict\nimport gleam/http\nimport gleam/http/request.{type Request}\nimport gleam/http/response.{type Response}\nimport gleam/list\nimport gleam/option.{None, Some}\nimport gleam/result\nimport gleam/string\nimport gleam/uri\nimport mist\n\npub fn handle(\n  req: Request(mist.Connection),\n  state: handlers.State,\n) -\u003e Response(mist.ResponseData) {\n  let method = req.method |\u003e http.method_to_string |\u003e string.uppercase\n  let path =\n    req.path\n    |\u003e string.split(on: \"/\")\n    |\u003e list.filter(fn(segment) { segment != \"\" })\n  let query =\n    req.query\n    |\u003e option.unwrap(\"\")\n    |\u003e uri.parse_query\n    |\u003e result.unwrap([])\n    |\u003e list.fold(dict.new(), fn(acc, kv) {\n      dict.upsert(acc, kv.0, fn(prev) {\n        case prev {\n          Some(values) -\u003e list.append(values, [kv.1])\n          None -\u003e [kv.1]\n        }\n      })\n    })\n  let headers = req.headers |\u003e dict.from_list\n  let body =\n    mist.read_body(req, 16_000_000)\n    |\u003e result.map(fn(read) { read.body })\n    |\u003e result.unwrap(\u003c\u003c\u003e\u003e)\n    |\u003e bit_array.to_string\n    |\u003e result.unwrap(\"\")\n\n  let resp = oas_router.route(state, method, path, query, headers, body)\n\n  let mist_body = case resp.body {\n    oas_router.TextBody(text) -\u003e mist.Bytes(bytes_tree.from_string(text))\n    oas_router.BytesBody(bytes) -\u003e\n      mist.Bytes(bytes_tree.from_bit_array(bytes))\n    oas_router.EmptyBody -\u003e mist.Bytes(bytes_tree.new())\n  }\n  resp.headers\n  |\u003e list.fold(response.new(resp.status), fn(r, header) {\n    response.set_header(r, header.0, header.1)\n  })\n  |\u003e response.set_body(mist_body)\n}\n```\n\nThe same shape works for [`wisp`](https://hexdocs.pm/wisp/): decompose\nits request into the six primitives, call `oas_router.route(...)`,\nrender the returned `ServerResponse` back into the framework's\nresponse type. Because the router is pure and synchronous, it is also\ntrivial to test in isolation without an HTTP server — see\n[`examples/server_adapter`](./examples/server_adapter) for a\nframework-free runnable example.\n\n\u003e **Note:** if any operation in your spec declares\n\u003e `application/octet-stream` on its request body, the generated\n\u003e router signature is `body: BitArray` (not `String`) so arbitrary\n\u003e binary payloads round-trip without going through\n\u003e `bit_array.to_string`. In that case drop the\n\u003e `|\u003e bit_array.to_string |\u003e result.unwrap(\"\")` step from the\n\u003e snippet above and pass `mist.read_body(...).body` directly to\n\u003e `oas_router.route(...)`. The router internally converts to String\n\u003e for the non-binary arms. (#485)\n\n## Configuration\n\nGenerated server code is written to `\u003cdir\u003e/\u003cpackage\u003e` and generated client code is written to `\u003cdir\u003e/\u003cpackage\u003e_client`. Both default paths land inside the same `\u003cdir\u003e`, so a single `gleam build` rooted at `\u003cdir\u003e` (e.g. when `\u003cdir\u003e` 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.\n\n| Field | Required | Default | Description |\n|-------|----------|---------|-------------|\n| `input` | yes | - | Path to an OpenAPI 3.x spec in YAML or JSON |\n| `package` | no | `api` | Gleam module namespace prefix |\n| `mode` | no | `both` | `server`, `client`, or `both` |\n| `validate` | no | mode-dependent (`true` for `server` / `both`, `false` for `client`) | Enable guard validation in generated server/client code |\n| `output.dir` | no | `./gen` | Base output directory |\n| `output.server` | no | `\u003cdir\u003e/\u003cpackage\u003e` | Server output path |\n| `output.client` | no | `\u003cdir\u003e/\u003cpackage\u003e_client` | Client output path |\n| `include.tags` | no | `[]` | Operation tag allowlist (filter) |\n| `include.paths` | no | `[]` | Operation path allowlist (filter, supports `/foo/**` glob) |\n| `targets` | no | - | Array of per-target overrides (multi-target codegen) |\n\n### Filtering operations with `include:`\n\nTo generate code for a subset of a large spec without modifying the\nspec file, set `include.tags` and / or `include.paths`:\n\n```yaml\ninput: github.yaml\npackage: github\nmode: client\ninclude:\n  tags: [issues, repos]\n  paths:\n    - \"/users/{username}\"\n    - \"/repos/**\"\n```\n\nBoth lists are optional; omitting one means there is no constraint on\nthat axis, and omitting both leaves the filter inactive. An operation\nis kept when its tag list intersects `include.tags` or its path matches\none of `include.paths`; the two lists are unioned rather than\nintersected, so adding entries to either list widens the result.\n\nPath patterns ending in `/**` match any path that extends the prefix\nwith a `/\u003crest\u003e` segment, so `\"/repos/**\"` matches `/repos/foo` and\n`/repos/foo/bar` but does not match the bare `/repos` — list `/repos`\nexplicitly when you also need it. Other patterns are compared by exact\nequality.\n\n### Splitting one spec into multiple packages with `targets:`\n\n`targets:` is an array of per-target overrides. The same input spec is\ngenerated once per entry, each with its own `package`, `output`, and\n`include`. The top-level `input`, `mode`, and `validate` are shared\nacross every target.\n\n```yaml\ninput: github.yaml\nmode: client\ntargets:\n  - package: dco_check/github/issues\n    output: { dir: ./src }\n    include:\n      tags: [issues]\n  - package: dco_check/github/repos\n    output: { dir: ./src }\n    include:\n      paths: [\"/repos/**\"]\n```\n\nThe example above produces two packages from one `oaspec generate` run,\nat `./src/dco_check/github/issues/...` and\n`./src/dco_check/github/repos/...`. Callers consume them as\n`import dco_check/github/issues/client` and\n`import dco_check/github/repos/client`.\n\nEach target must declare its own `package`; there is no fallback default\nfor multi-target configs because two targets sharing the same default\nwould overwrite each other. The CLI rejects configs whose targets\nresolve to overlapping output directories before writing any file. The\n`--output` CLI flag is also rejected with multi-target configs because\neach target already declares its own per-package output directory; use\nper-target `output:` blocks instead.\n\n### Configuration paths\n\nAll path-valued fields — `input`, `output.dir`, `output.server`,\n`output.client` — are resolved relative to the current working\ndirectory when oaspec runs, not the directory the config file lives in.\n\nA config at the repo root that refers to a sibling spec works with no\nprefix:\n\n```text\nmyproject/\n├── oaspec.yaml   # input: openapi.yaml\n└── openapi.yaml\n```\n\n```sh\ncd myproject\noaspec generate --config=oaspec.yaml   # resolves ./openapi.yaml\n```\n\nIf the config lives in a subdirectory, its `input` must be reachable\nfrom where the command is run, so either use a path relative to that\nCWD or keep invoking oaspec from the config's own directory:\n\n```text\nmyproject/\n├── api/\n│   ├── oaspec.yaml    # input: openapi.yaml\n│   └── openapi.yaml\n└── (other code)\n```\n\n```sh\ncd myproject/api\noaspec generate --config=oaspec.yaml   # resolves ./openapi.yaml\n\n# or, from the repo root:\noaspec generate --config=api/oaspec.yaml   # needs input: api/openapi.yaml\n```\n\nOutput directories (`output.dir`, `output.server`, `output.client`)\nare created automatically if they do not exist; existing files in the\ntarget directories are overwritten by the newly generated code.\n\nIf the input spec or the config file itself cannot be opened, oaspec\nexits with a `Config file not found` / `parse_file` diagnostic that\nincludes the path it attempted to read.\n\n### CLI commands\n\n| Command | Description |\n|---------|-------------|\n| `oaspec generate` | Generate Gleam code from an OpenAPI specification |\n| `oaspec validate` | Validate an OpenAPI specification without generating code |\n| `oaspec init` | Create a default `oaspec.yaml` config file |\n| `oaspec version` | Print the installed `oaspec` version (also available as `--version`) |\n\n### CLI options for `init`\n\n| Flag | Default | Description |\n|------|---------|-------------|\n| `--output=\u003cpath\u003e` | `./oaspec.yaml` | Output path for the generated config file |\n\n### CLI options for `generate`\n\n| Flag | Default | Description |\n|------|---------|-------------|\n| `--config=\u003cpath\u003e` | `./oaspec.yaml` | Path to config file |\n| `--mode=\u003cmode\u003e` | `both` | `server`, `client`, or `both` (overrides config) |\n| `--output=\u003cpath\u003e` | - | Override output base directory |\n| `--check` | `false` | Check that generated code matches existing files without writing |\n| `--fail-on-warnings` | `false` | Treat warnings as errors |\n| `--validate` | `false` | Force-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`. |\n\n### CLI options for `validate`\n\n| Flag | Default | Description |\n|------|---------|-------------|\n| `--config=\u003cpath\u003e` | `./oaspec.yaml` | Path to config file |\n| `--mode=\u003cmode\u003e` | `both` | `server`, `client`, or `both` (overrides config) |\n\n### Validate\n\nCheck a spec for unsupported patterns without generating code:\n\n```sh\noaspec validate --config=oaspec.yaml\n```\n\n### Guard validation\n\nBy 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:\n\n```yaml\nvalidate: true\n```\n\n```sh\noaspec generate --config=oaspec.yaml --validate\n```\n\nWhen enabled, generated routers validate request bodies against schema constraints and return 422 on failure. Generated clients validate request bodies before sending.\n\nThe 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:\n\n```json\n[\n  {\"field\": \"name\", \"code\": \"minLength\", \"message\": \"must be at least 1 character\"},\n  {\"field\": \"age\", \"code\": \"maximum\", \"message\": \"must be at most 150\"}\n]\n```\n\nGenerated clients surface the same failures via `ClientError.ValidationError(errors: List(guards.ValidationFailure))`.\n\n### CI integration\n\nUse `--check` and `--fail-on-warnings` to verify generated code stays in sync:\n\n```sh\n# Fail if generated code would differ from what's committed\noaspec generate --config=oaspec.yaml --check --fail-on-warnings\n```\n\n## Best For\n\n- Generating typed Gleam clients from an OpenAPI contract\n- Keeping request and response types in sync with an external API spec\n- Bootstrapping server-side types, handlers, and router support from the same source spec\n- Catching unsupported spec features early in CI instead of after code generation\n\n## OpenAPI Support\n\n`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.\n\n### operationId uniqueness\n\nEvery 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.\n\nCoverage is strongest in these areas:\n\n- Schemas: component schemas, primitive aliases, enums, nullable fields, arrays, objects, `allOf`, `oneOf`, `anyOf`, and typed `additionalProperties`\n- References: local `$ref` resolution for schemas, parameters, request bodies, responses, and path items, including circular-reference detection\n- Parameters: path, query, header, and cookie parameters, including array serialization (`style: form`, `style: pipeDelimited`, `style: spaceDelimited`) and objects via `style: deepObject`\n- Request bodies: `application/json`, `text/plain`, `application/x-www-form-urlencoded`, and `multipart/form-data`\n- Responses: typed status-code variants, `$ref` responses, `default` responses, typed response headers, and text or binary passthrough cases\n- Security: `apiKey` (header, query, cookie), HTTP auth (bearer, basic, digest), OAuth2, and OpenID Connect. **Client-side**: the generated client attaches credentials per the `security:` declaration on each operation, walking OR-of-AND alternatives and applying the first satisfied one. For OAuth2 and OpenID Connect, the generated client attaches a bearer token to requests; token acquisition, refresh, and flow execution are outside the generated code. **Server-side**: the `security:` declaration on an operation is parsed but **not enforced** by the generated router — the handler is invoked regardless of whether the request carries the declared credentials. Handlers must check `Authorization` / `X-Api-Key` / cookie themselves and return their own 401. See [Server security model](#server-security-model) below.\n- Generation safety: name collision handling, keyword escaping, validation guards, and capability errors with clear failure modes\n\n### `format: byte` and `format: binary`\n\nThe OpenAPI `format` keyword on a `string` schema is passed through as\nmetadata only in the current release. Generated fields keep the Gleam\ntype `String`; the encoded contract (`format: byte` = base64 per OAS 3.0\n§4.7.4 / OAS 3.1 alignment with JSON Schema, `format: binary` = raw\nbytes) is not enforced or materialised by the generator.\n\nPractical implications:\n\n- `format: byte`: the field is decoded and emitted as the literal\n  base64 character string. Callers that need the underlying bytes must\n  base64-decode themselves (e.g. with `yabase/facade.decode_base64`).\n  Invalid base64 input is not rejected at decode time.\n- `format: binary`: the field is decoded and emitted as a plain\n  `String`. For `multipart/form-data` request bodies, the higher-level\n  body codepath (`client_request`) already handles binary bodies\n  correctly via `BytesBody`; this caveat only applies when `binary`\n  appears as a field-level format on a string schema outside that\n  context.\n\nA future release may auto-decode `format: byte` to `BitArray` or emit\na `format` docstring on the generated field; tracking issue\n[#338](https://github.com/nao1215/oaspec/issues/338).\n\n\u003c!-- BEGIN GENERATED:BOUNDARIES --\u003e\n## Current Boundaries\n\nThis section stays in sync with `src/oaspec/internal/capability.gleam`.\n\n- Detected and rejected keywords: `$defs`, `prefixItems`, `if/then/else`, `dependentSchemas`, `not`, `unevaluatedProperties`, `unevaluatedItems`, `contentEncoding`, `contentMediaType`, `contentSchema`, `mutualTLS`, `$id`, `const (non-string)`, `type: [T1, T2] with type-specific constraints`\n- OpenAPI 3.1 `$id`-backed URL refs are still rejected during validation. Rewrite them to local `#/components/schemas/...` refs.\n- `const` is only supported on string schemas. Non-string `const` values and multi-type schemas with type-specific constraints are rejected explicitly.\n- Parsed but not used by codegen: callbacks, webhooks, externalDocs, tags, examples, links, encoding\n- `xml` annotations are ignored by the parser\n- Remaining server-mode request-shape boundaries: `server: complex path parameters`, `server: non-primitive query array items`, `server: non-primitive header array items`, `server: complex deepObject properties`, `server: mixed form-urlencoded request`, `server: complex form-urlencoded fields`, `server: mixed multipart request`, `server: complex multipart fields`, `server: unsupported request content type`\n- Detailed server-mode decisions and fixture coverage live in [doc/server-mode-boundaries.md](./doc/server-mode-boundaries.md)\n- Normalized to supported equivalents: `const` string values become single-value enums, `type: [T, null]` becomes nullable, and `type: [T1, T2]` becomes `oneOf`\n\u003c!-- END GENERATED:BOUNDARIES --\u003e\n\n## Mode-Specific Support\n\n`oaspec` generates different files depending on the `--mode` flag. Some features have mode-specific restrictions enforced at validation time.\n\n### Generated files\n\n| File | server | client |\n|------|--------|--------|\n| `types.gleam` | yes | yes |\n| `decode.gleam` | yes | yes |\n| `encode.gleam` | yes | yes |\n| `request_types.gleam` | yes | yes |\n| `response_types.gleam` | yes | yes |\n| `guards.gleam` | yes | yes |\n| `handlers.gleam` | yes (once) | - |\n| `handlers_generated.gleam` | yes | - |\n| `router.gleam` | yes | - |\n| `client.gleam` | - | yes |\n\n`handlers.gleam` is user-owned. The generator writes panic stubs on the first\nrun and skips the file on every subsequent run, so your implementations survive\nregeneration. `handlers_generated.gleam` is the sealed delegator the router\nimports, and each operation forwards to `handlers.\u003cop_name\u003e(req)`.\n\n### Feature restrictions by mode\n\n| Feature | server | client | Notes |\n|---------|--------|--------|-------|\n| JSON request/response bodies | yes | yes | |\n| Path / query / header / cookie parameters | yes | yes | |\n| `style: deepObject` parameters | restricted | yes | Server: only primitive scalars and primitive arrays |\n| Array query parameters | restricted | yes | Server: only inline primitive item schemas |\n| `style: pipeDelimited` / `style: spaceDelimited` query arrays | yes | yes | Query array parameters only; primitive item types. Non-exploded joins with `\\|` / `%20`, exploded degenerates to form-style `name=a\u0026name=b`. |\n| `application/x-www-form-urlencoded` | restricted | yes | Server: must be sole content type; only primitive fields and shallow nested objects |\n| `multipart/form-data` | restricted | yes | Server: must be sole content type; only primitive scalar fields or arrays of primitive scalars |\n| `text/plain` request body | yes | yes | Treated as a single `String` field on the request |\n| `application/octet-stream` request body | yes | yes | Treated as raw `BitArray`/binary on the request |\n| Security (apiKey, HTTP, OAuth2, OpenID Connect) | parsed (not enforced) | yes | Client attaches credentials via config; OAuth2/OpenID Connect: bearer token only. **Server-side**: see [Server security model](#server-security-model) — the spec's `security:` requirement is parsed but the generated router does not emit a 401 for missing/invalid credentials. Handlers must enforce auth themselves. (#484) |\n\n### Server security model\n\nSpec authors who declare `security:` on an operation expect \"this\nendpoint requires auth\". The current oaspec server codegen\n**does not enforce** that requirement: it parses the security\ndeclaration during validation (so a typo in a scheme name or a\nreference to an undefined `securityScheme` is still caught at\ngeneration time) but emits no auth check in the generated router.\nA request that omits the declared `Authorization` / `X-Api-Key` /\nsession cookie reaches the handler unchanged.\n\nThis is intentional in this release — picking a single auth\nenforcement model would prescribe more policy than the rest of\nthe codegen does — but it is also a sharp edge worth calling out.\nUntil the generator gains a verifier hook, server users have two\noptions:\n\n1. **Enforce in the handler.** The router already passes the full\n   `headers` and (for cookie-based schemes) the path / query /\n   body pieces it receives, so the handler can read\n   `dict.get(headers, \"authorization\")` etc. and short-circuit\n   with a 401 response variant before touching domain logic.\n   Generated `XxxResponse` types include any explicit `\"401\":`\n   variants, and after #483 the `default` response variant carries\n   a runtime `Int` so a single `Default(401, ...)` arm can cover\n   the catch-all 401 case for any operation.\n\n2. **Enforce in an outer adapter layer.** Wrap the generated\n   `router.route/6` in a thin auth-checking function that runs\n   before dispatch — typically the same place where the framework\n   adapter (`mist`, `wisp`, …) lives. This keeps the per-operation\n   handlers focused on domain logic and centralises the auth\n   policy in one place.\n\nTracking issue: [#484](https://github.com/nao1215/oaspec/issues/484).\nFuture work may add an opt-in verifier signature on the generated\n`State` (e.g. `verify_security: fn(scheme, value) -\u003e Result(...)`)\nso the router can emit the 401 itself; that direction is not yet\ncommitted to.\n\n## Library API\n\n`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.\n\n### Public modules at a glance\n\n| Module | Purpose |\n|--------|---------|\n| `oaspec/transport` | Runtime contract for generated clients (`Send` / `AsyncSend` types, `with_base_url`, `with_default_headers`, `with_security`) |\n| `oaspec/mock` | In-memory transport adapter for tests — no network, no FFI |\n| `oaspec/config` | Load config from YAML (`config.load/1` / `config.load_all/1`) or build a `Config` in code (`config.new/6`) |\n| `oaspec/generate` | Pure generation pipeline (`generate.generate/2`, `generate.validate_only/2`) — no IO |\n| `oaspec/openapi/parser` | Parse YAML/JSON spec text into an `OpenApiSpec(Unresolved)` |\n| `oaspec/openapi/diagnostic` | Structured warnings and errors used throughout the pipeline |\n| `oaspec/codegen/writer` | Write a `List(GeneratedFile)` to disk under `output.server` / `output.client` |\n\nIf you only consume generated clients, you only need `oaspec/transport` and\n`oaspec/mock`. Tools that drive generation in-process (CI checks, custom\nbuild steps, doctests) reach for `oaspec/openapi/parser` →\n`oaspec/generate` → `oaspec/codegen/writer`.\n\n### Pipeline overview\n\n```text\nparse → normalize → resolve → capability check → hoist → dedup → validate → codegen\n```\n\nThe `oaspec/generate` module wraps this pipeline into two entry points:\n\n- `generate.generate(spec, config)` — run the full pipeline and return generated files\n- `generate.validate_only(spec, config)` — run validation without code generation\n\n### Example: generate files from a parsed spec\n\n```gleam\nimport oaspec/config\nimport oaspec/generate\nimport oaspec/openapi/parser\n\nlet assert Ok(spec) = parser.parse_file(\"openapi.yaml\")\nlet cfg = config.new(\n  input: \"openapi.yaml\",\n  output_server: \"./gen/my_api\",\n  output_client: \"./gen/my_api_client\",\n  package: \"my_api\",\n  mode: config.Both,\n  validate: False,\n)\n\ncase generate.generate(spec, cfg) {\n  Ok(summary) -\u003e {\n    // summary.files: List(GeneratedFile) — path and content for each file\n    // summary.warnings: List(Diagnostic) — non-blocking warnings\n    // summary.spec_title: String\n    Nil\n  }\n  Error(generate.ValidationErrors(errors:)) -\u003e {\n    // errors: List(Diagnostic) — blocking validation errors\n    Nil\n  }\n}\n```\n\n### Example: validate without generating\n\n```gleam\ncase generate.validate_only(spec, cfg) {\n  Ok(_summary) -\u003e Nil\n  // spec has errors; surface `errors` to the user\n  Error(generate.ValidationErrors(errors: _errors)) -\u003e Nil\n}\n```\n\n## Development\n\nThis project uses [mise](https://mise.jdx.dev/) for tool versions and [just](https://just.systems/) as a task runner.\n\n```sh\nmise install\njust check\njust shellspec\njust integration\n```\n\nTest structure:\n\n| Command | Tool | What it tests |\n|---------|------|---------------|\n| `just test` | gleeunit | Parser, validator, naming, config, collision detection |\n| `just shellspec` | ShellSpec | CLI behaviour, file generation, content, unsupported feature detection |\n| `just integration` | gleeunit | Generated code compiles and the generated modules work together |\n\n## License\n\n[MIT](LICENSE)\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnao1215%2Foaspec","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fnao1215%2Foaspec","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fnao1215%2Foaspec/lists"}