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.1.16/openapi-nexus-installer.sh | sh

Nightly build (latest main):

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/rust-codegen-group/openapi-nexus/releases/download/nightly/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

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.

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.

Full Example

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

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

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

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

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

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

[generators.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 unions (external tagging)
  • response_structs — per-operation response type wrappers
[generators.rust-reqwest.extra_derives.structs]
derives = ["PartialEq", "Eq"]
dependencies = { fake = '"2"' }

The dependencies field adds entries to the generated Cargo.toml.

extra_derives.per_type

Target a specific schema by name:

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

utoipa

Native utoipa integration for OpenAPI schema generation at runtime.

[generators.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.

[generators.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"]

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"
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. Check script: vp check --no-fmt.

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

[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 + spread-based converters
  • Unions (oneOf): dual type aliases + cast-through converters

Referenced types that are themselves convertible (objects, intersections, unions) get their converter functions called recursively. Enums and simple aliases pass through unchanged.

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

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';

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>, Box<dyn Error + Send + Sync>>;
}
}

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

Code Emission

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::ir::types::IrSpec;

pub struct MyLangCodeGenerator { /* config fields */ }

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

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

4. Implement FileWriter

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

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

5. Register in the orchestrator

In 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),
    Array(Box<IrTypeExpr>),
    Map(Box<IrTypeExpr>),
    Nullable(Box<IrTypeExpr>),
    Union(Vec<IrTypeExpr>),
    Literal(serde_json::Value),
    Unknown,
}
}

Operations

Each IrOperation represents one HTTP method + path:

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

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

Request bodies map media types to IrTypeExpr:

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

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

Security Schemes

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

Golden Testing

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

How It Works

There are two layers:

Layer 1: Snapshot comparison (Rust)

Each generator has a test file that:

  1. Reads an OpenAPI fixture from tests/fixtures/valid/
  2. Runs it through the full pipeline (parse, lower, generate)
  3. Compares each generated file byte-for-byte against a .golden file
tests/
├── fixtures/valid/                  Input OpenAPI specs
├── golden/
│   ├── typescript/typescript-fetch/  Expected 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 --no-fmt && vp pack 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 --no-fmt && vp pack. This is the toolchain = "vp" path: installs deps, runs type-aware linting, then builds the library bundle.
  • 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

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 }