From 131ad0a3e7a247c867fc290319047fc086995271 Mon Sep 17 00:00:00 2001 From: konstin Date: Fri, 23 May 2025 22:35:46 +0200 Subject: [PATCH 1/3] Allow returning arbitrary errors Add `MockBuilder::respond_with_err` to respond with an arbitrary Rust error instead of an HTTP error. Due to overlapping impl constraints, `RespondErr` only supports passing a function that returns an error and not the error itself. Fixes #149 --- src/lib.rs | 2 ++ src/mock.rs | 34 ++++++++++++++++++++++++++++------ src/mock_server/bare_server.rs | 4 ++-- src/mock_server/hyper.rs | 16 ++++++++++++++-- src/mock_set.rs | 28 ++++++++++++++++------------ src/mounted_mock.rs | 9 +++++++-- src/respond.rs | 17 ++++++++++++++++- 7 files changed, 85 insertions(+), 25 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 3aa13b8..ba52ac4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -155,6 +155,8 @@ mod respond; mod response_template; mod verification; +pub type ErrorResponse = Box; + pub use mock::{Match, Mock, MockBuilder, Times}; pub use mock_server::{MockGuard, MockServer, MockServerBuilder}; pub use request::Request; diff --git a/src/mock.rs b/src/mock.rs index a71a2ab..69196f8 100644 --- a/src/mock.rs +++ b/src/mock.rs @@ -1,5 +1,5 @@ -use crate::respond::Respond; -use crate::{MockGuard, MockServer, Request, ResponseTemplate}; +use crate::respond::{Respond, RespondErr}; +use crate::{ErrorResponse, MockGuard, MockServer, Request, ResponseTemplate}; use std::fmt::{Debug, Formatter}; use std::ops::{ Range, RangeBounds, RangeFrom, RangeFull, RangeInclusive, RangeTo, RangeToInclusive, @@ -256,7 +256,7 @@ impl Debug for Matcher { #[must_use = "`Mock`s have to be mounted or registered with a `MockServer` to become effective"] pub struct Mock { pub(crate) matchers: Vec, - pub(crate) response: Box, + pub(crate) response: Result, Box>, /// Maximum number of times (inclusive) we should return a response from this Mock on /// matching requests. /// If `None`, there is no cap and we will respond to all incoming matching requests. @@ -629,8 +629,14 @@ impl Mock { /// Given a [`Request`] build an instance a [`ResponseTemplate`] using /// the responder associated with the `Mock`. - pub(crate) fn response_template(&self, request: &Request) -> ResponseTemplate { - self.response.respond(request) + pub(crate) fn response_template( + &self, + request: &Request, + ) -> Result { + match &self.response { + Ok(responder) => Ok(responder.respond(request)), + Err(responder_err) => Err(responder_err.respond_err(request)), + } } } @@ -656,7 +662,23 @@ impl MockBuilder { pub fn respond_with(self, responder: R) -> Mock { Mock { matchers: self.matchers, - response: Box::new(responder), + response: Ok(Box::new(responder)), + max_n_matches: None, + priority: 5, + name: None, + expectation_range: Times(TimesEnum::Unbounded(RangeFull)), + } + } + + /// Instead of response with an HTTP reply, return a Rust error. + /// + /// This can simulate lower level errors, e.g., a [`ConnectionReset`] IO Error. + /// + /// [`ConnectionReset`]: std::io::ErrorKind::ConnectionReset + pub fn respond_with_err(self, responder_err: R) -> Mock { + Mock { + matchers: self.matchers, + response: Err(Box::new(responder_err)), max_n_matches: None, priority: 5, name: None, diff --git a/src/mock_server/bare_server.rs b/src/mock_server/bare_server.rs index 70b6db4..5e97eed 100644 --- a/src/mock_server/bare_server.rs +++ b/src/mock_server/bare_server.rs @@ -2,7 +2,7 @@ use crate::mock_server::hyper::run_server; use crate::mock_set::MockId; use crate::mock_set::MountedMockSet; use crate::request::BodyPrintLimit; -use crate::{mock::Mock, verification::VerificationOutcome, Request}; +use crate::{mock::Mock, verification::VerificationOutcome, ErrorResponse, Request}; use http_body_util::Full; use hyper::body::Bytes; use std::fmt::{Debug, Write}; @@ -39,7 +39,7 @@ impl MockServerState { pub(super) async fn handle_request( &mut self, request: Request, - ) -> (hyper::Response>, Option) { + ) -> Result<(hyper::Response>, Option), ErrorResponse> { // If request recording is enabled, record the incoming request // by adding it to the `received_requests` stack if let Some(received_requests) = &mut self.received_requests { diff --git a/src/mock_server/hyper.rs b/src/mock_server/hyper.rs index 2079a83..c0b3e91 100644 --- a/src/mock_server/hyper.rs +++ b/src/mock_server/hyper.rs @@ -5,6 +5,17 @@ use std::sync::Arc; use tokio::net::TcpListener; use tokio::sync::RwLock; +/// Work around a lifetime error where, for some reason, +/// `Box` can't be converted to a +/// `Box` +struct ErrorLifetimeCast(Box); + +impl From for Box { + fn from(value: ErrorLifetimeCast) -> Self { + value.0 + } +} + /// The actual HTTP server responding to incoming requests according to the specified mocks. pub(super) async fn run_server( listener: std::net::TcpListener, @@ -24,7 +35,8 @@ pub(super) async fn run_server( .write() .await .handle_request(wiremock_request) - .await; + .await + .map_err(ErrorLifetimeCast)?; // We do not wait for the delay within the handler otherwise we would be // holding on to the write-side of the `RwLock` on `mock_set`. @@ -38,7 +50,7 @@ pub(super) async fn run_server( delay.await; } - Ok::<_, &'static str>(response) + Ok::<_, ErrorLifetimeCast>(response) } }; diff --git a/src/mock_set.rs b/src/mock_set.rs index cb56dbe..ba057ce 100644 --- a/src/mock_set.rs +++ b/src/mock_set.rs @@ -2,8 +2,9 @@ use crate::request::BodyPrintLimit; use crate::{ mounted_mock::MountedMock, verification::{VerificationOutcome, VerificationReport}, + ErrorResponse, }; -use crate::{Mock, Request, ResponseTemplate}; +use crate::{Mock, Request}; use http_body_util::Full; use hyper::body::Bytes; use log::debug; @@ -56,9 +57,9 @@ impl MountedMockSet { pub(crate) async fn handle_request( &mut self, request: Request, - ) -> (hyper::Response>, Option) { + ) -> Result<(hyper::Response>, Option), ErrorResponse> { debug!("Handling request."); - let mut response_template: Option = None; + let mut response_template: Option<_> = None; self.mocks.sort_by_key(|(m, _)| m.specification.priority); for (mock, mock_state) in &mut self.mocks { if *mock_state == MountedMockState::OutOfScope { @@ -70,19 +71,22 @@ impl MountedMockSet { } } if let Some(response_template) = response_template { - let delay = response_template.delay().map(sleep); - (response_template.generate_response(), delay) + match response_template { + Ok(response_template) => { + let delay = response_template.delay().map(sleep); + Ok((response_template.generate_response(), delay)) + } + Err(err) => Err(err), + } } else { let mut msg = "Got unexpected request:\n".to_string(); _ = request.print_with_limit(&mut msg, self.body_print_limit); debug!("{}", msg); - ( - hyper::Response::builder() - .status(hyper::StatusCode::NOT_FOUND) - .body(Full::default()) - .unwrap(), - None, - ) + let not_found_response = hyper::Response::builder() + .status(hyper::StatusCode::NOT_FOUND) + .body(Full::default()) + .unwrap(); + Ok((not_found_response, None)) } } diff --git a/src/mounted_mock.rs b/src/mounted_mock.rs index 47e917f..f1f1c89 100644 --- a/src/mounted_mock.rs +++ b/src/mounted_mock.rs @@ -2,7 +2,9 @@ use std::sync::{atomic::AtomicBool, Arc}; use tokio::sync::Notify; -use crate::{verification::VerificationReport, Match, Mock, Request, ResponseTemplate}; +use crate::{ + verification::VerificationReport, ErrorResponse, Match, Mock, Request, ResponseTemplate, +}; /// Given the behaviour specification as a [`Mock`], keep track of runtime information /// concerning this mock - e.g. how many times it matched on a incoming request. @@ -80,7 +82,10 @@ impl MountedMock { } } - pub(crate) fn response_template(&self, request: &Request) -> ResponseTemplate { + pub(crate) fn response_template( + &self, + request: &Request, + ) -> Result { self.specification.response_template(request) } diff --git a/src/respond.rs b/src/respond.rs index fe4955e..d027b8d 100644 --- a/src/respond.rs +++ b/src/respond.rs @@ -1,4 +1,4 @@ -use crate::{Request, ResponseTemplate}; +use crate::{ErrorResponse, Request, ResponseTemplate}; /// Anything that implements `Respond` can be used to reply to an incoming request when a /// [`Mock`] is activated. @@ -147,3 +147,18 @@ where (self)(request) } } + +/// Like [`Respond`], but it only allows returning an error through a function. +pub trait RespondErr: Send + Sync { + fn respond_err(&self, request: &Request) -> ErrorResponse; +} + +impl RespondErr for F +where + F: Send + Sync + Fn(&Request) -> Err, + Err: std::error::Error + Send + Sync + 'static, +{ + fn respond_err(&self, request: &Request) -> ErrorResponse { + Box::new((self)(request)) + } +} From 0665449e9d844c3a3474d12d7b0ac721ab571a72 Mon Sep 17 00:00:00 2001 From: konstin Date: Wed, 11 Jun 2025 15:59:38 +0200 Subject: [PATCH 2/3] Add tests --- tests/mocks.rs | 60 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/tests/mocks.rs b/tests/mocks.rs index e2ef13d..a13917f 100644 --- a/tests/mocks.rs +++ b/tests/mocks.rs @@ -1,11 +1,14 @@ use futures::FutureExt; use serde::Serialize; use serde_json::json; +use std::fmt::{Display, Formatter}; +use std::io::ErrorKind; +use std::iter; use std::net::TcpStream; use std::time::Duration; use surf::StatusCode; use wiremock::matchers::{body_json, body_partial_json, method, path, PathExactMatcher}; -use wiremock::{Mock, MockServer, ResponseTemplate}; +use wiremock::{Mock, MockServer, Request, ResponseTemplate}; #[async_std::test] async fn new_starts_the_server() { @@ -365,3 +368,58 @@ async fn debug_prints_mock_server_variants() { format!("{:?}", bare_mock_server) ); } + +#[tokio::test] +async fn io_err() { + // Act + let mock_server = MockServer::start().await; + let mock = Mock::given(method("GET")).respond_with_err(|_: &Request| { + std::io::Error::new(ErrorKind::ConnectionReset, "connection reset") + }); + mock_server.register(mock).await; + + // Assert + let err = reqwest::get(&mock_server.uri()).await.unwrap_err(); + let actual_err: Vec = + iter::successors::<&dyn std::error::Error, _>(Some(&err), |err| err.source()) + .map(|err| err.to_string()) + .collect(); + + let expected_err = vec![ + format!("error sending request for url ({}/)", mock_server.uri()), + "client error (SendRequest)".to_string(), + "connection closed before message completed".to_string(), + ]; + assert_eq!(actual_err, expected_err); +} + +#[tokio::test] +async fn custom_err() { + // Act + #[derive(Debug)] + struct CustomErr; + impl Display for CustomErr { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.write_str("custom error") + } + } + impl std::error::Error for CustomErr {} + + let mock_server = MockServer::start().await; + let mock = Mock::given(method("GET")).respond_with_err(|_: &Request| CustomErr); + mock_server.register(mock).await; + + // Assert + let err = reqwest::get(&mock_server.uri()).await.unwrap_err(); + let actual_err: Vec = + iter::successors::<&dyn std::error::Error, _>(Some(&err), |err| err.source()) + .map(|err| err.to_string()) + .collect(); + + let expected_err = vec![ + format!("error sending request for url ({}/)", mock_server.uri()), + "client error (SendRequest)".to_string(), + "connection closed before message completed".to_string(), + ]; + assert_eq!(actual_err, expected_err); +} From b79b69f62521df9f83a54e866432397562eae789 Mon Sep 17 00:00:00 2001 From: konstin Date: Wed, 11 Jun 2025 16:08:27 +0200 Subject: [PATCH 3/3] Skip unstable error message --- tests/mocks.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/tests/mocks.rs b/tests/mocks.rs index a13917f..db3db51 100644 --- a/tests/mocks.rs +++ b/tests/mocks.rs @@ -380,13 +380,14 @@ async fn io_err() { // Assert let err = reqwest::get(&mock_server.uri()).await.unwrap_err(); + // We're skipping the original error since it can be either `error sending request` or + // `error sending request for url (http://127.0.0.1:/)` let actual_err: Vec = - iter::successors::<&dyn std::error::Error, _>(Some(&err), |err| err.source()) + iter::successors(std::error::Error::source(&err), |err| err.source()) .map(|err| err.to_string()) .collect(); let expected_err = vec![ - format!("error sending request for url ({}/)", mock_server.uri()), "client error (SendRequest)".to_string(), "connection closed before message completed".to_string(), ]; @@ -411,13 +412,14 @@ async fn custom_err() { // Assert let err = reqwest::get(&mock_server.uri()).await.unwrap_err(); + // We're skipping the original error since it can be either `error sending request` or + // `error sending request for url (http://127.0.0.1:/)` let actual_err: Vec = - iter::successors::<&dyn std::error::Error, _>(Some(&err), |err| err.source()) + iter::successors(std::error::Error::source(&err), |err| err.source()) .map(|err| err.to_string()) .collect(); let expected_err = vec![ - format!("error sending request for url ({}/)", mock_server.uri()), "client error (SendRequest)".to_string(), "connection closed before message completed".to_string(), ];