sans-IO OpenAPI 3.1 Rust 2024 MSRV 1.88

Rust OpenAPI clients without HTTP lock-in

Satay turns an OpenAPI 3.1 spec into typed request builders, response decoders, and validation newtypes. It gives you the http::Request; your app decides how to send it.

shell
cargo install satay-cli
Satay logo: cubes on a skewer, standing in for OpenAPI, codegen, and transport adapters

The operation stays the same

Satay generates typed actions for the API operation. Async reqwest, blocking reqwest, ureq, and custom transports are just different ways to send that action.

use generated::{Api, BusServiceNumber};
use satay_reqwest::{reqwest, ReqwestActionExt};

type Error = Box<dyn std::error::Error>;

async fn via_reqwest(account_key: String) -> Result<(), Error> {
    let api = Api::new().account_key(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};

type Error = Box<dyn std::error::Error>;

fn via_reqwest_blocking(account_key: String) -> Result<(), Error> {
    let api = Api::new().account_key(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};

type Error = Box<dyn std::error::Error>;

fn via_ureq(account_key: String) -> Result<(), Error> {
    let api = Api::new().account_key(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;

type Error = Box<dyn std::error::Error>;

struct RawResponse {
    status: http::StatusCode,
    headers: http::HeaderMap,
    body: Vec<u8>,
}

async fn via_custom_transport(account_key: String) -> Result<(), Error> {
    let api = Api::new().account_key(account_key);
    let action = api
        .get_bus_arrival(83139)
        .service_no(BusServiceNumber::try_new("15")?);

    let request: http::Request<Vec<u8>> = action.request()?;
    let response = send_over_my_transport(request).await?;

    let _decoded = GetBusArrivalAction::decode(ResponseParts {
        status: response.status,
        headers: response.headers,
        body: response.body,
    })?;

    Ok(())
}

async fn send_over_my_transport(
    _request: http::Request<Vec<u8>>,
) -> Result<RawResponse, Error> {
    todo!("use your HTTP client, WASM fetch, queue, or test harness")
}

Action is the boundary

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

Runtime stays outside

Async reqwest awaits, blocking reqwest returns directly, ureq uses its agent, and manual transports can drive the raw request/response boundary.

Backends can change

Adding ureq, hyper, tests, WASM, or another adapter does not mean regenerating a transport-owned SDK.

Maintain the API, not the HTTP client

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

Without sans-IO

  • Async, blocking, and WASM each need their own client code path
  • Security and feature changes in reqwest, hyper, or ureq become your release work
  • Swapping transports means refactoring the client internals
  • Tests often need real HTTP or heavy mocks around the whole stack

With Satay

  • One generated surface for building requests and decoding responses
  • Transport adapters are thin and optional. Your app keeps the HTTP client
  • Backend updates stay at the edge. Regenerate when the spec changes, not when reqwest bumps
  • Unit tests can build requests and decode responses without the network

You maintain only the OpenAPI spec. Satay turns it into typed Rust. When the API changes, update the spec and regenerate. Your HTTP client stays separate.

Unit-test the client without running HTTP

Because actions expose request construction and response decoding as plain functions, tests can assert the outgoing request and decode fixture responses. No live server, no reqwest mock, no async runtime just to check client behavior.

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

type Error = Box<dyn std::error::Error>;

#[test]
fn tests_the_api_without_http() -> Result<(), 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 public library depends on Satay runtime, http, and serialization pieces. Reqwest only appears 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"

Rust you can read, not template soup

Builders, decoders, and validation newtypes from your OpenAPI spec.

sans-IO first

Actions build plain http::Request values. Send them with reqwest, ureq, hyper, tests, WASM, or whatever you already run.

Validation newtypes

String, number, integer, and array constraints from the spec become nutype newtypes. Bounds narrow to u8 when the maximum allows it.

OpenAPI 3.1

Reads the spec into a normalized IR, then emits structs, enums, builders, and decoders as ordinary Rust you can edit.

Optional adapters

Stay IO-free, or pull in satay-reqwest and satay-ureq when you want a short send_with call site.

In the wild

Open-source API clients built with Satay. Got one? 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 that request with reqwest, ureq, hyper, or your own client, then decode the response with the generated action 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.

Satay-rs leans into that image. OpenAPI describes the pieces: schemas, operations, constraints. Codegen threads them into typed request builders and response decoders. Your application picks the transport and sends them through. The skewer is the sans-IO boundary. The pieces stay the same no matter which grill you use.

  1. OpenAPI spec

    The marinated pieces: schemas, operations, constraints.

  2. Codegen

    Threads them into builders and decoders you can read.

  3. Your transport

    reqwest, ureq, hyper, tests, WASM, WebSocket, or your own client.

  4. sans-IO boundary

    The skewer itself. Same pieces, different grill.