Skip to content

gpu-cli/openapi-to-rust

Repository files navigation

openapi-to-rust

CI Crates.io docs.rs License: MIT

A Rust code generator that turns OpenAPI 3.0/3.1 (and experimental 3.2) specifications into strongly-typed structs, async HTTP clients, SSE streaming clients, and opt-in Axum server scaffolding — including for the messy, real-world specs everyone actually ships.

30-second trial

Install the CLI from crates.io, then generate a tiny client from a stable, hosted fixture:

cargo install --locked openapi-to-rust
openapi-to-rust generate https://raw.githubusercontent.com/gpu-cli/openapi-to-rust/v0.6.0/tests/fixtures/operation_extraction/simple_get.json

This writes src/generated/{types,client,mod}.rs and the exact dependencies needed to compile them in src/generated/REQUIRED_DEPS.toml. Use --dry-run to inspect the plan without writing, and --check in CI to detect stale committed output.

We originally built this internally at GPU CLI to generate typed Rust clients for OpenAI, Anthropic, Cloudflare, and other large APIs. After battle-testing it against real-world specs with complex union types, discriminated enums, streaming endpoints, and the occasional spec/API drift, we decided to open source it.

The repository contains 55 real-world specs. The supported OpenAPI corpus is 54 specs (one Gitea document is Swagger 2.0 and intentionally skipped). Pull requests compile-check the OpenAI and Anthropic production specs; a scheduled and manually runnable CI tier checks all 54 OpenAPI specs.

Release history and breaking changes live in the changelog.

Highlights

  • OpenAPI 3.0 and 3.1, with experimental 3.2 support — handles type: ["X", "null"], anyOf/oneOf/allOf, discriminated unions, const, inline objects, and accepts paths-less specs (components-only or webhooks-only).
  • Generates clients and servers — pick client calls with [client], hosted operations with [server], or keep the default all-operation client. Both share the same types.rs.
  • Typed scalarsformat: date-timechrono::DateTime<chrono::Utc>, uriurl::Url, binarybytes::Bytes, uuiduuid::Uuid, byteVec<u8> + base64 codec, unsigned-int formats → u32/u64. All opt-out per-format in TOML.
  • Async HTTP client — typed methods per operation, retry/backoff via reqwest-retry, distributed tracing via reqwest-tracing, Bearer / API-key / custom auth (honored at runtime), default headers, path-template percent-encoding.
  • Axum server scaffolding — trait per tag, status-code-typed response enum, SSE-ready OkStream variant, required-param HTTP 400 short-circuit at the handler boundary, combined build_router(...) factory for multi-tag selections.
  • SSE streaming clients — first-class Server-Sent Events with reconnection.
  • Smart discriminated unions — auto-detects implicit discriminators from const properties, falls back to #[serde(untagged)] when a union mixes scalar and object branches (e.g. "auto" or a tagged object).
  • Per-operation typed errors — each operation gets its own error enum with Status4xx(...) typed bodies; you can match on the exact API error shape.
  • Typed additionalProperties — extra keys become BTreeMap<String, T> instead of falling to serde_json::Value when the spec gives a value-type schema.
  • Constraint-as-docminLength/maxLength/minimum/pattern etc. are emitted as /// Constraint: … doc comments. No runtime validation is added, so generated code stays free of validator-crate dependencies.
  • TOML configuration with overrides for spec quirks (nullable, extensible enums, type aliases).
  • Snapshot testinginsta snapshots for generated output.
  • Optional specta::Type derives for cross-language type sharing.

Install

Rust users with Rust 1.88 or newer can install the CLI from crates.io:

cargo install --locked openapi-to-rust

This compiles the CLI locally and requires a Rust toolchain. Prebuilt binaries are not currently published. If you use the generator as a Rust library instead, run cargo add openapi-to-rust.

Try it in one command

Generate types and an async client directly from a local document or HTTPS URL:

openapi-to-rust generate openapi.yaml
# Writes src/generated/{types,client,mod}.rs and REQUIRED_DEPS.toml.

The async client is the default. Use --types-only to omit it, --dry-run to analyze without writing, or --check in CI to fail when committed output is stale. Remote fetches require HTTPS (except loopback development), reject URL credentials and redirects, and enforce time and response-size limits.

To keep a reusable config:

openapi-to-rust init openapi.yaml
openapi-to-rust generate

Quick start — client

openapi-to-rust.toml:

[generator]
spec_path = "openapi.json"
output_dir = "src/generated"
module_name = "api"
# All paths above are relative to this TOML file, not the shell's working directory.

[generator.builders]
# Add `*_builder()` when an operation has more than three optional values.
enabled = true
threshold = 3

[features]
enable_async_client = true

[http_client]
base_url = "https://api.example.com"
timeout_seconds = 30

[http_client.retry]
max_retries = 3

[http_client.auth]
type = "Bearer"
header_name = "Authorization"

Then:

openapi-to-rust generate --config openapi-to-rust.toml

Select only the client operations you use

[client] is optional. Without it—or with an empty operations list—the HTTP client keeps every operation for backward compatibility. Selectors share the server grammar: exact operationId, exact METHOD /path, or tag:<name>.

[client]
operations = [
  "createResponse",
  "GET /v1/models",
  "tag:Files",
]
prune_models = true

prune_models = true also trims types.rs. When client and server selections coexist, the generator retains the union of schemas reachable from both scopes, including inline generated request, response, and parameter types. [client] is ignored when enable_async_client = false, and it never filters an enabled operation registry.

Quick start — Axum server

Pick the operations you want to host. The generator emits a trait, a typed response enum, and a router factory. You implement the trait; axum does the rest.

[generator]
spec_path = "openai.yaml"
output_dir = "src/gen"
module_name = "openai"

[features]
enable_async_client = false        # server-only

[server]
framework = "axum"
operations = ["createResponse", "listInputItems"]
# Drop schemas not reachable from the picked operations. Safe when
# you're not also generating the HTTP client (the client would lose types).
prune_models = true

Discover and scaffold operations from the CLI:

# List operations in the spec (filter by tag / method / substring)
openapi-to-rust server list --tag Responses

# Add an operation to [server].operations (preserves TOML formatting)
openapi-to-rust server add createResponse
openapi-to-rust server add --all-tag Responses
openapi-to-rust server add createResponse --regenerate   # re-run codegen

# Remove
openapi-to-rust server remove createResponse

Then implement the trait:

use gen::server::{ResponsesApi, CreateResponseResponse, build_router, sse_response};
use gen::CreateResponse;
use axum::response::sse::Event;
use futures_util::stream;

#[derive(Clone)]
struct AppState;

#[axum::async_trait]
impl ResponsesApi for AppState {
    async fn create_response(&self, body: CreateResponse) -> CreateResponseResponse {
        if body.stream == Some(true) {
            // SSE branch — the generated `sse_response` helper takes any
            // Stream<Item = Result<Event, Infallible>> and returns the
            // exact payload the OkStream variant expects.
            CreateResponseResponse::OkStream(sse_response(stream::iter(vec![
                Ok(Event::default().event("response.created").data("{}")),
                Ok(Event::default().event("response.completed").data("{}")),
            ])))
        } else {
            // Unary branch — typed body, typed response.
            CreateResponseResponse::Ok(/* construct gen::Response */ todo!())
        }
    }
}

#[tokio::main]
async fn main() {
    let app = build_router(AppState);
    let lis = tokio::net::TcpListener::bind("127.0.0.1:3000").await.unwrap();
    axum::serve(lis, app).await.unwrap();
}

Two complete examples are in the repo:

Generated Output

File Description
types.rs All struct/enum definitions from OpenAPI schemas
client.rs Async HTTP client with typed methods per operation (when enable_async_client)
streaming.rs SSE streaming client with event parsing (when configured)
server/mod.rs Module re-exports for the server (when [server] is set)
server/api.rs trait <Tag>Api { async fn <op>(&self, …) -> <Op>Response; } per tag
server/errors.rs enum <Op>Response { Ok(T), BadRequest(E), …, OkStream(Sse<…>) } with IntoResponse
server/router.rs Per-tag Router factory; combined build_router<…>(…) for multi-tag selections
mod.rs Module declarations + re-exports
REQUIRED_DEPS.toml Complete direct dependencies and crate features for the exact generated modes and selected operations — append or merge into the consuming Cargo.toml

Generated client usage

use crate::generated::client::HttpClient;
use crate::generated::types::*;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let client = HttpClient::new()
        .with_base_url("https://api.example.com")
        .with_api_key(std::env::var("API_KEY")?);

    let req = CreateResourceRequest { /* … */ };
    let resource = client.create_resource(req).await?;
    Ok(())
}

What the generated types look like

A tour of patterns the generator emits, from real outputs.

Typed scalars

// format: date-time → chrono::DateTime<chrono::Utc>
pub created_at: chrono::DateTime<chrono::Utc>,
pub archived_at: Option<chrono::DateTime<chrono::Utc>>,

// format: uri → url::Url
pub url: url::Url,
pub callback_url: Option<url::Url>,

// format: binary (multipart) → bytes::Bytes
Binary(bytes::Bytes),

// format: uuid → uuid::Uuid
pub request_id: uuid::Uuid,

Typed additionalProperties

pub additional_properties: std::collections::BTreeMap<String, f64>,    // usage maps
pub additional_properties: std::collections::BTreeMap<String, String>, // labels

Constraints as doc comments

///Constraint: minLength=1, maxLength=64, pattern=`^[a-zA-Z0-9_-]{1,64}$`
pub custom_id: String,

///Constraint: minimum=0, maximum=1
pub temperature: Option<f64>,

No runtime validation is generated. The generator never adds the validator crate or #[validate(...)] attributes — constraints are documentation only. Validate at boundaries you control.

Discriminated unions (tagged enums)

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MessageContent {
    Text(TextContent),
    Image(ImageContent),
}

Hybrid string-or-object unions

When an anyOf/oneOf mixes a string-enum branch with tagged-object branches (a common OpenAI pattern), the generator emits an untagged enum so both forms deserialize:

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(untagged)]                                   // not #[serde(tag="type")]
pub enum ToolChoiceParam {
    ToolChoiceOptions(ToolChoiceOptions),            // string-enum: "none"|"auto"|"required"
    ToolChoiceFunction(ToolChoiceFunction),
    ToolChoiceMCP(ToolChoiceMCP),
    // …
}

Extensible enums (with Custom(String) fallback)

When the spec declares an anyOf of const strings plus an open string branch (or you opt in via [extensible_enums], see below), the enum has a Custom(String) arm so unknown values still deserialize:

pub enum Model {
    ClaudeSonnet46,
    ClaudeOpus46,
    ClaudeHaiku45,
    Claude3Haiku20240307,
    Custom(String),         // ← anything not in the known set
}

Per-operation typed errors

Each operation has its own error enum that wraps the typed body of each documented response code:

let resp = client.create_response(req).await;
match resp {
    Ok(body) => { /* … */ }
    Err(ApiOpError::Api(err)) => match err.typed {
        Some(CreateResponseApiError::Status4xx(typed)) => {
            // typed is the spec's typed 4xx body, e.g. ResponseInfo {
            //   code: 10042,
            //   message: "Please enable R2 through the Cloudflare Dashboard.",
            // }
        }
        _ => eprintln!("raw body: {}", err.body),
    },
    Err(ApiOpError::Transport(e)) => eprintln!("transport: {}", e),
}

Streaming (SSE)

use openapi_to_rust::streaming::*;

let streaming_config = StreamingConfig {
    endpoints: vec![StreamingEndpoint {
        operation_id: "createChatCompletion".to_string(),
        path: "chat/completions".to_string(),
        stream_parameter: "stream".to_string(),
        event_union_type: "ChatCompletionStreamEvent".to_string(),
        event_flow: EventFlow::StartDeltaStop {
            start_events: vec!["response.created".to_string()],
            delta_events: vec!["response.output_text.delta".to_string()],
            stop_events: vec!["response.completed".to_string()],
        },
        ..Default::default()
    }],
    reconnection_config: Some(ReconnectionConfig {
        max_retries: 5,
        initial_delay_ms: 500,
        max_delay_ms: 16000,
        backoff_multiplier: 2.0,
    }),
    ..Default::default()
};

Generated event types are tagged enums you can match on directly:

match serde_json::from_str::<ResponseStreamEvent>(&data)? {
    ResponseStreamEvent::TextDelta(d)  => out.push_str(&d.delta),
    ResponseStreamEvent::Completed(_)  => break,
    _                                   => {}
}

The generator also auto-detects streaming endpoints: any response declaring content: text/event-stream flips supports_streaming = true automatically. Explicit [[streaming.endpoints]] config still wins when present.

Spec-quirk overrides

Real specs lie. These TOML knobs let you patch quirks without forking the spec.

schema_extensions — overlay JSON/YAML fragments onto the spec

A list of files whose top-level objects are deep-merged into the main spec before analysis. Use it to add a text/event-stream content entry, add a missing operation, or tweak a schema without forking the upstream spec. Example: the Anthropic server example overlays sse-overlay.json so messages_post gets an SSE response variant.

[generator]
# Relative paths are resolved from the directory containing this TOML file.
schema_extensions = ["sse-overlay.json"]

nullable_overrides — force a field to Option<T>

When a spec marks a field as required + non-nullable but the API actually returns null. Format: "SchemaName.fieldName" = true.

[nullable_overrides]
# OpenAI's spec uses a bare $ref for `error`; the API actually returns null on success.
"Response.error" = true

extensible_enums — force a closed enum to accept unknown values

When the spec declares a fixed enum but the API actually returns values outside the set (real-world drift). Renders the enum with a Custom(String) fallback variant. Accepts either the raw spec name or the rendered Rust type name.

[extensible_enums]
# CF spec declares lowercase ["apac", ..., "wnam"] but the API returns "WNAM".
"R2BucketLocation" = true
# OpenAI spec declares ["in-memory", "24h"] but the API returns "in_memory".
"ModelResponsePropertiesPromptCacheRetention" = true

type_mappings — override a primitive's Rust type

[type_mappings]
"DateTime" = "chrono::DateTime<chrono::Utc>"

[generator.types] — typed-scalar strategy per format

Opt out of any individual typed scalar (e.g. fall back to String for date-times if you don't want chrono):

[generator.types]
date_time = "string"        # default: "chrono"
uri       = "string"        # default: "url"
binary    = "string"        # default: "bytes"

The CLI also supports --types-conservative, which collapses every typed scalar to String/i64/etc. Use it when you want zero optional-crate dependencies.

[generator.builders] — additive operation builders

Large operations can keep their existing flat async method and also expose a *_builder() entry point:

[generator.builders]
enabled = true
threshold = 3

A builder is emitted when its optional query/header/body values and reachable optional request-body fields exceed threshold. Required path, query, header, and request-model values stay explicit. Setters own their values, and .send().await delegates to the existing flat method, so request serialization and response errors stay identical. Builder generation is disabled by default to preserve existing generated call sites.

OpenAPI 3.1 / 3.2 support

The generator accepts 3.0.x and 3.1.x specs; 3.2.x parses with an experimental warning. Unknown fields are retained for compatibility, but an unrecognized field may be ignored by analysis and code generation. Add a focused fixture when relying on a less-common OpenAPI or JSON Schema keyword.

3.1 (JSON Schema 2020-12) keywords now modeled and read from typed fields:

Keyword group Status
type as array (e.g. ["string", "null"]) typed + used for nullability
prefixItems, unevaluatedItems, contains / minContains / maxContains typed
patternProperties, propertyNames, unevaluatedProperties typed
dependentRequired, dependentSchemas, if / then / else typed
contentEncoding, contentMediaType, contentSchema typed
$dynamicRef, $dynamicAnchor, $defs, $id, $schema, $comment typed (anchor-scope resolution is a follow-up)
Path Item $ref resolution resolved at analysis time
Webhooks (webhooks:) ingested as operations
examples, example, title, deprecated, readOnly/writeOnly typed

3.2 deltas (experimental):

Delta Status
query HTTP method + PathItem.additionalOperations parses + emits client methods via reqwest::Method::from_bytes(...)
OAuth deviceAuthorization flow + oauth2MetadataUrl typed
Server.name, Tag.parent/kind/summary typed
Discriminator.defaultMapping typed (captured; _Other(Value) fallback emission is a follow-up)
MediaType.itemSchema, prefixEncoding, itemEncoding typed
mediaTypes in Components typed

Modeled Components family: Server, ServerVariable, SecurityScheme (apiKey / http / mutualTLS / oauth2 / openIdConnect with all flows), OAuthFlows, Encoding, Header, Example, Link, Callback, Tag, ExternalDocs, Discriminator. Modeling does not imply runtime support for every security or linking behavior; for example, mutual TLS credentials are not configured by generated clients.

The fixture harness under tests/conformance/ currently gates lossless L0 parsing; its later L1–L5 markers are tracked but deferred. The vendored JSON Schema Test Suite runner enforces a no-regression ceiling for parse failures and lossless round trips; it does not yet enforce semantic, feature-level pass thresholds.

CLI

# Direct mode: client by default; use --types-only to omit it.
openapi-to-rust generate openapi.yaml
openapi-to-rust generate https://example.com/openapi.json --dry-run --json
openapi-to-rust generate openapi.yaml --check --quiet

# Create a starter config, then generate with the config defaults.
openapi-to-rust init openapi.yaml
openapi-to-rust generate

# Explicit config mode.
openapi-to-rust generate --config openapi-to-rust.toml
openapi-to-rust generate --config openapi-to-rust.toml --types-conservative
openapi-to-rust validate --config openapi-to-rust.toml

# Server scope management — preserves TOML formatting (toml_edit)
openapi-to-rust server list                    # all operations
openapi-to-rust server list --tag Responses    # filter by tag
openapi-to-rust server list --method POST --grep response
openapi-to-rust server list --json             # JSON output
openapi-to-rust server add createResponse                 # add one
openapi-to-rust server add --all-tag Responses            # expand a tag
openapi-to-rust server add createResponse --dry-run       # preview
openapi-to-rust server add createResponse --regenerate    # add + regenerate
openapi-to-rust server remove createResponse              # remove

Selectors are forgiving — operationId, METHOD /path, or tag:<name>. Typos surface Levenshtein-based "Did you mean …?" suggestions.

TOML reference

[generator]
spec_path = "openapi.json"              # required
output_dir = "src/generated"            # required
module_name = "types"                   # informational label, not a directory
schema_extensions = []                  # optional list of JSON/YAML overlays merged into the spec

[features]
enable_sse_client = false               # generate SSE streaming client (requires [[streaming.endpoints]])
enable_async_client = true              # generate HTTP REST client
enable_specta = false                   # add specta::Type derives
enable_registry = false                 # generate static operation registry (CLI/proxy routing)
registry_only = false                   # only generate the registry (skip types/client/streaming)

[http_client]
base_url = "https://api.example.com"
timeout_seconds = 30                    # 1-3600

[http_client.retry]
max_retries = 3                         # 0-10
initial_delay_ms = 500                  # 100-10000
max_delay_ms = 16000                    # 1000-300000

[http_client.tracing]
enabled = true

[http_client.auth]
type = "Bearer"                         # Bearer | ApiKey | Custom (honored at runtime)
header_name = "Authorization"

[[http_client.headers]]
name  = "content-type"
value = "application/json"

[[streaming.endpoints]]
operation_id     = "createChatCompletion"
path             = "chat/completions"
http_method      = "POST"
stream_parameter = "stream"
event_union_type = "ChatCompletionStreamEvent"
content_type     = "text/event-stream"

[streaming.endpoints.event_flow]
type          = "StartDeltaStop"        # or "Continuous"
start_events  = ["response.created"]
delta_events  = ["response.output_text.delta"]
stop_events   = ["response.completed"]

[server]
framework  = "axum"                     # only axum supported today
operations = [                          # selectors: operationId | "METHOD /path" | "tag:<name>"
  "createResponse",
  "POST /v1/messages",
  "tag:Responses",
]
prune_models = false                    # drop schemas outside the combined selected
                                        # client/server operation closure

[client]
operations = [                          # absent or empty means every client operation
  "createResponse",
  "GET /v1/models",
  "tag:Files",
]
prune_models = false                    # opt in to the same union model pruning

[nullable_overrides]
"Response.error" = true                 # see "Spec-quirk overrides" above

[extensible_enums]
"R2BucketLocation" = true               # see "Spec-quirk overrides" above

[type_mappings]
"DateTime" = "chrono::DateTime<chrono::Utc>"

[generator.types]
date_time = "chrono"                     # chrono (default) | time | string
uri       = "url"                        # url     (default) | string
binary    = "bytes"                      # bytes   (default) | vec_u8 | string
uuid      = "uuid"                       # uuid    (default) | string
byte      = "base64"                     # base64 (default) | base64_url_unpadded | vec_u8 | string
unsigned  = true                         # uint32/uint64 -> u32/u64

[generator.types.shape]
additional_properties_typed = true

Testing

cargo test                # unit + integration tests
cargo insta test          # snapshot tests
cargo insta review        # review snapshot diffs
scripts/spec-compile.sh   # generate + cargo-check every spec in specs/ (full corpus)

The compile tiers are intentionally different:

  • Every pull request and push to main generates all 54 supported OpenAPI specs, then compile-checks the Anthropic and OpenAI production specs against each generated REQUIRED_DEPS.toml.
  • Weekly scheduled CI and manual workflow runs compile-check all 54 supported OpenAPI specs. The bundled Gitea Swagger 2.0 document is reported as skipped.
  • Local scripts/spec-compile.sh runs the same full tier; pass spec names as arguments for a smaller targeted run.

Examples

# Library / generator examples
cargo run --example basic_generation
cargo run --example client_generation_example
cargo run --example discriminated_unions
cargo run --example anyof_unions
cargo run --example allof_composition
cargo run --example openai_patterns
cargo run --example toml_config_example

# End-to-end Axum server examples (generate + run)
cargo run -p openapi-to-rust -- generate --config examples/server-openai-responses/openapi-to-rust.toml
cargo run --manifest-path examples/server-openai-responses/Cargo.toml

cargo run -p openapi-to-rust -- generate --config examples/server-anthropic-messages/openapi-to-rust.toml
cargo run --manifest-path examples/server-anthropic-messages/Cargo.toml

Versioning

Until 1.0.0, a minor version may change generated Rust APIs when correcting output that was wrong or incomplete on the wire. Regenerating lets the compiler identify affected call sites. See the changelog for release history and migration notes.

Contributing

See CONTRIBUTING.md for setup, targeted test commands, snapshot guidance, and the generated-API compatibility checklist. Questions belong in GitHub Discussions; bugs and concrete feature requests use the repository issue forms.

By participating, you agree to the Code of Conduct. Security reports follow SECURITY.md; please do not file a public issue for a suspected vulnerability.

License

MIT