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 code generator written in Rust. It reads an OpenAPI specification (3.0, 3.1, or 3.2) and produces type-safe client libraries for multiple languages.

Supported Languages

LanguageGenerator IDHTTP ClientStatus
TypeScripttypescript-fetchfetchBeta
Gogo-httpnet/httpBeta
Rustrust-reqwestreqwestBeta
Rustrust-urequreqBeta
Rustrust-aioductaioductBeta
Pythonpython-httpxhttpxBeta
Pythonpython-requestsrequestsBeta
Javajava-okhttpOkHttpBeta
Kotlinkotlin-okhttpOkHttpBeta

How It Works

openapi-nexus follows a compiler-like pipeline:

OpenAPI YAML / JSONParse (auto-detect OAS3.0 / 3.1 / 3.2)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 language-specific compile checks on every generated file: tsc --noEmit (TypeScript), go build (Go), cargo check (Rust), pyright (Python), gradle compileJava (Java), gradle compileKotlin (Kotlin).
  • Single binary. The CLI is a self-contained Rust binary with no runtime dependencies.

Getting Started

Installation

Shell installer (no Rust toolchain needed):

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/rust-codegen-group/openapi-nexus/releases/download/0.3.0/openapi-nexus-installer.sh | sh

Build from source:

cargo install 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 \
  --generators typescript-fetch

Generate another target language into a separate directory:

openapi-nexus generate \
  --input spec.yaml \
  --output output/go \
  --generators go-http

openapi-nexus generate \
  --input spec.yaml \
  --output output/python \
  --generators python-httpx

All nine generators, each with its own output directory:

for generator in \
  typescript-fetch \
  go-http \
  rust-reqwest \
  rust-ureq \
  rust-aioduct \
  python-httpx \
  python-requests \
  java-okhttp \
  kotlin-okhttp
do
  openapi-nexus generate -i spec.yaml -o "output/${generator}" -g "${generator}"
done

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_GENERATORS="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"

[generators.rust-reqwest]
crate_name = "my-api-client"
workspace_mode = true
workspace_deps = "workspace_version"  # "explicit" | "workspace_version" | "full"

[generators.rust-reqwest.extra_derives.structs]
derives = ["PartialEq"]

[generators.rust-reqwest.extra_derives.enums]
derives = ["Hash"]

[generators.rust-reqwest.utoipa]
enabled = true
dependency = '{ version = "5" }'

[generators.typescript-fetch]
emit_enum_constants = true
emit_type_guards = true
property_naming = "camelCase"

CLI Reference

openapi-nexus generate --help

Migrating from 0.2.3 to 0.3.0

Rust library integrations

OpenApiCodeGenerator::generate(&config) now returns Result<GenerationReport, PipelineError>. An outer Err means the shared parse or lowering pipeline failed. An outer Ok does not guarantee every selected target succeeded: inspect report.is_success() or report.failures.

#![allow(unused)]
fn main() {
use openapi_nexus::{GenerationReport, OpenApiCodeGenerator, PipelineError};
use openapi_nexus::config::Config;

fn generate(config: &Config) -> Result<GenerationReport, PipelineError> {
    let generator = OpenApiCodeGenerator::new(config);
    let report = generator.generate(config)?;
    for failure in &report.failures {
        eprintln!("{failure}");
    }
    // The caller must decide whether a partial result is acceptable.
    Ok(report)
}
}

Custom CodeGenerator implementations now return Result<Vec<FileInfo>, GeneratorError>. Custom FileWriter implementations return Result<(), WriteError> from write_files; handle the concrete error variants instead of relying on dynamic error strings.

Update configuration error matches/construction from ConfigError::Validation(message) to ConfigError::Validation { message }, and likewise for ParseOverrides.

Code constructing OpenAPI 3.0 ObjectSchema values must use ExclusiveBound::Boolean(...) or ExclusiveBound::Number(...) for exclusive-bound fields. JSON/YAML boolean flags and legacy numeric forms are both accepted.

Code constructing IrTaggedVariant values must initialize additional_properties: normally None, or Some(value_type) for extensions on an external wrapper. Internal-variant extensions remain part of the payload type.

CLI automation

Treat nonzero generator exit codes as failures. Multi-generator runs still collect per-target failures while continuing other selected targets, but the overall CLI command now exits unsuccessfully when any target fails. Do not treat partial output or a logged completion message as proof of success.

Java and Kotlin generated clients

Regenerate clients and recompile consumers. Supported unions now expose typed alternatives instead of untyped Object/Any values. Use the names in each generated model: they depend on schema shape and must not be guessed globally.

For example, the union-with-any fixture generates Kotlin Config.VariantString(value0) and Java new Config.VariantString(value0); payloads are accessed as value0 in Kotlin and value0() in Java. This is an example, not a universal naming contract for every union.

Keep the generated Gson adapters: they serialize the schema’s original wire shape, not a generic wrapper containing the variant class name. Confirm round trips with the schemas your application actually uses.

Generated Kotlin projects use Kotlin Gradle plugin 2.2.21; the generated JVM toolchain target remains 17. Java closed-sum models use sealed interfaces/records and require the generated project’s Java 17 target or newer.

TypeScript generated clients

Regenerate and rebuild clients. Declared additional-property dictionaries are now readonly, including compatible allOf aliases. Construct replacement objects instead of assigning directly through their string indexes. Readonly is a type contract; generated runtime objects are not frozen.

In camelCase mode, named fields are renamed but extension keys stay verbatim. Typed additional values are converted recursively; opaque JSON contents are not renamed. Use FromJSON and ToJSON at the wire/domain boundary.

For a collision such as {known_field: 'fixed', knownField: 'extension'}, ergonomic access remains value.knownField === 'fixed'. The extension is retained under enumerable Symbol.for('openapi-nexus.additionalProperties') metadata. {...value} retains it; JSON.stringify(value) and JSON-based cloning do not. Serialize through the generated ToJSON converter to restore the complete wire payload. Preserve mode and $Wire types are alternatives when original wire-key access is preferable.

Compatible object/map allOf values are planned together, including arbitrary-JSON types that render as unknown. Ambiguous multi-member unions retain their prior conversion policy; this release does not introduce universal runtime validation.

Installation and security scope

Use the versioned 0.3.0 release installers after the release exists, or install the 0.3.0 crate after crates.io publication. The nightly workflow has been removed.

The ZIP security fix removes an unused fixture-generator build dependency. It is not a claim that generated SDKs have received new archive-validation features or that every dependency has undergone a complete security audit.

Authentication

Every generated SDK includes a runtime authentication module with built-in support for Bearer tokens, API keys, and HTTP Basic auth. Security schemes declared in your OpenAPI spec are reflected in the generated client interface — you supply the credentials when constructing the client, and they are automatically attached to every outgoing request.

Usage Patterns

Static Token

The simplest case: a long-lived token known at startup.

TypeScript

import { Configuration, PetsApi } from "@example/petstore";

const api = new PetsApi(new Configuration({
  basePath: "https://api.example.com",
  accessToken: process.env.API_TOKEN,
}));

const pet = await api.getPetById({ petId: 42 });

Go

client := runtime.NewClient("https://api.example.com",
    runtime.WithAuth(runtime.BearerAuth{Token: os.Getenv("API_TOKEN")}),
)
api := sdk.NewPetsApi(client)
pet, err := api.GetPetById(context.Background(), 42)

Python

import os
from sdk import PetsApi
from sdk.runtime import Client, BearerAuth

api = PetsApi(Client(
    base_url="https://api.example.com",
    authenticator=BearerAuth(os.environ["API_TOKEN"]),
))
pet = api.get_pet_by_id(pet_id=42)

Rust

#![allow(unused)]
fn main() {
use sdk::PetsApi;
use sdk::runtime::{Client, BearerAuth};

let client = Client::new("https://api.example.com")
    .with_auth(BearerAuth::new(std::env::var("API_TOKEN").unwrap()));
let api = PetsApi::new(client);
let pet = api.get_pet_by_id(42).await?;
}

Dynamic Token (OAuth2 refresh)

When tokens expire and need periodic refresh, pass a function that returns the current token. The function is called on every request, so it always picks up the latest value.

TypeScript

class TokenStore {
  private token: string | null = null;
  private expiresAt = 0;

  async getToken(): Promise<string> {
    if (Date.now() > this.expiresAt) {
      const res = await fetch("/oauth/token", {
        method: "POST",
        body: new URLSearchParams({
          grant_type: "client_credentials",
          client_id: process.env.CLIENT_ID!,
          client_secret: process.env.CLIENT_SECRET!,
        }),
      });
      const data = await res.json();
      this.token = data.access_token;
      this.expiresAt = Date.now() + data.expires_in * 1000;
    }
    return this.token!;
  }
}

const store = new TokenStore();
const api = new PetsApi(new Configuration({
  basePath: "https://api.example.com",
  accessToken: () => store.getToken(),  // evaluated per-request
}));

Go

type TokenStore struct {
    mu        sync.Mutex
    token     string
    expiresAt time.Time
}

func (s *TokenStore) GetToken() string {
    s.mu.Lock()
    defer s.mu.Unlock()
    if time.Now().After(s.expiresAt) {
        // refresh logic ...
        s.token = refreshedToken
        s.expiresAt = time.Now().Add(1 * time.Hour)
    }
    return s.token
}

store := &TokenStore{}
client := runtime.NewClient("https://api.example.com",
    runtime.WithAuth(runtime.BearerAuth{
        TokenProvider: store.GetToken,  // evaluated per-request
    }),
)

Python

import time
import httpx
from sdk import PetsApi
from sdk.runtime import Client, BearerAuth

class TokenStore:
    def __init__(self):
        self._token: str | None = None
        self._expires_at = 0.0

    def get_token(self) -> str:
        if time.time() > self._expires_at:
            resp = httpx.post("https://auth.example.com/oauth/token", data={
                "grant_type": "client_credentials",
                "client_id": os.environ["CLIENT_ID"],
                "client_secret": os.environ["CLIENT_SECRET"],
            })
            data = resp.json()
            self._token = data["access_token"]
            self._expires_at = time.time() + data["expires_in"]
        return self._token

store = TokenStore()
api = PetsApi(Client(
    base_url="https://api.example.com",
    authenticator=BearerAuth(store.get_token),  # evaluated per-request
))

Rust

#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use sdk::PetsApi;
use sdk::runtime::{Client, BearerAuth};

struct TokenStore {
    token: Mutex<String>,
    expires_at: Mutex<Instant>,
}

impl TokenStore {
    fn get_token(&self) -> String {
        let mut expires = self.expires_at.lock().unwrap();
        if Instant::now() > *expires {
            // refresh logic ...
            *self.token.lock().unwrap() = refreshed_token;
            *expires = Instant::now() + Duration::from_secs(3600);
        }
        self.token.lock().unwrap().clone()
    }
}

let store = Arc::new(TokenStore { /* ... */ });
let s = Arc::clone(&store);
let client = Client::new("https://api.example.com")
    .with_auth(BearerAuth::from_provider(move || s.get_token()));
let api = PetsApi::new(client);
}

Authenticator Interface

All generators expose an Authenticator interface (trait in Rust, abstract base class in Python) that the generated client calls before each request. The runtime ships with three concrete implementations:

Auth TypeClass / StructWire Format
Bearer tokenBearerAuthAuthorization: Bearer <token>
API keyApiKeyAuthConfigurable header, query parameter, or cookie
HTTP BasicBasicAuthAuthorization: Basic <base64(user:pass)>

Bearer Tokens

Static Token

Pass a string at construction time. The same token is sent with every request.

TypeScript

import { Configuration, DefaultApi } from "./runtime";

const config = new Configuration({
  basePath: "https://api.example.com",
  accessToken: "my-static-token",
});

const api = new DefaultApi(config);

Go

import "example.com/sdk/runtime"

client := runtime.NewClient("https://api.example.com",
    runtime.WithAuth(runtime.BearerAuth{Token: "my-static-token"}),
)

Rust

#![allow(unused)]
fn main() {
use sdk::runtime::{Client, BearerAuth};

let client = Client::new("https://api.example.com")
    .with_auth(BearerAuth::new("my-static-token"));
}

Python

from sdk.runtime import Client, BearerAuth

client = Client(
    base_url="https://api.example.com",
    authenticator=BearerAuth("my-static-token"),
)

Java

import com.example.sdk.runtime.ApiClient;
import com.example.sdk.runtime.BearerAuth;

ApiClient client = new ApiClient(
    "https://api.example.com",
    new OkHttpClient(),
    new BearerAuth("my-static-token"),
    Map.of()
);

Kotlin

import com.example.sdk.runtime.ApiClient
import com.example.sdk.runtime.BearerAuth

val client = ApiClient(
    baseUrl = "https://api.example.com",
    client = OkHttpClient(),
    authenticator = BearerAuth("my-static-token"),
    defaultHeaders = emptyMap()
)

Dynamic Token Provider

When the token changes over time — after user login, or when refreshing an OAuth2 access token — pass a function instead of a string. The function is called on every request, so it always produces the current token.

TypeScript

const config = new Configuration({
  basePath: "https://api.example.com",
  accessToken: () => getCurrentBearerToken(),  // called per-request
});

The accessToken field accepts string | Promise<string> | ((name?, scopes?) => string | Promise<string>). A plain function is called synchronously; an async function or Promise is awaited.

Go

client := runtime.NewClient("https://api.example.com",
    runtime.WithAuth(runtime.BearerAuth{
        TokenProvider: func() string {
            return getCurrentToken()  // called per-request
        },
    }),
)

If both Token and TokenProvider are set, the provider takes precedence.

Rust

#![allow(unused)]
fn main() {
use sdk::runtime::{Client, BearerAuth};

let client = Client::new("https://api.example.com")
    .with_auth(BearerAuth::from_provider(|| {
        get_current_token()  // called per-request
    }));
}

BearerAuth::new(token) stores a static string. BearerAuth::from_provider(fn) wraps your closure in an Arc and evaluates it on every authenticate() call. The closure must be Fn() -> String + Send + Sync + 'static.

Python

from sdk.runtime import Client, BearerAuth

client = Client(
    base_url="https://api.example.com",
    authenticator=BearerAuth(lambda: get_current_token()),  # called per-request
)

BearerAuth accepts str | Callable[[], str]. If you pass a callable, it is invoked inside auth_headers() on every request.

Java

import java.util.function.Supplier;

ApiClient client = new ApiClient(
    "https://api.example.com",
    new OkHttpClient(),
    new BearerAuth(() -> getCurrentToken()),  // called per-request
    Map.of()
);

BearerAuth has two constructors: BearerAuth(String token) for static tokens, and BearerAuth(Supplier<String> tokenProvider) for dynamic ones.

Kotlin

val client = ApiClient(
    baseUrl = "https://api.example.com",
    client = OkHttpClient(),
    authenticator = BearerAuth { getCurrentToken() },  // called per-request
    defaultHeaders = emptyMap()
)

Kotlin’s trailing-lambda syntax makes the provider form especially concise. BearerAuth has two constructors: constructor(token: String) and constructor(tokenProvider: () -> String).

API Key Authentication

ApiKeyAuth sends a named key in a header, query parameter, or cookie. Like BearerAuth, it supports both static keys and dynamic key providers evaluated per-request.

Static Key

Go

client := runtime.NewClient("https://api.example.com",
    runtime.WithAuth(runtime.ApiKeyAuth{
        Key:      "sk-abc123",
        Name:     "X-API-Key",
        Location: runtime.APIKeyInHeader,
    }),
)

Rust

#![allow(unused)]
fn main() {
use sdk::runtime::{Client, ApiKeyAuth};

let client = Client::new("https://api.example.com")
    .with_auth(ApiKeyAuth::new("X-API-Key", "sk-abc123"));
}

Python

from sdk.runtime import Client, ApiKeyAuth

client = Client(
    base_url="https://api.example.com",
    authenticator=ApiKeyAuth(header_name="X-API-Key", api_key="sk-abc123"),
)

Java

import com.example.sdk.runtime.ApiKeyAuth;
import com.example.sdk.runtime.ApiKeyLocation;

new ApiKeyAuth("sk-abc123", "X-API-Key", ApiKeyLocation.HEADER);

Kotlin

import com.example.sdk.runtime.ApiKeyAuth
import com.example.sdk.runtime.ApiKeyLocation

ApiKeyAuth("sk-abc123", "X-API-Key", ApiKeyLocation.HEADER)

TypeScript

TypeScript handles API keys through the apiKey field on ConfigurationParameters, which supports the same string | Promise<string> | ((name: string) => string | Promise<string>) union as accessToken. Because the wire placement (header name, query parameter, or cookie) depends on the OpenAPI security scheme definition, the key is available for use in custom middleware rather than being auto-wired:

const config = new Configuration({
  basePath: "https://api.example.com",
  apiKey: (name) => getApiKey(name),  // called with the scheme name
});

Dynamic Key Provider

Go

client := runtime.NewClient("https://api.example.com",
    runtime.WithAuth(runtime.ApiKeyAuth{
        KeyProvider: func() string {
            return getCurrentApiKey()  // called per-request
        },
        Name:     "X-API-Key",
        Location: runtime.APIKeyInHeader,
    }),
)

Rust

#![allow(unused)]
fn main() {
let client = Client::new("https://api.example.com")
    .with_auth(ApiKeyAuth::from_provider("X-API-Key", || {
        get_current_api_key()  // called per-request
    }));
}

Python

client = Client(
    base_url="https://api.example.com",
    authenticator=ApiKeyAuth(
        header_name="X-API-Key",
        api_key=lambda: get_current_api_key(),  # called per-request
    ),
)

Java

new ApiKeyAuth(() -> getCurrentApiKey(), "X-API-Key", ApiKeyLocation.HEADER);

Kotlin

ApiKeyAuth({ getCurrentApiKey() }, "X-API-Key", ApiKeyLocation.HEADER)

HTTP Basic Authentication

BasicAuth sends a base64-encoded username:password pair. Available in Go and TypeScript; for other languages, implement a custom Authenticator.

Static Credentials

Go

client := runtime.NewClient("https://api.example.com",
    runtime.WithAuth(runtime.BasicAuth{
        Username: "alice",
        Password: "s3cret",
    }),
)

TypeScript

const config = new Configuration({
  basePath: "https://api.example.com",
  username: "alice",
  password: "s3cret",
});

Dynamic Credentials

Go

client := runtime.NewClient("https://api.example.com",
    runtime.WithAuth(runtime.BasicAuth{
        UsernameProvider: func() string { return getCurrentUser() },
        PasswordProvider: func() string { return getCurrentPass() },
    }),
)

TypeScript

const config = new Configuration({
  basePath: "https://api.example.com",
  username: () => getCurrentUser(),
  password: () => getCurrentPass(),
});

The username and password fields accept string | (() => string | Promise<string>). A plain function is called synchronously; an async function is awaited. Basic auth is only applied when no Authorization header has already been set by accessToken.

Custom Authenticators

Every generator’s Authenticator is an open interface — you can implement it for custom auth schemes without modifying the generated code.

TypeScript

Use middleware:

const api = new DefaultApi(config).withPreMiddleware({
  pre: async (ctx) => {
    ctx.init.headers = { ...ctx.init.headers, 'X-Custom': 'value' };
    return ctx;
  },
});

Go

type myAuth struct{}

func (myAuth) AuthenticateRequest(req *http.Request) error {
    req.Header.Set("X-Custom", "value")
    return nil
}

Rust

#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
struct MyAuth;

impl Authenticator for MyAuth {
    fn authenticate(&self, headers: &mut HeaderMap) -> Result<(), Error> {
        headers.insert(
            HeaderName::from_static("x-custom"),
            HeaderValue::from_static("value"),
        );
        Ok(())
    }
}
}

Python

class MyAuth(Authenticator):
    def auth_headers(self) -> dict[str, str]:
        return {"X-Custom": "value"}

Java

public class MyAuth implements Authenticator {
    @Override
    public void authenticate(Request.Builder builder) {
        builder.header("X-Custom", "value");
    }
}

Kotlin

class MyAuth : Authenticator {
    override fun authenticate(builder: Request.Builder) {
        builder.header("X-Custom", "value")
    }
}

Multipart and Binary Bodies

OpenAPI uses the same type: string, format: binary schema shape in several places, but generated clients intentionally expose different APIs depending on the HTTP body.

  • Multipart binary parts use a generated upload wrapper so callers can provide a filename.
  • Raw application/octet-stream request bodies stay as raw bytes or blob values.
  • Binary response bodies stay as raw bytes or blob values.
  • JSON and text multipart parts keep their normal generated model or scalar types.

See examples/multipart-binary/openapi.yaml for a complete spec.

Multipart Uploads

For object-shaped multipart/form-data request bodies, openapi-nexus generates an operation-specific request body model. Binary properties in that model use an upload wrapper instead of the normal raw binary type.

Given this request body:

requestBody:
  required: true
  content:
    multipart/form-data:
      schema:
        type: object
        required: [file, profile, purpose]
        properties:
          file:
            type: string
            format: binary
          profile:
            $ref: '#/components/schemas/ProfileAttributes'
          purpose:
            type: string
      encoding:
        file:
          contentType: image/png
        profile:
          contentType: application/json
        purpose:
          contentType: text/plain

The generated request model has a binary file upload field, a normal JSON profile field, and a normal text purpose field. The multipart field name remains file; the filename is read from the upload wrapper. If the caller does not provide a filename, generated clients fall back to the field name.

Upload Filenames

Use the wrapper when the filename matters, for example when the multipart field name is file but the uploaded filename should be avatar.png.

TypeScript

TypeScript accepts browser-native File values directly. It also accepts { data: Blob, filename?: string } for runtimes or tests that have Blob but not File.

import { AvatarsApi } from './generated/apis/AvatarsApi';

const api = new AvatarsApi();

await api.uploadAvatar({
  body: {
    file: { data: new Blob([bytes], { type: 'image/png' }), filename: 'avatar.png' },
    profile: { display_name: 'Ada Lovelace' },
    purpose: 'profile',
  },
});

await api.uploadAvatar({
  body: {
    file: new File([bytes], 'avatar.png', { type: 'image/png' }),
    profile: { display_name: 'Ada Lovelace' },
    purpose: 'profile',
  },
});

Passing a plain Blob is still accepted, but the filename falls back to the multipart field name.

Go

body := &models.UploadAvatarMultipartRequestBody{
	File: runtime.NewUploadFile(pngBytes, "avatar.png"),
	Profile: models.ProfileAttributes{
		DisplayName: "Ada Lovelace",
	},
	Purpose: "profile",
}

resp, err := avatars.UploadAvatar(ctx, body)

Use runtime.NewUploadFileBytes(pngBytes) when the fallback filename is acceptable.

Python

The same UploadFile wrapper is generated for python-httpx and python-requests.

from generated.models.upload_avatar_multipart_request_body import UploadAvatarMultipartRequestBody
from generated.models.profile_attributes import ProfileAttributes
from generated.runtime import UploadFile

body = UploadAvatarMultipartRequestBody(
    file=UploadFile.from_bytes(png_bytes, filename="avatar.png"),
    profile=ProfileAttributes(display_name="Ada Lovelace"),
    purpose="profile",
)

client.avatars.upload_avatar(body=body)

Use UploadFile.from_bytes(png_bytes) when the fallback filename is acceptable.

Java

UploadAvatarMultipartRequestBody body = new UploadAvatarMultipartRequestBody(
    UploadFile.of(pngBytes, "avatar.png"),
    new ProfileAttributes(null, "Ada Lovelace"),
    "profile"
);

UploadAvatarResponse response = avatarsApi.uploadAvatar(body);

Use UploadFile.ofBytes(pngBytes) when the fallback filename is acceptable.

Kotlin

val body = UploadAvatarMultipartRequestBody(
    file = UploadFile(pngBytes, "avatar.png"),
    profile = ProfileAttributes(displayName = "Ada Lovelace"),
    purpose = "profile",
)

val response = avatarsApi.uploadAvatar(body)

Use UploadFile(pngBytes) when the fallback filename is acceptable.

Rust

The same UploadFile wrapper is generated for rust-reqwest, rust-ureq, and rust-aioduct.

#![allow(unused)]
fn main() {
let body = UploadAvatarMultipartRequestBody {
    file: UploadFile::new(png_bytes, "avatar.png"),
    profile: ProfileAttributes {
        display_name: "Ada Lovelace".to_string(),
        alt_text: None,
    },
    purpose: "profile".to_string(),
};

let response = avatars_api.upload_avatar(&body).await?;
}

Use UploadFile::from_bytes(png_bytes) when the fallback filename is acceptable.

Raw Binary Bodies

For non-multipart application/octet-stream bodies, openapi-nexus does not use the upload wrapper. The method body type remains the normal binary type for the target language:

GeneratorRequest body type
typescript-fetchBlob | File
go-http[]byte
python-httpxbytes
python-requestsbytes
java-okhttpbyte[]
kotlin-okhttpByteArray
rust-reqwestVec<u8>
rust-ureqVec<u8>
rust-aioductVec<u8>

This keeps ordinary binary request bodies and binary responses separate from multipart filename handling.

Supported Multipart Shape

Multipart request bodies must be object-shaped. Each object property becomes one multipart part.

  • Binary parts use UploadFile or UploadFileInput.
  • String, number, boolean, and enum parts are emitted as text.
  • Object and array parts are emitted as JSON.
  • encoding.<part>.contentType controls the per-part Content-Type when present.

Schemas that do not describe an object-shaped multipart body are rejected by generators with an explicit unsupported multipart error.

Response Headers

Generated clients retain response headers for both successful and non-successful HTTP responses. Header preservation is not limited to headers declared in the OpenAPI document: an undeclared Retry-After, request ID, tracing header, cookie, or vendor header remains available through the HTTP backend’s native header collection.

OpenAPI-declared response headers additionally receive generated convenience accessors. Simple string, integer, number, and boolean schemas map to the corresponding language type. Accessors are always optional because a server can omit a declared header or send a value that does not match its schema. A missing or malformed value does not turn an otherwise valid response into an error. Scalar parsing uses the same wire policy across generators: booleans are the lowercase literals true and false, integers are signed decimal digits, and numbers are finite decimal values with an optional exponent. Other spellings return the accessor’s empty result while remaining available in the native header collection.

Retry-After should normally be declared as a string because HTTP allows either delay seconds or an HTTP date:

components:
  headers:
    RetryAfter:
      description: Delay in seconds or an HTTP date before retrying.
      schema:
        type: string

paths:
  /jobs:
    post:
      responses:
        "429":
          description: Too many requests.
          headers:
            Retry-After:
              $ref: "#/components/headers/RetryAfter"

Reading headers

Generated accessor names follow the operation and header names in the OpenAPI document. This Rust example uses an accessor for each declared header and falls back to the backend-native header map for an undeclared tracing header:

#![allow(unused)]
fn main() {
match api.create_resource(&request).await {
    Ok(response) => {
        let request_id = response.x_request_id_header();
        let traceparent = response
            .headers
            .get("traceparent")
            .and_then(|value| value.to_str().ok());

        println!("request_id={request_id:?} traceparent={traceparent:?}");
    }
    Err(error) => {
        let retry_after = error.retry_after_header().map(str::to_owned);

        // Erase the payload type when only common response metadata is needed.
        let error: ApiCallError = error.into();
        let traceparent = error
            .headers()
            .and_then(|headers| headers.get("traceparent"))
            .and_then(|value| value.to_str().ok());

        eprintln!("retry_after={retry_after:?} traceparent={traceparent:?}");
    }
}
}

In this example, X-Request-Id and Retry-After are declared in OpenAPI, so the generator provides typed accessors. traceparent is undeclared and remains available through the native header map. Match the operation-specific error before converting it to ApiCallError when its decoded body is needed; see Generated Error Handling.

Generated access

GeneratorSuccessful response headersError response headers
TypeScript Fetchraw response wrapper headers; generated get...Header functionsResponseError.response.headers; generated methods on the operation error
Go HTTPresponse.Raw.Header; generated (value, ok) methodserror.Header; generated (value, ok) methods
Rust reqwest, ureq, aioductnative HeaderMap in response.headers; generated Option<T> methodsnative HeaderMap from ApiError::headers(); generated Option<T> methods on the operation error
Python httpx, requestsmethod_with_http_info(...).headers; generated optional propertiesApiError.headers; generated optional properties
Java OkHttpresponse.getRaw().headers(); generated nullable gettersexception.headers(); generated nullable getters
Kotlin OkHttpresponse.raw.headers; generated nullable methodsexception.headers; generated nullable methods

The native collection remains the authority when a caller needs undeclared headers or backend-specific parsing. A generated scalar accessor reads one value only. Duplicate-field behavior follows the HTTP backend; some backends retain each field while others coalesce duplicate fields into one value.

Browser visibility

TypeScript Fetch follows the browser Fetch API. For cross-origin requests, JavaScript can only read CORS-safelisted response headers plus headers named by the server’s Access-Control-Expose-Headers response header. The browser may therefore hide Retry-After even though the generated client does not drop it.

Rust Generator Configuration

All three Rust backends (rust-reqwest, rust-ureq, rust-aioduct) share the same configuration options.

The rust-aioduct backend has an additional [aioduct] section for controlling aioduct-specific features.

Generated Error Handling

Every operation keeps its generated error enum, so callers can match a specific status and use its typed body:

#![allow(unused)]
fn main() {
match api.create_resource(&request).await {
    Err(CreateResourceError::NotFound(error)) => {
        if let Ok(body) = error.body() {
            println!("missing resource: {}", body.message);
        }
    }
    Err(error) => return Err(error.into()),
    Ok(response) => use_response(response),
}
}

Each operation error also implements Into<ApiCallError>. Use the common type when a workflow calls multiple operations and does not need their distinct body types:

#![allow(unused)]
fn main() {
use generated_sdk::runtime::error::ApiCallError;

async fn run_workflow(
    api: &ResourcesApi<'_>,
    request: &CreateResourceRequest,
    resource_id: &str,
) -> Result<(), ApiCallError> {
    api.create_resource(request).await?;
    api.refresh_resource(resource_id).await?;
    Ok(())
}
}

ApiCallError exposes the operation identifier (generated from the HTTP method and path if the OpenAPI document omits one) and optional HTTP status, native headers, and raw response body. Transport errors and error-body decoding failures remain available through std::error::Error::source().

Conversion consumes the operation-specific error, performs one heap allocation for the pointer-sized erased representation, and erases its decoded payload type. Operation-specific errors remain inline until conversion. Match the operation error before conversion when its decoded payload is needed.

thiserror

thiserror does not chain two From conversions for ?. A single generic adapter on the application error converts every generated operation error directly:

#![allow(unused)]
fn main() {
#[derive(Debug, thiserror::Error)]
#[error("SDK request failed: {source}")]
struct AppError {
    #[source]
    source: ApiCallError,
}

impl<E> From<E> for AppError
where
    E: Into<ApiCallError>,
{
    fn from(error: E) -> Self {
        Self {
            source: error.into(),
        }
    }
}
}

Do not also put #[from] on the ApiCallError field; the generic implementation already includes ApiCallError itself.

SNAFU

SNAFU 0.9’s generic source conversion provides the same direct ? behavior:

#![allow(unused)]
fn main() {
#[derive(Debug, snafu::Snafu)]
#[snafu(context(false))]
struct AppError {
    #[snafu(source(from(generic)))]
    source: ApiCallError,
}
}

Untagged Union Deserialization

Untagged unions keep their existing typed variants and untagged serialization. Deserialization selects members using the shared IR’s JSON shapes, required wire keys, finite scalar values (including numeric and boolean enums), nested arrays and maps, and productive recursive references. This prevents an earlier member with an unconstrained Rust integer field from swallowing a later member’s payload. Repeated recursive checks reuse results within one member-selection pass, without changing candidate order or sharing cached results across decoded values.

untagged_union_strategy

ValueGenerated API and behavior
"firstMatch" (default)Keep typed variants; select the first structural match in schema order, preferring concrete members to opaque fallbacks.
"strict"Keep the same typed API; reject multiple concrete matches before deserializing any member. Use an opaque fallback only when no concrete member matches.
"raw"Explicitly replace each untagged union with an untagged enum containing only Raw(serde_json::Value). Preserve known, ambiguous, unmatched, and null JSON without typed selection.

Both typed policies reject unmatched values. After selection, the chosen member is deserialized once; its errors (including nested strict-union errors) propagate instead of being hidden by another member or an opaque fallback. Structural matching is not a full JSON Schema validator: formats, bounds, and other validation keywords are not selection rules, and native Serde decoding can still reject a selected payload. Serialization does not add schema validation.

The typed enum does not gain synthetic Raw or Null variants. Explicitly nullable use-site expressions continue to use Option<Union>; decode a standalone nullable union through Option<Union> as well. As before, a named union’s root nullable flag alone does not change its Rust enum or automatically wrap references to it. Explicit raw changes the union’s Rust API, so callers construct and match Union::Raw(value) rather than typed variants. It does not change tagged unions or turn ordinary models into raw JSON.

[rust-reqwest]
untagged_union_strategy = "strict"

The same setting works for rust-ureq and rust-aioduct, or as a CLI override:

openapi-nexus generate --input api.yaml --output sdk --generators rust-reqwest \
  --generator-config rust-reqwest.untagged_union_strategy=raw

Typed decoding retains the selected member’s declared payload, not arbitrary unknown fields. Dictionary/additional-property representation and multi-member allOf behavior are unchanged. Choose raw when the full JSON must be retained.

Full Example

[rust-reqwest]
crate_name = "my-api-client"
workspace_mode = true
workspace_deps = "workspace_version"
untagged_union_strategy = "firstMatch"

[rust-reqwest.extra_derives.structs]
derives = ["PartialEq", "Eq"]

[rust-reqwest.extra_derives.enums]
derives = ["Hash"]

[rust-reqwest.extra_derives.unions]
derives = ["PartialEq"]

[rust-reqwest.extra_derives.response_structs]
derives = ["PartialEq"]

[rust-reqwest.extra_derives.per_type.MySpecialSchema]
derives = ["Default"]

[rust-reqwest.utoipa]
enabled = true
dependency = '{ version = "5" }'

Options Reference

crate_name / package_name

Override the generated crate name. Defaults to the spec title converted to kebab-case.

crate_name = "my-api-client"

workspace_mode

When true, the generated Cargo.toml uses version.workspace = true and edition.workspace = true instead of inline values. Also emits [lints] workspace = true.

workspace_mode = true

workspace_deps

Controls how dependencies are declared in the generated Cargo.toml.

ModeBehavior
"explicit" (default)Inline version specs: serde = { version = "1", features = ["derive"] }
"workspace_version"Workspace with features: serde = { workspace = true, features = ["derive"] }
"full"Fully delegated: serde.workspace = true
workspace_deps = "workspace_version"

extra_derives

Add custom derive macros to generated types. Each category targets a different schema kind:

  • structs — object schemas
  • enums — string and integer enums
  • unions — tagged and untagged unions (including explicit raw wrappers)
  • response_structs — per-operation response type wrappers
[rust-reqwest.extra_derives.structs]
derives = ["PartialEq", "Eq"]
dependencies = { fake = '"2"' }

The dependencies field adds entries to the generated Cargo.toml. For untagged unions, the emitter owns Serde implementations and, when enabled, utoipa implementations. Equivalent short, crate-qualified, and absolute derive paths (such as Deserialize, serde::Deserialize, and ::serde::Deserialize) do not add conflicting implementations.

extra_derives.per_type

Target a specific schema by name:

[rust-reqwest.extra_derives.per_type.UserProfile]
derives = ["Default", "Hash"]

utoipa

Native utoipa integration for OpenAPI schema generation at runtime.

[rust-reqwest.utoipa]
enabled = true
dependency = '{ version = "5" }'

When enabled:

  • Structs, string enums, integer enums, intersections, and aliases get #[derive(utoipa::ToSchema)]
  • Tagged unions (internal/adjacent) and untagged unions get manual impl utoipa::PartialSchema + ToSchema using OneOfBuilder (the derive macro doesn’t support these patterns)
  • The utoipa crate is added to generated Cargo.toml using the dependency spec
  • Variant schemas for internal/adjacent tagged unions are emitted as standalone files (not inlined) so they can be referenced by PartialSchema

The dependency field accepts any valid TOML inline table or string that would appear after utoipa = in Cargo.toml. If omitted, defaults to "*".

You do NOT need to add utoipa::ToSchema to extra_derives when using this config. The [utoipa] section handles everything, including the cases where the derive macro cannot be used.

aioduct (rust-aioduct only)

Configure aioduct-specific dependency features for the generated crate. This section only applies to the rust-aioduct generator.

[rust-aioduct.aioduct]
version = "0.2"
runtime = "tokio"
tls = "rustls-ring"
compression = ["gzip", "brotli", "zstd"]
features = ["tracing", "http3"]

All fields are optional. Defaults: runtime = "tokio", tls = "rustls-ring", no compression, no extra features. The json feature is always included.

version

Override the aioduct version requirement. Defaults to "0.2".

runtime

Which async runtime to use. One of:

ValueDescription
"tokio" (default)Tokio runtime
"smol"smol runtime
"compio"compio (io_uring) runtime

tls

TLS backend selection:

ValueDescription
"rustls-ring" (default)rustls with ring crypto
"rustls-aws-lc-rs"rustls with AWS-LC crypto
"false"Disable TLS (HTTP-only)

compression

List of decompression codecs to enable. Valid values: "gzip", "brotli", "zstd", "deflate".

compression = ["gzip", "zstd"]

features

Pass-through feature flags appended to the aioduct dependency. Use this for features not covered by the structured fields above (e.g., "tracing", "otel", "http3", "hickory-dns", "doh", "dot", "blocking", "tower").

features = ["tracing", "http3", "blocking"]

Go Generator Configuration

[go-http]
module_path = "example.com/sdk"
all_of_strategy = "composed"

Object and map composition

The default composed policy retains named object members as embedded Go fields and gives anonymous map members named storage such as AdditionalProperties. Generated JSON methods combine the members into one flat wire object rather than inheriting one embedded member’s JSON methods and losing its siblings.

value := models.Pair{
    Base: models.Base{KnownField: "fixed"},
    Other: models.Other{OtherField: 7},
}
// json.Marshal(value): {"known_field":"fixed","other_field":7}

Existing named member entrypoints and promoted fields remain available. Repeated member names or map-storage collisions receive numeric suffixes. Aliases and nested object compositions are followed without rewriting ordinary model codecs.

On reading, each member receives its own declared keys and globally unknown keys. A sibling’s declared keys are not duplicated into extension storage, so editing a named field does not leave stale values in a map member. Typed members retain their native encoding/json behavior. If member writes produce different JSON encodings for the same key, marshaling returns an error instead of silently overwriting data. Projection retains input key order and duplicate keys, including native case-insensitive field matching and errors from earlier duplicate values.

This is not a complete JSON Schema validator. It uses retained IR object facts and conservative representation compatibility; ordinary member validation, optional fields, null behavior and dynamic any number conversion remain unchanged. In particular, any entries still follow Go’s native floating-point conventions. It does not infer missing shared-lowering facts or redesign ordinary dictionary codecs.

Raw escape hatch

Set all_of_strategy = "raw" to emit every intersection as an alias of json.RawMessage, preserving JSON kinds, nulls and exact numeric literals without typed member selection. The same raw representation is the default fallback for non-object intersections, nullable members, unproductive composition cycles and object/map types that cannot be conservatively composed. Nullable members also use raw JSON when reached through nested compositions or aliases: no pointer embedding is needed, and a root null is not replaced by an empty object. This conservative policy applies even when a sibling is non-nullable; raw mode is not intersection validation. No unconditional generation error or invalid anonymous embedding is introduced for these shapes.

openapi-nexus generate --input api.yaml --output sdk --generators go-http \
  --generator-config go-http.all_of_strategy=raw

Raw values intentionally do not enforce intersection constraints. Use them when wire preservation is more useful than inferred typed composition. This policy does not change untagged unions, ordinary objects, other generators or generated dependencies.

TypeScript Generator Configuration

Full Example

[generators.typescript-fetch]
file_naming_convention = "PascalCase"
package_scope = "@myorg"
package_name = "my-api-client"
generate_package = true
ts_target = "ES2020"
ts_module = "ES2020"
ts_lib = ["ES2020", "DOM"]
generate_esm_config = true
include_build_scripts = true
emit_enum_constants = true
emit_type_guards = true
property_naming = "camelCase"
untagged_union_strategy = "firstMatch"
indent = "    "

Options Reference

file_naming_convention

Controls the file naming style. One of "PascalCase", "camelCase", "kebab-case", "snake_case". Defaults to "PascalCase".

package_scope

NPM package scope prefix, e.g. "@myorg". If set, the generated package.json uses "name": "@myorg/my-api-client".

package_name

Override the generated package name. Defaults to the spec title converted to kebab-case.

generate_package

Whether to generate npm package files (package.json, tsconfig.json, tsconfig.esm.json). Defaults to true.

ts_target

TypeScript compiler target. Defaults to "ES2020".

ts_module

TypeScript module system. One of "commonjs", "ES2020", "ES2022", "ESNext". Defaults to "ES2020".

ts_lib

TypeScript compiler lib array. Accepts a TOML array or comma-separated string. Defaults to ["ES2020", "DOM"].

generate_esm_config

Whether to generate an ESM tsconfig (tsconfig.esm.json). Defaults to true.

include_build_scripts

Whether to include build scripts in package.json. Defaults to true.

emit_enum_constants

When true, emits a companion const object alongside each enum type alias:

export type ItemKind = 'BOOK' | 'MOVIE' | 'MUSIC';

export const ItemKind = {
    BOOK: 'BOOK' as const,
    MOVIE: 'MOVIE' as const,
    MUSIC: 'MUSIC' as const,
};

Consumers can use the same import for both type annotations and runtime value comparisons:

import { ItemKind } from "@scope/package";

function getLabel(kind: ItemKind) {
    switch (kind) {
        case ItemKind.BOOK: return "Book";
        // ...
    }
}

Handles string, integer, number, and mixed-value enums. Quotes keys that aren’t valid JavaScript identifiers. Defaults to false.

emit_type_guards

When true, emits is* type guard functions alongside each tagged union type alias:

export type Shape = ({ kind: 'circle' } & Circle) | ({ kind: 'rectangle' } & Rectangle);

export function isCircle(value: Shape): value is ({ kind: 'circle' } & Circle) {
    return value.kind === 'circle';
}

export function isRectangle(value: Shape): value is ({ kind: 'rectangle' } & Rectangle) {
    return value.kind === 'rectangle';
}

The narrowing expression depends on the tagging style:

  • Internal/Adjacent: value.<field> === '<value>'
  • External: '<value>' in value

Variants with “UNSPECIFIED” in the discriminator value, discriminator values equal to the field name, or bare string-literal content types are skipped. Defaults to false.

toolchain

Selects the type checking and build toolchain. One of "tsc" (default) or "vp".

  • "tsc" — bare TypeScript compiler. Generates tsconfig.json only. Build script: tsc.
  • "vp" — vite-plus. Generates vite.config.ts with pack config for library output (ESM + .d.ts). Build script: vp pack. Format script: vp fmt. Check script: vp check.

When toolchain = "vp", the generated package.json includes vite-plus (^1.5.0) and typescript (^7.0.2) as dev dependencies, and scripts use vp pack (library mode) rather than vp build (app mode).

Vite+ manages Vite, Vitest, Rolldown, Oxfmt, Oxlint, oxlint-tsgolint, and tsdown as one toolchain. Upgrading vite-plus upgrades these bundled components together. Run vp --version after vp install in a generated project to inspect the resolved component versions.

Vite+ 1.5.0 requires Node.js ^22.18.0 || ^24.11.0 || >=26.0.0.

After generating or regenerating a Vite+ client, run:

vp install
vp check
vp pack

The generator emits sources, JSON, and Markdown using the configured single-quote style and standard Oxfmt layout. vp check validates formatting, lint rules, and types directly on these files without modifying them. No formatter is required between generation and validation. Use vp fmt (or npm run fmt) for subsequent manual edits. The generated configuration excludes dist/, so checks also pass after packing. Generation itself requires only Rust, without Node.js or Vite+.

[generators.typescript-fetch]
toolchain = "vp"

indent

Controls the indentation string used in generated TypeScript files. Accepts any string; common values are " " (2 spaces, default), " " (4 spaces), or "\t" (tabs).

[generators.typescript-fetch]
indent = "    "

property_naming

Controls the naming convention for properties in generated interfaces. When set to "camelCase", generates dual-type model files:

  • A Name$Wire interface preserving the original wire-format property names (snake_case, kebab-case, etc.)
  • A Name interface with camelCase property names for ergonomic usage
  • nameFromJSON(json: Name$Wire): Name converter function
  • nameToJSON(value: Name): Name$Wire converter function
[generators.typescript-fetch]
property_naming = "camelCase"

Example output for a schema with snake_case properties:

export interface User$Wire {
  readonly user_id: number;
  readonly first_name: string;
  readonly last_name: string;
}

export interface User {
  readonly userId: number;
  readonly firstName: string;
  readonly lastName: string;
}

export function userFromJSON(json: User$Wire): User {
  return {
    userId: json.user_id,
    firstName: json.first_name,
    lastName: json.last_name,
  };
}

export function userToJSON(value: User): User$Wire {
  return {
    user_id: value.userId,
    first_name: value.firstName,
    last_name: value.lastName,
  };
}

Works with all schema kinds:

  • Objects: dual interfaces + converters
  • Tagged unions (internal, adjacent, external): dual type aliases + switch/if-chain converters
  • Intersections (allOf): dual type aliases; compatible open objects share one conversion plan, while other intersections use spread-based converters
  • Untagged unions (oneOf/anyOf): dual type aliases + member-selecting converters

Models, JSON request bodies, JSON multipart parts, successful responses, and lazy error values use one value plan per generation. It projects public and wire types and delegates conversion recursively through aliases, properties, arrays, and dictionaries. An unchanged type does not remove a strict runtime check: even an inline integer | number union must still detect overlapping matches.

Compound conversions and structural predicates use statically generated helper functions in internal runtime modules, not a runtime schema interpreter. They are not re-exported by the public barrel. Conversion helpers retain separate dependency modules so importing a model does not load unrelated model graphs. Helper inputs accept readonly arrays without widening public API results or changing model array mutability. Source layout is emitted directly; full Vite+ checks need no formatter preparation.

Defaults to "preserve" (no renaming, single interface, no converters).

untagged_union_strategy

Controls untagged-union conversion when property_naming = "camelCase":

  • "firstMatch" (default): select the first structurally matching member and call its converter. Concrete members precede unconstrained JSON/object/map fallbacks, with schema order preserved within each group. Unmatched values pass through unchanged rather than making imperfect specifications unusable.
  • "strict": use the same structural selection, but throw TypeError for multiple concrete matches or no matching member. A raw fallback is considered only after the concrete members, so it does not itself create ambiguity.
  • "raw": do not select or convert union members. Both Name and Name$Wire expose the wire representation; pass original wire keys such as input_schema, not ergonomic keys such as inputSchema. This is an explicit escape hatch, not an identity cast pretending to return a renamed model. This applies to the entire union subtree, including inline container unions and aliases; ordinary enclosing objects and sibling properties still convert normally.
[generators.typescript-fetch]
property_naming = "camelCase"
untagged_union_strategy = "strict"

The same choice is available through a CLI override:

openapi-nexus generate --input openapi.yaml --generators typescript-fetch \
  --generator-config typescript-fetch.property_naming=camelCase \
  --generator-config typescript-fetch.untagged_union_strategy=strict

Selection checks generated value kinds, required own fields, declared property shapes, typed additional values, and scalar literal/enum values in the appropriate wire or ergonomic naming direction. Named fields are excluded from the additional value check, even though TypeScript widens the dictionary’s index type to include them. Nested enum constraints intersect at their original nullable boundaries; an outer enum does not replace or widen an inner enum. Untyped scalar enums match their finite values without treating the legacy string type fallback as an authored constraint. Arrays and dictionaries inspect their element/value shapes; dictionary keys and external variant tags stay verbatim. This is conversion dispatch, not full JSON Schema validation: formats, numeric bounds, and other validation keywords are not checked. Productive recursive references use statically generated matching functions rather than becoming unrestricted guards. These helpers are private to the generated runtime, not public barrel exports; selected member converters still handle recursive payload conversion. Same-value circular type aliases are not made representable by this mechanism. Binary properties match their generated Blob | File representation rather than strings. Adjacent-tagged unit variants do not require a content property; payload variants retain their content checks.

A named nested union can provide both concrete matches and a raw fallback. Its concrete matches take priority over fallbacks, and both paths delegate to the nested converter so its strict ambiguity policy is not bypassed. Nullable and allOf wrappers do not turn an unrestricted map/JSON fallback into a concrete match. A dictionary of open nested unions retains a concrete selection path when at least one entry matches concretely, even when other entries are opaque. Empty or entirely opaque dictionaries remain fallbacks. Both paths delegate through the dictionary converter, preserving inner strict errors.

Matching is pure: select/count the original member candidates before running a converter, then invoke only the selected delegate. A nested strict error propagates instead of triggering a retry with another member. Repeated nullable wrappers are idempotent; they do not create extra candidates or promote an opaque fallback. An explicit null alternative does not bypass ambiguity among other nullable members, or suppress a selected nested converter’s strict error. An unrestricted fallback beside authored nullable members does not erase those members’ concrete null matches. Null permission added only by an opaque wrapper remains a fallback rather than creating an extra concrete candidate. Property, array and dictionary nullable wrappers delegate null when the inner value already accepts it. Null admitted only by the outer wrapper still bypasses a non-nullable inner converter. This remains true when property nullability was extracted through scalar enum metadata: conversion restores the wrapper rather than passing null directly to the nonnullable delegate.

Inline unions inside a selected array or dictionary member use the same selection policy recursively, rather than returning their contents unchanged. A single-member allOf alias delegates without spreading its result, preserving primitive, array, and null values too.

For example, a mixed ClientTool | SearchTool union can distinguish a required input_schema / inputSchema field from a search member’s required literal type, then convert inputSchema and maxUses to input_schema and max_uses. Conversions also work through containing arrays, dictionaries, and model aliases. Declared additional-property behavior remains owned by the selected member’s converter. Direct tagged payloads and optional type guards use the same type and conversion projections. Preserve mode does not add JSON converters.

Tagged declarations and guards namespace-qualify built-in utilities when a model name shadows them, including open external wrappers. A tagged schema named Record or Readonly keeps its public model and converter names; its generated utility types use globalThis.Record or globalThis.Readonly instead. Planned dictionaries and structural matching casts use the same qualification.

A required JSON body must be supplied, but a schema-permitted null is a value, not a missing body. Such bodies serialize as JSON null; omitted bodies still throw RequiredError before sending a request. Optional JSON bodies skip conversion only when omitted (undefined). A supplied null uses the same planned conversion and strict ambiguity checks as a required body or direct model conversion.

Additional properties

Objects declaring additionalProperties expose a readonly string dictionary in both naming modes, including objects that also declare named fields. Typed map values keep their model types; arbitrary JSON values use unknown.

TypeScript’s string index covers named fields too. For mixed schemas, the index value type therefore includes the additional-value type, every declared field type, and undefined when a declared field is optional. Named fields retain their more precise types. For example, an optional known string plus string extensions extends Readonly<Record<string, string | undefined>>.

In camelCase mode, both converters preserve extension keys verbatim. Typed model values are converted recursively, including arrays, nullable values, and nested maps; opaque JSON contents are not renamed. Omitted optional fields stay absent on these open objects. Object/map allOf composition and open tagged variants also retain their extensions. additionalProperties: false and unspecified additional-property behavior are unchanged.

Compatible allOf dictionary contracts are narrowed together (for example, true with string uses string values), and named fields are planned across all members before conversion. This includes JSON unions that render as unknown: intersecting an arbitrary-JSON dictionary with a model dictionary converts the additional values as that model, without treating other members’ named fields as extensions. Their public intersection aliases retain the original member types inside Readonly<...> in preserve, ergonomic, and wire declarations. Aliases of models, arrays, maps, and nullable values receive matching wire aliases and converters, including alias chains. External tag keys stay verbatim while both the referenced payload and any typed outer extension values are converted; each closed alternative remains closed.

An extension can collide with a renamed field: {known_field: 'fixed', knownField: 'extension'} must still expose value.knownField === 'fixed'. The converter retains the colliding extension under the enumerable symbol Symbol.for('openapi-nexus.additionalProperties'); nameToJSON restores it. Object spread preserves this metadata. Serialize through nameToJSON, not JSON.stringify(value) or a JSON-based clone, which omits symbol metadata. Use Name$Wire or property_naming = "preserve" when direct access to all original wire keys is preferable.

Barrel Exports

When either feature is enabled, models/index.ts automatically emits value re-exports:

// Enum const: single line covers both type and value
export { ItemKind } from './ItemKind';

// Tagged union: separate type and guard re-exports
export type { Shape } from './Shape';
export { isCircle, isRectangle } from './Shape';

Python Generator Configuration

Both python-httpx and python-requests accept the same options. Generated clients require Python 3.12 or newer.

[generators.python-httpx]
package_name = "my_client"
untagged_union_strategy = "firstMatch"

[generators.python-requests]
package_name = "my_client"
untagged_union_strategy = "strict"

The equivalent CLI override is:

openapi-nexus generate --input openapi.yaml --output client \
  --generators python-httpx \
  --generator-config python-httpx.untagged_union_strategy=strict

package_name

Overrides the generated import package and project name. The default is derived from the API title.

untagged_union_strategy

Controls conversion of untagged oneOf and anyOf values, not general schema validation. Both keywords use this policy; strict does not impose oneOf cardinality on every anyOf elsewhere in a specification.

ValueMember selectionNo matchPublic representation
firstMatchFirst concrete match, in schema orderPreserve the original valueExisting dataclass/primitive union
strictReject multiple concrete matchesRaise ValueErrorExisting dataclass/primitive union
rawDo not select or convert the union subtreePreserve the original valueDynamic object

The default is firstMatch. Concrete alternatives take precedence over opaque Any/empty-object fallbacks, even when the fallback appears first. Strict counts original concrete members before calling any converter; an opaque fallback does not create false ambiguity. Selected nested unions retain their own policy and errors rather than silently trying another member. Nullable wrappers permit null without inventing an extra authored member.

Matching uses required fields, present optional fields, JSON kinds, and retained finite enum/literal constraints. Booleans are not numbers despite Python’s True == 1. Arrays, dictionary values, aliases, and productive recursion retain the same conversion boundaries. Formats and numeric/string bounds do not serve as member-selection validators.

For tagged members inside an untagged union, outbound matching follows the existing public payload representation. The selected tagged converter restores its wire wrapper only after selection; strict ambiguity counting never calls a converter to probe whether a member matches.

Conversion entrypoints

Existing dataclass constructors and to_dict / from_dict methods remain public. For a named untagged union Choice, its module also provides choice_from_dict(value) and choice_to_dict(value). These helpers accept or return arbitrary JSON values, not only dictionaries; their names align with the existing Python tagged-union convention. They are not added to package barrels.

Model fields, JSON HTTP payloads, success responses, and lazy error bodies use the same conversion plan. Direct typed members can therefore be used in union fields without manual conversion before json.dumps. Required union request bodies containing None are encoded as JSON null, not an absent HTTP body. Optional request bodies retain the existing convention that None means omit.

In raw mode, supply JSON-compatible values yourself; generated dataclasses inside a raw subtree are deliberately not auto-serialized. Ordinary containing objects still use their dataclass conversion. Default unmatched values are kept as an escape hatch, so annotations describe the normal matching path rather than proving validation. Choose strict to reject unmatched inputs.

Dictionary/additional-property model preservation and multi-member allOf composition are separate concerns and are not redesigned by this option.

Additional properties

An object with explicit additionalProperties gets typed mutable additional_properties storage, including a pure dictionary with no named fields. The storage is a keyword-only dataclass field with an independent empty-dict default. from_dict separates known wire keys from extension keys; to_dict flattens the entries back into the same JSON object, without a storage wrapper.

value = MixedMap(
    known_field="fixed",
    additional_properties={"keep_key": NestedValue(wire_value="nested")},
)
value.to_dict()  # {"known_field": "fixed", "keep_key": {"wire_value": "nested"}}

Extension keys remain verbatim, even when they resemble renamed Python fields. Opaque values (true or {}) keep arbitrary JSON, including null entries and large integers. Typed values use the declared model/container/alias converters; recursive references and union policies use the same boundaries as ordinary values. The storage does not make a pure dictionary a dict subtype: use its additional_properties field and normal from_dict / to_dict entrypoints.

If an authored field already uses the Python name additional_properties, the storage name gains trailing underscores until it is distinct. Authored fields are not renamed. Supplying an extension key equal to a declared wire key raises ValueError on serialization, even when that named field is currently omitted; extensions never overwrite or impersonate named fields.

Objects without explicit additional properties and closed objects keep their existing behavior. Ordinary named-field codecs and optional-null omission are unchanged. This is not new JSON Schema validation or an allOf/tagged-wrapper representation redesign.

Value conversion follows the retained schema facts. General inline composed additional-property schemas currently lowered as opaque values remain opaque; this storage repair does not change that shared lowering behavior.

Java Generator Configuration

The java-okhttp backend emits Java 17 sealed interfaces and records for unions, with Gson adapters that retain the selected member’s declared payload.

Untagged union selection

[java-okhttp]
package_name = "com.example.sdk"
untagged_union_strategy = "firstMatch"
PolicyBehavior
firstMatch (default)Keep typed variants; choose the first structural match in schema order, with concrete members before opaque fallbacks.
strictKeep the same typed API; reject multiple authored concrete matches before decoding. Use an opaque fallback only when no concrete member matches.
rawExplicitly emit only VariantRaw(JsonElement), preserving known, ambiguous, unmatched, and null JSON without typed selection.

Both typed policies reject unmatched non-null values. Matching uses required wire keys, finite scalar constraints (including numeric and boolean enums), nested arrays and maps, and productive recursive references. Finite numbers use exact BigDecimal significands and BigInteger exponents, retaining distinctions above double precision without Gson’s decimal-accessor scale limit. Recursive checks share results by JSON node identity and predicate within one selection, not across decoded values. Private recursive-cache types are qualified so models named Integer or Boolean do not shadow the runtime’s boxed types.

After selection, the original member is decoded once through its existing Gson codec. Its errors, including nested strict-union errors, propagate rather than being hidden by another member or opaque fallback. This is structural selection, not full JSON Schema validation: formats, numeric bounds, and ordinary Gson coercion or overflow behavior are not redesigned. Serialization does not add schema validation.

Null and representation compatibility

Typed policies preserve Java’s existing root-null API: JSON null becomes Java null for a nonnullable union, or the existing VariantNull.INSTANCE for a nullable union. No synthetic raw variant is added to typed unions. Explicit raw mode instead wraps JSON null in VariantRaw(JsonNull.INSTANCE). As with typed cases, a case name equal to its enclosing interface is disambiguated (for example, VariantRaw.VariantRawVariant).

Java aliases remain objects with a value field. Compatible allOf models now have flat object codecs, described below; union matchers follow that flat wire shape rather than the source-facing member names. Tagged unions and ordinary models keep their existing APIs and codecs.

Typed variants retain declared member payloads, not arbitrary unknown fields. Explicit additional-property storage is described below. Use raw mode when all JSON must be retained, including unknown keys, null-valued object entries, and large numeric literals. Raw writing restores the surrounding Gson writer’s null policy; ordinary sibling model fields keep their normal null omission.

The policy can also be set through the CLI:

openapi-nexus generate --input api.yaml --output sdk --generators java-okhttp \
  --generator-config java-okhttp.untagged_union_strategy=strict

Additional properties

Objects with explicit additionalProperties expose typed, mutable storage through getAdditionalProperties(). This includes pure dictionaries with no named fields; they remain ordinary model classes rather than Map subtypes. Existing named constructor parameters and getters stay unchanged, with an independent empty map available on each constructed or decoded object.

MixedMap value = new MixedMap("fixed");
value.getAdditionalProperties().put("keep_key", new NestedValue("nested"));
// Gson writes {"known_field":"fixed","keep_key":{"wire_value":"nested"}}

The Gson adapter flattens extensions into the same JSON object, retaining keys verbatim. Opaque values (true or {}) use JsonElement so null entries and large numeric literals survive without coercion to Object/double. Declared typed values use their native Gson adapters, including model/enum/nullable values, containers, recursive references and configured untagged-union policies.

Storage is transient to the ordinary Gson model delegate. Known fields retain their configured codecs, native scalar failures and null omission; extension entries preserve their own nulls without changing surrounding writer settings. The runtime distinguishes Gson’s streaming and tree readers and uses its native tree writer so their existing coercion and configured numeric output stay intact. This support depends on the tree reader/writer classes in the unchanged Gson 2.11.0 dependency; native contracts cover both input modes. Aliases keep their existing value wrapper instead of acquiring a different wire shape. No generated dependency version or general validation policy changes.

If an authored field/getter already uses the storage name, the storage and getter receive a numeric suffix, such as additionalProperties2 and getAdditionalProperties2(). Authored members are not renamed. An extension key equal to a declared wire key raises IllegalArgumentException on serialization, even if the named field is currently omitted, rather than silently overwriting it.

Closed objects and those without explicit additional properties keep their existing behavior. Shared lowering, general inline-composition type loss, multi-member allOf, tagged-wrapper extension storage and generation formatting are separate concerns, not redesigned by this object-storage repair.

allOf composition

[java-okhttp]
all_of_strategy = "composed"

The default composed policy retains compatible object/map member constructor arguments and getters, but reads and writes a single flat JSON object:

MixedAlias value = new MixedAlias(new MixedMap("fixed"));
// Gson writes {"known_field":"fixed"}, not {"mixedMap":{...}}.
value.getMixedMap(); // The source-facing member entrypoint remains available.

Members are decoded once through the configured Gson instance. Named model members keep their ordinary codecs, field naming, null omission and native failures. Projection excludes sibling declared keys from extension storage while retaining input order, repeated keys and numeric lexemes, including inside nested values. It preserves the distinction between streaming and tree input modes. Nested compositions and repeated members retain their member APIs; conflicting member write encodings raise IllegalArgumentException instead of losing data.

Anonymous dictionary members retain their Map source-facing types. Their opaque Object entries decode as JsonElement values (with Java null for JSON null) so large numbers and null entries survive. Typed dictionary values use configured leaf adapters; containers retain explicit null entries. This is not a change to ordinary Object fields or ordinary dictionary model codecs.

This is conservative representation planning, not full JSON Schema validation. Non-object, nullable, incompatible, cyclic and ordinary-wrapper-alias members use a raw representation rather than guessing a flat typed codec. Ordinary alias codecs themselves remain unchanged. Missing shared-lowering facts and implicit additional-property storage are not inferred. Nullable members conservatively choose raw even alongside a non-nullable sibling.

Set all_of_strategy = "raw" to use raw representation for every intersection:

openapi-nexus generate --input api.yaml --output sdk --generators java-okhttp \
  --generator-config java-okhttp.all_of_strategy=raw

A raw allOf model has a JsonElement constructor and getValue() accessor. It retains arbitrary JSON kinds, nulls and numeric literals without enforcing intersection constraints. Consumers of formerly unsupported or nested-wrapper representations must regenerate and adjust construction/serialization code; no binary-compatibility claim is made. Successful composed member APIs are retained. AllOf selection is independent of untagged_union_strategy.

The private runtime uses Gson 2.11.0’s JsonTreeReader, JsonTreeWriter and LazilyParsedNumber implementation classes to preserve input modes, configured numeric output and lexemes. Generated dependencies remain unchanged. Kotlin composition, ordinary alias repair and generation formatting remain separate.

An existing typed-union writer limitation can omit explicit null entries when it re-emits a selected member’s JSON tree, including ordinary open objects without any allOf. Flat matcher support does not repair that surrounding writer boundary; allOf model, ordinary containing-object and HTTP codec behavior are tested independently of it.

Kotlin Generator Configuration

The kotlin-okhttp backend keeps sealed classes and typed data-class variants for untagged unions, selecting a member before invoking its existing Gson codec.

[kotlin-okhttp]
package_name = "com.example.sdk"
untagged_union_strategy = "firstMatch"
PolicyBehavior
firstMatch (default)First structural match in authored order; concrete members precede opaque fallbacks.
strictSame typed API; reject multiple authored concrete matches before decoding. Use a fallback only if no concrete member matches.
rawExplicit VariantRaw(JsonElement) API, preserving known, ambiguous, unmatched, and null JSON without typed selection.

Typed policies reject unmatched non-null values. Selection uses required wire keys, finite scalar constraints (including numeric and boolean enums), nested arrays/maps, and productive recursion. Numeric matching compares exact decimal significands and unbounded exponents, without rounding through Double or imposing Gson’s decimal-accessor scale cap. Recursive predicates cache both successful and failed checks by JSON-node identity. Matching is not full JSON Schema validation: format bounds remain with the existing native codecs.

After selection, the adapter invokes the original member’s codec once. Native decode errors propagate; they do not trigger another candidate or raw fallback. Writing uses the declared payload type, so a nested sealed union uses its own adapter instead of reflecting its runtime Variant* wrapper as a value0 field. Both typed policies retain the existing root-null behavior: nonnullable unions return Kotlin null, while nullable unions use VariantNull.

Kotlin typealias declarations remain transparent. Intersections retain their existing wrapper fields (for example, leftPart and rightPart); this is not an allOf codec redesign. Ordinary objects, aliases, enum declarations, and tagged unions keep their existing APIs/codecs. Explicit dictionary and mixed-object additional properties have the representations described below.

Raw mode uses Gson’s native tree adapter and temporarily enables writer null serialization, restoring its prior value even on failure. This preserves null entries inside raw trees without changing ordinary model null-field policy. An enclosing model named VariantRaw uses VariantRawVariant for its raw case.

The policy can also be set through the CLI:

openapi-nexus generate --input api.yaml --output sdk --generators kotlin-okhttp \
  --generator-config kotlin-okhttp.untagged_union_strategy=strict

Pure dictionary models

A named object with no named properties and an explicit additionalProperties schema generates a data class with additionalProperties: MutableMap<String, V> storage, defaulting to an empty map. Its Gson adapter reads and writes a flat JSON object: the storage property’s name is not added to the wire representation.

val labels = Labels()
labels.additionalProperties["keep_key"] = "value"
val json = Gson().toJson(labels) // {"keep_key":"value"}

Keys stay verbatim. Values retain their declared model, scalar, nullable or container types; additionalProperties: true and {} use JsonElement values to preserve arbitrary JSON, null entries and large numeric literals. Dictionary aliases remain transparent and containing fields, lists, maps and unions use the same adapter. JSON null at the root stays Kotlin null; non-object roots fail instead of becoming empty dictionaries.

Dictionary entry nulls are preserved without changing the writer’s surrounding null policy. Values decode from the original JSON reader through their declared Gson codecs, preserving native decode errors as well as ordinary model null-field omission. Authored model types remain distinct from Gson helper types, even when they share names such as TypeToken or JsonElement. Mixed-object preservation is described below; allOf wrapper redesign remains separate.

Mixed-object additional properties

An object with named fields and explicit additionalProperties gets a typed var additionalProperties: MutableMap<String, V> as its final defaulted primary constructor property. Each instance starts with an independent empty map.

val value = MixedMap(knownField = "fixed")
value.additionalProperties["keep_key"] = NestedValue(wireValue = "nested")
// Gson writes {"known_field":"fixed","keep_key":{"wire_value":"nested"}}
val duplicate = value.copy() // Retains the same map, as a normal shallow copy.

Existing named constructor arguments and component positions remain unchanged; an overload retains the old Java named-field constructor entrypoint. The appended storage participates in generated copy, equality, hash and component operations. Adding a data-class property changes generated binary signatures: regenerate and recompile consumers rather than mixing old and new SDK binaries.

The storage field is transient to Gson’s ordinary delegate, and the flat-object adapter handles it separately. Extension keys stay verbatim. A storage-name clash adds a numeric suffix such as additionalProperties2, without renaming authored members. An extension key equal to a declared wire key raises IllegalArgumentException on writing, even if that named field is omitted.

Opaque values (true or {}) use JsonElement to retain null entries and large numeric literals. Declared models, enums, containers, nullable values, recursive references and transparent aliases keep their native value codecs; union values retain the configured selection policy. The actual configured Gson instance continues to own named fields, field naming and custom value adapters.

Native streaming/tree coercion differences, configured numeric writing and ordinary null omission are retained. Extension entry nulls do not force null fields inside ordinary model values or alter the surrounding writer’s policy, including on failure. This support uses the tree reader/writer implementation classes from the unchanged Gson 2.11.0 dependency; native consumers cover both input modes.

Pure-dictionary codecs are unchanged. Closed/implicit objects, general inline composition lowering, opaque named-alias codecs, multi-member allOf, tagged wrapper extension storage and generation formatting remain separate concerns.

Architecture

Pipeline

"]OpenAPI YAML / JSONAutBB["ParserCC["IRDEEachFileInfo&gtLowersParsesReturnsVersionWritesagnosticdetects

Lowering happens once in the orchestrator (src/generators/orchestrator.rs). Generators never touch raw OpenAPI types.

Module Layout

openapi-nexus is a single crate. All modules live under src/:

src/
├── cli/              CLI argument parsing and entry point
├── codegen/          CodeGenerator/FileWriter traits, GeneratorType, Language enums
├── config/           Configuration loading (CLI > env > TOML > defaults)
├── ir/               IrSpec types and lowering passes
│   └── lower/        v30.rs, v31.rs, v32.rs
├── spec/             Raw OAS types (v30, v31, v32)
├── parser/           YAML/JSON parsing, OAS version auto-detection
└── generators/       One submodule per generator
    ├── typescript/fetch/
    ├── go/http/
    ├── rust/common/         Shared model + API emission for Rust backends
    ├── rust/reqwest/
    ├── rust/ureq/
    ├── rust/aioduct/
    ├── python/common/       Shared model + API emission for Python backends
    ├── python/httpx/
    ├── python/requests/
    ├── java/okhttp/
    ├── kotlin/okhttp/
    ├── orchestrator.rs      Orchestrates parse → lower → generate → write
    └── registry.rs          Maps GeneratorType to constructor

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>, GeneratorError>;
}
}

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

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

  • Language-specific type systems (TypeScript, Go, Rust, Python, Java, Kotlin)
  • Import tracking and deduplication
  • Width-aware pretty printing
  • The sigil_quote! macro for inline code templates with $if, $for, $let directives

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. Add a GeneratorType variant

In src/codegen/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.

Also add the corresponding Language variant in src/codegen/language.rs if the target language doesn’t exist yet.

2. Create the generator module

Create a new directory under src/generators/:

src/generators/my_lang/
├── mod.rs              Module root, exports the generator struct
├── sigil_emit.rs       Model emission (IR schemas → target-language types)
└── sigil_emit_api.rs   API emission (IR operations → client methods)

Add the module to src/generators/mod.rs.

3. Implement CodeGenerator

#![allow(unused)]
fn main() {
use crate::codegen::traits::code_generator::CodeGenerator;
use crate::codegen::traits::file_writer::{FileInfo, FileWriter};
use crate::generators::GeneratorError;
use crate::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>, GeneratorError> {
        // 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 src/generators/registry.rs, add the generator to the registry:

#![allow(unused)]
fn main() {
GeneratorType::MyLang => {
    Box::new(MyLangCodeGenerator::new(/* config */))
}
}

6. Add golden tests

  1. Create tests/golden_tests_my_lang.rs
  2. Use the run_golden_test harness from openapi_nexus::codegen::test_utils
  3. Create golden files: tests/golden/my_lang/my-lang/<fixture>/
  4. Generate initial goldens with UPDATE_GOLDEN=1 cargo test --test golden_tests_my_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 compile-check command.

Add a Justfile submodule at just/golden-my-lang.just with update and build recipes.

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),
    StringLiteral(String),
    StringEnum(Vec<String>),
    ScalarEnum {
        representation: Box<IrTypeExpr>,
        representation_is_constraint: bool,
        representation_nullable: bool,
        values: Vec<serde_json::Value>,
    },
    Array(Box<IrTypeExpr>),
    Map(Box<IrTypeExpr>),
    Nullable(Box<IrTypeExpr>),
    Union(Vec<IrTypeExpr>),
    Any,
}
}

Nullability belongs to the node that declares it. OpenAPI 3.0 nullable: true and OpenAPI 3.1/3.2 type sets containing null retain that distinction in nested arrays and maps:

Schema meaningIR shape
Map whose values may be nullMap(Nullable(T))
Array whose items may be nullArray(Nullable(T))
Array that may itself be nullNullable(Array(T))

Array aliases and references to promoted inline items preserve these wrappers. For object properties, the outer nullable wrapper is recorded in IrProperty.nullable; nested wrappers remain in type_expr. A nullable container does not make its contents nullable, and nullability is independent of whether a property is required.

Inline scalar enum facts survive all three lowerers, including property, array item, and dictionary value positions. ScalarEnum retains finite values together with the pre-existing representation; existing string literal/enum expressions already carry their values. TypeScript uses these facts for structural union selection. Other generators consume the retained representation without changing their enum policy. Rust callers exhaustively matching IrTypeExpr must account for this variant.

Finite-value constraints apply to the original node: a nullable type with enum: [1] still excludes null during selection, while enum: [1, null] permits it. Extracting IrProperty.nullable does not widen that constraint. The representation_nullable fact records a removed nullable wrapper at its exact enum boundary: an outer enum intersects the inner constraints without moving null permission across another enum. It defaults to false when constructing a new ScalarEnum whose complete nullable representation is still present. Nested finite constraints are intersected rather than replaced by outer values. representation_is_constraint distinguishes an authored type or constrained wrapper from the legacy string fallback used for an enum with no declared type. The latter keeps its existing emitted type, but numeric/boolean/null enum values are matched directly rather than intersected with a synthetic string check. The representation() view exposes the legacy type beneath scalar metadata for generators that do not use finite values. OpenAPI 3.1/3.2 nullable oneOf/anyOf wrappers retain enums attached to the wrapper, including inside arrays and maps. TypeScript conversion restores an extracted nullable wrapper around its original delegate; matching metadata does not imply that a nonnullable delegate accepts null. Null already accepted by the inner union still reaches its strict policy. Inner finite constraints remain inside the restored wrapper so null excluded by that inner constraint can still be admitted exclusively by the outer wrapper.

OpenAPI 3.0 boolean exclusiveMinimum / exclusiveMaximum flags retain both true and false in validation metadata. The public 3.0 ObjectSchema fields use ExclusiveBound to preserve those booleans as well as the legacy numeric form already accepted by the parser. The numeric form retains its previous lowering behavior; OpenAPI 3.1/3.2 numeric-bound handling is unchanged.

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 TypeScript output per fixture
│   ├── go/go-http/                   Expected Go output per fixture
│   ├── rust/rust-reqwest/            Expected Rust (reqwest) output
│   ├── rust/rust-ureq/               Expected Rust (ureq) output
│   ├── rust/rust-aioduct/            Expected Rust (aioduct) output
│   ├── python/python-httpx/          Expected Python (httpx) output
│   ├── python/python-requests/       Expected Python (requests) output
│   ├── java/java-okhttp/             Expected Java output
│   └── kotlin/kotlin-okhttp/         Expected Kotlin output

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:

LanguageCommandMarker file
TypeScriptvp check && vp pack && vp check or tsc --noEmit (see below)tsconfig.json.golden
Gogo build ./...go.mod.golden
Rustcargo checkCargo.toml.golden
Pythonpyrightpyproject.toml.golden
Javagradle compileJavabuild.gradle.golden
Kotlingradle compileKotlinbuild.gradle.kts.golden

This catches type errors that snapshot comparison alone cannot.

TypeScript: vp pack vs tsc --noEmit

The TypeScript golden build script selects the toolchain per-test based on whether vite.config.ts.golden exists in the test directory:

  • With vite.config.ts — runs vp install && vp check && vp pack && vp check. This is the toolchain = "vp" path: installs deps, checks the original generated files for formatting and type-aware linting/types, then builds the library bundle. The second check verifies that formatting still passes after packing; generated dist/ output is excluded from formatting.
  • Without vite.config.ts — runs tsc --noEmit. This is the toolchain = "tsc" path: type-checks only.

Both paths are always exercised in CI. Both vp and tsc must be available.

vp pack is library mode (uses pack.entry from vite.config.ts, emits .mjs + .d.mts). vp build is app mode (expects index.html). Generated packages are libraries, never apps.

Running Golden Tests

# Run all snapshot tests
cargo test

# Run specific generator's golden tests
cargo test --test golden_tests_typescript_fetch
cargo test --test golden_tests_go_http
cargo test --test golden_tests_rust_reqwest
cargo test --test golden_tests_rust_ureq
cargo test --test golden_tests_rust_aioduct
cargo test --test golden_tests_python_httpx
cargo test --test golden_tests_python_requests
cargo test --test golden_tests_java_okhttp
cargo test --test golden_tests_kotlin_okhttp

# Run a single test by name
cargo test --test golden_tests_go_http -- minimal

Updating Golden Files

When you intentionally change generator output:

# Update all
UPDATE_GOLDEN=1 cargo test

# Update one generator
UPDATE_GOLDEN=1 cargo test --test golden_tests_typescript_fetch
UPDATE_GOLDEN=1 cargo test --test golden_tests_rust_reqwest

After updating, verify the compile check passes:

just golden-typescript::build
just golden-go::build
just golden-rust::build
just golden-python::build
just golden-java::build
just golden-kotlin::build
just golden-build-all           # all languages

TypeScript Client Runtime Tests

just golden-typescript-clients::test

This generates fresh preserve/camelCase clients, validates, and packs them with Vite+, checks their packed declarations with strict TypeScript, and runs ordinary Node tests against the real bundle. Full additional-property payload equality, typed dictionary conversion, allOf and search-event composition, renamed-key collisions, optional-field absence, and prototype-sensitive keys are covered. The preserve client supplies type-contract coverage, not a second runtime conversion path (it does not emit converters).

Generation, compilation, and execution are separate just recipes. Builds run in temporary directories so Vite+ does not skip gitignored generated sources; no generated manifests or source files receive compatibility patches or formatter rewrites. Full vp check runs on the original generated output, then runs again after packing. The consumer type check uses the generated package’s installed compiler rather than the global tsc selection. The current global Vite+ CLI also needs TypeScript installed alongside the CLI for declaration packing (npm install -g typescript@7.0.2 vite-plus@1.6.0). CI reuses that bootstrap and its generator cache in the existing TypeScript/Vite+ golden-build job; no separate consumer job is added. just ci includes these checks too. See tests/generated-clients/typescript/README.md for details.

The tagged-allof-payloads fixture has recurring golden compile coverage for TypeScript, Go, all three Rust transports, Java and Kotlin. Native TypeScript consumers also check both conversion directions, nested sibling fields and outer requiredness. The TypeScript-specific overlapping-allof fixture checks recursive object views, arrays/maps, null/optional values, opaque JSON and authored-name collisions. These are not cross-language wire-round-trip guarantees: Kotlin’s existing allOf wire representation is unchanged. The retained recursive Python fixture still exposes a separately tracked unresolved-symbol defect and is not registered as a passing Python compile case; its render coverage and existing Python golden/consumer suites remain distinct checks.

Rust Client Runtime Tests

Generated Rust clients also have standalone consumer test projects under tests/generated-clients/rust/. Unlike golden comparison, these tests call real generated clients over local HTTP and check decoding, error metadata, response dispatch, and strict non-nullable controls.

just golden-rust::test          # reqwest, ureq, and aioduct
just golden-rust::test-reqwest  # one backend

Preparation runs the public generator CLI against the fixtures. Each consumer project has its own checked-in manifest, lockfile, and ordinary Rust test files; generated clients are unmodified path dependencies in an ignored directory. One Cargo invocation runs all cases for a backend, with a persistent dependency cache.

These projects are intentionally outside the generator workspace. just test-all and generator coverage do not recursively build client crates. CI has a separate Rust Client Tests matrix, and just ci includes these tests. They complement, rather than replace, snapshot comparison and the all-golden compile checks.

See the consumer test README for direct Cargo/IDE use and the retained coverage per fixture.

JVM Client Runtime Tests

Java and Kotlin runtime checks are standalone Gradle consumer builds under tests/generated-clients/jvm/. just golden-jvm::test prepares fresh generated dependencies and runs normal JUnit tests, one Gradle invocation per backend. Generated manifests are never patched. Each fixture has an isolated consumer classpath, and both backends assert union precedence and complete payload round-trips, not only wrapper identity or discriminator substrings.

There are no JVM build subprocesses or global directory locks in the Rust test suite. The independent JVM Client Tests CI matrix and just ci run these checks.

Native Python Union Consumers

just golden-python-clients::test httpx and just golden-python-clients::test requests generate fresh clients for firstMatch, strict, and raw, run strict pyright on the unchanged files, then run ordinary unittest consumers. The same contracts cover dataclasses, nested containers, aliases, finite values, recursion, JSON/multipart transport encoding, tagged payloads, nulls, and lazy error decoding. In-process HTTP transports avoid network IO without bypassing each library’s request encoder. Native tests do not launch generation, type checkers, or other runners.

The same just golden-python::build-httpx / build-requests entrypoints run all golden checks, fresh public-declaration checks and native consumers, both locally and in CI. golden-build-all and just ci inherit this complete validation; CI does not append a Python-specific test step. Dependencies are installed once in a temporary environment created with the active Python (locally 3.15.0, CI 3.x), and the same checker and interpreter are used throughout the backend’s checks. The generated SDK’s minimum and explicit pyright target remain 3.12, a static contract, not a claim that the native tests execute on the minimum runtime. See the Python configuration for selection-policy semantics.

Validation Contracts and Toolchains

  • Missing fixture files fail their parser tests. Nested fixture paths are relative to the fixture tree exactly once; an empty discovery produces a failing test, not a green placeholder. Known malformed upstream documents are executed as explicit expected parse failures in test_fixture_rejections.txt. Missing files or read errors cannot satisfy those rejection expectations.
  • Every golden directory must have its build manifest. Native validators reject empty selections and missing manifests instead of reporting 0/0 passed.
  • Rust golden builds retain their cache under target/golden-build/rust-<backend> or the explicitly configured CARGO_TARGET_DIR. CI caches the same directory.
  • Python prepares dependencies and resolves pyright once per backend, sharing that environment across goldens, declaration checks and runtime consumers. PYTHON selects the interpreter used to create the temporary environment; the active pyright is used when available, otherwise npm resolves it once.
  • Development and ordinary CI validation select current Rust stable (locally validated with Rust 1.100.0). MSRV checks explicitly select Rust 1.90.0; they do not substitute for stable validation.
  • Local TypeScript validation uses TypeScript 7.0.2 and Vite+ 1.6.0 on Node 26.13.1, resolving Vite 8.3.7, Rolldown 1.2.18 and Oxfmt 0.78.0. The native validators use the active tsc and vp on PATH. To select that compiler explicitly, npm exec --package=typescript@7.0.2 -- just golden-typescript::build-tsc supplies it without changing generated packages.
  • Current local JVM validation uses Homebrew JDK 27.0.1 with Gradle 9.8.2, respecting JAVA_HOME or the active JVM. CI validates on JDK 27. Generated libraries may still declare a Java 17 compile target, independently of the consumer test JVM.

Fixture discovery is tested in-process through the same build-support module used by build.rs. Cargo compiles and executes the registered parser tests normally; no test harness launches a compiler, build tool, or validation script. just script-check lints the native build entrypoints. Tool orchestration belongs to just/CI, which directly run the golden builds and ordinary consumer projects.

just ci includes generator tests and coverage, all nine generators’ native golden builds, and TypeScript/Python/Rust/JVM consumer tests. Golden comparison is already included in test-all, so the full recipe does not repeat it as another standalone step.

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 }