From 0641752a292b1261bd3187899051b0f5f3c248ab Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Sun, 17 Nov 2024 09:18:18 +0000 Subject: [PATCH] feat(net)!: net api: net.{get,post,etc..}(url) alternative to net.send(request) --- examples/get.rs | 19 ++-- src/net/mod.rs | 5 +- src/net/result.rs | 6 +- src/net/system.rs | 269 ++++++++++++++++++++++++++++++++++++++++++---- tests/net.rs | 46 ++++---- 5 files changed, 294 insertions(+), 51 deletions(-) diff --git a/examples/get.rs b/examples/get.rs index 3cc8938..ef6dc90 100644 --- a/examples/get.rs +++ b/examples/get.rs @@ -61,10 +61,6 @@ async fn download_and_save_to_file( ) -> kxio::Result<()> { println!("fetching: {url}"); - // Uses the network abstraction to create a perfectly normal `reqwest::ResponseBuilder`. - // `kxio::net::RequestBuilder` is an alias. - let request: kxio::net::RequestBuilder = net.client().get(url); - // Rather than calling `.build().send()?` on the request, pass it to the `net` // This allows the `net` to either make the network request as normal, or, if we are // under test, to handle the request as the test dictates. @@ -72,7 +68,13 @@ async fn download_and_save_to_file( // a real network request being made, even under test conditions. Only ever use the // `net.send(...)` function to keep your code testable. // `kxio::net::Response` is an alias for `reqwest::Response`. - let response: kxio::net::Response = net.send(request).await?; + let response: kxio::net::Response = net.get(url).header("key", "value").send().await?; + // Other options: + // Uses the network abstraction to create a perfectly normal `reqwest::ResponseBuilder`. + // `kxio::net::RequestBuilder` is an alias. + // let response = net.send(net.client().get(url)).await?; + // + // let response = net.post(url).body("{}").send().await?; let body = response.text().await?; println!("fetched {} bytes", body.bytes().len()); @@ -118,7 +120,12 @@ mod tests { let url = "http://localhost:8080"; // declare what response should be made for a given request - mock_net.on().get(url).respond(StatusCode::OK).body("contents"); + mock_net + .on() + .get(url) + .respond(StatusCode::OK) + .body("contents") + .expect("valid mock"); // Create a temporary directory that will be deleted with `fs` goes out of scope let fs = kxio::fs::temp().expect("temp fs"); diff --git a/src/net/mod.rs b/src/net/mod.rs index 2337ab2..202a85c 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -84,9 +84,9 @@ //! # async fn main() -> net::Result<()> { //! # let mock_net = net::mock(); //! mock_net.on().get("https://example.com") -//! .respond().status(StatusCode::OK).body(""); +//! .respond(StatusCode::OK).body(""); //! mock_net.on().get("https://example.com/foo") -//! .respond().status(StatusCode::INTERNAL_SERVER_ERROR).body("Mocked response"); +//! .respond(StatusCode::INTERNAL_SERVER_ERROR).body("Mocked response"); //! # mock_net.reset(); //! # Ok(()) //! # } @@ -151,6 +151,7 @@ pub use system::{MockNet, Net}; pub use http::HeaderMap; pub use http::StatusCode; pub use reqwest::Client; +pub use reqwest::Error as RequestError; pub use reqwest::Request; pub use reqwest::RequestBuilder; pub use reqwest::Response; diff --git a/src/net/result.rs b/src/net/result.rs index 939a9cc..754ee5c 100644 --- a/src/net/result.rs +++ b/src/net/result.rs @@ -37,7 +37,11 @@ pub enum Error { InvalidMock(MockError), - MockResponseHasNoBody, + /// The returned response is has an error status code (i.e. 4xx or 5xx) + #[display("response error: {}", response.status())] + ResponseError { + response: reqwest::Response, + }, } impl std::error::Error for Error {} impl Clone for Error { diff --git a/src/net/system.rs b/src/net/system.rs index 014de7f..393f008 100644 --- a/src/net/system.rs +++ b/src/net/system.rs @@ -1,16 +1,23 @@ // use std::{ - cell::RefCell, collections::HashMap, marker::PhantomData, ops::Deref, rc::Rc, sync::Arc, + cell::RefCell, + collections::HashMap, + fmt::{Debug, Display}, + marker::PhantomData, + ops::Deref, + rc::Rc, + sync::Arc, }; +use bytes::Bytes; use derive_more::derive::{Display, From}; -use http::{Method, StatusCode}; -use reqwest::Client; +use http::StatusCode; +use reqwest::{Client, RequestBuilder}; use tokio::sync::Mutex; use url::Url; -use crate::net::{Request, RequestBuilder, Response}; +use crate::net::{Request, Response}; use super::{Error, Result}; @@ -28,7 +35,7 @@ struct Plan { impl Plan { fn matches(&self, request: &Request) -> bool { self.match_request.iter().all(|criteria| match criteria { - MatchRequest::Method(method) => request.method() == method, + MatchRequest::Method(method) => request.method() == http::Method::from(method), MatchRequest::Url(uri) => request.url() == uri, MatchRequest::Header { name, value } => { request @@ -47,6 +54,14 @@ impl Plan { }) } } +impl Display for Plan { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for m in &self.match_request { + write!(f, " {m}")?; + } + writeln!(f, " => {:?}", self.response) + } +} /// An abstraction for the network #[derive(Debug, Clone, Default)] @@ -70,6 +85,7 @@ impl Net { /// let client = net.client(); /// let request = client.get("https://hyper.rs"); /// ``` + #[must_use] pub fn client(&self) -> Client { Default::default() } @@ -77,10 +93,16 @@ impl Net { /// Constructs the Request and sends it to the target URL, returning a /// future Response. /// + /// However, if this request is from a [Net] that was created from a [MockNet], + /// then the request will be matched and any stored response returned, or an + /// error if no matched request was found. + /// /// # Errors /// /// This method fails if there was an error while sending request, /// redirect loop was detected or redirect limit was exhausted. + /// If the response has a Status Code of `4xx` or `5xx` then the + /// response will be returned as an [Error::ResponseError]. /// /// # Example /// @@ -98,6 +120,7 @@ impl Net { return request.into().send().await.map_err(Error::from); }; let request = request.into().build()?; + eprintln!("? {} {} {:?}", request.method(), request.url(), request.headers()); let index = plans .lock() .await @@ -107,12 +130,54 @@ impl Net { .position(|plan| plan.matches(&request)); match index { Some(i) => { - let response = plans.lock().await.borrow_mut().remove(i).response; - Ok(response) + let plan = plans.lock().await.borrow_mut().remove(i); + eprintln!("- matched: {plan}"); + let response = plan.response; + if response.status().is_success() { + Ok(response) + } else { + Err(crate::net::Error::ResponseError { response }) + } } None => Err(Error::UnexpectedMockRequest(request)), } } + + /// Starts building an http DELETE request for the URL. + #[must_use] + pub fn delete(&self, url: impl Into) -> ReqBuilder { + ReqBuilder::new(self, NetMethod::Delete, url) + } + + /// Starts building an http GET request for the URL. + #[must_use] + pub fn get(&self, url: impl Into) -> ReqBuilder { + ReqBuilder::new(self, NetMethod::Get, url) + } + + /// Starts building an http HEAD request for the URL. + #[must_use] + pub fn head(&self, url: impl Into) -> ReqBuilder { + ReqBuilder::new(self, NetMethod::Head, url) + } + + /// Starts building an http PATCH request for the URL. + #[must_use] + pub fn patch(&self, url: impl Into) -> ReqBuilder { + ReqBuilder::new(self, NetMethod::Patch, url) + } + + /// Starts building an http POST request for the URL. + #[must_use] + pub fn post(&self, url: impl Into) -> ReqBuilder { + ReqBuilder::new(self, NetMethod::Post, url) + } + + /// Starts building an http PUT request for the URL. + #[must_use] + pub fn put(&self, url: impl Into) -> ReqBuilder { + ReqBuilder::new(self, NetMethod::Put, url) + } } impl MockNet { pub async fn try_from(net: Net) -> std::result::Result { @@ -125,6 +190,120 @@ impl MockNet { } } +#[derive(Debug, Clone, Display, PartialEq, Eq)] +pub enum NetMethod { + Delete, + Get, + Head, + Patch, + Post, + Put, +} +impl From<&NetMethod> for http::Method { + fn from(value: &NetMethod) -> Self { + match value { + NetMethod::Delete => http::Method::DELETE, + NetMethod::Get => http::Method::GET, + NetMethod::Head => http::Method::HEAD, + NetMethod::Patch => http::Method::PATCH, + NetMethod::Post => http::Method::POST, + NetMethod::Put => http::Method::PUT, + } + } +} + +/// A builder for an http request. +pub struct ReqBuilder<'net> { + net: &'net Net, + url: String, + method: NetMethod, + headers: Vec<(String, String)>, + body: Option, +} +impl<'net> ReqBuilder<'net> { + #[must_use] + fn new(net: &'net Net, method: NetMethod, url: impl Into) -> Self { + Self { + net, + url: url.into(), + method, + headers: vec![], + body: None, + } + } + + /// Constructs the Request and sends it to the target URL, returning a + /// future Response. + /// + /// However, if this request is from a [Net] that was created from a [MockNet], + /// then the request will be matched and any stored response returned, or an + /// error if no matched request was found. + /// + /// # Errors + /// + /// This method fails if there was an error while sending request, + /// redirect loop was detected or redirect limit was exhausted. + /// If the response has a Status Code of `4xx` or `5xx` then the + /// response will be returned as an [Error::ResponseError]. + /// + /// # Example + /// + /// ```no_run + /// # use kxio::net::Result; + /// # async fn run() -> Result<()> { + /// let net = kxio::net::new(); + /// let response = net.get("https://hyper.rs") + /// .header("foo", "bar") + /// .body("{}") + /// .send().await?; + /// # Ok(()) + /// # } + /// ``` + pub async fn send(self) -> Result { + let client = self.net.client(); + // Method + let mut req = match self.method { + NetMethod::Delete => client.delete(self.url), + NetMethod::Get => client.get(self.url), + NetMethod::Head => client.head(self.url), + NetMethod::Patch => client.patch(self.url), + NetMethod::Post => client.post(self.url), + NetMethod::Put => client.put(self.url), + }; + // Headers + for (name, value) in self.headers.into_iter() { + req = req.header(name, value); + } + // Body + if let Some(bytes) = self.body { + req = req.body(bytes); + } + + self.net.send(req).await + } + + /// Adds the header and value to the request. + #[must_use] + pub fn header(mut self, name: impl Into, value: impl Into) -> Self { + self.headers.push((name.into(), value.into())); + self + } + + /// Adds the headers to the request. + #[must_use] + pub fn headers(mut self, headers: HashMap) -> Self { + self.headers.extend(headers); + self + } + + /// Sets the request body. + #[must_use] + pub fn body(mut self, bytes: impl Into) -> Self { + self.body = Some(bytes.into()); + self + } +} + /// A struct for defining the expected requests and their responses that should be made /// during a test. /// @@ -142,7 +321,7 @@ impl MockNet { /// let client = mock_net.client(); /// // define an expected requet, and the response that should be returned /// mock_net.on().get("https://hyper.rs") -/// .respond().status(StatusCode::OK).body("Ok"); +/// .respond(StatusCode::OK).body("Ok"); /// let net: kxio::net::Net = mock_net.into(); /// // use 'net' in your program, by passing it as a reference /// @@ -182,7 +361,7 @@ impl MockNet { /// let mock_net = kxio::net::mock(); /// let client = mock_net.client(); /// mock_net.on().get("https://hyper.rs") - /// .respond().status(StatusCode::OK).body("Ok"); + /// .respond(StatusCode::OK).body("Ok"); /// # Ok(()) /// # } /// ``` @@ -235,18 +414,36 @@ impl Drop for MockNet { impl Drop for Net { fn drop(&mut self) { if let Some(plans) = &self.plans { - assert!(plans.try_lock().expect("lock plans").take().is_empty()) + let unused = plans.try_lock().expect("lock plans").take(); + if unused.is_empty() { + return; // all good + } + eprintln!("These requests were expected, but not made:"); + for plan in unused { + eprintln!("-{plan}"); + } + panic!("There were expected requests that were not made."); } } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum MatchRequest { - Method(Method), + Method(NetMethod), Url(Url), Header { name: String, value: String }, Body(bytes::Bytes), } +impl Display for MatchRequest { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Method(method) => write!(f, "{method}"), + Self::Url(url) => write!(f, "{url}"), + Self::Header { name, value } => write!(f, "({name}: {value})"), + Self::Body(body) => write!(f, "Body: {body:?}"), + } + } +} #[derive(Debug, Clone, PartialEq, Eq)] pub enum RespondWith { @@ -293,37 +490,43 @@ impl<'net> WhenRequest<'net, WhenBuildRequest> { } } + /// Starts mocking a GET http request. #[must_use] pub fn get(self, url: impl Into) -> Self { - self._url(Method::GET, url) + self._url(NetMethod::Get, url) } + /// Starts mocking a POST http request. #[must_use] pub fn post(self, url: impl Into) -> Self { - self._url(Method::POST, url) + self._url(NetMethod::Post, url) } + /// Starts mocking a PUT http request. #[must_use] pub fn put(self, url: impl Into) -> Self { - self._url(Method::PUT, url) + self._url(NetMethod::Put, url) } + /// Starts mocking a DELETE http request. #[must_use] pub fn delete(self, url: impl Into) -> Self { - self._url(Method::DELETE, url) + self._url(NetMethod::Delete, url) } + /// Starts mocking a HEAD http request. #[must_use] pub fn head(self, url: impl Into) -> Self { - self._url(Method::HEAD, url) + self._url(NetMethod::Head, url) } + /// Starts mocking a PATCH http request. #[must_use] pub fn patch(self, url: impl Into) -> Self { - self._url(Method::PATCH, url) + self._url(NetMethod::Patch, url) } - fn _url(mut self, method: http::Method, url: impl Into) -> Self { + fn _url(mut self, method: NetMethod, url: impl Into) -> Self { self.match_on.push(MatchRequest::Method(method)); match Url::parse(&url.into()) { Ok(url) => { @@ -336,6 +539,9 @@ impl<'net> WhenRequest<'net, WhenBuildRequest> { self } + /// Specifies a header that the mock will match against. + /// + /// Any request that does not have this header will not match the mock. #[must_use] pub fn header(mut self, name: impl Into, value: impl Into) -> Self { self.match_on.push(MatchRequest::Header { @@ -345,12 +551,27 @@ impl<'net> WhenRequest<'net, WhenBuildRequest> { self } + /// Specifies headers that the mock will match against. + /// + /// Any request that does not have this header will not match the mock. + #[must_use] + pub fn headers(mut self, headers: HashMap) -> Self { + for (name, value) in headers { + self.match_on.push(MatchRequest::Header { name, value }); + } + self + } + + /// Specifies the body that the mock will match against. + /// + /// Any request that does not have this body will not match the mock. #[must_use] pub fn body(mut self, body: impl Into) -> Self { self.match_on.push(MatchRequest::Body(body.into())); self } + /// Specifies the http Status Code that will be returned for the matching request. #[must_use] pub fn respond(self, status: StatusCode) -> WhenRequest<'net, WhenBuildResponse> { WhenRequest:: { @@ -363,6 +584,7 @@ impl<'net> WhenRequest<'net, WhenBuildRequest> { } } impl<'net> WhenRequest<'net, WhenBuildResponse> { + /// Specifies a header that will be on the response sent for the matching request. #[must_use] pub fn header(mut self, name: impl Into, value: impl Into) -> Self { let name = name.into(); @@ -371,6 +593,7 @@ impl<'net> WhenRequest<'net, WhenBuildResponse> { self } + /// Specifies headers that will be on the response sent for the matching request. #[must_use] pub fn headers(mut self, headers: impl Into>) -> Self { let h: HashMap = headers.into(); @@ -380,11 +603,13 @@ impl<'net> WhenRequest<'net, WhenBuildResponse> { self } - pub fn body(mut self, body: impl Into) { + /// Specifies the body of the response sent for the matching request. + pub fn body(mut self, body: impl Into) -> Result<()> { self.respond_with.push(RespondWith::Body(body.into())); - self.mock().expect("valid mock"); + self.mock() } + /// Marks a response that has no body as complete. pub fn mock(self) -> Result<()> { if let Some(error) = self.error { return Err(crate::net::Error::InvalidMock(error)); @@ -402,9 +627,7 @@ impl<'net> WhenRequest<'net, WhenBuildResponse> { } } - let Some(body) = response_body else { - return Err(crate::net::Error::MockResponseHasNoBody); - }; + let body = response_body.unwrap_or_default(); let response = builder.body(body)?; self.net._when(Plan { match_request: self.match_on, diff --git a/tests/net.rs b/tests/net.rs index 182d128..f06aca4 100644 --- a/tests/net.rs +++ b/tests/net.rs @@ -10,23 +10,20 @@ use assert2::let_assert; async fn test_get_url() { //given let mock_net = kxio::net::mock(); - let client = mock_net.client(); let url = "https://www.example.com"; mock_net .on() - .get("https://www.example.com") + .get(url) .respond(StatusCode::OK) .header("foo", "bar") .headers(HashMap::new()) - .body("Get OK"); + .body("Get OK") + .expect("mock"); //when - let response = Net::from(mock_net) - .send(client.get(url)) - .await - .expect("response"); + let response = Net::from(mock_net).get(url).send().await.expect("response"); //then assert_eq!(response.status(), http::StatusCode::OK); @@ -44,7 +41,8 @@ async fn test_post_url() { net.on() .post(url) .respond(StatusCode::OK) - .body("post OK"); + .body("post OK") + .expect("mock"); //when let response = Net::from(net) @@ -68,7 +66,8 @@ async fn test_put_url() { net.on() .put(url) .respond(StatusCode::OK) - .body("put OK"); + .body("put OK") + .expect("mock"); //when let response = Net::from(net).send(client.put(url)).await.expect("reponse"); @@ -89,7 +88,8 @@ async fn test_delete_url() { net.on() .delete(url) .respond(StatusCode::OK) - .body("delete OK"); + .body("delete OK") + .expect("mock"); //when let response = Net::from(net) @@ -113,7 +113,8 @@ async fn test_head_url() { net.on() .head(url) .respond(StatusCode::OK) - .body("head OK"); + .body("head OK") + .expect("mock"); //when let response = Net::from(net) @@ -137,7 +138,8 @@ async fn test_patch_url() { net.on() .patch(url) .respond(StatusCode::OK) - .body("patch OK"); + .body("patch OK") + .expect("mock"); //when let response = Net::from(net) @@ -161,7 +163,8 @@ async fn test_get_wrong_url() { net.on() .get(url) .respond(StatusCode::OK) - .body("Get OK"); + .body("Get OK") + .expect("mock"); let net = Net::from(net); @@ -186,7 +189,7 @@ async fn test_post_by_method() { let client = net.client(); // NOTE: No URL specified - so should match any URL - net.on().respond(StatusCode::OK).body(""); + net.on().respond(StatusCode::OK).body("").expect("mock"); //when let response = Net::from(net) @@ -209,7 +212,8 @@ async fn test_post_by_body() { net.on() .body("match on body") .respond(StatusCode::OK) - .body("response body"); + .body("response body") + .expect("mock"); //when let response = Net::from(net) @@ -234,7 +238,8 @@ async fn test_post_by_header() { net.on() .header("test", "match") .respond(StatusCode::OK) - .body("response body"); + .body("response body") + .expect("mock"); //when let response = Net::from(net) @@ -265,7 +270,8 @@ async fn test_post_by_header_wrong_value() { .on() .header("test", "match") .respond(StatusCode::OK) - .body("response body"); + .body("response body") + .expect("mock"); let net = Net::from(mock_net); //when @@ -296,7 +302,8 @@ async fn test_unused_post_as_net() { .on() .post(url) .respond(StatusCode::OK) - .body("Post OK"); + .body("Post OK") + .expect("mock"); let _net = Net::from(mock_net); @@ -320,7 +327,8 @@ async fn test_unused_post_as_mocknet() { .on() .post(url) .respond(StatusCode::OK) - .body("Post OK"); + .body("Post OK") + .expect("mock"); //when // don't send the planned request