oaspec
[!IMPORTANT] Not fully supporting the entire OpenAPI 3.x specification, oaspec can only perform limited code generation at this stage. Support will be expanded incrementally.
Generate strongly typed Gleam code from OpenAPI 3.x specifications.
- Custom types for every component schema (no generic maps)
- JSON decoders and encoders
- Server handler stubs with TODO placeholders
- Client SDK with query parameter serialization
- Composable middleware system (logging, retry)
- OpenAPI descriptions propagated as doc comments
Install
From GitHub Release (recommended)
Download the oaspec escript binary from the Releases page. Requires Erlang/OTP 27+ runtime.
# Download (replace URL with the latest release)
curl -fSL -o oaspec https://github.com/nao1215/oaspec/releases/latest/download/oaspec
chmod +x oaspec
sudo mv oaspec /usr/local/bin/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 # produces ./oaspec escript binary
sudo mv oaspec /usr/local/bin/Usage
1. Create a config file
oaspec init
This creates oaspec.yaml with a commented template. Edit it for your project:
input: openapi.yaml
package: my_api
output:
dir: ./gen # base directory (default: ./gen)
Generated code is placed at <dir>/<package> and <dir>/<package>_client. To use the generated code in your Gleam project, copy or symlink the output into src/.
| Field | Required | Default | Description |
|---|---|---|---|
input | yes | - | Path to OpenAPI 3.x spec (YAML or JSON) |
package | no | api | Gleam module namespace prefix |
mode | no | both |
Generation mode: server, client, or both |
output.dir | no | ./gen | Base output directory |
output.server | no | <dir>/<package> | Server code output (overrides dir-based default) |
output.client | no | <dir>/<package>_client | Client code output (overrides dir-based default) |
The directory basename must match package so that Gleam imports (import my_api/types) resolve correctly. The CLI --output flag works the same as output.dir in the config file.
2. Run the generator
oaspec generate --config=oaspec.yamlOptions:
--config=<path> Path to config file (default: ./oaspec.yaml)
--mode=<mode> server, client, or both (default: both)
--output=<path> Override output base directory
When developing oaspec itself, you can also run via gleam run -- generate --config=oaspec.yaml.
3. Generated output
gen/my_api/ # server (package = "my_api")
types.gleam # Domain model types
request_types.gleam # Request parameter types
response_types.gleam # Response types (tagged unions by status code)
decode.gleam # JSON decoders
encode.gleam # JSON encoders
middleware.gleam # Middleware types and utilities
handlers.gleam # Handler stubs (TODO placeholders)
router.gleam # Route dispatcher skeleton
gen/my_api_client/ # client
types.gleam # Same domain types
decode.gleam # Same decoders
encode.gleam # Same encoders
middleware.gleam # Same middleware (with retry)
client.gleam # HTTP client functions
request_types.gleam
response_types.gleamGenerated code examples
Given a Petstore OpenAPI spec, oaspec generates:
Types
/// A pet in the store
pub type Pet {
Pet(
id: Int,
name: String,
status: PetStatus,
tag: Option(String)
)
}
/// The status of a pet in the store
pub type PetStatus {
PetStatusAvailable
PetStatusPending
PetStatusSold
}Server handlers
/// List all pets
pub fn list_pets(req: request_types.ListPetsRequest) -> response_types.ListPetsResponse {
let _ = req
// TODO: Implement list_pets
todo
}Client SDK
pub fn get_pet(config: ClientConfig, pet_id: Int) -> Result(ClientResponse, ClientError) {
let path = "/pets/{petId}"
let path = string.replace(path, "{petId}", int.to_string(pet_id))
let assert Ok(req) = request.to(config.base_url <> path)
let req = request.set_method(req, http.Get)
config.send(req)
}Middleware
pub type Handler(req, res) =
fn(req) -> Result(res, MiddlewareError)
pub type Middleware(req, res) =
fn(Handler(req, res)) -> Handler(req, res)
pub fn compose(first: Middleware(req, res), second: Middleware(req, res)) -> Middleware(req, res)
pub fn apply(middlewares: List(Middleware(req, res)), handler: Handler(req, res)) -> Handler(req, res)
pub fn retry(max_retries: Int) -> Middleware(req, res)OpenAPI support
Supported
- OpenAPI 3.x (YAML and JSON input)
- Paths and operations (GET, POST, PUT, DELETE, PATCH)
- Path, query, header, and cookie parameters (path-level merged into operations, serialized in client)
-
Request bodies with
$refschema resolution (typed, not raw String) -
Inline allOf in request body (property merging from
$ref+ inline objects) -
Responses with status codes, including
$refresponses fromcomponents.responses $refparameters, requestBodies, and responses resolved fromcomponents-
Component schemas with
$refresolution (types, decoders, encoders) - String enums with unknown-value rejection (decode returns Error, not silent fallback)
- Inline enums in properties (auto-named types generated)
- Inline objects in response/requestBody (anonymous types auto-generated)
-
oneOf/anyOf with
$refvariants (sum types generated) -
Nullable fields, arrays (including array of
$ref) -
allOf (property merging from inline and
$refschemas, including required fields) -
Encode/decode roundtrip safety:
decode(encode(value)) == Ok(value) -
Circular
$refdetection (returns error instead of infinite recursion) - Fail-fast parser: missing required fields and unknown parameter locations return Error
Explicitly unsupported (generator exits with error)
These patterns are detected before code generation. The generator prints a clear error message and exits non-zero instead of generating broken code.
style: deepObjectquery parametersmultipart/form-datarequest bodiesadditionalProperties: true(untyped map)- Inline oneOf/anyOf with primitive types (
String | Int | Bool)
Not yet supported
- Client response decoding: Client returns raw
ClientResponse, not typed response variants - Client request body encoding: Client accepts
body: String, not typed body parameter - oneOf / anyOf decoders/encoders: Types generated for
$refvariants, but no decoder/encoder - Discriminator in decoders: Parsed but not used in generated decoder logic
- Validation constraints (minLength, maxLength, pattern, minimum, maximum): Parsed but not enforced
- Callbacks: Parsed but ignored
- Security schemes: Parsed but not applied to generated client
Schema-to-type mapping
| OpenAPI type | Gleam type |
|---|---|
string | String |
integer | Int |
number | Float |
boolean | Bool |
array | List(T) |
object | Custom type |
enum | Custom type with variants |
| nullable | Option(T) |
Development
This project uses mise for tool versions and just as a task runner.
mise install # install Gleam, Erlang, rebar3
just check # format check, typecheck, build, unit tests
just shellspec # CLI integration tests (ShellSpec)
just integration # generated code compile + roundtrip testsTest structure
| Command | Tool | What it tests |
|---|---|---|
just test | gleeunit | Unit tests (parser, naming, config) |
just shellspec | ShellSpec | CLI behaviour, file generation, content verification |
just integration | gleeunit | Generated code compiles, types/decoders/encoders/handlers/middleware work |