From 212aa7e0ae44f54730cb93e0c7b337eff024becf Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Tue, 12 Nov 2024 07:13:59 +0000 Subject: [PATCH] feat(net): mock matcher no longer uses a prebuilt request --- Cargo.toml | 1 + examples/get.rs | 15 ++-- src/net/mod.rs | 30 +++---- src/net/result.rs | 2 + src/net/system.rs | 198 +++++++++++++++++--------------------------- tests/net.rs | 206 +++++++++++++++++++++++----------------------- 6 files changed, 197 insertions(+), 255 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 80ab5fc..a954d4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ derive_more = { version = "1.0", features = [ http = "1.1" path-clean = "1.0" reqwest = "0.12" +url = "2.5" tempfile = "3.10" [dev-dependencies] diff --git a/examples/get.rs b/examples/get.rs index 337b53b..96edaea 100644 --- a/examples/get.rs +++ b/examples/get.rs @@ -114,14 +114,11 @@ mod tests { let url = "http://localhost:8080"; // declare what response should be made for a given request - let response = mock_net.response().body("contents"); - let request = mock_net.client().get(url); + let response = mock_net.response().body("contents").expect("response body"); mock_net - .on(request) - // By default, the METHOD and URL must match, equivalent to: - //.match_on(vec![MatchOn::Method, MatchOn::Url]) - .respond_with_body(response) - .expect("mock"); + .on(http::Method::GET) + .url(url::Url::parse(url).expect("parse url")) + .respond(response); // Create a temporary directory that will be deleted with `fs` goes out of scope let fs = kxio::fs::temp().expect("temp fs"); @@ -146,7 +143,7 @@ mod tests { // not needed for this test, but should it be needed, we can avoid checking for any // unconsumed request matches. - let mock_net = kxio::net::MockNet::try_from(net).expect("recover mock"); - mock_net.reset(); + // let mock_net = kxio::net::MockNet::try_from(net).expect("recover mock"); + // mock_net.reset(); } } diff --git a/src/net/mod.rs b/src/net/mod.rs index 2970da6..8ee38b0 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -82,19 +82,12 @@ //! # #[tokio::main] //! # async fn main() -> net::Result<()> { //! # let mock_net = net::mock(); -//! # let client = mock_net.client(); -//! mock_net.on(client.get("https://example.com")) -//! .match_on(vec![ -//! MatchOn::Url, -//! MatchOn::Method -//! ]) -//! .respond(mock_net.response().status(200))?; -//! mock_net.on(client.get("https://example.com/foo")) -//! .match_on(vec![ -//! MatchOn::Url, -//! MatchOn::Method -//! ]) -//! .respond_with_body(mock_net.response().status(500).body("Mocked response"))?; +//! mock_net.on(http::Method::GET) +//! .url(url::Url::parse("https://example.com")?) +//! .respond(mock_net.response().status(200).body("")?); +//! mock_net.on(http::Method::GET) +//! .url(url::Url::parse("https://example.com/foo")?) +//! .respond(mock_net.response().status(500).body("Mocked response")?); //! # mock_net.reset(); //! # Ok(()) //! # } @@ -231,12 +224,9 @@ //! # async fn main() -> net::Result<()> { //! # let mock_net = net::mock(); //! # let client = mock_net.client(); -//! mock_net.on(client.get("https://example.com")) -//! .match_on(vec![ -//! MatchOn::Url, -//! MatchOn::Method -//! ]) -//! .respond_with_body(mock_net.response().status(200).body("Mocked response"))?; +//! mock_net.on(http::Method::GET) +//! .url(url::Url::parse("https://example.com")?) +//! .respond(mock_net.response().status(200).body("Mocked response")?); //! # mock_net.reset(); //! # Ok(()) //! # } @@ -297,7 +287,7 @@ mod system; pub use result::{Error, Result}; -pub use system::{MatchOn, MockNet, Net, OnRequest}; +pub use system::{MatchOn, MockNet, Net}; /// Creates a new `Net`. pub const fn new() -> Net { diff --git a/src/net/result.rs b/src/net/result.rs index 0742be6..eb4d962 100644 --- a/src/net/result.rs +++ b/src/net/result.rs @@ -16,6 +16,8 @@ pub enum Error { /// The cause has been converted to a String. Request(String), + Url(url::ParseError), + /// There was network request that doesn't match any that were expected #[display("Unexpected request: {0}", 0.to_string())] UnexpectedMockRequest(reqwest::Request), diff --git a/src/net/system.rs b/src/net/system.rs index 6c31bda..f81337d 100644 --- a/src/net/system.rs +++ b/src/net/system.rs @@ -1,7 +1,7 @@ // use std::cell::RefCell; -use reqwest::Client; +use reqwest::{Body, Client}; use super::{Error, Result}; @@ -27,11 +27,31 @@ pub enum MatchOn { /// A planned request and the response to return /// /// Contains a list of the criteria that a request must meet before being considered a match. -#[derive(Debug)] struct Plan { - request: reqwest::Request, + match_request: Vec, response: reqwest::Response, - match_on: Vec, +} +impl Plan { + fn matches(&self, request: &reqwest::Request) -> bool { + self.match_request.iter().all(|criteria| match criteria { + MatchRequest::Method(method) => request.method() == method, + MatchRequest::Url(uri) => request.url() == uri, + MatchRequest::Header { name, value } => { + request + .headers() + .iter() + .any(|(request_header_name, request_header_value)| { + let Ok(request_header_value) = request_header_value.to_str() else { + return false; + }; + request_header_name.as_str() == name && request_header_value == value + }) + } + MatchRequest::Body(body) => { + request.body().and_then(Body::as_bytes) == Some(body.as_bytes()) + } + }) + } } /// An abstraction for the network @@ -93,36 +113,10 @@ impl Net { return request.into().send().await.map_err(Error::from); }; let request = request.into().build()?; - let index = plans.borrow().iter().position(|plan| { - // METHOD - (if plan.match_on.contains(&MatchOn::Method) { - plan.request.method() == request.method() - } else { - true - }) - // URL - && (if plan.match_on.contains(&MatchOn::Url) { - plan.request.url() == request.url() - } else { - true - }) - // BODY - && (if plan.match_on.contains(&MatchOn::Body) { - match (plan.request.body(), request.body()) { - (None, None) => true, - (Some(plan), Some(req)) => plan.as_bytes().eq(&req.as_bytes()), - _ => false, - } - } else { - true - }) - // HEADERS - && (if plan.match_on.contains(&MatchOn::Headers) { - plan.request.headers() == request.headers() - } else { - true - }) - }); + let index = plans + .borrow() + .iter() + .position(|plan| plan.matches(&request)); match index { Some(i) => { let response = plans.borrow_mut().remove(i).response; @@ -159,8 +153,9 @@ impl TryFrom for MockNet { /// let mock_net = kxio::net::mock(); /// let client = mock_net.client(); /// // define an expected requet, and the response that should be returned -/// mock_net.on(client.get("https://hyper.rs")) -/// .respond_with_body(mock_net.response().status(200).body("Ok"))?; +/// mock_net.on(http::Method::GET) +/// .url(url::Url::parse("https://hyper.rs")?) +/// .respond(mock_net.response().status(200).body("Ok")?); /// let net: kxio::net::Net = mock_net.into(); /// // use 'net' in your program, by passing it as a reference /// @@ -197,34 +192,18 @@ impl MockNet { /// # fn run() -> Result<()> { /// let mock_net = kxio::net::mock(); /// let client = mock_net.client(); - /// mock_net.on(client.get("https://hyper.rs")) - /// .respond_with_body(mock_net.response().status(200).body("Ok"))?; + /// mock_net.on(http::Method::GET) + /// .url(url::Url::parse("https://hyper.rs")?) + /// .respond(mock_net.response().status(200).body("Ok")?); /// # Ok(()) /// # } /// ``` - pub fn on(&self, request_builder: impl Into) -> OnRequest { - match request_builder.into().build() { - Ok(request) => OnRequest::Valid { - net: self, - request, - match_on: vec![MatchOn::Method, MatchOn::Url], - }, - Err(err) => OnRequest::Error(err.into()), - } + pub fn on(&self, method: impl Into) -> WhenRequest { + WhenRequest::new(self, method) } - fn _on( - &self, - request: reqwest::Request, - response: reqwest::Response, - match_on: Vec, - ) -> Result<()> { - self.plans.borrow_mut().push(Plan { - request, - response, - match_on, - }); - Ok(()) + fn _when(&self, plan: Plan) { + self.plans.borrow_mut().push(plan); } /// Creates a [http::response::Builder] to be extended and returned by a mocked network request. @@ -276,77 +255,48 @@ impl Drop for Net { } } -/// Intermediate struct used while declaring an expected request and its response. -pub enum OnRequest<'net> { - Valid { - net: &'net MockNet, - request: reqwest::Request, - match_on: Vec, - }, - Error(super::Error), +pub enum MatchRequest { + Method(http::Method), + Url(reqwest::Url), + Header { name: String, value: String }, + Body(String), } -impl<'net> OnRequest<'net> { - /// Specify the criteria that a request should be compared against. - /// - /// Given an candidate request, it will be matched against the current request if it has the - /// same HTTP Method and Url by default. i.e. if this method is not called. - /// - /// All criteria must be met for the match to be made. - /// - /// Calling this method replaces the default criteria with the new criteria. - pub fn match_on(self, match_on: Vec) -> Self { - if let OnRequest::Valid { - net, - request, - match_on: _, - } = self - { - Self::Valid { - net, - request, - match_on, - } - } else { - self - } - } - /// Constructs the response to be returned when a request matched the criteria. - /// - /// The response will have an empty HTTP Body. - /// - /// Each request and response pair can only be matched once each. - pub fn respond(self, response: impl Into) -> Result<()> { - match self { - OnRequest::Valid { - net: _, - request: _, - match_on: _, - } => self.respond_with_body(response.into().body("")), - OnRequest::Error(error) => Err(error), - } - } +pub struct WhenRequest<'net> { + net: &'net MockNet, + match_on: Vec, +} - /// Constructs the response to be returned when a request matched the criteria. - /// - /// Each request and response pair can only be matched once each. - pub fn respond_with_body( - self, - body_result: std::result::Result, http::Error>, - ) -> Result<()> +impl<'net> WhenRequest<'net> { + pub fn url(mut self, url: impl Into) -> Self { + self.match_on.push(MatchRequest::Url(url.into())); + self + } + pub fn header(mut self, name: impl Into, value: impl Into) -> Self { + self.match_on.push(MatchRequest::Header { + name: name.into(), + value: value.into(), + }); + self + } + pub fn body(mut self, body: impl Into) -> Self { + self.match_on.push(MatchRequest::Body(body.into())); + self + } + pub fn respond(self, response: http::Response) where T: Into, { - match body_result { - Ok(response) => match self { - OnRequest::Valid { - net, - request, - match_on, - } => net._on(request, response.into(), match_on), - OnRequest::Error(error) => Err(error), - }, - Err(err) => Err(err.into()), + self.net._when(Plan { + match_request: self.match_on, + response: response.into(), + }); + } + + fn new(net: &'net MockNet, method: impl Into) -> Self { + Self { + net, + match_on: vec![MatchRequest::Method(method.into())], } } } diff --git a/tests/net.rs b/tests/net.rs index d297225..6a5e010 100644 --- a/tests/net.rs +++ b/tests/net.rs @@ -1,23 +1,29 @@ use assert2::let_assert; +use http::Method; // -use kxio::net::{Error, MatchOn, MockNet, Net}; +use kxio::net::{Error, MockNet, Net}; +use reqwest::Url; #[tokio::test] async fn test_get_url() { //given - let net = kxio::net::mock(); - let client = net.client(); + let mock_net = kxio::net::mock(); + let client = mock_net.client(); let url = "https://www.example.com"; - let request = client.get(url); - let my_response = net.response().status(200).body("Get OK"); + let my_response = mock_net + .response() + .status(200) + .body("Get OK") + .expect("body"); - net.on(request) - .respond_with_body(my_response) - .expect("on request, respond"); + mock_net + .on(Method::GET) + .url(Url::parse(url).expect("parse url")) + .respond(my_response); //when - let response = Net::from(net) + let response = Net::from(mock_net) .send(client.get(url)) .await .expect("response"); @@ -34,13 +40,16 @@ async fn test_get_wrong_url() { let client = mock_net.client(); let url = "https://www.example.com"; - let request = client.get(url); - let my_response = mock_net.response().status(200).body("Get OK"); + let my_response = mock_net + .response() + .status(200) + .body("Get OK") + .expect("body"); mock_net - .on(request) - .respond_with_body(my_response) - .expect("on request, respond"); + .on(Method::GET) + .url(Url::parse(url).expect("parse url")) + .respond(my_response); let net = Net::from(mock_net); @@ -65,12 +74,11 @@ async fn test_post_url() { let client = net.client(); let url = "https://www.example.com"; - let request = client.post(url); - let my_response = net.response().status(200).body("Post OK"); + let my_response = net.response().status(200).body("Post OK").expect("body"); - net.on(request) - .respond_with_body(my_response) - .expect("on request, respond"); + net.on(Method::POST) + .url(Url::parse(url).expect("parse url")) + .respond(my_response); //when let response = Net::from(net) @@ -89,20 +97,13 @@ async fn test_post_by_method() { let net = kxio::net::mock(); let client = net.client(); - let url = "https://www.example.com"; - let request = client.post(url); - let my_response = net.response().status(200); + let my_response = net.response().status(200).body("").expect("response body"); - net.on(request) - .match_on(vec![ - MatchOn::Method, - // MatchOn::Url - ]) - .respond(my_response) - .expect("on request, respond"); + net.on(Method::POST) + // NOTE: No URL specified - so shou∂ match any URL + .respond(my_response); //when - // This request is a different url - but should still match let response = Net::from(net) .send(client.post("https://some.other.url")) .await @@ -113,59 +114,26 @@ async fn test_post_by_method() { assert_eq!(response.bytes().await.expect("response body"), ""); } -#[tokio::test] -async fn test_post_by_url() { - //given - let net = kxio::net::mock(); - let client = net.client(); - - let url = "https://www.example.com"; - let request = client.post(url); - let my_response = net.response().status(200).body("Post OK"); - - net.on(request) - .match_on(vec![ - // MatchOn::Method, - MatchOn::Url, - ]) - .respond_with_body(my_response) - .expect("on request, respond"); - - //when - // This request is a GET, not POST - but should still match - let response = Net::from(net) - .send(client.get(url)) - .await - .expect("response"); - - //then - assert_eq!(response.status(), http::StatusCode::OK); - assert_eq!(response.bytes().await.expect("response body"), "Post OK"); -} - #[tokio::test] async fn test_post_by_body() { //given let net = kxio::net::mock(); let client = net.client(); - let url = "https://www.example.com"; - let request = client.post(url).body("match on body"); - let my_response = net.response().status(200).body("response body"); + let my_response = net + .response() + .status(200) + .body("response body") + .expect("body"); - net.on(request) - .match_on(vec![ - // MatchOn::Method, - // MatchOn::Url - MatchOn::Body, - ]) - .respond_with_body(my_response) - .expect("on request, respond"); + net.on(Method::POST) + // No URL - so any POST with a matching body + .body("match on body") + .respond(my_response); //when - // This request is a GET, not POST - but should still match let response = Net::from(net) - .send(client.get("https://some.other.url").body("match on body")) + .send(client.post("https://some.other.url").body("match on body")) .await .expect("response"); @@ -178,31 +146,27 @@ async fn test_post_by_body() { } #[tokio::test] -async fn test_post_by_headers() { +async fn test_post_by_header() { //given let net = kxio::net::mock(); let client = net.client(); - let url = "https://www.example.com"; - let request = client.post(url).body("foo").header("test", "match"); - let my_response = net.response().status(200).body("response body"); + let my_response = net + .response() + .status(200) + .body("response body") + .expect("body"); - net.on(request) - .match_on(vec![ - // MatchOn::Method, - // MatchOn::Url - MatchOn::Headers, - ]) - .respond_with_body(my_response) - .expect("on request, respond"); + net.on(Method::POST) + .header("test", "match") + .respond(my_response); //when - // This request is a GET, not POST - but should still match let response = Net::from(net) .send( client - .get("https://some.other.url") - .body("match on body") + .post("https://some.other.url") + .body("nay body") .header("test", "match"), ) .await @@ -217,20 +181,56 @@ async fn test_post_by_headers() { } #[tokio::test] -#[should_panic] -async fn test_unused_post_as_net() { +async fn test_post_by_header_wrong_value() { //given let mock_net = kxio::net::mock(); let client = mock_net.client(); - let url = "https://www.example.com"; - let request = client.post(url); - let my_response = mock_net.response().status(200).body("Post OK"); + let my_response = mock_net + .response() + .status(200) + .body("response body") + .expect("body"); mock_net - .on(request) - .respond_with_body(my_response) - .expect("on request, respond"); + .on(Method::POST) + .header("test", "match") + .respond(my_response); + let net = Net::from(mock_net); + + //when + let response = net + .send( + client + .post("https://some.other.url") + .body("nay body") + .header("test", "no match"), + ) + .await; + + //then + let_assert!(Err(kxio::net::Error::UnexpectedMockRequest(_)) = response); + + MockNet::try_from(net).expect("recover mock").reset(); +} + +#[tokio::test] +#[should_panic] +async fn test_unused_post_as_net() { + //given + let mock_net = kxio::net::mock(); + + let url = "https://www.example.com"; + let my_response = mock_net + .response() + .status(200) + .body("Post OK") + .expect("body"); + + mock_net + .on(Method::POST) + .url(Url::parse(url).expect("prase url")) + .respond(my_response); let _net = Net::from(mock_net); @@ -247,16 +247,18 @@ async fn test_unused_post_as_net() { async fn test_unused_post_as_mocknet() { //given let mock_net = kxio::net::mock(); - let client = mock_net.client(); let url = "https://www.example.com"; - let request = client.post(url); - let my_response = mock_net.response().status(200).body("Post OK"); + let my_response = mock_net + .response() + .status(200) + .body("Post OK") + .expect("body"); mock_net - .on(request) - .respond_with_body(my_response) - .expect("on request, respond"); + .on(Method::POST) + .url(Url::parse(url).expect("parse url")) + .respond(my_response); //when // don't send the planned request