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.
- Recorder
Error - The failure a script asked for, carrying the message it was queued with.
Traits§
- Async
Client - The same, for a caller inside a runtime.
Sendon the future so that a call can betokio::spawned. - Sync
Client - 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:
bodyrendered into the body andcontent-type: application/json.
Type Aliases§
- Http
Request - What every adapter takes.
- Http
Response - What every adapter returns.