OpenAPI·Rust·Free

OpenAPI to
Rust.

Turn an OpenAPI 3.0 or 3.1 spec into a Rust client built on reqwest + serde. Every oneOf with a discriminator emits a #[serde(tag = "...")] enum, so polymorphic JSON decodes straight into the right variant and an exhaustive match at the call site catches every new variant at compile time. Async on tokio, Option<T> for nullable fields, a concrete Error enum for failures. No signup, no install.

OpenAPI 3.0 + 3.1·reqwest·serde·tokio·MIT
payments.openapi.yamlInput
openapi: 3.0.3
info:
  title: Payments API
  version: 1.2.0
paths:
  /payments/{id}:
    get:
      operationId: getPayment
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
        - name: expand
          in: query
          schema: { type: string }
      responses:
        '200':
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Payment'
components:
  schemas:
    Payment:
      type: object
      required: [id, amount, method]
      properties:
        id:       { type: string }
        amount:   { type: integer, format: int64 }
        currency: { type: string, nullable: true }
        method:
          oneOf:
            - $ref: '#/components/schemas/CardPayment'
            - $ref: '#/components/schemas/BankPayment'
          discriminator:
            propertyName: kind
            mapping:
              card: '#/components/schemas/CardPayment'
              bank: '#/components/schemas/BankPayment'
compile
src/client/payments_client.rs tsc cleanOutput
// src/client/payments_client.rs
use crate::error::{Error, Result};
use crate::client::client::{Client};
use crate::model::payment::{Payment};

pub struct PaymentsClient {
    pub client: std::sync::Arc<Client>,
}

impl PaymentsClient {
    pub fn new(client: std::sync::Arc<Client>) -> Self {
        Self { client }
    }

    pub async fn get_payment(&self, id: String, expand: Option<String>) -> Result<Payment> {
        let request_url = format!("{base_url}/payments/{id}", base_url = self.client.base_url);
        let mut request = self.client.client.get(&request_url);

        if let Some(val) = expand {
            request = request.query(&[("expand", val.to_string())]);
        }

        let response = request
            .send()
            .await
            .map_err(Error::Reqwest)?;

        self.client.handle_response(response).await
    }
}
What gets generated

Structs, a tagged enum, an async client.

Below: types emitted from a small payments spec with a oneOf discriminator, an enum, a nullable field, and a timestamp. Each schema becomes a #[derive(Serialize, Deserialize)] struct in src/model/. The oneOf lands in src/union_types.rs as a #[serde(tag = "kind")] enum, so JSON decoding routes to the right variant without a custom deserializer. The string enum carries an Unknown arm marked #[serde(other)] so a new wire value from the server decodes cleanly instead of failing the response. Timestamps come through as chrono::DateTime<Utc>.

// src/model/payment.rs
use chrono::{DateTime, Utc};
use crate::union_types::{PaymentMethodUnion};
use serde::{Serialize, Deserialize};
use crate::model::payment_status::{PaymentStatus};

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Payment {
    pub id: String,
    pub amount: i64,
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(default)]
    pub currency: Option<String>,
    pub status: PaymentStatus,
    pub created_at: DateTime<Utc>,
    pub method: PaymentMethodUnion,
}

// src/union_types.rs
use serde::{Serialize, Deserialize};
use crate::model::card_payment::{CardPayment};
use crate::model::bank_payment::{BankPayment};

// oneOf with a discriminator becomes a serde-tagged enum.
// The wire shape stays flat — the "kind" field on the JSON
// object routes to the right variant at deserialization,
// and an exhaustive 'match' at the call site rejects any
// new variant added to the spec until you handle it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum PaymentMethodUnion {
    #[serde(rename = "card")]
    CardPayment(CardPayment),
    #[serde(rename = "bank")]
    BankPayment(BankPayment),
}

// src/model/payment_status.rs
use serde::{Serialize, Deserialize};

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum PaymentStatus {
    #[serde(rename = "pending")]
    Pending,
    #[serde(rename = "authorized")]
    Authorized,
    #[serde(rename = "captured")]
    Captured,
    #[serde(rename = "refunded")]
    Refunded,
    #[serde(rename = "failed")]
    Failed,
    #[serde(other)]
    Unknown
}
Three things the Rust output gets right

Grounded in how Rust reads the wire.

Each claim is something you can verify by running the converter on your own spec. Click a card for the spec fragment that triggered it and the Rust code that came out.

01ProofoneOf as a serde-tagged enum.Spec · InCode · Out
spec.yaml fragment
method:
  oneOf:
    - $ref: '#/components/schemas/CardPayment'
    - $ref: '#/components/schemas/BankPayment'
  discriminator:
    propertyName: kind
    mapping:
      card: '#/components/schemas/CardPayment'
      bank: '#/components/schemas/BankPayment'
emitted .rs + call-site
// src/union_types.rs
use serde::{Serialize, Deserialize};
use crate::model::card_payment::{CardPayment};
use crate::model::bank_payment::{BankPayment};

#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind")]
pub enum PaymentMethodUnion {
    #[serde(rename = "card")]
    CardPayment(CardPayment),
    #[serde(rename = "bank")]
    BankPayment(BankPayment),
}

// Narrow at the call site with an exhaustive 'match'.
// The wire value (kind=card / kind=bank) routes through
// serde, so no custom deserializer ships with the union:
let payment: Payment = serde_json::from_str(body)?;

let label = match &payment.method {
    PaymentMethodUnion::CardPayment(c) => format!("card {}", c.last4),
    PaymentMethodUnion::BankPayment(b) => format!("bank {}", b.iban),
};

// Add a third variant to the spec and this 'match'
// stops compiling until the new arm is handled.
Rust · reqwest

One Client. Every generated service.

You construct the Client once with a base URL, wrap it in Arc, and share it across every generated service struct. The transport is reqwest behind reqwest-middleware, so the client field has the same type whether you turn on retries or auth or neither. Add bearer-auth, retries, or a request timeout and the only thing that changes is the constructor body — call sites stay identical.

src/client/client.rs generated
// src/client/client.rs
use crate::error::{Error, Result};
use reqwest;
use reqwest_middleware;

pub struct Client {
    pub client: reqwest_middleware::ClientWithMiddleware,
    pub base_url: String,
}

impl Client {
    pub fn new(base_url: impl Into<String>) -> Self {
        let client = reqwest_middleware::ClientBuilder::new(reqwest::Client::new())
            .build();

        Self {
            client,
            base_url: base_url.into(),
        }
    }
}

impl Client {
    pub async fn handle_response<T: serde::de::DeserializeOwned>(&self, response: reqwest::Response) -> Result<T> {
        if !response.status().is_success() {
            let status = response.status();
            let message = response.text().await.unwrap_or_default();
            return Err(Error::Api { status, message });
        }
        response.json().await.map_err(Error::Deserialization)
    }
}

// One Client. Share it across every generated service via Arc.
// reqwest::Client itself is cheap to clone — it already wraps an
// Arc — but Arc<Client> keeps base_url and middleware co-located.
#[tokio::main]
async fn main() -> Result<()> {
    let client = std::sync::Arc::new(Client::new("https://api.example.com"));
    let payments = PaymentsClient::new(client.clone());

    let payment = payments.get_payment("pay_123".into(), None).await?;
    println!("{:?}", payment);
    Ok(())
}
Also available via MCP

Or ask Claude to do it.

The same generator ships as a Model Context Protocol server. It calls the same web API this converter uses, so the output is identical. Point Claude Desktop, Cursor, or any MCP-aware agent at a spec and it produces the Rust client as a diff — free, no token, no leaving the chat.

// claude-desktop config · .mcp.json
{
  "mcpServers": {
    "metaengine": {
      "command": "npx",
      "args": ["-y", "@metaengine/mcp-server"]
    }
  }
}

// Then in Claude:
// "Load specs/payments.yaml and generate a Rust Reqwest
//  client. Async on tokio, serde-tagged enums for oneOf,
//  Option<T> for nullable fields."
See the MCP page
Questions

Things people ask.

The questions that come up most when generating Rust clients from OpenAPI. If yours is not here, run the converter — the answer is usually in the output.

Both OpenAPI 3.0 and 3.1. The 3.1 path admits the JSON Schema 2020-12 forms — type: ["string", "null"] nullable arrays, numeric exclusiveMinimum/exclusiveMaximum values, const reified to single-variant enums, $dynamicAnchor/$dynamicRef for recursive schemas, top-level webhooks, and discriminator mapping resolved across multi-file specs. readOnly: true properties carry through as doc-comment markers on the generated Rust fields.
More converters

Same engine, other stacks.

Every converter runs the same spec-to-IR pipeline. Pick another source format or target stack.

OpenAPITypeScript
Angular httpResource, React TanStack Query, or framework-agnostic fetch.
Open converter
OpenAPIAngular
Typed services with httpResource, Signals, inject() DI, and interceptors.
Open converter
OpenAPIReact
TanStack Query hooks with stable query keys, or plain async functions.
Open converter
OpenAPIFetch
Framework-agnostic fetch client with ApiResult, middleware, and retries.
Open converter
OpenAPIJava Spring
RestClient services, Java records, sealed interfaces, Spring Boot 3.
Open converter
OpenAPIPython
Async httpx clients paired with Pydantic v2 models.
Open converter
OpenAPIGo
Idiomatic net/http client, context-aware, pointer-nullable fields.
Open converter
OpenAPIKotlin
Ktor client, kotlinx.serialization, sealed interfaces for oneOf.
Open converter
OpenAPIC#
Records, nullable reference types, JsonPolymorphic, HttpClient.
Open converter
GraphQLAngular
Observable services with typed queries, mutations, and graphql-ws subscriptions.
Open converter
GraphQLReact
TanStack Query hooks with discriminated-union types and query keys.
Open converter
GraphQLKotlin
Ktor, kotlinx.serialization, Flow-based subscriptions.
Open converter
GraphQLC#
Records, nullable types, [JsonPolymorphic] unions, IAsyncEnumerable subscriptions.
Open converter
ProtobufKotlin
Ktor, coroutines, and sealed classes straight from .proto.
Open converter
ProtobufC#
Records, nullable types, Connect RPC over HttpClient.
Open converter
SQLTypeScript
Postgres DDL to typed interfaces with foreign-key navigation.
Open converter
SQLKotlin
CREATE TABLE scripts to Kotlin @Serializable data classes.
Open converter

Try it on your own spec.

The converter runs in the browser. Your spec never leaves the page.

Open the converter