use std::collections::{HashMap, VecDeque};
use std::fmt;
use std::sync::{Mutex, MutexGuard, PoisonError};
use http::{Method, Request, Response, StatusCode, header};
use thiserror::Error;
pub type HttpRequest = Request<Vec<u8>>;
pub type HttpResponse = Response<Vec<u8>>;
pub trait SyncClient {
type Error: std::error::Error + Send + Sync + 'static;
fn send(&self, request: HttpRequest) -> Result<HttpResponse, Self::Error>;
}
impl<C: SyncClient + ?Sized> SyncClient for &C {
type Error = C::Error;
fn send(&self, request: HttpRequest) -> Result<HttpResponse, C::Error> {
(**self).send(request)
}
}
pub trait AsyncClient {
type Error: std::error::Error + Send + Sync + 'static;
fn send(
&self,
request: HttpRequest,
) -> impl Future<Output = Result<HttpResponse, Self::Error>> + Send;
}
impl<C: AsyncClient + ?Sized> AsyncClient for &C {
type Error = C::Error;
fn send(
&self,
request: HttpRequest,
) -> impl Future<Output = Result<HttpResponse, C::Error>> + Send {
(**self).send(request)
}
}
#[derive(Debug, Error)]
#[error("{message}")]
pub struct RecorderError {
message: String,
}
impl RecorderError {
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
}
#[derive(Debug, Default)]
pub struct Recorder {
held: Mutex<Held>,
strict: bool,
}
#[derive(Debug, Default)]
struct Held {
per_route: HashMap<Route, VecDeque<Answer>>,
anything: VecDeque<Answer>,
sent: Vec<HttpRequest>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Route {
method: Method,
path: String,
}
impl Route {
fn new(method: Method, path: &str) -> Self {
Self {
method,
path: path.to_owned(),
}
}
fn of(request: &HttpRequest) -> Self {
Self::new(request.method().clone(), request.uri().path())
}
}
impl fmt::Display for Route {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} {}", self.method, self.path)
}
}
#[derive(Debug)]
enum Answer {
Response(HttpResponse),
Failure(String),
}
impl Recorder {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn answering_route(
self,
method: Method,
path: &str,
status: StatusCode,
body: &serde_json::Value,
) -> Self {
self.queue_for(
Route::new(method, path),
Answer::Response(json_response(status, body)),
)
}
#[must_use]
pub fn answering_route_with(self, method: Method, path: &str, response: HttpResponse) -> Self {
self.queue_for(Route::new(method, path), Answer::Response(response))
}
#[must_use]
pub fn failing_route(self, method: Method, path: &str, message: &str) -> Self {
self.queue_for(
Route::new(method, path),
Answer::Failure(message.to_owned()),
)
}
#[must_use]
pub fn answering(self, status: StatusCode, body: &serde_json::Value) -> Self {
self.queue_for_anything(Answer::Response(json_response(status, body)))
}
#[must_use]
pub fn answering_with(self, response: HttpResponse) -> Self {
self.queue_for_anything(Answer::Response(response))
}
#[must_use]
pub fn failing(self, message: &str) -> Self {
self.queue_for_anything(Answer::Failure(message.to_owned()))
}
#[must_use]
pub fn strict(mut self) -> Self {
self.strict = true;
self
}
pub fn take(&self) -> Vec<HttpRequest> {
std::mem::take(&mut self.held().sent)
}
#[must_use]
pub fn unused(&self) -> usize {
let held = self.held();
held.anything.len() + held.per_route.values().map(VecDeque::len).sum::<usize>()
}
fn queue_for(mut self, route: Route, answer: Answer) -> Self {
self.script()
.per_route
.entry(route)
.or_default()
.push_back(answer);
self
}
fn queue_for_anything(mut self, answer: Answer) -> Self {
self.script().anything.push_back(answer);
self
}
fn script(&mut self) -> &mut Held {
self.held.get_mut().unwrap_or_else(PoisonError::into_inner)
}
fn held(&self) -> MutexGuard<'_, Held> {
self.held.lock().unwrap_or_else(PoisonError::into_inner)
}
fn answer(&self, request: HttpRequest) -> Result<HttpResponse, RecorderError> {
let mut held = self.held();
let route = Route::of(&request);
held.sent.push(request);
let for_the_route = held.per_route.get_mut(&route).and_then(VecDeque::pop_front);
let queued = match for_the_route {
Some(answer) => Some(answer),
None => held.anything.pop_front(),
};
match queued {
Some(Answer::Response(response)) => Ok(response),
Some(Answer::Failure(message)) => Err(RecorderError { message }),
None if self.strict => unscripted(&route, &held),
None => Ok(json_response(StatusCode::OK, &serde_json::json!({}))),
}
}
}
fn unscripted(route: &Route, held: &Held) -> ! {
let mut queued: Vec<String> = held
.per_route
.iter()
.filter(|(_, answers)| !answers.is_empty())
.map(|(route, answers)| format!("{route} ({} left)", answers.len()))
.collect();
queued.sort();
panic!(
"the recorder was asked for `{route}` and has no answer for it. It is holding {queued:?} \
and {} queued for anything. Queue one with `answering_route` or `answering`, or drop \
`strict()` to let it answer 200 {{}}.",
held.anything.len(),
);
}
impl SyncClient for Recorder {
type Error = RecorderError;
fn send(&self, request: HttpRequest) -> Result<HttpResponse, RecorderError> {
self.answer(request)
}
}
impl AsyncClient for Recorder {
type Error = RecorderError;
fn send(
&self,
request: HttpRequest,
) -> impl Future<Output = Result<HttpResponse, RecorderError>> + Send {
std::future::ready(self.answer(request))
}
}
#[must_use]
pub fn json_response(status: StatusCode, body: &serde_json::Value) -> HttpResponse {
let mut response = Response::new(body.to_string().into_bytes());
*response.status_mut() = status;
response.headers_mut().insert(
header::CONTENT_TYPE,
header::HeaderValue::from_static("application/json"),
);
response
}
#[cfg(test)]
mod tests {
#![expect(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
reason = "a failed unwrap or a panicking index is a failing test"
)]
use std::pin::pin;
use std::task::{Context, Poll, Waker};
use serde_json::json;
use super::{
AsyncClient, HttpRequest, HttpResponse, Method, Recorder, RecorderError, Request,
StatusCode, SyncClient, header, json_response,
};
fn request(method: Method, uri: &str) -> HttpRequest {
Request::builder()
.method(method)
.uri(uri)
.body(Vec::new())
.unwrap()
}
fn sent(client: &Recorder, method: Method, uri: &str) -> HttpResponse {
SyncClient::send(client, request(method, uri)).unwrap()
}
fn body(response: &HttpResponse) -> serde_json::Value {
serde_json::from_slice(response.body()).unwrap()
}
fn block_on<F: Future>(future: F) -> F::Output {
let mut future = pin!(future);
let mut cx = Context::from_waker(Waker::noop());
loop {
if let Poll::Ready(value) = future.as_mut().poll(&mut cx) {
return value;
}
}
}
#[test]
fn answers_queued_for_one_route_come_back_in_the_order_they_were_queued() {
let client = Recorder::new()
.answering_route(Method::GET, "/vouchers", StatusCode::OK, &json!(["first"]))
.answering_route(Method::GET, "/vouchers", StatusCode::OK, &json!(["second"]))
.answering_route(Method::GET, "/vouchers", StatusCode::OK, &json!([]));
let pages: Vec<serde_json::Value> = ["?page=1", "?page=2", "?page=3"]
.into_iter()
.map(|query| body(&sent(&client, Method::GET, &format!("/vouchers{query}"))))
.collect();
assert_eq!(pages, [json!(["first"]), json!(["second"]), json!([])]);
}
#[test]
fn a_route_is_answered_from_its_own_queue_and_not_from_another_routes() {
let client = Recorder::new()
.answering_route(Method::GET, "/vouchers", StatusCode::OK, &json!("the list"))
.answering_route(
Method::POST,
"/vouchers",
StatusCode::CREATED,
&json!("the new one"),
);
assert_eq!(
body(&sent(&client, Method::POST, "/vouchers")),
json!("the new one")
);
assert_eq!(
body(&sent(&client, Method::GET, "/vouchers")),
json!("the list")
);
}
#[test]
fn a_route_with_nothing_queued_falls_back_to_anything_and_then_to_the_default() {
let client = Recorder::new().answering(StatusCode::ACCEPTED, &json!("whatever you asked"));
let from_anything = sent(&client, Method::GET, "/vouchers");
assert_eq!(from_anything.status(), StatusCode::ACCEPTED);
assert_eq!(body(&from_anything), json!("whatever you asked"));
let from_nothing = sent(&client, Method::GET, "/vouchers");
assert_eq!(from_nothing.status(), StatusCode::OK);
assert_eq!(body(&from_nothing), json!({}));
}
#[test]
fn a_route_with_something_queued_leaves_the_anything_queue_alone() {
let client = Recorder::new()
.answering_route(
Method::GET,
"/vouchers",
StatusCode::OK,
&json!("for the route"),
)
.answering(StatusCode::OK, &json!("for anything"));
assert_eq!(
body(&sent(&client, Method::GET, "/vouchers")),
json!("for the route")
);
assert_eq!(client.unused(), 1);
}
#[test]
fn a_scripted_failure_reaches_the_caller_and_the_request_is_recorded_anyway() {
let client =
Recorder::new().failing_route(Method::POST, "/vouchers", "the request never left");
let failed =
SyncClient::send(&client, request(Method::POST, "/vouchers")).expect_err("scripted");
assert_eq!(failed.message(), "the request never left");
assert_eq!(failed.to_string(), "the request never left");
let sent = client.take();
assert_eq!(sent.len(), 1, "a refused request went out like any other");
assert_eq!(sent[0].uri().path(), "/vouchers");
}
#[test]
fn two_failures_are_told_apart_by_the_messages_they_were_queued_with() {
let client = Recorder::new()
.failing_route(Method::POST, "/vouchers", "the request never left")
.failing("the request left and nothing came back");
let never = SyncClient::send(&client, request(Method::POST, "/vouchers"))
.expect_err("the route's own failure");
let silent = SyncClient::send(&client, request(Method::GET, "/vouchers"))
.expect_err("the failure queued for anything");
assert_eq!(never.message(), "the request never left");
assert_eq!(silent.message(), "the request left and nothing came back");
}
#[test]
fn what_is_left_unused_is_something_a_test_can_ask_about() {
let client = Recorder::new()
.answering_route(Method::GET, "/vouchers", StatusCode::OK, &json!("first"))
.answering_route(Method::GET, "/vouchers", StatusCode::OK, &json!("second"))
.answering(StatusCode::OK, &json!("third"));
assert_eq!(client.unused(), 3);
let _ = sent(&client, Method::GET, "/vouchers");
assert_eq!(client.unused(), 2);
let _ = sent(&client, Method::GET, "/elsewhere");
assert_eq!(client.unused(), 1);
}
#[test]
fn one_script_drives_the_sync_and_the_async_path_identically() {
let script = || {
Recorder::new()
.answering_route(Method::GET, "/vouchers", StatusCode::OK, &json!("the list"))
.failing_route(Method::POST, "/vouchers", "the request never left")
};
let synchronous = script();
let asynchronous = script();
let read = body(&sent(&synchronous, Method::GET, "/vouchers"));
let wrote = SyncClient::send(&synchronous, request(Method::POST, "/vouchers"))
.expect_err("scripted");
let read_async = block_on(AsyncClient::send(
&asynchronous,
request(Method::GET, "/vouchers"),
))
.unwrap();
let wrote_async = block_on(AsyncClient::send(
&asynchronous,
request(Method::POST, "/vouchers"),
))
.expect_err("scripted");
assert_eq!(read, body(&read_async));
assert_eq!(wrote.message(), wrote_async.message());
let routes = |client: &Recorder| -> Vec<String> {
client
.take()
.iter()
.map(|request| format!("{} {}", request.method(), request.uri()))
.collect()
};
assert_eq!(routes(&synchronous), routes(&asynchronous));
}
#[test]
fn a_response_queued_whole_keeps_the_headers_it_was_built_with() {
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 mut created = json_response(StatusCode::CREATED, &json!(null));
created.headers_mut().insert(
header::LOCATION,
header::HeaderValue::from_static("/vouchers/6"),
);
let client = Recorder::new()
.answering_route_with(Method::GET, "/vouchers", page)
.answering_with(created);
let listed = sent(&client, Method::GET, "/vouchers");
assert_eq!(
listed.headers()[header::LINK],
r#"</vouchers?page=2>; rel="next""#
);
assert_eq!(body(&listed), json!([{"id": 5}]));
let made = sent(&client, Method::POST, "/vouchers");
assert_eq!(made.status(), StatusCode::CREATED);
assert_eq!(made.headers()[header::LOCATION], "/vouchers/6");
}
#[test]
fn the_long_spelling_of_an_answer_and_the_short_one_build_the_same_response() {
let short = Recorder::new().answering(StatusCode::OK, &json!({"id": 5}));
let long = Recorder::new().answering_with(json_response(StatusCode::OK, &json!({"id": 5})));
let from_short = sent(&short, Method::GET, "/vouchers");
let from_long = sent(&long, Method::GET, "/vouchers");
assert_eq!(from_short.status(), from_long.status());
assert_eq!(from_short.headers(), from_long.headers());
assert_eq!(from_short.body(), from_long.body());
}
#[test]
#[should_panic(
expected = "asked for `GET /vouchers` and has no answer for it. It is holding \
[\"POST /vouchers (2 left)\", \"PUT /vouchers/5 (1 left)\"] and 0 queued \
for anything"
)]
fn a_strict_recorder_refuses_what_nobody_queued_and_names_what_it_holds() {
let client = Recorder::new()
.strict()
.answering_route(Method::POST, "/vouchers", StatusCode::CREATED, &json!(null))
.answering_route(Method::POST, "/vouchers", StatusCode::CREATED, &json!(null))
.answering_route(Method::PUT, "/vouchers/5", StatusCode::OK, &json!(null));
let _refused = SyncClient::send(&client, request(Method::GET, "/vouchers"));
}
#[test]
fn a_strict_recorder_answers_and_fails_from_the_script_like_a_lenient_one() {
let client = Recorder::new()
.strict()
.answering_route(Method::GET, "/vouchers", StatusCode::OK, &json!("the list"))
.failing_route(Method::POST, "/vouchers", "the request never left");
assert_eq!(
body(&sent(&client, Method::GET, "/vouchers")),
json!("the list")
);
assert_eq!(
SyncClient::send(&client, request(Method::POST, "/vouchers"))
.expect_err("scripted")
.message(),
"the request never left"
);
assert_eq!(client.take().len(), 2, "both went out");
}
#[test]
#[should_panic(expected = "asked for `GET /vouchers`")]
fn the_async_path_refuses_when_send_is_called_rather_than_when_it_is_awaited() {
let client = Recorder::new().strict();
let _refused = AsyncClient::send(&client, request(Method::GET, "/vouchers"));
}
struct Eager(std::cell::Cell<usize>);
impl AsyncClient for Eager {
type Error = RecorderError;
fn send(
&self,
_request: HttpRequest,
) -> impl Future<Output = Result<HttpResponse, RecorderError>> + Send {
self.0.set(self.0.get() + 1);
std::future::ready(Ok(json_response(
StatusCode::OK,
&json!({ "answered": self.0.get() }),
)))
}
}
#[test]
fn a_reference_to_a_client_is_an_async_client_and_the_client_owes_no_sync() {
fn is_send<F: Send>(future: F) -> F {
future
}
let client = Eager(std::cell::Cell::new(0));
let through_reference: &Eager = &client;
let pending = is_send(AsyncClient::send(
&through_reference,
request(Method::GET, "/vouchers"),
));
let answered = block_on(pending).expect("the eager client answers");
assert_eq!(body(&answered), json!({ "answered": 1 }));
}
}