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
| Language | Generator ID | HTTP Client | Status |
|---|---|---|---|
| TypeScript | typescript-fetch | fetch | Beta |
| Go | go-http | net/http | Beta |
| Rust | rust-reqwest | reqwest | Beta |
| Rust | rust-ureq | ureq | Beta |
| Rust | rust-aioduct | aioduct | Beta |
| Python | python-httpx | httpx | Beta |
| Python | python-requests | requests | Beta |
| Java | java-okhttp | OkHttp | Beta |
| Kotlin | kotlin-okhttp | OkHttp | Beta |
How It Works
openapi-nexus follows a compiler-like pipeline:
Parsing and lowering happen once in the orchestrator. Each generator receives a pre-lowered IrSpec and produces a list of files. Generators use sigil-stitch for type-safe, import-aware code emission.
Key Properties
- Deterministic output. The same spec always produces the same files. Golden tests enforce byte-for-byte reproducibility.
- Compile-checked output. CI runs 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.
Links
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):
- Command-line arguments
- Environment variables (prefixed with
OPENAPI_NEXUS_) - Configuration file (
openapi-nexus-config.toml) - Defaults
Environment Variables
export OPENAPI_NEXUS_INPUT="spec.yaml"
export OPENAPI_NEXUS_OUTPUT="generated"
export OPENAPI_NEXUS_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 Type | Class / Struct | Wire Format |
|---|---|---|
| Bearer token | BearerAuth | Authorization: Bearer <token> |
| API key | ApiKeyAuth | Configurable header, query parameter, or cookie |
| HTTP Basic | BasicAuth | Authorization: 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-streamrequest 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:
| Generator | Request body type |
|---|---|
typescript-fetch | Blob | File |
go-http | []byte |
python-httpx | bytes |
python-requests | bytes |
java-okhttp | byte[] |
kotlin-okhttp | ByteArray |
rust-reqwest | Vec<u8> |
rust-ureq | Vec<u8> |
rust-aioduct | Vec<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
UploadFileorUploadFileInput. - String, number, boolean, and enum parts are emitted as text.
- Object and array parts are emitted as JSON.
encoding.<part>.contentTypecontrols the per-partContent-Typewhen 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
| Generator | Successful response headers | Error response headers |
|---|---|---|
| TypeScript Fetch | raw response wrapper headers; generated get...Header functions | ResponseError.response.headers; generated methods on the operation error |
| Go HTTP | response.Raw.Header; generated (value, ok) methods | error.Header; generated (value, ok) methods |
| Rust reqwest, ureq, aioduct | native HeaderMap in response.headers; generated Option<T> methods | native HeaderMap from ApiError::headers(); generated Option<T> methods on the operation error |
| Python httpx, requests | method_with_http_info(...).headers; generated optional properties | ApiError.headers; generated optional properties |
| Java OkHttp | response.getRaw().headers(); generated nullable getters | exception.headers(); generated nullable getters |
| Kotlin OkHttp | response.raw.headers; generated nullable methods | exception.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
| Value | Generated 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.
| Mode | Behavior |
|---|---|
"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 schemasenums— string and integer enumsunions— 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 + ToSchemausingOneOfBuilder(the derive macro doesn’t support these patterns) - The
utoipacrate is added to generatedCargo.tomlusing thedependencyspec - 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:
| Value | Description |
|---|---|
"tokio" (default) | Tokio runtime |
"smol" | smol runtime |
"compio" | compio (io_uring) runtime |
tls
TLS backend selection:
| Value | Description |
|---|---|
"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. Generatestsconfig.jsononly. Build script:tsc."vp"— vite-plus. Generatesvite.config.tswithpackconfig 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$Wireinterface preserving the original wire-format property names (snake_case, kebab-case, etc.) - A
Nameinterface with camelCase property names for ergonomic usage nameFromJSON(json: Name$Wire): Nameconverter functionnameToJSON(value: Name): Name$Wireconverter 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 throwTypeErrorfor 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. BothNameandName$Wireexpose the wire representation; pass original wire keys such asinput_schema, not ergonomic keys such asinputSchema. 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.
| Value | Member selection | No match | Public representation |
|---|---|---|---|
firstMatch | First concrete match, in schema order | Preserve the original value | Existing dataclass/primitive union |
strict | Reject multiple concrete matches | Raise ValueError | Existing dataclass/primitive union |
raw | Do not select or convert the union subtree | Preserve the original value | Dynamic 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"
| Policy | Behavior |
|---|---|
firstMatch (default) | Keep typed variants; choose the first structural match in schema order, with concrete members before opaque fallbacks. |
strict | Keep the same typed API; reject multiple authored concrete matches before decoding. Use an opaque fallback only when no concrete member matches. |
raw | Explicitly 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"
| Policy | Behavior |
|---|---|
firstMatch (default) | First structural match in authored order; concrete members precede opaque fallbacks. |
strict | Same typed API; reject multiple authored concrete matches before decoding. Use a fallback only if no concrete member matches. |
raw | Explicit 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
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,$letdirectives
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
- Create
tests/golden_tests_my_lang.rs - Use the
run_golden_testharness fromopenapi_nexus::codegen::test_utils - Create golden files:
tests/golden/my_lang/my-lang/<fixture>/ - 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 meaning | IR shape |
|---|---|
| Map whose values may be null | Map(Nullable(T)) |
| Array whose items may be null | Array(Nullable(T)) |
| Array that may itself be null | Nullable(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:
- Reads an OpenAPI fixture from
tests/fixtures/valid/ - Runs it through the full pipeline (parse, lower, generate)
- Compares each generated file byte-for-byte against a
.goldenfile
tests/
├── fixtures/valid/ Input OpenAPI specs
├── golden/
│ ├── typescript/typescript-fetch/ Expected 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:
| Language | Command | Marker file |
|---|---|---|
| TypeScript | vp check && vp pack && vp check or tsc --noEmit (see below) | tsconfig.json.golden |
| Go | go build ./... | go.mod.golden |
| Rust | cargo check | Cargo.toml.golden |
| Python | pyright | pyproject.toml.golden |
| Java | gradle compileJava | build.gradle.golden |
| Kotlin | gradle compileKotlin | build.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— runsvp install && vp check && vp pack && vp check. This is thetoolchain = "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; generateddist/output is excluded from formatting. - Without
vite.config.ts— runstsc --noEmit. This is thetoolchain = "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 configuredCARGO_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.
PYTHONselects the interpreter used to create the temporary environment; the activepyrightis 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
tscandvponPATH. To select that compiler explicitly,npm exec --package=typescript@7.0.2 -- just golden-typescript::build-tscsupplies it without changing generated packages. - Current local JVM validation uses Homebrew JDK 27.0.1 with Gradle 9.8.2, respecting
JAVA_HOMEor 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
| # | Decision | Rationale |
|---|---|---|
| 1 | Field terminator is ; (not ,) | Standard idiomatic TypeScript |
| 2 | Drop FromJSON / ToJSON / FromJSONTyped / ToJSONTyped | Interfaces are structural; runtime conversion is noise when field names already match |
| 3 | Drop instanceOfX | Structural checks in TS are a code smell; use discriminators or validators |
| 4 | Drop XxxPropertyValidationAttributesMap | Dead data; validation belongs in a schema library |
| 5 | Drop /* tslint:disable */ and /* eslint-disable */ headers | tslint is deprecated; keep @generated header |
| 6 | Validator is opt-in via --validator zod|valibot|none (default none) | Schemas ship as siblings, never force-imported |
| 7 | No models/index.ts barrel | Barrels defeat tree-shaking and slow down IDEs at scale |
| 8 | Every field is readonly | Generated DTOs are immutable views of wire data |
| 9 | Runtime is embedded, ~80 lines target (was 450) | Replace dead abstractions with a single request() helper |
| 10 | Per-tag API classes + top-level Client wrapper | Ergonomic new Client({ baseUrl }).pets.getPetById(...) |
| 11 | Client name derived from info.title via PascalCase + Client suffix | "Studio AMS — ASM" becomes StudioAmsAsmClient |
| 12 | Errors throw ApiError with ApiNetworkError and ApiResponseError subclasses | Idiomatic JS try/catch + instanceof |
| 13 | Methods take an options object; path params, query, body are named keys | Consistent regardless of param count |
Model File
Before (~90 lines)
/* tslint:disable */
/* eslint-disable */
import { type Category, CategoryFromJSON, CategoryToJSON, instanceOfCategory } from './Category';
export interface Pet {
category?: null | Category,
id?: number | null,
name: string,
}
export function instanceOfPet(value: unknown): value is Pet { ... }
export function PetFromJSON(json: any): Pet { ... }
export function PetToJSON(value?: Pet | null): any { ... }
After (~20 lines)
/**
* @generated by openapi-nexus. Do not edit.
*
* Petstore API — 1.0.0
*/
import type { Category } from './Category';
export interface Pet {
readonly category?: Category | null;
readonly id?: number | null;
readonly name: string;
}
readonlyon every fieldtypeimports only (no value imports)Array<T>becomesT[]for simple types
Enum
/** Pet status in the store */
export type PetStatus = 'available' | 'pending' | 'sold';
String-literal union type alias. No enum (not erasable-syntax compatible).
Validator Schemas (Opt-in)
Emitted only when --validator zod or --validator valibot is set. Lives next to the model file as Pet.schema.ts.
Zod
import { z } from 'zod';
export const PetSchema = z.object({
category: CategorySchema.nullable().optional(),
name: z.string(),
});
export const parsePet = (input: unknown) => PetSchema.parse(input);
Valibot
import * as v from 'valibot';
export const PetSchema = v.object({
category: v.optional(v.nullable(CategorySchema)),
name: v.string(),
});
export const parsePet = (input: unknown) => v.parse(PetSchema, input);
API File
Per-tag class with methods taking options objects:
export class PetApi {
constructor(private readonly config: ClientConfig) {}
async getPetById(options: { petId: number; signal?: AbortSignal }): Promise<Pet> {
return request<Pet>(this.config, {
method: 'GET',
path: `/pet/${encodeURIComponent(options.petId)}`,
signal: options.signal,
});
}
}
Removed: ApiXxxRequest wrappers, PetApiInterface, xxxRaw variants, typed response unions.
Client Wrapper
Name derived from info.title (e.g., PetstoreApiClient):
export class PetstoreApiClient {
readonly pets: PetApi;
readonly store: StoreApi;
constructor(options: PetstoreApiClientOptions = {}) {
const config: ClientConfig = { /* ... */ };
this.pets = new PetApi(config);
this.store = new StoreApi(config);
}
}
Runtime (~80 lines)
export interface ClientConfig {
baseUrl: string;
fetch: typeof fetch;
headers: Record<string, string>;
beforeRequest?: (init: RequestInit & { url: string }) => ...;
}
export class ApiError extends Error { }
export class ApiNetworkError extends ApiError { }
export class ApiResponseError<T = unknown> extends ApiError { }
export async function request<T>(config: ClientConfig, options: RequestOptions): Promise<T> { }
No BaseAPI, no response wrappers, no RequiredError.
Directory Layout
petstore/
├── apis/
│ ├── PetApi.ts
│ ├── StoreApi.ts
│ └── UserApi.ts
├── models/
│ ├── Pet.ts
│ ├── Pet.schema.ts ← only with --validator
│ └── PetStatus.ts
├── runtime.ts
├── Client.ts
├── index.ts ← re-exports Client + types
├── package.json
└── tsconfig.json
Go Side
Go keeps its current shape: per-tag client structs, struct + method receiver idiom, encoding/json marshaling. Same info.title-derived client naming. Concrete Go target goes in a follow-up spec doc.
Migration Table
| Old | New |
|---|---|
import { Pet, PetFromJSON } from 'petstore/models/Pet' | import type { Pet } from 'petstore/models/Pet' |
PetFromJSON(json) | Direct assign or parsePet(json) with --validator |
instanceOfPet(x) | parsePet(x) with validator, or write your own guard |
new PetApi(config).getPetByIdRaw(...) | new PetstoreApiClient({ baseUrl }).pets.getPetById(...) |
catch (e: ResponseError) { e.response } | catch (e) { if (e instanceof ApiResponseError) e.body } |