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 (())
} 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.
Quick start
cargo install satay-cli Same action, different transports
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, 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" )
} 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.
Why sans-IO
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.
Tests
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 exampleuse 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 (())
} Library dependencies
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.
# 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" Features
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.
Built with Satay
In the wild
Open-source clients using Satay. Missing yours? Send a PR.
InfiniteUnion/nea-rs
Type-safe, sans-IO Rust client for Singapore NEA weather and environmental APIs.
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.
How it works
Generate the client, send it yourself
- 01 Install the CLI and point it at your OpenAPI file.
- 02 Satay writes builders that produce IO-free
http::Requestvalues. - 03 Send the request with reqwest, ureq, hyper, or your own client, then decode the response with the generated types.
$ satay generate --input openapi.yaml --output src/generated --rustfmt
$ satay --help Origin
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.