sans-IO OpenAPI 3.1 Rust 2024 MSRV 1.88

Rust OpenAPI clients without HTTP lock-in

Satay reads an OpenAPI 3.1 spec and writes Rust for building requests and decoding responses. You get the http::Request. Your app sends it.

shell
cargo install satay-cli

One operation, many ways to send it

Satay generates a typed action for each API operation. Async reqwest, blocking reqwest, ureq, and a custom client all start from the same action.

use generated::{Api, BusServiceNumber};
use satay_reqwest::{reqwest, ReqwestActionExt};
use std::{env, error::Error};

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let api = Api::new().account_key(env::var("LTA_ACCOUNT_KEY")?);
    let action = api
        .get_bus_arrival(83139)
        .service_no(BusServiceNumber::try_new("15")?);

    let _response = action
        .send_with(&reqwest::Client::new())
        .await?;

    Ok(())
}
use generated::{Api, BusServiceNumber};
use satay_reqwest::{reqwest, ReqwestBlockingActionExt};
use std::{env, error::Error};

fn main() -> Result<(), Box<dyn Error>> {
    let api = Api::new().account_key(env::var("LTA_ACCOUNT_KEY")?);
    let action = api
        .get_bus_arrival(83139)
        .service_no(BusServiceNumber::try_new("15")?);

    let _response = action
        .send_with(&reqwest::blocking::Client::new())?;

    Ok(())
}
use generated::{Api, BusServiceNumber};
use satay_ureq::{ureq, UreqActionExt};
use std::{env, error::Error};

fn main() -> Result<(), Box<dyn Error>> {
    let api = Api::new().account_key(env::var("LTA_ACCOUNT_KEY")?);
    let action = api
        .get_bus_arrival(83139)
        .service_no(BusServiceNumber::try_new("15")?);

    let agent: ureq::Agent = ureq::Agent::config_builder()
        .http_status_as_error(false)
        .build()
        .into();

    let _response = action.send_with(&agent)?;

    Ok(())
}
use generated::{Api, BusServiceNumber, GetBusArrivalAction};
use satay_runtime::ResponseParts;
use std::{env, error::Error, mem};

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    let api = Api::new().account_key(env::var("LTA_ACCOUNT_KEY")?);
    let action = api
        .get_bus_arrival(83139)
        .service_no(BusServiceNumber::try_new("15")?);

    let request: reqwest::Request = action.request()?.try_into()?;
    let mut response = reqwest::Client::new().execute(request).await?;

    let _decoded = GetBusArrivalAction::decode(ResponseParts {
        status: response.status(),
        headers: mem::take(response.headers_mut()),
        body: response.bytes().await?,
    })?;

    Ok(())
}

The action stops at HTTP

The generated builder knows the path, query, headers, validation, and response enum. The adapter just runs the resulting http::Request.

Runtime stays outside

Async reqwest awaits. Blocking reqwest returns. ureq uses its agent. A manual transport can drive the raw request/response boundary.

Swap backends freely

Add ureq, hyper, a test harness, or WASM without regenerating a transport-owned SDK.

Keep the API layer, swap the HTTP client

Most OpenAPI clients ship with a transport baked in. Your SDK ends up tracking reqwest releases, TLS changes, and separate async, blocking, and WASM paths. Satay generates the API layer only. You send the requests.

Without sans-IO

  • Async, blocking, and WASM often fork into separate client code
  • A reqwest or hyper bump can become a release for your SDK
  • Changing transports usually means refactoring client internals
  • Tests tend to need real HTTP or mocks around the whole stack

With Satay

  • One generated API for building requests and decoding responses
  • Transport adapters are optional and thin; your app keeps its HTTP client
  • Regenerate when the OpenAPI spec changes, not when reqwest bumps
  • Unit tests can build requests and decode fixture responses offline

You maintain the OpenAPI spec. Satay emits typed Rust. When the API changes, update the spec and regenerate. The HTTP client stays separate.

Test the client without HTTP

Actions expose request construction and response decoding as plain functions. Tests can assert the outgoing request and feed fixture bytes through the decoder. No live server, no reqwest mock, no async runtime.

View in reqwest example
test.rs
use generated::{
    Api, BusServiceNumber, GetBusArrivalAction,
    GetBusArrivalResponse,
};
use std::error::Error;

#[test]
fn tests_the_api_without_http() -> Result<(), Box<dyn Error>> {
    let api = Api::new().base_url("").account_key("test-key");
    let action = api
        .get_bus_arrival(83139)
        .service_no(BusServiceNumber::try_new("15")?);

    let request = action.request()?;
    assert_eq!(request.method(), http::Method::GET);
    assert_eq!(request.uri().path(), "/v3/BusArrival");
    assert_eq!(request.headers()["AccountKey"], "test-key");

    let query = request.uri().query().unwrap_or_default();
    assert!(query.contains("BusStopCode=83139"));
    assert!(query.contains("ServiceNo=15"));

    let response = GetBusArrivalAction::decode(
        satay_runtime::ResponseParts {
            status: http::StatusCode::OK,
            headers: http::HeaderMap::new(),
            body: br#"{
                "odata.metadata": "https://datamall2.mytransport.sg/ltaodataservice/v3/BusArrival",
                "BusStopCode": "83139",
                "Services": []
            }"#,
        },
    )?;

    let GetBusArrivalResponse::Ok(arrival) = response else {
        panic!("expected 200 OK bus arrival response");
    };
    assert_eq!(arrival.bus_stop_code, 83139);
    assert!(arrival.services.is_empty());
    Ok(())
}

Your library does not need to depend on an HTTP backend

In a generated client crate like nea-rs, the library depends on Satay runtime, http, and serialization. Reqwest shows up in dev-dependencies for examples and tests.

View nea-rs Cargo.toml
Cargo.toml
# library API: no reqwest, ureq, hyper, or TLS backend
[dependencies]
satay-runtime = { version = "0.1.2", default-features = false }
nutype = { version = "0.7", features = ["serde", "regex"] }
regex = "1"
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
http = "1"

Generated Rust you can read

sans-IO first

Actions build plain http::Request values. Send them with reqwest, ureq, hyper, a test harness, WASM, or whatever you already use.

Validation newtypes

String, number, integer, and array constraints from the spec become nutype newtypes. A max of 255 becomes u8 instead of a bare integer.

OpenAPI 3.1

The spec is parsed into a normalized IR, then emitted as structs, enums, builders, and decoders you can read and edit.

Optional adapters

Stay IO-free, or add satay-reqwest / satay-ureq for a one-line send_with call site.

When the spec alone is not enough

Most schemas map cleanly to Rust. For the rest, annotate the OpenAPI with x-satay vendor extensions and regenerate.

Parse wire strings as stronger types

Use on type: string schemas when an API sends JSON strings but the Rust field should be a number, date, or time. Serde keeps the wire format as a string.

Supported values

u8u16u32u64i8i16i32i64f32f64booldatenaive-datetimeoffset-datetimetime
openapi.yaml
BusStopCode:
  type: string
  x-satay:
    parse-as: u32

EstimatedArrival:
  type: string
  x-satay:
    parse-as: offset-datetime

Monitored:
  type: integer
  x-satay:
    parse-as: bool
generated.rs
pub struct Bus {
    #[cfg_attr(feature = "serde", serde(with = "satay_runtime::serde_string::as_u32"))]
    pub bus_stop_code: u32,

    #[cfg_attr(feature = "serde", serde(with = "satay_runtime::serde_string::as_offset_datetime"))]
    pub estimated_arrival: satay_runtime::OffsetDateTime,

    #[cfg_attr(feature = "serde", serde(with = "satay_runtime::serde_integer::as_bool"))]
    pub monitored: bool,
}

Override inferred integer primitives

Satay infers the smallest Rust integer from minimum and maximum bounds. Use integer-type to opt out or pick a specific primitive. auto is the default.

Supported values

autou8u16u32u64i8i16i32i64
openapi.yaml
Direction:
  type: integer
  format: int32
  description: >
    The direction in which the bus travels. Valid values are 1 or 2;
    loop services only have 1 direction.
  example: 1
  minimum: 1
  maximum: 2
generated.rs
/// The direction in which the bus travels. Valid values are 1 or 2; loop services only have 1 direction.
#[nutype::nutype(
    validate(greater_or_equal = 1, less_or_equal = 2),
    derive(
        Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, AsRef, Deref, TryFrom, Into, Display
    ),
    cfg_attr(feature = "serde", derive(Serialize, Deserialize))
)]
pub struct BusRouteDirection(u8);

Descriptive enum variant names

Map terse wire values to readable Rust variants. Mapping a value to Unknown folds it into Satay's generated fallback variant.

openapi.yaml
Type:
  type: string
  description: >
    Vehicle type of the bus.
  enum: [SD, DD, BD, ""]
  example: SD
  x-satay:
    enum-variants:
      SD: SingleDecker
      DD: DoubleDecker
      BD: Bendy
      "": Unknown
generated.rs
/// Vehicle type of the bus.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum BusArrivalTimingType {
    #[cfg_attr(feature = "serde", serde(rename = "SD"))]
    SingleDecker,
    #[cfg_attr(feature = "serde", serde(rename = "DD"))]
    DoubleDecker,
    #[cfg_attr(feature = "serde", serde(rename = "BD"))]
    Bendy,
    #[default]
    #[cfg_attr(feature = "serde", serde(other))]
    Unknown,
}

Treat deserialization errors as None

Make a field Option<T>. When nested deserialization fails, the field resolves to None instead of failing the whole response. Requires the generated crate's json feature.

openapi.yaml
BusServiceArrival:
  type: object
  required: [ServiceNo, NextBus]
  properties:
    ServiceNo:
      type: string
    NextBus:
      $ref: "#/components/schemas/BusArrivalTiming"
      x-satay:
        treat-error-as-none: true
generated.rs
pub struct BusServiceArrival {
    pub service_no: String,
    #[cfg_attr(feature = "serde", serde(
        rename = "NextBus",
        deserialize_with = "satay_runtime::treat_error_as_none::deserialize",
        serialize_with = "satay_runtime::treat_error_as_none::serialize",
        default,
        skip_serializing_if = "Option::is_none"
    ))]
    pub next_bus: Option<BusArrivalTiming>,
}

Full reference in docs/extensions.md on GitHub.

In the wild

Open-source clients using Satay. Missing yours? Send a PR.

Add your project

Fork the repo, add a row to website/src/data/users.ts with your name, description, and links. Logo goes in public/users/ if you have one; set logo in the entry.

Generate the client, send it yourself

  1. 01 Install the CLI and point it at your OpenAPI file.
  2. 02 Satay writes builders that produce IO-free http::Request values.
  3. 03 Send the request with reqwest, ureq, hyper, or your own client, then decode the response with the generated types.
generate
$ satay generate --input openapi.yaml --output src/generated --rustfmt
$ satay --help

Named for the skewer, not the sauce

sate

/ˈsɑː.teɪ/

n.

(Malay, Indonesian)

a skewer; meat (or other food) cut into pieces, threaded onto a skewer, and grilled.

English satay comes from the Malay and Indonesian word sate, which refers to both the skewer and the dish. Street vendors thread marinated pieces onto bamboo sticks, grill them over charcoal, and serve them with peanut sauce. The name caught on abroad as the dish spread across Southeast Asia.

The name is the metaphor. OpenAPI holds the pieces: schemas, operations, constraints. Codegen threads them into request builders and response decoders. Your app picks the transport. The skewer is the sans-IO boundary; the pieces do not change when you swap grills.

  1. OpenAPI spec

    Schemas, operations, and constraints.

  2. Codegen

    Threads them into builders and decoders you can read.

  3. Your transport

    reqwest, ureq, hyper, a test harness, WASM, or your own client.

  4. sans-IO boundary

    The skewer. Same pieces, different grill.