From fab5c1ba11c261949b66539152d8021a6c13b95a 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 | 15 ++- src/net/mod.rs | 1 + src/net/result.rs | 6 +- src/net/system.rs | 316 ++++++++++++++++++++++++++++++++++++++++++---- tests/net.rs | 61 ++++++--- 5 files changed, 351 insertions(+), 48 deletions(-) diff --git a/examples/get.rs b/examples/get.rs index 8899ece..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()); @@ -122,7 +124,8 @@ mod tests { .on() .get(url) .respond(StatusCode::OK) - .body("contents"); + .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 8de68d0..e2da8e4 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -152,6 +152,7 @@ pub use http::HeaderMap; pub use http::Method; 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 67e8108..f1d1dd0 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,12 @@ 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 +135,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 +195,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. /// @@ -229,24 +413,50 @@ impl From for Net { impl Drop for MockNet { fn drop(&mut self) { - assert!(self.plans.borrow().is_empty()) + let unused = self.plans.take(); + if unused.is_empty() { + return; // all good + } + panic_with_unused_plans(unused); } } 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 + } + panic_with_unused_plans(unused); } } } +fn panic_with_unused_plans(unused: Vec) { + 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 +503,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 +552,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 +564,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 +597,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 +606,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 +616,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 +640,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, @@ -428,4 +664,40 @@ mod tests { is_normal::(); // is_normal::(); // only used in test setup - no need to be Send or Sync } + + #[test] + fn plan_display() { + let plan = Plan { + match_request: vec![ + MatchRequest::Method(NetMethod::Put), + MatchRequest::Header { + name: "alpha".into(), + value: "1".into(), + }, + MatchRequest::Body("req body".into()), + ], + response: http::response::Builder::default() + .status(204) + .header("foo", "bar") + .header("baz", "buck") + .body("contents") + .expect("body") + .into(), + }; + let result = plan.to_string(); + + let expected = [ + "Put", + "(alpha: 1)", + "Body: b\"req body\"", + "=>", + "Response {", + "url: \"http://no.url.provided.local/\",", + "status: 204,", + "headers: {\"foo\": \"bar\", \"baz\": \"buck\"}", + "}\n", + ] + .join(" "); + assert_eq!(result, expected); + } } diff --git a/tests/net.rs b/tests/net.rs index 0d5821b..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); @@ -41,7 +38,11 @@ async fn test_post_url() { let url = "https://www.example.com"; - net.on().post(url).respond(StatusCode::OK).body("post OK"); + net.on() + .post(url) + .respond(StatusCode::OK) + .body("post OK") + .expect("mock"); //when let response = Net::from(net) @@ -62,7 +63,11 @@ async fn test_put_url() { let url = "https://www.example.com"; - net.on().put(url).respond(StatusCode::OK).body("put OK"); + net.on() + .put(url) + .respond(StatusCode::OK) + .body("put OK") + .expect("mock"); //when let response = Net::from(net).send(client.put(url)).await.expect("reponse"); @@ -83,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) @@ -104,7 +110,11 @@ async fn test_head_url() { let url = "https://www.example.com"; - net.on().head(url).respond(StatusCode::OK).body("head OK"); + net.on() + .head(url) + .respond(StatusCode::OK) + .body("head OK") + .expect("mock"); //when let response = Net::from(net) @@ -125,7 +135,11 @@ async fn test_patch_url() { let url = "https://www.example.com"; - net.on().patch(url).respond(StatusCode::OK).body("patch OK"); + net.on() + .patch(url) + .respond(StatusCode::OK) + .body("patch OK") + .expect("mock"); //when let response = Net::from(net) @@ -146,7 +160,11 @@ async fn test_get_wrong_url() { let url = "https://www.example.com"; - net.on().get(url).respond(StatusCode::OK).body("Get OK"); + net.on() + .get(url) + .respond(StatusCode::OK) + .body("Get OK") + .expect("mock"); let net = Net::from(net); @@ -171,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) @@ -194,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) @@ -219,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) @@ -250,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 @@ -281,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); @@ -305,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