Rust
truewire generate rust renders a project's plan (docs/plan.md) as the modules of a Rust
crate with the same guarantees as the Python and TypeScript packages: typed requests and
responses, runtime validation with a raw escape hatch, and resumable pagination walkers.
The runtime it depends on is truewire-core (crates/truewire-core), the third after
truewire-core (Python) and @truewire/core (TypeScript). Both were designed from the
plan, the language-neutral IR every backend renders from, rather than by transliterating
either earlier runtime.
Status: the runtime exists and is tested (cargo test, cargo clippy -- -D warnings and
cargo fmt --check run in CI, job runtime-rust). The generator renders HTTP rpc
endpoints, the type tree, routers, the root and the PaginatedResponse walkers;
examples/github is generated, compiled and replayed against truewire mock in CI (job
examples-rust). Stream endpoints, WebSocket commands and composite cores are not rendered
yet and are reported as skipped, the way the TypeScript backend reported them before its
second pass. The crate is not on crates.io (the truewire crate name is reserved by
crates/truewire, and truewire-core will be published beside it). See the end of this
page for what is left.
Using it
# truewire.toml
[rust]
package = "github" # the modules live at src/github/, lib.rs at their top
src = "src"
name = "GitHub" # the root structtruewire generate rust # writes src/github/**/*.rs and .truewire/rust-files.json
truewire generate rust --check # CI: every owned file exists and is what the plan rendersThen write a Cargo.toml whose library is the generated crate root, and the core
module the root declares:
[lib]
path = "src/github/lib.rs"
[dependencies]
async-trait = "0.1"
serde = { version = "1", features = ["derive"] }
truewire-core = "0.1"use github::core::{Core, CoreOptions};
use github::repos::get::Request;
use github::{CallOptions, GitHub};
let client = GitHub::new(Arc::new(Core::new(CoreOptions::default())));
let repo = client.repos.get(Request { owner: "truewire-dev".into(), repo: "truewire".into(), ..Default::default() }, CallOptions::default()).await?;
repo.created_at // a TimestampIso, deref to DateTime<Utc>
let raw = client.repos.get_raw(request, CallOptions::default()).await?; // the serde_json::Value as it came
let commits = client.repos.list_commits_paged(request, CallOptions::default()).await?; // every rowexamples/github is the reference: its truewire.toml, Cargo.toml,
src/github/core/mod.rs and tests/ are what a project copies.
What is generated
One file per spec node, beside the Python and TypeScript packages when all are declared:
| file | contents |
|---|---|
types/mod.rs, types/<scope>.rs |
the shared schemas.json types, one module per scope |
<router>/<endpoint>.rs |
the endpoint's Request, its response types, the enums hoisted out of them, and a struct with the method, its _raw twin and the walker |
<router>/mod.rs |
a router struct delegating to its endpoints and holding its child routers as pub fields |
client.rs |
the root struct, GitHub::new(core) |
meta.rs |
one struct per [cores.<name>] with a meta schema (DefaultMeta) |
lib.rs |
the crate root: pub mod for every module above and for the hand-written core, pub use client::GitHub and CallOptions |
Names Truewire invents are snake_case/PascalCase of the function segment
(list_commits, list_commits_paged, struct ListCommits). Rust has one convention per
kind of identifier and rustc warns on every departure, so the wire's names are not kept
verbatim as TypeScript keeps them: a field is snake_case of the wire name and carries
#[serde(rename = "...")] with the wire name whenever the two differ (htmlUrl,
X-Rate, a keyword such as type becoming type_), so the struct still dumps to the
wire object and a recorded request.json decodes into it as it stands. The output is
printed by the generator itself; no formatter runs over it, but the printer reproduces
rustfmt's decisions (import order and packing, struct-literal, attribute and chain
widths, signature breaking) so cargo fmt --check passes on what it writes.
A record is a serde struct with the newtype for every narrowed scalar, an Option per
optional key, and a flattened map so an undocumented field never breaks a client (the
same rule the Python TypedDict and the TypeScript object codec follow):
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
pub struct Label {
pub id: i64,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[serde(with = "truewire_core::validation::double_option")]
pub description: Option<Option<String>>,
pub created_at: TimestampIso,
/// Keys the spec does not document, kept as they came.
#[serde(flatten)]
pub extra: serde_json::Map<String, serde_json::Value>,
}Default is derived when every required field has one (a timestamp has no meaningful
zero), so a request is Request { owner, repo, ..Default::default() }. A literal of
strings is an enum with #[serde(rename)]s; a literal of numbers or booleans widens to
its scalar (serde renames only strings) with the values in its doc comment. A union is
an #[serde(untagged)] enum whose variants are tried in order, a list a Vec, a tuple
a Rust tuple, a dict a HashMap<String, _>, a nullable type an Option, a field both
optional and nullable an Option<Option<T>> with double_option. Rust needs a name for
every enum and the plan names only the types it lists, so an inline literal or union is
hoisted into a sibling type named after its position (Issue.state_reason becomes
IssueStateReason; the non-null half of an alias Response = A | B | null becomes
ResponseValue). A field whose type reaches back to its own record is boxed. A scalar
is rendered by base and format: string/integer/number/boolean/any to
String/i64/f64/bool/serde_json::Value, and each format in
truewire_core::types to its newtype. Every newtype derefs to the value inside and
converts From it, so a request built from a real DateTime<Utc> is .into() away from
its field; that is the Rust form of the converters' pass-through of an already-parsed
value, which Python's converters gained in core 0.2.1.
A generated method dumps the request, hands the core a call, and decodes the reply:
pub async fn get(&self, request: Request, options: CallOptions) -> Result<Repository> {
let raw = self.get_raw(request, options).await?;
decode(raw)
}
/// `get` without validation: the wire body as it came.
pub async fn get_raw(&self, request: Request, options: CallOptions) -> Result<serde_json::Value> {
let meta = DefaultMeta { public: Some(true) };
let call = HttpCall {
method: Some("GET"),
path: "/repos/{owner}/{repo}",
request: Some(dump(&request)?),
meta: &meta,
options,
};
self.core.request(call).await
}validate: false is a second method rather than a flag because Rust has no overload
returning a different type: the raw method returns the serde_json::Value the core
returned, and the typed method is decode on top of it. A method whose endpoint returns
nothing has no _raw twin. A request field the spec fixes to one value (a required
single-value enum, wire dispatch plumbing) is left out of Request and inserted into
the dumped object by the method. Every failure in decode is a ValidationError whose
path() is the JSON pointer of the offending value (/bids/1/1) and whose message is
serde's own (invalid type: integer \4`, expected a string at /bids/1/1`), the same
shape the TypeScript codecs report.
The core contract
The generated code never imports the project's core. Each generated struct holds its core
as an Arc<dyn HttpEndpoint<Meta>> (or CommandEndpoint/StreamEndpoint, once those are
rendered), the Rust half of truewire_core.contract and contract.ts (ADR 0011):
pub struct HttpCall<'a, Meta> {
pub method: Option<&'a str>, // the wire HTTP method; None when the spec leaves it to the core
pub path: &'a str, // the wire path template; {name} placeholders are filled from request
pub request: Option<Value>, // the dumped request (wire keys, wire forms), or None
pub meta: &'a Meta, // the endpoint's declared meta, in the shape the core's schema states
pub options: CallOptions, // { timeout: Option<Duration> }
}
#[async_trait]
pub trait HttpEndpoint<Meta = ()>: Send + Sync {
async fn request(&self, call: HttpCall<'_, Meta>) -> Result<Value>;
}
#[async_trait]
pub trait CommandEndpoint<Meta = ()>: Send + Sync {
async fn request(&self, call: CommandCall<'_, Meta>) -> Result<Value>; // path is the wire method name
}
#[async_trait]
pub trait StreamEndpoint<Meta = ()>: Send + Sync {
async fn subscribe(&self, call: SubscribeCall<'_, Meta>) -> Result<Stream<Value>>;
}Two decisions differ from the other runtimes, both forced by what the plan carries and what Rust can type:
- The request reaches the core already dumped, and the reply leaves it undecoded. A
core sees
serde_json::Values on both sides: the wire keys and wire forms of the request (aTimestampMillisis already an integer), and the wire body of the reply, envelope unwrapped and errors mapped. Generated code decodes it, or hands it back raw. A core therefore never validates and never reads avalidateflag; that decision is the generated method's, after the call. In Python and TypeScript the core receives the request type or codec and validates itself; in Rust that would make every trait generic in two types the core does not care about, anddyndispatch impossible. - There is no
ClientRootorCompositetrait. The root is a struct the generator writes, and the hand-written code builds it:GitHub::new(Arc::new(core)). A router whose endpoints all hold the same contract takes thatArc<dyn HttpEndpoint<Meta>>and hands a clone to every child; one whose subtree needs severalmetashapes is generic in the core,new<C>(core: Arc<C>) where C: HttpEndpoint<DefaultMeta> + HttpEndpoint<FuturesMeta> + 'static, and theArc<C>coerces to each child's trait object. Nothing needs anew(...)protocol;paramsare what the hand-written core takes when it is built.
A hand-written HTTP core does four things in request, as examples/github's
src/github/core/mod.rs does: fill the {placeholders} from the request object and send
the rest as the query (or as a JSON body for POST/PUT/PATCH) through HttpClient, add its
headers and signing, map a non-2xx reply to Error::Api with the status and decoded body
(Error::auth(msg).with_status(401).with_body(body); ApiKind::{BadRequest, Auth, RateLimited, Api}), and return response.json()? (or the envelope.payload inside it).
http::query_from renders a dumped request object as query pairs the way the example
cores do (null skipped, a nested value as JSON text). The crate root declares pub mod core; for it, the one module the generator does not write, as the Python package's
[python.cores.<name>].base names the class it does not write.
meta is the generated <package>/meta.rs struct for the core's [cores.<name>].meta
schema (DefaultMeta { public: Option<bool> } in the examples, a property required by the
schema being a plain field), or () for a core with no schema. Its properties map narrowly:
scalars, an enum of one scalar kind to that scalar, arrays of those, and
serde_json::Value for anything else.
Recording
HttpClient::recording() is the Rust form of truewire_core.http.recording(): every
exchange the client makes until the Recording is stopped or dropped, in order, at the
wire level (method, URL with query, headers, body sent; status, headers, body received),
before the core unwraps an envelope or maps an error. It is what a future truewire capture reads through a generated Rust client. Recordings may overlap, and
HttpClientOptions::on_exchange is the permanent form.
Streams
The runtime is built; the generator does not render stream endpoints yet (they are
reported as skipped). When it does, a stream endpoint's generated method calls
subscribe with the channel template, the dumped parameters (or None for a
direct-channel or connect-only stream, where the method filled the template itself, as in
the other backends) and the endpoint's meta, and maps the core's Stream<Value> into
its message type:
pub async fn ticker(&self, parameters: Parameters, options: CallOptions) -> Result<Stream<TickerMessage>> {
let stream = self.core.subscribe(SubscribeCall { channel: "ticker", parameters: Some(dump(¶meters)?), meta: &(), options }).await?;
Ok(stream.map(decode))
}Stream<N> is a futures::Stream<Item = Result<N>> with the acknowledging reply and an
unsubscribe(); a dropped connection yields one NetworkError item and ends it. There is
no lazy Subscription and no await using: subscribe is an async fn, and a value that
is dropped without unsubscribe() forgets the subscription locally without sending a
frame.
The hand-written WebSocket core is a Dialect given to a Socket. Where @truewire/core
has Socket/Rpc/Streams/StreamsRpc/SerialReplies to subclass, the Rust runtime
owns all connection and correlation state in Socket and asks the core only for the wire
dialect: parse(frame) into Incoming::{Reply{id, reply}, Push{channel, notification}, Serial(reply), Ignore}, encode_request(id, request), subscribe/unsubscribe returning
Outgoing::{Rpc(request), Serial(frame), Nothing} (acknowledged by the correlated reply,
by the next serial acknowledgement, or by nothing), check_ack to refuse a subscription,
ping for an application-level ping frame, and on_open for a handshake on every fresh
connection (a Link to send on before the connection is current). Kraken's req_id
dialect is Outgoing::Rpc everywhere; the ws template's uncorrelated subscribed acks
are Incoming::Serial and Outgoing::Serial. Socket then serves rpc_request,
subscribe (with SubscribeOptions::request_channel/message_key for one wire channel
feeding several local ones), serial_request, wait and close; the connection opens on
first use, and one the peer dropped fails every caller on it and is reopened by the next
use. crates/truewire-core/tests/ws.rs holds three dialects (Rpc-only, Streams with
serial acks, StreamsRpc with a handshake) driven against an in-process server.
Pagination
<method>_paged is rendered from the plan's pagination decisions, as in TypeScript:
walker: paginated(apagewalk ended byshort_page,emptyortotal; atokenwalk ended byabsent_cursor; a plainseekwalk): a plain method returningPaginatedResponse<Row, State>:.await(every row, flattened; alsoall()),rows()(one non-empty page at a time),pages()(every page with the state before and after it, for checkpointing),resume(state)andvia(invoker). Itsnext(state)is pure instate, so a page can be retried and a walk resumed. The request type is the endpoint'sRequestwithout the driver parameter (ListPagedRequest), unless the cursor is required and seeds the walk, in which case it isRequestitself.- The terminator checks are the runtime's, not the emitter's:
exhausted(rows, size)forshort_page/empty,total_reached(...)for apagewalk ended bytotal,cursor_or_done(cursor)forabsent_cursor(a zero-valued cursor is absent, as the plan'shas_zero_valuesays), andTotalSeenfor the rule that a missingtotal, or one that disagrees with an earlier page of the same walk, is aLogicError.
The page-strategy body of GitHub's issues.list walker reads:
pub fn list_paged(&self, request: ListPagedRequest, options: CallOptions) -> PaginatedResponse<Issue, i64> {
let endpoint = self.clone();
let size = request.per_page.unwrap_or(30);
let size = Some(size as usize);
let next = move |page: i64| {
let endpoint = endpoint.clone();
let request = request.clone();
let options = options.clone();
async move {
let request = request.at(Some(page));
let response = endpoint.list(request, options).await?;
let rows = response;
if exhausted(rows.len(), size) {
return Ok((rows, None));
}
Ok((rows, Some(page + 1)))
}
};
PaginatedResponse::new(1, next)
}at(page) is a private method of ListPagedRequest building the single-call Request
for one page. Every read of the response (rows, a cursor, a total) is rendered through
the type tree, so a nullable page or an optional cursor is read through Option rather
than assumed present.
Testing
crates/truewire-core/tests is at the granularity of packages/core-ts/test: errors.rs,
validation.rs (a generated-shaped struct through parse, dump, every format, every
container, every failure path), times.rs (the converters), http.rs (a loopback HTTP/1.1
listener: query, JSON and raw bodies, statuses, network failures, timeouts, recording),
paging.rs (the resumable contract and the terminator helpers) and ws.rs (three
dialects against an in-process tokio-tungstenite server: correlation out of order, lazy
open, drop and reconnect, close, refused and hanging connections, close frames, dialect
errors, pings, subscribe/push/unsubscribe, message_key routing, refused subscriptions,
map/filter, serial ordering, and an on_open handshake). No test touches the network.
examples/github/tests is the pattern for a generated package. common/mod.rs spawns
truewire mock --http-port 0 --ws-port 0 and builds the client against its URL;
replay.rs walks spec/endpoints/**/examples/*.request.json, decodes each recorded
request into its Request struct, calls the method and its _raw twin, and checks the
typed value dumps back to exactly the body the wire sent; paging.rs walks the same
recorded multi-page captures as test/test_paging.py. CI (examples-rust) runs
truewire generate rust --check, cargo fmt --check, cargo clippy --all-targets -- -D warnings and cargo test. The backend's own tests (packages/truewire/test/ test_codegen_rust.py) render the fixture client and, when rustfmt is installed, prove
every file passes rustfmt --check.
Not generated yet
streamendpoints,rpcendpoints reached only over a WebSocket, and routers under a composite core (children/forwardintruewire.toml) with everything beneath them: the runtime hasStreamEndpoint,CommandEndpointandSocketfor them, and the generator reports each as skipped.examples/krakentherefore has no Rust package yet.windowwalks,seekwalks withoverlap, theunchangedterminator, and the walks the plan markswalker: generator(offset, and the shapes the resumable form does not cover): the plain method is generated and the walker reported as skipped.- Publishing
truewire-coreto crates.io, and arelease/core-rustworkflow in the shape ofrelease-core-ts.yml. truewire docs checkfor ```rust blocks andtruewire surfacefor the snake_case rule.
Two gaps are the plan's rather than the backend's, recorded in docs/plan.md: every
integer renders as i64 (the plan carries no width), and a union renders
#[serde(untagged)] (the plan carries no discriminator). Two runtime limitations a
reader should know: rust_decimal holds 28 significant digits, so a decimal-string
beyond that fails validation rather than losing precision silently, and a literal mixing
strings with numbers widens to serde_json::Value.