Skip to main content

Module transport

Module transport 

Source
Expand description

The one seam between a decided request and the network.

The vocabulary is http::Request<Vec<u8>> in, http::Response<Vec<u8>> out, which is what every client-agnostic Rust crate converges on (oauth2, rustify, atrium-xrpc, kube-core). There is no de-facto trait, so this crate defines its own two — a sync one and an async one, because a single trait cannot be both and maybe-async makes the flavour a global switch.

This crate ships no adapter and depends on no HTTP client, in any feature combination: an adopter’s choice of client is theirs, and a crate that pinned one would make it everyone’s. examples/toy/cli/src/client.rs has both adapters in full — a ureq 3 agent in nine lines, a reqwest::Client in twelve — written to be copied rather than depended on.

An adapter in an adopter’s crate wraps the client in a newtype, because SyncClient and the client are both foreign there. A crate that owns either one writes impl SyncClient for ureq::Agent directly.

Both send methods take &self, so a client is shared rather than borrowed exclusively — which is what ureq::Agent and reqwest::Client are built for, and what lets one client serve a whole command tree. An adapter over a client that needs &mut to send holds it behind a Mutex, and Recorder holds its script the same way; that is why a recorder is scripted, sent through and read back without ever being mut.

No adapter may turn a status code into an error: the status belongs to the layer above, which needs the body that came with it. ureq does this by default and must be built with http_status_as_error(false).

§Writing a script

Recorder is the client the tests of this crate and of its example adoption run against. It sends nothing, answers from a script, and keeps every request it was given.

An answer is queued either for one route — the method and the path of the request as it goes out, with the query string left out of it — or for anything. A request takes the next answer queued for its own route; when that queue is empty it takes the next answer queued for anything; when that is empty too the answer is 200 {}.

use http::{Method, StatusCode};
use serde_json::json;
use typed_openapi::{Recorder, SyncClient};

let client = Recorder::new()
    .answering_route(Method::GET, "/vouchers", StatusCode::OK, &json!([{"id": 5}]))
    .answering_route(Method::GET, "/vouchers", StatusCode::OK, &json!([]))
    .failing_route(Method::POST, "/vouchers", "the request never left")
    .answering(StatusCode::OK, &json!({}));

let get = |uri: &str| http::Request::get(uri).body(Vec::new()).unwrap();
assert_eq!(client.send(get("/vouchers?page=1")).unwrap().body(), br#"[{"id":5}]"#);
assert_eq!(client.send(get("/vouchers?page=2")).unwrap().body(), b"[]");

// One answer queued for anything is left, and nothing asked for it.
assert_eq!(client.unused(), 2);
assert_eq!(client.take().len(), 2);

Several answers queued for one route come back in the order they were queued, which is what a pager needs — page, next page, empty page — and what a retry policy needs for “a failure, then an answer”. Queueing per route is what keeps a scenario that crosses several endpoints from being pinned to the order the code under test happens to send in.

A scripted failure reaches the caller as RecorderError through whichever send was called, so everything a caller does with a transport failure — a retry policy, a ledger line for an attempt whose outcome never arrived — is reachable from a test. The request that got it is recorded like any other.

Recorder::take hands back what was sent, oldest first, and Recorder::unused how many answers were never reached: a run that left answers behind did not do what the test set it up to do, and that is worth asserting on rather than inferring.

§An answer that carries headers

answering and answering_route are the short spelling of the common case, and they build a JSON response. A caller that branches on a header — a pager following Link, a backoff reading Retry-After, a create reading Location — queues a whole HttpResponse instead, through Recorder::answering_with or Recorder::answering_route_with. json_response builds the one the short spelling builds, so “the same answer, plus a header” is two lines:

use http::{Method, StatusCode, header};
use serde_json::json;
use typed_openapi::{Recorder, SyncClient, json_response};

let mut page = json_response(StatusCode::OK, &json!([{"id": 5}]));
page.headers_mut().insert(
    header::LINK,
    header::HeaderValue::from_static(r#"</vouchers?page=2>; rel="next""#),
);

let client = Recorder::new().answering_route_with(Method::GET, "/vouchers", page);
let answer = client.send(http::Request::get("/vouchers").body(Vec::new()).unwrap()).unwrap();

assert_eq!(answer.headers()[header::LINK], r#"</vouchers?page=2>; rel="next""#);

§Refusing what nobody queued

An empty script answers 200 {}, which is what a test that only cares what went out wants: it queues nothing and asserts on Recorder::take.

A test that does care what came back wants the opposite, and asks for it with Recorder::strict. A strict recorder panics on a request no queue has an answer for, naming the route it was asked for and what it is still holding. That is a bug in the test rather than in the code under test, and a panic puts it on the line that caused it — where a plausible, empty, successful answer would surface as an assertion going red three layers away. It is a panic and not a RecorderError for the same reason: a failure the script asked for and a request the script forgot are different mistakes, and a test scripted to expect the first must not pass on the second.

Strictness says nothing about what a queued answer is. A strict recorder answers, fails and records exactly as a lenient one does; only the empty case differs. Both send methods do their work when they are called, so the panic lands on the send line even on the async path, before anything is awaited.

Structs§

Recorder
A client that sends nothing, answers from a script, and keeps every request it was given.
RecorderError
The failure a script asked for, carrying the message it was queued with.

Traits§

AsyncClient
The same, for a caller inside a runtime. Send on the future so that a call can be tokio::spawned.
SyncClient
Something that can send a request and wait for the answer.

Functions§

json_response
A JSON response, which is what the short spelling of a scripted answer builds: body rendered into the body and content-type: application/json.

Type Aliases§

HttpRequest
What every adapter takes.
HttpResponse
What every adapter returns.