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
| Language | Generator ID | Status |
|---|---|---|
| TypeScript (fetch) | typescript-fetch | Stable |
| Go (net/http) | go-http | Stable |
How It Works
openapi-nexus follows a compiler-like pipeline:
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 --noEmiton every generated TypeScript file andgo build ./...on every generated Go file. - Single binary. The CLI is a self-contained Rust binary with no runtime dependencies.
Links
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):
- Command-line arguments
- Environment variables (prefixed with
OPENAPI_NEXUS_) - Configuration file (
openapi-nexus-config.toml) - 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
Lowering happens once in the orchestrator (OpenApiCodeGenerator). Generators never touch raw OpenAPI types.
Workspace Crates
Core pipeline
| Crate | Purpose |
|---|---|
openapi-nexus | CLI binary, orchestrator (OpenApiCodeGenerator, GeneratorRegistry) |
openapi-nexus-parser | Parses YAML/JSON into ParsedSpec |
openapi-nexus-spec | Raw OpenAPI types (OpenApiV30Spec, OpenApiV31Spec) |
openapi-nexus-ir | Lowers parsed spec to IrSpec via lower::lower() |
openapi-nexus-core | Shared traits (CodeGenerator, FileWriter, CombinedGenerator), enums (GeneratorType, Language) |
openapi-nexus-config | Configuration loading (CLI > env > TOML > defaults) |
Generators
| Crate | Language | Emission |
|---|---|---|
openapi-nexus-typescript-fetch | TypeScript | sigil-stitch |
openapi-nexus-go-http | Go | sigil-stitch |
Both generators live under crates/generators/.
Test infrastructure
| Crate | Purpose |
|---|---|
openapi-nexus-test-utils | Shared 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
- Create
crates/generators/openapi-nexus-<lang>/tests/golden_tests_<lang>.rs - Use the
run_golden_testharness fromopenapi-nexus-test-utils - Create golden files:
tests/golden/<lang>/<generator-id>/ - 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:
- Reads an OpenAPI fixture from
tests/fixtures/valid/ - Runs it through the full pipeline (parse, lower, generate)
- Compares each generated file byte-for-byte against a
.goldenfile
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
| # | Decision | Rationale |
|---|---|---|
| 1 | Field terminator is ; (not ,) | Standard idiomatic TypeScript |
| 2 | Drop FromJSON / ToJSON / FromJSONTyped / ToJSONTyped | Interfaces are structural; runtime conversion is noise when field names already match |
| 3 | Drop instanceOfX | Structural checks in TS are a code smell; use discriminators or validators |
| 4 | Drop XxxPropertyValidationAttributesMap | Dead data; validation belongs in a schema library |
| 5 | Drop /* tslint:disable */ and /* eslint-disable */ headers | tslint is deprecated; keep @generated header |
| 6 | Validator is opt-in via --validator zod|valibot|none (default none) | Schemas ship as siblings, never force-imported |
| 7 | No models/index.ts barrel | Barrels defeat tree-shaking and slow down IDEs at scale |
| 8 | Every field is readonly | Generated DTOs are immutable views of wire data |
| 9 | Runtime is embedded, ~80 lines target (was 450) | Replace dead abstractions with a single request() helper |
| 10 | Per-tag API classes + top-level Client wrapper | Ergonomic new Client({ baseUrl }).pets.getPetById(...) |
| 11 | Client name derived from info.title via PascalCase + Client suffix | "Studio AMS — ASM" becomes StudioAmsAsmClient |
| 12 | Errors throw ApiError with ApiNetworkError and ApiResponseError subclasses | Idiomatic JS try/catch + instanceof |
| 13 | Methods take an options object; path params, query, body are named keys | Consistent 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;
}
readonlyon every fieldtypeimports only (no value imports)Array<T>becomesT[]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
| Old | New |
|---|---|
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 } |