Verified with the 0.1.0 publication: the sparse index needs no _cargo-index repository and keeps crates.io registry URLs. gitzone's release checkout does not put node_modules/.bin on PATH for rsrepo.
rustsupport
Rust for api.global servers: a transport that speaks @api.global/typedrequest 9 over HTTP and @api.global/typedsocket 9 over WebSocket (TypedSocket 9 and 10 servers), and the plumbing of Rust contracts generated from the servers' typedopenapi contract documents, so a Rust client calls client.call::<ListInvoices>(&request) checked at compile time instead of hand-writing envelopes. The Rust counterpart of @api.global/swiftsupport.
Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.
Install
The crates are in the Cargo registry of code.foss.global, readable without a sign-in. Name the registry once, in the consumer's .cargo/config.toml:
[registries.api-global]
index = "sparse+https://code.foss.global/api/packages/api.global/cargo/"
[dependencies]
rustsupport = { version = "0.1.0", registry = "api-global" }
[dev-dependencies]
# the scripted test transport, for the consumer's own tests
rustsupport = { version = "0.1.0", registry = "api-global", features = ["mock"] }
rustsupport-codegen = { version = "0.1.0", registry = "api-global" }
Rust 1.95 (edition 2024), on tokio. TLS is rustls with the aws-lc-rs provider and the platform certificate verifier, without pinning. WebAssembly is not supported.
Contracts
A contract implements TypedContract: its wire name and its request and response types. The generator writes them; written by hand they look the same:
use rustsupport::TypedContract;
#[derive(serde::Serialize, serde::Deserialize)]
pub struct ListInvoicesRequest {
pub organization_id: String,
}
#[derive(serde::Serialize, serde::Deserialize)]
pub struct InvoicePage {
pub items: Vec<serde_json::Value>,
}
pub struct ListInvoices;
impl TypedContract for ListInvoices {
const METHOD: &'static str = "listInvoices";
type Request = ListInvoicesRequest;
type Response = InvoicePage;
}
client.call::<ListInvoices>(&request) then takes only that contract's request and returns its response.
HTTP: TypedRequestClient
let client = TypedRequestClient::new("https://example.com/typedrequest")?
.with_timeout(Duration::from_secs(20))? // default 30 s
.with_headers(move || {
let token = token.clone();
async move { Ok(vec![("authorization".to_owned(), format!("Bearer {token}"))]) }
});
let page = client.call::<ListInvoices>(&request).await?;
Each call posts one TypedRequest 9 envelope (method, request, response: null, a fresh requestInstanceId, correlation) and accepts only the answer of exactly that request. The header provider runs once per call; its headers stay through the server's retries. retry: { waitForMs } answers are followed up to with_max_retries (default 3), each with a new request instance of the same correlation. The deadline covers the retries and their waits. Redirects are refused, so a credential header never follows one, and plain http is allowed to loopback hosts only. A call ends with the contract's response or an Error:
| Error | When |
|---|---|
Error::Remote |
the server answered with its error envelope: method, text (error.text), data (error.data), code() (data.code where the server puts one), retry (a retry hint sent with the error), decode_data() for a typed refusal |
Error::Http |
an answer outside 2xx, such as a gateway's or a rate limiter's problem document: its status, headers and body, as TypedRequest 9.2 reports it |
Error::InvalidResponse |
an answer of another request, a malformed error or retry, or a payload of another shape than the contract's |
Error::Timeout / Error::RetryLimitExceeded |
the deadline passed; the server asked for more retries than allowed (an error the last retry hint came with is reported as itself) |
Error::Transport / Error::Headers |
the exchange did not complete; the header provider failed |
rustsupport::decode_response_envelope::<C>(text) reads a recorded answer the same way, for fixture tests.
WebSocket: TypedSocketClient
let socket = TypedSocketClient::builder("https://example.com/")
.options(SocketOptions {
terminal_close_codes: vec![4403], // e.g. a revoked device: never reconnect
..SocketOptions::default()
})
.prepare_connection(move |_attempt| {
let origin = origin.clone();
async move { Ok(vec![("origin".to_owned(), origin)]) }
})
.restore_connection(|context| async move {
context.call::<Resubscribe>(&resubscribe).await?;
Ok(())
})
.build()?;
socket.handle::<InvoiceChanged, _, _>(|change| async move { // requests the server sends
store.apply(change);
Ok(InvoiceChangedResponse {})
})?;
socket.connect().await?;
let page = socket.call::<ListInvoices>(&request).await?;
socket.set_tag("workspace", &json!({ "workspaceId": id })).await?;
let mut updates = socket.status_updates(); // a Stream of SocketStatus
socket.stop().await;
The client speaks TypedSocket 9 exactly:
- envelopes travel as text frames, each with its own
requestInstanceId; - the first frame is the package-major handshake (
__typedsocket_checkVersion, major 9,typedrequest-cancellation-v1), answered key by key; a server of another major, a malformed answer, or a server that refuses the handshake (close 1008) ends the client (Error::HandshakeFailed), while a handshake that merely did not finish reconnects; prepare_connectionsupplies the headers of every upgrade;restore_connectionruns on every new connection after the handshake, then the client's tags (__typedsocket_setTag) are set again, and only then is the clientConnected.RestoreError::Deferdefers the attempt without using up a reconnect; a call that failed because the connection did is retried; any other failure, such as the server's refusal, ends the client;- a request that times out (default 30 s, the wait for a connection included) or whose future is dropped is cancelled on the server with
__typedsocket_cancelRequest; a request the server cancels drops its handler's future and is not answered; - a handler that returns
TypedResponseErroranswers with that error; a request that does not decode or a handler that panics is answeredInternal server error, a method without handler the TypedRouter's own refusal; - a lost connection reconnects with exponential backoff (1 s doubling to 60 s, 20 % jitter, 100 reconnects, reset by a ready connection); requests in flight fail with
Error::ConnectionLost, since they may have arrived; - a close frame the server sends with one of
terminal_close_codesends the client (SocketEnd::Closedwith the code and reason); nothing reconnects after it. A connection that merely drops always reconnects.
SocketStatus is New, Connecting { attempt }, Connected, Disconnected { close } (with the server's close frame), Reconnecting { failures, delay } and finally Ended(SocketEnd): Stopped, Released (every handle dropped), Closed(frame), Lost(frame) (reconnecting off) or Failed(error). Calls after the end fail with Error::Ended. The socket task never outlives the client: it ends on stop(), when the last handle is dropped, or on a terminal end.
Plain ws/http is allowed to loopback hosts only. VirtualStreams (binary frames) and TypedSocket events (__typedsocket_event) are not supported.
Testing with the mock transport
The mock feature adds rustsupport::mock::MockTransport: HTTP answers from closures, sockets whose server end the test drives, and tokio's paused clock, so timeouts and backoff run instantly and in order.
#[tokio::test(start_paused = true)]
async fn reconnects() {
let mock = MockTransport::default();
let socket = TypedSocketClient::builder_with_transport("http://127.0.0.1:3000/", mock.clone())
.build()
.unwrap();
let mut server = mock.accept();
let (connected, ()) = tokio::join!(socket.connect(), server.handshake());
connected.unwrap();
socket.stop().await;
mock.tasks_ended().await; // fails when a task outlives the client
}
Switch the feature on in [dev-dependencies] only; release builds must not carry it.
Generating contracts
rustsupport-codegen is the plumbing of contracts generated from a typedopenapi contract document (generateContractSchemaDocument() of @api.global/typedopenapi). A consumer vendors the document next to a pin.json and describes both in rustsupport-codegen.json:
{
"document": "schema/contracts.openapi.json",
"out": "src/generated.rs",
"pin": "schema/pin.json",
"package": "@example/interfaces",
"entry": "package/schema/contracts.openapi.json",
"serverInitiated": ["IReq_PushEvent"],
"markers": [{ "name": "AUTHENTICATED", "requestProperty": "jwt" }]
}
pin.json records package, the exact version, the tarball, its integrity (sha512-…, as the registry publishes it), the entry inside the tarball and the sha256 of the vendored file. The crate reads the document and its contracts (Source, Contract: the method, whether the server initiates it, the markers), refreshes it to an exact published version after verifying the integrity (refresh), refuses generated code that needs a crate beyond std, serde, serde_json and rustsupport (refuse_new_dependencies), and runs the regeneration gate: the checked-in file must equal a fresh generation, compared in memory (check). Its limits: a document has at most 16 MiB, vendored or unpacked; refresh downloads at most 16 MiB of version metadata and 64 MiB of tarball and unpacks at most 256 MiB of it; anything larger is refused with the limit named. Documents parse with serde_json's default limit of 128 nested JSON containers, so a chain of properties reaches level 62 of a named schema (61 of a contract part); the emitter's 64 levels are reachable through items and additionalProperties. The emitter that turns the schemas into Rust plugs in through the Emitter trait; the crate's own is RustEmitter, and its binary rustsupport-codegen runs the generate, check and refresh commands over it (rustsupport-codegen check --config rustsupport-codegen.json).
RustEmitter writes a type for every contract and named schema and an implementation of ::rustsupport::TypedContract for every contract, and covers exactly the schema subset of its specification (docs/cleanroom/rust-emitter.md): scalars, {}, string enums (open with an Unknown catch-all where the client decodes them, closed where it only sends them), struct objects, maps, arrays, fixed-length arrays and tuples, tagged and untagged oneOf unions, nullable patterns and $refs. Any other document is refused with a reason of the form <Kind> at <JSON pointer>: <detail>, never emitted approximately. Its limits: 64 levels, 256 properties per object, 256 branches per union and 16 per untagged one, 255 bytes per written name, arrays of 1 to 32 and tuples of 1 to 16 elements. Include the generated file unformatted, as #[rustfmt::skip] mod generated;; with markers configured it also defines the trait ContractMarkers.
Development
pnpm install # the Node side of the tests: TypedRequest 9.2, TypedSocket 10, SmartServe 10, typedopenapi
cargo test --workspace # unit, mock and Node fixture tests
cargo clippy --workspace --all-targets -- -D warnings
scripts/check-release-no-mock.sh # release builds carry no mock code
pnpm run fixture:schema # rewrite fixtures/*.openapi.json from fixtures/contracts
The fixture tests start fixtures/typedserver.mjs with node (on PATH) for a real TypedRequest 9 HTTP endpoint and TypedSocket server. Without node or the installed dependencies they fail; RUSTSUPPORT_SKIP_NODE_FIXTURES=1 skips them and says so.
A pushed release tag runs .gitea/workflows/cargo-registry.yml: the checks, then scripts/publish-cargo-registry.sh publishes both crates to the registry, reads each back without a sign-in and compares checksums. A version the registry already holds passes only with the same archive. The job token may not write packages, so the upload uses the organisation's Actions secret CARGO_REGISTRY_TOKEN, an access token with package write scope.
License and Legal Information
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the license.md file.
Please note: The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
Company Information
Task Venture Capital GmbH
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.