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.1.16/openapi-nexus-installer.sh | sh
Nightly build (latest main):
curl --proto '=https' --tlsv1.2 -LsSf https://github.com/rust-codegen-group/openapi-nexus/releases/download/nightly/openapi-nexus-installer.sh | sh
Build from source:
cargo install openapi-nexus
Requires Rust 1.90+ (edition 2024).
Basic Usage
Generate a TypeScript fetch client:
openapi-nexus generate \
--input path/to/openapi.yaml \
--output generated \
--generators typescript-fetch
Generate another target language into a separate directory:
openapi-nexus generate \
--input spec.yaml \
--output output/go \
--generators go-http
openapi-nexus generate \
--input spec.yaml \
--output output/python \
--generators python-httpx
All nine generators, each with its own output directory:
for generator in \
typescript-fetch \
go-http \
rust-reqwest \
rust-ureq \
rust-aioduct \
python-httpx \
python-requests \
java-okhttp \
kotlin-okhttp
do
openapi-nexus generate -i spec.yaml -o "output/${generator}" -g "${generator}"
done
Configuration
Configuration is resolved with the following precedence (highest to lowest):
- 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
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.
Rust Generator Configuration
All three Rust backends (rust-reqwest, rust-ureq, rust-aioduct) share the same configuration options.
The rust-aioduct backend has an additional [aioduct] section for controlling aioduct-specific features.
Full Example
[generators.rust-reqwest]
crate_name = "my-api-client"
workspace_mode = true
workspace_deps = "workspace_version"
[generators.rust-reqwest.extra_derives.structs]
derives = ["PartialEq", "Eq"]
[generators.rust-reqwest.extra_derives.enums]
derives = ["Hash"]
[generators.rust-reqwest.extra_derives.unions]
derives = ["PartialEq"]
[generators.rust-reqwest.extra_derives.response_structs]
derives = ["PartialEq"]
[generators.rust-reqwest.extra_derives.per_type.MySpecialSchema]
derives = ["Default"]
[generators.rust-reqwest.utoipa]
enabled = true
dependency = '{ version = "5" }'
Options Reference
crate_name / package_name
Override the generated crate name. Defaults to the spec title converted to kebab-case.
crate_name = "my-api-client"
workspace_mode
When true, the generated Cargo.toml uses version.workspace = true and edition.workspace = true instead of inline values. Also emits [lints] workspace = true.
workspace_mode = true
workspace_deps
Controls how dependencies are declared in the generated Cargo.toml.
| 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 unions (external tagging)response_structs— per-operation response type wrappers
[generators.rust-reqwest.extra_derives.structs]
derives = ["PartialEq", "Eq"]
dependencies = { fake = '"2"' }
The dependencies field adds entries to the generated Cargo.toml.
extra_derives.per_type
Target a specific schema by name:
[generators.rust-reqwest.extra_derives.per_type.UserProfile]
derives = ["Default", "Hash"]
utoipa
Native utoipa integration for OpenAPI schema generation at runtime.
[generators.rust-reqwest.utoipa]
enabled = true
dependency = '{ version = "5" }'
When enabled:
- Structs, string enums, integer enums, intersections, and aliases get
#[derive(utoipa::ToSchema)] - Tagged unions (internal/adjacent) and untagged unions get manual
impl utoipa::PartialSchema + 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.
[generators.rust-aioduct.aioduct]
version = "0.2"
runtime = "tokio"
tls = "rustls-ring"
compression = ["gzip", "brotli", "zstd"]
features = ["tracing", "http3"]
All fields are optional. Defaults: runtime = "tokio", tls = "rustls-ring", no compression, no extra features. The json feature is always included.
version
Override the aioduct version requirement. Defaults to "0.2".
runtime
Which async runtime to use. One of:
| 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"]
TypeScript Generator Configuration
Full Example
[generators.typescript-fetch]
file_naming_convention = "PascalCase"
package_scope = "@myorg"
package_name = "my-api-client"
generate_package = true
ts_target = "ES2020"
ts_module = "ES2020"
ts_lib = ["ES2020", "DOM"]
generate_esm_config = true
include_build_scripts = true
emit_enum_constants = true
emit_type_guards = true
property_naming = "camelCase"
indent = " "
Options Reference
file_naming_convention
Controls the file naming style. One of "PascalCase", "camelCase", "kebab-case", "snake_case". Defaults to "PascalCase".
package_scope
NPM package scope prefix, e.g. "@myorg". If set, the generated package.json uses "name": "@myorg/my-api-client".
package_name
Override the generated package name. Defaults to the spec title converted to kebab-case.
generate_package
Whether to generate npm package files (package.json, tsconfig.json, tsconfig.esm.json). Defaults to true.
ts_target
TypeScript compiler target. Defaults to "ES2020".
ts_module
TypeScript module system. One of "commonjs", "ES2020", "ES2022", "ESNext". Defaults to "ES2020".
ts_lib
TypeScript compiler lib array. Accepts a TOML array or comma-separated string. Defaults to ["ES2020", "DOM"].
generate_esm_config
Whether to generate an ESM tsconfig (tsconfig.esm.json). Defaults to true.
include_build_scripts
Whether to include build scripts in package.json. Defaults to true.
emit_enum_constants
When true, emits a companion const object alongside each enum type alias:
export type ItemKind = 'BOOK' | 'MOVIE' | 'MUSIC';
export const ItemKind = {
BOOK: 'BOOK' as const,
MOVIE: 'MOVIE' as const,
MUSIC: 'MUSIC' as const,
};
Consumers can use the same import for both type annotations and runtime value comparisons:
import { ItemKind } from "@scope/package";
function getLabel(kind: ItemKind) {
switch (kind) {
case ItemKind.BOOK: return "Book";
// ...
}
}
Handles string, integer, number, and mixed-value enums. Quotes keys that aren’t valid JavaScript identifiers. Defaults to false.
emit_type_guards
When true, emits is* type guard functions alongside each tagged union type alias:
export type Shape = ({ kind: 'circle' } & Circle) | ({ kind: 'rectangle' } & Rectangle);
export function isCircle(value: Shape): value is ({ kind: 'circle' } & Circle) {
return value.kind === 'circle';
}
export function isRectangle(value: Shape): value is ({ kind: 'rectangle' } & Rectangle) {
return value.kind === 'rectangle';
}
The narrowing expression depends on the tagging style:
- Internal/Adjacent:
value.<field> === '<value>' - External:
'<value>' in value
Variants with “UNSPECIFIED” in the discriminator value, discriminator values equal to the field name, or bare string-literal content types are skipped. Defaults to false.
toolchain
Selects the type checking and build toolchain. One of "tsc" (default) or "vp".
"tsc"— bare TypeScript compiler. Generatestsconfig.jsononly. Build script:tsc."vp"— vite-plus. Generatesvite.config.tswithpackconfig for library output (ESM + .d.ts). Build script:vp pack. Check script:vp check --no-fmt.
When toolchain = "vp", the generated package.json includes vite-plus and typescript as dev dependencies, and scripts use vp pack (library mode) rather than vp build (app mode).
[generators.typescript-fetch]
toolchain = "vp"
indent
Controls the indentation string used in generated TypeScript files. Accepts any string; common values are " " (2 spaces, default), " " (4 spaces), or "\t" (tabs).
[generators.typescript-fetch]
indent = " "
property_naming
Controls the naming convention for properties in generated interfaces. When set to "camelCase", generates dual-type model files:
- A
Name$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 + spread-based converters
- Unions (oneOf): dual type aliases + cast-through converters
Referenced types that are themselves convertible (objects, intersections, unions) get their converter functions called recursively. Enums and simple aliases pass through unchanged.
Defaults to "preserve" (no renaming, single interface, no converters).
Barrel Exports
When either feature is enabled, models/index.ts automatically emits value re-exports:
// Enum const: single line covers both type and value
export { ItemKind } from './ItemKind';
// Tagged union: separate type and guard re-exports
export type { Shape } from './Shape';
export { isCircle, isRectangle } from './Shape';
Architecture
Pipeline
Lowering happens once in the orchestrator (src/generators/orchestrator.rs). Generators never touch raw OpenAPI types.
Module Layout
openapi-nexus is a single crate. All modules live under src/:
src/
├── cli/ CLI argument parsing and entry point
├── codegen/ CodeGenerator/FileWriter traits, GeneratorType, Language enums
├── config/ Configuration loading (CLI > env > TOML > defaults)
├── ir/ IrSpec types and lowering passes
│ └── lower/ v30.rs, v31.rs, v32.rs
├── spec/ Raw OAS types (v30, v31, v32)
├── parser/ YAML/JSON parsing, OAS version auto-detection
└── generators/ One submodule per generator
├── typescript/fetch/
├── go/http/
├── rust/common/ Shared model + API emission for Rust backends
├── rust/reqwest/
├── rust/ureq/
├── rust/aioduct/
├── python/common/ Shared model + API emission for Python backends
├── python/httpx/
├── python/requests/
├── java/okhttp/
├── kotlin/okhttp/
├── orchestrator.rs Orchestrates parse → lower → generate → write
└── registry.rs Maps GeneratorType to constructor
The CodeGenerator Trait
#![allow(unused)]
fn main() {
pub trait CodeGenerator {
fn language(&self) -> Language;
fn generator_type(&self) -> GeneratorType;
fn generate(&self, ir: &IrSpec) -> Result<Vec<FileInfo>, Box<dyn Error + Send + Sync>>;
}
}
CombinedGenerator is a blanket impl of CodeGenerator + FileWriter. The orchestrator stores generators as Box<dyn CombinedGenerator + Send + Sync> and calls generate() then write_files().
Code Emission
All generators use sigil-stitch, a type-safe code generation framework. sigil-stitch provides:
- Language-specific type systems (TypeScript, Go, Rust, Python, Java, Kotlin)
- Import tracking and deduplication
- Width-aware pretty printing
- The
sigil_quote!macro for inline code templates with$if,$for,$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::ir::types::IrSpec;
pub struct MyLangCodeGenerator { /* config fields */ }
impl CodeGenerator for MyLangCodeGenerator {
fn language(&self) -> Language { Language::MyLang }
fn generator_type(&self) -> GeneratorType { GeneratorType::MyLang }
fn generate(&self, ir: &IrSpec) -> Result<Vec<FileInfo>, Box<dyn Error + Send + Sync>> {
// Transform IrSpec into generated source files
todo!()
}
}
}
4. Implement FileWriter
FileWriter writes Vec<FileInfo> to disk. A default implementation is provided, so you typically only need:
#![allow(unused)]
fn main() {
impl FileWriter for MyLangCodeGenerator {}
}
5. Register in the orchestrator
In src/generators/registry.rs, add the generator to the registry:
#![allow(unused)]
fn main() {
GeneratorType::MyLang => {
Box::new(MyLangCodeGenerator::new(/* config */))
}
}
6. Add golden tests
- 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),
Array(Box<IrTypeExpr>),
Map(Box<IrTypeExpr>),
Nullable(Box<IrTypeExpr>),
Union(Vec<IrTypeExpr>),
Literal(serde_json::Value),
Unknown,
}
}
Operations
Each IrOperation represents one HTTP method + path:
#![allow(unused)]
fn main() {
pub struct IrOperation {
pub operation_id: String,
pub tags: Vec<String>,
pub method: String,
pub path: String,
pub summary: Option<String>,
pub description: Option<String>,
pub deprecated: bool,
pub parameters: Vec<IrParameter>,
pub request_body: Option<IrRequestBody>,
pub responses: Vec<IrResponse>,
pub security: Vec<IrSecurityRequirement>,
}
}
Parameters carry a ParameterLocation (Query, Header, Path, Cookie).
Request bodies map media types to IrTypeExpr:
#![allow(unused)]
fn main() {
pub struct IrRequestBody {
pub required: bool,
pub content: IndexMap<String, IrTypeExpr>,
}
}
Responses carry status code, description, content map, and headers.
Security Schemes
#![allow(unused)]
fn main() {
pub enum IrSecurityScheme {
ApiKey { name, location, description },
Http { scheme, bearer_format, description },
OAuth2 { flows, description },
OpenIdConnect { open_id_connect_url, description },
MutualTls { description },
}
}
Golden Testing
Golden tests are the primary correctness mechanism for openapi-nexus. They ensure that generated output is byte-for-byte reproducible and that it compiles in the target language.
How It Works
There are two layers:
Layer 1: Snapshot comparison (Rust)
Each generator has a test file that:
- Reads an OpenAPI fixture from
tests/fixtures/valid/ - Runs it through the full pipeline (parse, lower, generate)
- Compares each generated file byte-for-byte against a
.goldenfile
tests/
├── fixtures/valid/ Input OpenAPI specs
├── golden/
│ ├── typescript/typescript-fetch/ Expected 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 --no-fmt && vp pack 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 --no-fmt && vp pack. This is thetoolchain = "vp"path: installs deps, runs type-aware linting, then builds the library bundle. - 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
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 } |