From ba8d85cc09447b3b93acc3e1e3427792136c3e76 Mon Sep 17 00:00:00 2001 From: Paul Campbell Date: Mon, 4 Nov 2024 10:22:31 +0000 Subject: [PATCH] feat(net)!: fluent api Closes kemitix/kxio#43 --- Cargo.toml | 21 +--- src/lib.rs | 3 + src/net/mock.rs | 77 ++++++++++++++ src/net/mod.rs | 22 ++++ src/net/result.rs | 29 ++++++ src/net/system.rs | 149 +++++++++++++++++++++++++++ tests/net.rs | 253 ++++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 538 insertions(+), 16 deletions(-) create mode 100644 src/net/mock.rs create mode 100644 src/net/mod.rs create mode 100644 src/net/result.rs create mode 100644 src/net/system.rs create mode 100644 tests/net.rs diff --git a/Cargo.toml b/Cargo.toml index 8800973..183f02a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,35 +14,24 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(tarpaulin_include)'] } # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] -# logging -tracing = "0.1" - -# network async-trait = "0.1" +derive_more = { version = "1.0", features = [ "from", "display", "constructor" ] } http = "1.1" +path-clean = "1.0" reqwest = "0.12" secrecy = "0.10" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" serde-xml-rs = "0.6" -thiserror = "2.0" - -# fs tempfile = "3.10" -path-clean = "1.0" - -# boilerplate -derive_more = { version = "1.0.0-beta", features = [ - "from", - "display", - "constructor", -] } +thiserror = "2.0" +tracing = "0.1" [dev-dependencies] -# testing assert2 = "0.3" pretty_assertions = "1.4" test-log = "0.2" +tokio = { version = "1.41", features = ["macros"] } tokio-test = "0.4" [package.metadata.bin] diff --git a/src/lib.rs b/src/lib.rs index ac1f34b..82273b6 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,6 @@ // pub mod fs; +pub mod net; + +#[deprecated] pub mod network; diff --git a/src/net/mock.rs b/src/net/mock.rs new file mode 100644 index 0000000..d9e9861 --- /dev/null +++ b/src/net/mock.rs @@ -0,0 +1,77 @@ +// +use super::{Error, Net, Result}; + +#[derive(Debug)] +struct Plan { + request: reqwest::Request, + response: reqwest::Response, +} + +#[derive(Default, Debug)] +pub struct MockNet { + plans: Vec, +} +impl MockNet { + pub(crate) fn new() -> Self { + Self::default() + } + + pub fn into_net(self) -> Net { + Net::mock(self) + } + + pub fn client(&self) -> reqwest::Client { + reqwest::Client::new() + } + + pub fn response(&self) -> http::response::Builder { + http::Response::builder() + } + + pub fn on(&mut self, request: reqwest::Request) -> OnRequest { + OnRequest { + mock: self, + request, + } + } + + fn _on(&mut self, request: reqwest::Request, response: reqwest::Response) { + self.plans.push(Plan { request, response }) + } + + pub(crate) async fn send( + &mut self, + request: reqwest::RequestBuilder, + ) -> Result { + let request = request.build()?; + let index = self.plans.iter().position(|plan| { + // TODO: add support or only matching on selected criteria + plan.request.method() == request.method() + && plan.request.url() == request.url() + && match (plan.request.body(), request.body()) { + (None, None) => true, + (Some(plan), Some(request)) => plan.as_bytes() == request.as_bytes(), + _ => false, + } + && plan.request.headers() == request.headers() + }); + match index { + Some(i) => Ok(self.plans.remove(i).response), + None => Err(Error::UnexpectedMockRequest(request)), + } + } + + pub fn assert(&self) -> Result<()> { + todo!() + } +} + +pub struct OnRequest<'mock> { + mock: &'mock mut MockNet, + request: reqwest::Request, +} +impl<'mock> OnRequest<'mock> { + pub fn response(self, response: reqwest::Response) { + self.mock._on(self.request, response) + } +} diff --git a/src/net/mod.rs b/src/net/mod.rs new file mode 100644 index 0000000..a1d2f29 --- /dev/null +++ b/src/net/mod.rs @@ -0,0 +1,22 @@ +//! Provides a generic interface for network operations. +//! +//! + +mod system; +mod result; + +pub use result::{Error, Result}; + +pub use system::{MatchOn, Net}; +use system::{Mocked, Unmocked}; + + +/// Creates a new `Net`. +pub const fn new() -> Net { + Net::::new() +} + +/// Creates a new `MockNet` for use in tests. +pub fn mock() -> Net { + Net::::new() +} diff --git a/src/net/result.rs b/src/net/result.rs new file mode 100644 index 0000000..205f3b6 --- /dev/null +++ b/src/net/result.rs @@ -0,0 +1,29 @@ +// + +use derive_more::derive::From; + +/// Represents a error accessing the network. +#[derive(Debug, From, derive_more::Display)] +pub enum Error { + Reqwest(reqwest::Error), + Request(String), + #[display("Unexpected request: {0}", 0.to_string())] + UnexpectedMockRequest(reqwest::Request), +} +impl std::error::Error for Error {} +impl Clone for Error { + fn clone(&self) -> Self { + match self { + Self::Reqwest(req) => Self::Request(req.to_string()), + Self::Request(req) => Self::Request(req.clone()), + Self::UnexpectedMockRequest(_) => todo!(), + } + } +} + +/// Represents a success or a failure. +/// +/// Any failure is related to `std::io`, a Path Traversal +/// (i.e. trying to escape the base of the `FileSystem`), +/// or attempting to use a file as a directory or /vise versa/. +pub type Result = core::result::Result; diff --git a/src/net/system.rs b/src/net/system.rs new file mode 100644 index 0000000..6c465b1 --- /dev/null +++ b/src/net/system.rs @@ -0,0 +1,149 @@ +// +use std::marker::PhantomData; + +use super::{Error, Result}; + +pub trait NetType {} + +pub struct Mocked; +impl NetType for Mocked {} +pub struct Unmocked; +impl NetType for Unmocked {} + +type Plans = Vec; + +#[derive(Debug, PartialEq, Eq)] +pub enum MatchOn { + Method, + Url, + Body, + Headers, +} + +#[derive(Debug)] +pub struct Plan { + request: reqwest::Request, + response: reqwest::Response, + match_on: Vec, +} + +pub struct Net { + _type: PhantomData, + plans: Plans, +} +impl Net { + pub(crate) const fn new() -> Self { + Self { + _type: PhantomData, + plans: vec![], + } + } + + pub async fn send(&mut self, request: reqwest::RequestBuilder) -> Result { + request.send().await.map_err(Error::from) + } +} + +impl Net { + pub fn client(&self) -> reqwest::Client { + Default::default() + } +} +impl Net { + pub(crate) const fn new() -> Self { + Self { + _type: PhantomData, + plans: vec![], + } + } + pub async fn send(&mut self, request: reqwest::RequestBuilder) -> Result { + let request = request.build()?; + let index = self.plans.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 + }) + }); + match index { + Some(i) => Ok(self.plans.remove(i).response), + None => Err(Error::UnexpectedMockRequest(request)), + } + } + + /// Creates a [ResponseBuilder] to be extended and returned by a mocked network request. + pub fn response(&self) -> http::response::Builder { + Default::default() + } + + pub fn on(&mut self, request: reqwest::Request) -> OnRequest { + OnRequest { + net: self, + request, + match_on: vec![MatchOn::Method, MatchOn::Url], + } + } + + fn _on( + &mut self, + request: reqwest::Request, + response: reqwest::Response, + match_on: Vec, + ) { + self.plans.push(Plan { + request, + response, + match_on, + }) + } + + pub fn reset(&mut self) { + self.plans = vec![]; + } +} +impl Drop for Net { + fn drop(&mut self) { + assert!(self.plans.is_empty()) + } +} + +pub struct OnRequest<'net> { + net: &'net mut Net, + request: reqwest::Request, + match_on: Vec, +} +impl<'net> OnRequest<'net> { + pub fn match_on(self, match_on: Vec) -> Self { + Self { + net: self.net, + request: self.request, + match_on, + } + } + pub fn respond(self, response: reqwest::Response) { + self.net._on(self.request, response, self.match_on) + } +} diff --git a/tests/net.rs b/tests/net.rs new file mode 100644 index 0000000..993929b --- /dev/null +++ b/tests/net.rs @@ -0,0 +1,253 @@ +use assert2::let_assert; +// +use kxio::net::{Error, MatchOn}; + +#[tokio::test] +async fn test_get_url() { + //given + let mut net = kxio::net::mock(); + let client = net.client(); + + let url = "https://www.example.com"; + let request = client.get(url).build().expect("build request"); + let my_response = net + .response() + .status(200) + .body("Get OK") + .expect("request body"); + + net.on(request).respond(my_response.into()); + + //when + let response = net.send(client.get(url)).await.expect("response"); + + //then + assert_eq!(response.status(), http::StatusCode::OK); + assert_eq!(response.bytes().await.expect("response body"), "Get OK"); +} + +#[tokio::test] +async fn test_get_wrong_url() { + //given + let mut net = kxio::net::mock(); + let client = net.client(); + + let url = "https://www.example.com"; + let request = client.get(url).build().expect("build request"); + let my_response = net + .response() + .status(200) + .body("Get OK") + .expect("request body"); + + net.on(request).respond(my_response.into()); + + //when + let_assert!(Err(Error::UnexpectedMockRequest(invalid_request)) = net.send(client.get("https://some.other.url/")).await); + + //then + assert_eq!(invalid_request.url().to_string(), "https://some.other.url/"); + + // remove pending unmatched request - we never meant to match against it + net.reset(); +} + +#[tokio::test] +async fn test_post_url() { + //given + let mut net = kxio::net::mock(); + let client = net.client(); + + let url = "https://www.example.com"; + let request = client.post(url).build().expect("build request"); + let my_response = net + .response() + .status(200) + .body("Post OK") + .expect("request body"); + + net.on(request).respond(my_response.into()); + + //when + let response = net.send(client.post(url)).await.expect("reponse"); + + //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_method() { + //given + let mut net = kxio::net::mock(); + let client = net.client(); + + let url = "https://www.example.com"; + let request = client.post(url).build().expect("build request"); + let my_response = net + .response() + .status(200) + .body("Post OK") + .expect("request body"); + + net.on(request) + .match_on(vec![ + MatchOn::Method, + // MatchOn::Url + ]) + .respond(my_response.into()); + + //when + // This request is a different url - but should still match + let response = net + .send(client.post("https://some.other.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_url() { + //given + let mut net = kxio::net::mock(); + let client = net.client(); + + let url = "https://www.example.com"; + let request = client.post(url).build().expect("build request"); + let my_response = net + .response() + .status(200) + .body("Post OK") + .expect("request body"); + + net.on(request) + .match_on(vec![ + // MatchOn::Method, + MatchOn::Url, + ]) + .respond(my_response.into()); + + //when + // This request is a GET, not POST - but should still match + let response = 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 mut net = kxio::net::mock(); + let client = net.client(); + + let url = "https://www.example.com"; + let request = client + .post(url) + .body("match on body") + .build() + .expect("build request"); + let my_response = net + .response() + .status(200) + .body("response body") + .expect("request body"); + + net.on(request) + .match_on(vec![ + // MatchOn::Method, + // MatchOn::Url + MatchOn::Body, + ]) + .respond(my_response.into()); + + //when + // This request is a GET, not POST - but should still match + let response = net + .send(client.get("https://some.other.url").body("match on body")) + .await + .expect("response"); + + //then + assert_eq!(response.status(), http::StatusCode::OK); + assert_eq!( + response.bytes().await.expect("response body"), + "response body" + ); +} + +#[tokio::test] +async fn test_post_by_headers() { + //given + let mut net = kxio::net::mock(); + let client = net.client(); + + let url = "https://www.example.com"; + let request = client + .post(url) + .body("foo") + .header("test", "match") + .build() + .expect("build request"); + let my_response = net + .response() + .status(200) + .body("response body") + .expect("request body"); + + net.on(request) + .match_on(vec![ + // MatchOn::Method, + // MatchOn::Url + MatchOn::Headers, + ]) + .respond(my_response.into()); + + //when + // This request is a GET, not POST - but should still match + let response = net + .send( + client + .get("https://some.other.url") + .body("match on body") + .header("test", "match"), + ) + .await + .expect("response"); + + //then + assert_eq!(response.status(), http::StatusCode::OK); + assert_eq!( + response.bytes().await.expect("response body"), + "response body" + ); +} + +#[tokio::test] +#[should_panic] +async fn test_unused_post() { + //given + let mut net = kxio::net::mock(); + let client = net.client(); + + let url = "https://www.example.com"; + let request = client.post(url).build().expect("build request"); + let my_response = net + .response() + .status(200) + .body("Post OK") + .expect("request body"); + + net.on(request).respond(my_response.into()); + + //when + // don't send the planned request + // let _response = net.send(client.post(url)).await.expect("send"); + + //then + // Drop implementation for net should panic +}