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
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'// 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
}
}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
}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.
method:
oneOf:
- $ref: '#/components/schemas/CardPayment'
- $ref: '#/components/schemas/BankPayment'
discriminator:
propertyName: kind
mapping:
card: '#/components/schemas/CardPayment'
bank: '#/components/schemas/BankPayment'// 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.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
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(())
}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."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.
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.