Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

openapi-nexus is a modular OpenAPI 3.1 code generator written in Rust. It reads an OpenAPI specification and produces type-safe client libraries for multiple languages.

Supported Languages

LanguageGenerator IDStatus
TypeScript (fetch)typescript-fetchStable
Go (net/http)go-httpStable

How It Works

openapi-nexus follows a compiler-like pipeline:

OpenAPI YAML / JSONParse (auto-detect OAS3.0 / 3.1)Lower to IR (IrSpec)CodeGenerator::generate(&IrSpec)Vec<FileInfo> →write to disk

Parsing and lowering happen once in the orchestrator. Each generator receives a pre-lowered IrSpec and produces a list of files. Generators use sigil-stitch for type-safe, import-aware code emission.

Key Properties

  • Deterministic output. The same spec always produces the same files. Golden tests enforce byte-for-byte reproducibility.
  • Compile-checked output. CI runs tsc --noEmit on every generated TypeScript file and go build ./... on every generated Go file.
  • Single binary. The CLI is a self-contained Rust binary with no runtime dependencies.

Getting Started

Installation

Download the latest binary from the releases page, or build from source:

cargo install --path crates/openapi-nexus

Requires Rust 1.90+ (edition 2024).

Basic Usage

Generate a TypeScript fetch client:

openapi-nexus generate \
  --input path/to/openapi.yaml \
  --output generated \
  --generator typescript-fetch

Generate both TypeScript and Go clients at once:

openapi-nexus generate \
  --input spec.yaml \
  --output output \
  --generators typescript-fetch,go-http

Configuration

Configuration is resolved with the following precedence (highest to lowest):

  1. Command-line arguments
  2. Environment variables (prefixed with OPENAPI_NEXUS_)
  3. Configuration file (openapi-nexus-config.toml)
  4. Defaults

Environment Variables

export OPENAPI_NEXUS_INPUT="spec.yaml"
export OPENAPI_NEXUS_OUTPUT="generated"
export OPENAPI_NEXUS_GENERATOR="typescript-fetch"

Configuration File

Create an openapi-nexus-config.toml in your project root. See the sample configuration file for all available options.

Generator-specific options live under [generators.<name>] sections:

[generators.go-http]
module_path = "github.com/myorg/myproject/sdk"

CLI Reference

openapi-nexus generate --help

Architecture

Pipeline

"]OpenAPI YAML / JSONAutBB["openapi-nexus-parserCC["openapi-nexus-irDEEachFileInfo&gtLowersParsesReturnsVersionWritesagnosticdetects

Lowering happens once in the orchestrator (OpenApiCodeGenerator). Generators never touch raw OpenAPI types.

Workspace Crates

Core pipeline

CratePurpose
openapi-nexusCLI binary, orchestrator (OpenApiCodeGenerator, GeneratorRegistry)
openapi-nexus-parserParses YAML/JSON into ParsedSpec
openapi-nexus-specRaw OpenAPI types (OpenApiV30Spec, OpenApiV31Spec)
openapi-nexus-irLowers parsed spec to IrSpec via lower::lower()
openapi-nexus-coreShared traits (CodeGenerator, FileWriter, CombinedGenerator), enums (GeneratorType, Language)
openapi-nexus-configConfiguration loading (CLI > env > TOML > defaults)

Generators

CrateLanguageEmission
openapi-nexus-typescript-fetchTypeScriptsigil-stitch
openapi-nexus-go-httpGosigil-stitch

Both generators live under crates/generators/.

Test infrastructure

CratePurpose
openapi-nexus-test-utilsShared golden-test harness (run_golden_test, generate_files)
fixture-generators/*Generate type-checked OAS fixtures from Rust + utoipa

The CodeGenerator Trait

#![allow(unused)]
fn main() {
pub trait CodeGenerator {
    fn language(&self) -> Language;
    fn generator_type(&self) -> GeneratorType;
    fn generate(&self, ir: &IrSpec) -> Result<Vec<FileInfo>, Box<dyn Error + Send + Sync>>;
}
}

CombinedGenerator is a blanket impl of CodeGenerator + FileWriter. The orchestrator stores generators as Box<dyn CombinedGenerator + Send + Sync> and calls generate() then write_files().

Code Emission

Both generators use sigil-stitch, a type-safe code generation framework. sigil-stitch provides:

  • Language-specific type systems (TypeScript, Go)
  • Import tracking and deduplication
  • Width-aware pretty printing
  • The sigil_quote! macro for inline code templates

Each generator’s sigil_emit*.rs files contain the emission logic that transforms IR types into sigil-stitch AST nodes.

Adding a Generator

This guide walks through adding a new language generator to openapi-nexus.

1. Create the crate

Create a new crate under crates/generators/:

cargo new --lib crates/generators/openapi-nexus-<lang>

Add it to the workspace members list in the root Cargo.toml:

members = [
    # ...
    "crates/generators/openapi-nexus-<lang>",
]

Add workspace dependencies:

# In the new crate's Cargo.toml
[dependencies]
openapi-nexus-core.workspace = true
openapi-nexus-ir.workspace = true
sigil-stitch.workspace = true

2. Add a GeneratorType variant

In crates/openapi-nexus-core/src/generator_type.rs, add a new variant:

#![allow(unused)]
fn main() {
pub enum GeneratorType {
    TypeScriptFetch,
    GoHttp,
    MyLang,  // <-- new
}
}

Implement the Display and FromStr traits to map the CLI name (e.g., "my-lang") to the variant.

3. Implement CodeGenerator

#![allow(unused)]
fn main() {
use openapi_nexus_core::traits::code_generator::CodeGenerator;
use openapi_nexus_core::traits::file_writer::FileWriter;
use openapi_nexus_ir::types::IrSpec;

pub struct MyLangCodeGenerator { /* config fields */ }

impl CodeGenerator for MyLangCodeGenerator {
    fn language(&self) -> Language { Language::MyLang }
    fn generator_type(&self) -> GeneratorType { GeneratorType::MyLang }

    fn generate(&self, ir: &IrSpec) -> Result<Vec<FileInfo>, Box<dyn Error + Send + Sync>> {
        // Transform IrSpec into generated source files
        todo!()
    }
}
}

4. Implement FileWriter

FileWriter writes Vec<FileInfo> to disk. A default implementation is provided, so you typically only need:

#![allow(unused)]
fn main() {
impl FileWriter for MyLangCodeGenerator {}
}

5. Register in the orchestrator

In crates/openapi-nexus/src/openapi_code_generator.rs, inside OpenApiCodeGenerator::new():

#![allow(unused)]
fn main() {
registry.register_generator(Box::new(
    MyLangCodeGenerator::new(/* config */)
));
}

6. Add golden tests

  1. Create crates/generators/openapi-nexus-<lang>/tests/golden_tests_<lang>.rs
  2. Use the run_golden_test harness from openapi-nexus-test-utils
  3. Create golden files: tests/golden/<lang>/<generator-id>/
  4. Generate initial goldens with UPDATE_GOLDEN=1 cargo test --test golden_tests_<lang>

See the Golden Testing chapter for details.

7. Add to CI

In .github/workflows/ci.yml, add a new entry to the golden-build matrix with the appropriate language toolchain setup and just golden::build-<lang> command.

IR Reference

The Intermediate Representation (IrSpec) is the version-agnostic data structure that generators consume. The lowering pass resolves all $ref references, classifies schemas, and normalizes operations into a flat list.

IrSpec

#![allow(unused)]
fn main() {
pub struct IrSpec {
    pub info: IrInfo,
    pub servers: Vec<IrServer>,
    pub schemas: IndexMap<String, IrSchema>,
    pub operations: Vec<IrOperation>,
    pub security_schemes: IndexMap<String, IrSecurityScheme>,
    pub security: Vec<IrSecurityRequirement>,
}
}

All maps use IndexMap for deterministic iteration order.

Schemas

Each IrSchema has a name, optional description, and a classified kind:

#![allow(unused)]
fn main() {
pub enum IrSchemaKind {
    Object(IrObject),
    Enum(IrEnum),
    TaggedUnion(IrTaggedUnion),
    Union(IrUnion),
    Intersection(IrIntersection),
    Alias(IrTypeExpr),
}
}

Object

An object with named properties and optional additional properties:

#![allow(unused)]
fn main() {
pub struct IrObject {
    pub properties: IndexMap<String, IrProperty>,
    pub additional_properties: Option<IrTypeExpr>,
}
}

Each IrProperty carries type_expr, required, nullable, optional description, default_value, format, and validation.

Enum

A typed enumeration with string, integer, number, or mixed values:

#![allow(unused)]
fn main() {
pub struct IrEnum {
    pub value_type: IrEnumValueType,
    pub values: Vec<IrEnumValue>,
}
}

TaggedUnion

A discriminated union (oneOf with a discriminator property):

#![allow(unused)]
fn main() {
pub struct IrTaggedUnion {
    pub discriminator_property: String,
    pub style: TaggedUnionStyle,
    pub variants: Vec<IrTaggedVariant>,
}
}

Styles: InternallyTagged, ExternallyTagged, AdjacentlyTagged, Untagged.

Union

An untagged union (oneOf/anyOf without a discriminator):

#![allow(unused)]
fn main() {
pub struct IrUnion {
    pub members: Vec<IrTypeExpr>,
}
}

Intersection

An allOf intersection:

#![allow(unused)]
fn main() {
pub struct IrIntersection {
    pub members: Vec<IrTypeExpr>,
}
}

Alias

A type alias wrapping a single type expression (e.g., from a $ref):

#![allow(unused)]
fn main() {
Alias(IrTypeExpr)
}

Type Expressions

IrTypeExpr represents type references throughout the IR:

#![allow(unused)]
fn main() {
pub enum IrTypeExpr {
    Named(String),
    Primitive(IrPrimitive),
    Array(Box<IrTypeExpr>),
    Map(Box<IrTypeExpr>),
    Nullable(Box<IrTypeExpr>),
    Union(Vec<IrTypeExpr>),
    Literal(serde_json::Value),
    Unknown,
}
}

Operations

Each IrOperation represents one HTTP method + path:

#![allow(unused)]
fn main() {
pub struct IrOperation {
    pub operation_id: String,
    pub tags: Vec<String>,
    pub method: String,
    pub path: String,
    pub summary: Option<String>,
    pub description: Option<String>,
    pub deprecated: bool,
    pub parameters: Vec<IrParameter>,
    pub request_body: Option<IrRequestBody>,
    pub responses: Vec<IrResponse>,
    pub security: Vec<IrSecurityRequirement>,
}
}

Parameters carry a ParameterLocation (Query, Header, Path, Cookie).

Request bodies map media types to IrTypeExpr:

#![allow(unused)]
fn main() {
pub struct IrRequestBody {
    pub required: bool,
    pub content: IndexMap<String, IrTypeExpr>,
}
}

Responses carry status code, description, content map, and headers.

Security Schemes

#![allow(unused)]
fn main() {
pub enum IrSecurityScheme {
    ApiKey { name, location, description },
    Http { scheme, bearer_format, description },
    OAuth2 { flows, description },
    OpenIdConnect { open_id_connect_url, description },
    MutualTls { description },
}
}

Golden Testing

Golden tests are the primary correctness mechanism for openapi-nexus. They ensure that generated output is byte-for-byte reproducible and that it compiles in the target language.

How It Works

There are two layers:

Layer 1: Snapshot comparison (Rust)

Each generator has a test file that:

  1. Reads an OpenAPI fixture from tests/fixtures/valid/
  2. Runs it through the full pipeline (parse, lower, generate)
  3. Compares each generated file byte-for-byte against a .golden file
tests/
├── fixtures/valid/           # Input OpenAPI specs
├── golden/
│   ├── typescript/
│   │   └── typescript-fetch/ # Expected TS output per fixture
│   └── go/
│       └── go-http/          # Expected Go output per fixture

Each golden directory contains files with a .golden suffix:

tests/golden/go/go-http/petstore/
├── README.md.golden
├── go.mod.golden
├── apis/pets_api.go.golden
├── models/pet.go.golden
├── runtime/auth.go.golden
├── runtime/client.go.golden
└── runtime/errors.go.golden

Layer 2: Compile check

CI materializes each golden directory into a temp folder (stripping the .golden suffix) and runs the target language’s compiler:

  • TypeScript: tsc --noEmit (marker: tsconfig.json.golden)
  • Go: go build ./... (marker: go.mod.golden)

This catches type errors that snapshot comparison alone cannot.

Running Golden Tests

# Run all snapshot tests
cargo test

# Run only TypeScript golden tests
cargo test -p openapi-nexus-typescript-fetch --test golden_tests_typescript_fetch

# Run only Go golden tests
cargo test -p openapi-nexus-go-http --test golden_tests_go_http

# Run a single test by name
cargo test -p openapi-nexus-go-http --test golden_tests_go_http -- minimal

Updating Golden Files

When you intentionally change generator output:

# Update all
UPDATE_GOLDEN=1 cargo test

# Update TypeScript only
UPDATE_GOLDEN=1 cargo test --test golden_tests_typescript_fetch

# Update Go only
UPDATE_GOLDEN=1 cargo test --test golden_tests_go_http

After updating, verify the compile check passes:

just golden::build-ts    # TypeScript compile check
just golden::build-go    # Go compile check
just golden::build-all   # Both

Extra File Detection

The test harness detects when a generator produces files that have no corresponding .golden file. This prevents regressions where new output silently goes untested. If a generator adds a new file, the test will fail with a clear message listing the unmatched files and instructing you to run UPDATE_GOLDEN=1.

Fixture Generators

The fixture-generators/ crates generate OpenAPI specs from Rust code using utoipa annotations. This ensures fixtures are type-checked and valid:

cargo run --bin fixture-generator-petstore-spec-generator
cargo run --bin fixture-generator-enum-repr-spec-generator
cargo run --bin fixture-generator-additional-properties-spec-generator

Output goes to tests/fixtures/valid/.

Target Output Spec

This document defines the target output for the sigil-stitch based code generation. It shows what generated code should look like, serving as the design reference for golden test updates.

This is a breaking change from pre-sigil-stitch output. Users who depend on FromJSON/ToJSON/instanceOfX helpers, ApiXxxRequest wrapper interfaces, or barrel index.ts must migrate.

Locked Decisions

#DecisionRationale
1Field terminator is ; (not ,)Standard idiomatic TypeScript
2Drop FromJSON / ToJSON / FromJSONTyped / ToJSONTypedInterfaces are structural; runtime conversion is noise when field names already match
3Drop instanceOfXStructural checks in TS are a code smell; use discriminators or validators
4Drop XxxPropertyValidationAttributesMapDead data; validation belongs in a schema library
5Drop /* tslint:disable */ and /* eslint-disable */ headerstslint is deprecated; keep @generated header
6Validator is opt-in via --validator zod|valibot|none (default none)Schemas ship as siblings, never force-imported
7No models/index.ts barrelBarrels defeat tree-shaking and slow down IDEs at scale
8Every field is readonlyGenerated DTOs are immutable views of wire data
9Runtime is embedded, ~80 lines target (was 450)Replace dead abstractions with a single request() helper
10Per-tag API classes + top-level Client wrapperErgonomic new Client({ baseUrl }).pets.getPetById(...)
11Client name derived from info.title via PascalCase + Client suffix"Studio AMS — ASM" becomes StudioAmsAsmClient
12Errors throw ApiError with ApiNetworkError and ApiResponseError subclassesIdiomatic JS try/catch + instanceof
13Methods take an options object; path params, query, body are named keysConsistent regardless of param count

Model File

Before (~90 lines)

/* tslint:disable */
/* eslint-disable */
import { type Category, CategoryFromJSON, CategoryToJSON, instanceOfCategory } from './Category';

export interface Pet {
  category?: null | Category,
  id?: number | null,
  name: string,
}

export function instanceOfPet(value: unknown): value is Pet { ... }
export function PetFromJSON(json: any): Pet { ... }
export function PetToJSON(value?: Pet | null): any { ... }

After (~20 lines)

/**
 * @generated by openapi-nexus. Do not edit.
 *
 * Petstore API — 1.0.0
 */
import type { Category } from './Category';

export interface Pet {
  readonly category?: Category | null;
  readonly id?: number | null;
  readonly name: string;
}
  • readonly on every field
  • type imports only (no value imports)
  • Array<T> becomes T[] for simple types

Enum

/** Pet status in the store */
export type PetStatus = 'available' | 'pending' | 'sold';

String-literal union type alias. No enum (not erasable-syntax compatible).

Validator Schemas (Opt-in)

Emitted only when --validator zod or --validator valibot is set. Lives next to the model file as Pet.schema.ts.

Zod

import { z } from 'zod';
export const PetSchema = z.object({
  category: CategorySchema.nullable().optional(),
  name: z.string(),
});
export const parsePet = (input: unknown) => PetSchema.parse(input);

Valibot

import * as v from 'valibot';
export const PetSchema = v.object({
  category: v.optional(v.nullable(CategorySchema)),
  name: v.string(),
});
export const parsePet = (input: unknown) => v.parse(PetSchema, input);

API File

Per-tag class with methods taking options objects:

export class PetApi {
  constructor(private readonly config: ClientConfig) {}

  async getPetById(options: { petId: number; signal?: AbortSignal }): Promise<Pet> {
    return request<Pet>(this.config, {
      method: 'GET',
      path: `/pet/${encodeURIComponent(options.petId)}`,
      signal: options.signal,
    });
  }
}

Removed: ApiXxxRequest wrappers, PetApiInterface, xxxRaw variants, typed response unions.

Client Wrapper

Name derived from info.title (e.g., PetstoreApiClient):

export class PetstoreApiClient {
  readonly pets: PetApi;
  readonly store: StoreApi;

  constructor(options: PetstoreApiClientOptions = {}) {
    const config: ClientConfig = { /* ... */ };
    this.pets = new PetApi(config);
    this.store = new StoreApi(config);
  }
}

Runtime (~80 lines)

export interface ClientConfig {
  baseUrl: string;
  fetch: typeof fetch;
  headers: Record<string, string>;
  beforeRequest?: (init: RequestInit & { url: string }) => ...;
}

export class ApiError extends Error { }
export class ApiNetworkError extends ApiError { }
export class ApiResponseError<T = unknown> extends ApiError { }

export async function request<T>(config: ClientConfig, options: RequestOptions): Promise<T> { }

No BaseAPI, no response wrappers, no RequiredError.

Directory Layout

petstore/
├── apis/
│   ├── PetApi.ts
│   ├── StoreApi.ts
│   └── UserApi.ts
├── models/
│   ├── Pet.ts
│   ├── Pet.schema.ts       ← only with --validator
│   └── PetStatus.ts
├── runtime.ts
├── Client.ts
├── index.ts                 ← re-exports Client + types
├── package.json
└── tsconfig.json

Go Side

Go keeps its current shape: per-tag client structs, struct + method receiver idiom, encoding/json marshaling. Same info.title-derived client naming. Concrete Go target goes in a follow-up spec doc.

Migration Table

OldNew
import { Pet, PetFromJSON } from 'petstore/models/Pet'import type { Pet } from 'petstore/models/Pet'
PetFromJSON(json)Direct assign or parsePet(json) with --validator
instanceOfPet(x)parsePet(x) with validator, or write your own guard
new PetApi(config).getPetByIdRaw(...)new PetstoreApiClient({ baseUrl }).pets.getPetById(...)
catch (e: ResponseError) { e.response }catch (e) { if (e instanceof ApiResponseError) e.body }