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.

Try it on your own spec.

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

Open the converter