feat(net)!: fluent api

Closes kemitix/kxio#43
This commit is contained in:
Paul Campbell 2024-11-04 10:22:31 +00:00
parent 4bd3d5a91d
commit cd4d25d125
7 changed files with 538 additions and 16 deletions

View file

@ -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 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
# logging
tracing = "0.1"
# network
async-trait = "0.1" async-trait = "0.1"
derive_more = { version = "1.0", features = [ "from", "display", "constructor" ] }
http = "1.1" http = "1.1"
path-clean = "1.0"
reqwest = "0.12" reqwest = "0.12"
secrecy = "0.10" secrecy = "0.10"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
serde-xml-rs = "0.6" serde-xml-rs = "0.6"
thiserror = "2.0"
# fs
tempfile = "3.10" tempfile = "3.10"
path-clean = "1.0" thiserror = "2.0"
tracing = "0.1"
# boilerplate
derive_more = { version = "1.0.0-beta", features = [
"from",
"display",
"constructor",
] }
[dev-dependencies] [dev-dependencies]
# testing
assert2 = "0.3" assert2 = "0.3"
pretty_assertions = "1.4" pretty_assertions = "1.4"
test-log = "0.2" test-log = "0.2"
tokio = { version = "1.41", features = ["macros"] }
tokio-test = "0.4" tokio-test = "0.4"
[package.metadata.bin] [package.metadata.bin]

View file

@ -1,3 +1,6 @@
// //
pub mod fs; pub mod fs;
pub mod net;
#[deprecated]
pub mod network; pub mod network;

77
src/net/mock.rs Normal file
View file

@ -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<Plan>,
}
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<reqwest::Response> {
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)
}
}

22
src/net/mod.rs Normal file
View file

@ -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<Unmocked> {
Net::<Unmocked>::new()
}
/// Creates a new `MockNet` for use in tests.
pub fn mock() -> Net<Mocked> {
Net::<Mocked>::new()
}

29
src/net/result.rs Normal file
View file

@ -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<T> = core::result::Result<T, Error>;

149
src/net/system.rs Normal file
View file

@ -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<Plan>;
#[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<MatchOn>,
}
pub struct Net<T: NetType> {
_type: PhantomData<T>,
plans: Plans,
}
impl Net<Unmocked> {
pub(crate) const fn new() -> Self {
Self {
_type: PhantomData,
plans: vec![],
}
}
pub async fn send(&mut self, request: reqwest::RequestBuilder) -> Result<reqwest::Response> {
request.send().await.map_err(Error::from)
}
}
impl<T: NetType> Net<T> {
pub fn client(&self) -> reqwest::Client {
Default::default()
}
}
impl Net<Mocked> {
pub(crate) const fn new() -> Self {
Self {
_type: PhantomData,
plans: vec![],
}
}
pub async fn send(&mut self, request: reqwest::RequestBuilder) -> Result<reqwest::Response> {
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<MatchOn>,
) {
self.plans.push(Plan {
request,
response,
match_on,
})
}
pub fn reset(&mut self) {
self.plans = vec![];
}
}
impl<T: NetType> Drop for Net<T> {
fn drop(&mut self) {
assert!(self.plans.is_empty())
}
}
pub struct OnRequest<'net> {
net: &'net mut Net<Mocked>,
request: reqwest::Request,
match_on: Vec<MatchOn>,
}
impl<'net> OnRequest<'net> {
pub fn match_on(self, match_on: Vec<MatchOn>) -> 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)
}
}

253
tests/net.rs Normal file
View file

@ -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
}