diff --git a/Cargo.toml b/Cargo.toml index 19a9ab2..373498f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,9 +9,26 @@ edition = "2021" # logging tracing = "0.1" +# network +async-trait = "0.1" +http = "0.2" +reqwest = "0.11" +secrecy = "0.8" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +serde-xml-rs = "0.6" +thiserror = "1.0" + # fs tempfile = "3.10" +[dev-dependencies] +# testing +assert2 = "0.3" +pretty_assertions = "1.4" +test-log = "0.2" +tokio-test = "0.4" + [package.metadata.bin] # Conventional commits githook cc-cli = { version = "0.1" } diff --git a/src/filesystem.rs b/src/filesystem.rs new file mode 100644 index 0000000..2170b45 --- /dev/null +++ b/src/filesystem.rs @@ -0,0 +1,151 @@ +#![allow(unused)] + +use std::{ + ops::Deref, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use tempfile::{tempdir, TempDir}; +use tracing::info; + +#[derive(Clone, Debug)] +pub enum FileSystem { + Real(RealFileSystemEnv), + Temp(TempFileSystemEnv), +} +impl FileSystem { + pub fn new_real(cwd: Option) -> Self { + let cwd = cwd.unwrap_or_default(); + Self::Real(RealFileSystemEnv::new(cwd)) + } + pub fn new_temp() -> std::io::Result { + TempFileSystemEnv::new().map(Self::Temp) + } +} +impl Deref for FileSystem { + type Target = dyn FileSystemEnv; + + fn deref(&self) -> &Self::Target { + match self { + Self::Real(env) => env, + Self::Temp(env) => env, + } + } +} + +pub trait FileSystemEnv: Sync + Send + std::fmt::Debug { + fn cwd(&self) -> &PathBuf; + + fn in_cwd(&self, name: &str) -> PathBuf { + self.cwd().join(name) + } + + fn write_file(&self, file_name: &str, content: &str) -> std::io::Result { + use std::fs::File; + use std::io::{LineWriter, Write}; + + let path = self.in_cwd(file_name); + info!("writing to {:?}", path); + let file = File::create(path.clone())?; + let mut file = LineWriter::new(file); + file.write_all(content.as_bytes())?; + Ok(path) + } + + fn file_exists(&self, name: &PathBuf) -> bool { + use std::fs::File; + File::open(name).is_ok() + } + + fn read_file(&self, file_name: &str) -> std::io::Result { + use std::fs::File; + use std::io::Read; + + let path = self.in_cwd(file_name); + info!("reading from {:?}", path); + let mut file = File::open(path)?; + let mut content = String::new(); + file.read_to_string(&mut content)?; + Ok(content) + } +} + +#[derive(Clone, Debug, Default)] +pub struct RealFileSystemEnv { + cwd: PathBuf, +} + +#[derive(Clone, Debug)] +pub struct TempFileSystemEnv { + cwd: PathBuf, + temp_dir: Arc>, +} + +impl FileSystemEnv for TempFileSystemEnv { + fn cwd(&self) -> &PathBuf { + &self.cwd + } +} + +impl FileSystemEnv for RealFileSystemEnv { + fn cwd(&self) -> &PathBuf { + &self.cwd + } +} + +impl RealFileSystemEnv { + const fn new(cwd: PathBuf) -> Self { + Self { cwd } + } +} + +impl TempFileSystemEnv { + fn new() -> std::io::Result { + let temp_dir = tempdir()?; + info!("temp dir: {:?}", temp_dir.path()); + let cwd = temp_dir.path().to_path_buf(); + let temp_dir = Arc::new(Mutex::new(temp_dir)); + Ok(Self { cwd, temp_dir }) + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + use std::path::PathBuf; + + #[test_log::test] + fn test_cwd() { + let cwd = PathBuf::from("/tmp"); + let env = RealFileSystemEnv::new(cwd.clone()); + assert_eq!(env.cwd(), &cwd); + } + + #[test_log::test] + fn test_create_on_temp_fs() -> std::io::Result<()> { + let env = TempFileSystemEnv::new()?; + assert!(env.cwd().exists()); + Ok(()) + } + + #[test_log::test] + fn test_create_on_real_fs() { + let cwd = PathBuf::from("/tmp"); + let env = RealFileSystemEnv::new(cwd.clone()); + assert_eq!(env.cwd(), &cwd); + } + + #[test_log::test] + fn test_write_and_read_file() -> std::io::Result<()> { + let env = TempFileSystemEnv::new()?; + let file_name = "test.txt"; + let content = "Hello, World!"; + let path = env.write_file(file_name, content)?; + assert_eq!(env.read_file(file_name)?, content); + assert!(path.exists()); + Ok(()) + } +} diff --git a/src/lib.rs b/src/lib.rs index e7a11a9..b4cb65a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,2 @@ -fn main() { - println!("Hello, world!"); -} +pub mod filesystem; +pub mod network; diff --git a/src/network/mock.rs b/src/network/mock.rs new file mode 100644 index 0000000..2416ef1 --- /dev/null +++ b/src/network/mock.rs @@ -0,0 +1,795 @@ +#![cfg(not(tarpaulin_include))] + +use serde::de::DeserializeOwned; +use tracing::{event, Level}; + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use crate::network::StatusCode; + +use super::network_env::NetworkTrait; +use super::{ + NetRequest, NetResponse, NetUrl, Network, NetworkError, RequestBody, RequestMethod, + ResponseType, SavedRequest, +}; + +#[derive(Debug, Clone)] +pub struct MockNetwork { + requests: Arc>>, + get_responses: HashMap, + get_errors: HashMap, + post_responses: HashMap, + post_errors: HashMap, + put_responses: HashMap, + put_errors: HashMap, + patch_responses: HashMap, + patch_errors: HashMap, + delete_responses: HashMap, + delete_errors: HashMap, + propfind_responses: HashMap, + propfind_errors: HashMap, +} +impl MockNetwork { + pub fn new() -> Self { + Self { + requests: Arc::new(Mutex::new(Vec::new())), + get_responses: HashMap::new(), + get_errors: HashMap::new(), + post_responses: HashMap::new(), + post_errors: HashMap::new(), + put_responses: HashMap::new(), + put_errors: HashMap::new(), + patch_responses: HashMap::new(), + patch_errors: HashMap::new(), + delete_responses: HashMap::new(), + delete_errors: HashMap::new(), + propfind_responses: HashMap::new(), + propfind_errors: HashMap::new(), + } + } + pub fn requests(&self) -> Vec { + unsafe { self.requests.lock().unwrap_unchecked().clone() } + } + pub fn add_get_response(&mut self, url: &str, status: StatusCode, body: &str) { + self.get_responses + .insert(NetUrl::new(url.to_string()), (status, body.to_string())); + } + pub fn add_get_error(&mut self, url: &str, error: &str) { + self.get_errors + .insert(NetUrl::new(url.to_string()), error.to_string()); + } + pub fn add_post_response(&mut self, url: &str, status: StatusCode, body: &str) { + self.post_responses + .insert(NetUrl::new(url.to_string()), (status, body.to_string())); + } + pub fn add_post_error(&mut self, url: &str, error: &str) { + self.post_errors + .insert(NetUrl::new(url.to_string()), error.to_string()); + } + pub fn add_put_response(&mut self, url: &str, status: StatusCode, body: &str) { + self.put_responses + .insert(NetUrl::new(url.to_string()), (status, body.to_string())); + } + pub fn add_put_error(&mut self, url: &str, error: &str) { + self.put_errors + .insert(NetUrl::new(url.to_string()), error.to_string()); + } + pub fn add_patch_response(&mut self, url: &str, status: StatusCode, body: &str) { + self.patch_responses + .insert(NetUrl::new(url.to_string()), (status, body.to_string())); + } + pub fn add_patch_error(&mut self, url: &str, error: &str) { + self.patch_errors + .insert(NetUrl::new(url.to_string()), error.to_string()); + } + pub fn add_delete_response(&mut self, url: &str, status: StatusCode, body: &str) { + self.delete_responses + .insert(NetUrl::new(url.to_string()), (status, body.to_string())); + } + pub fn add_delete_error(&mut self, url: &str, error: &str) { + self.delete_errors + .insert(NetUrl::new(url.to_string()), error.to_string()); + } + pub fn add_propfind_response(&mut self, url: &str, status: StatusCode, body: &str) { + self.propfind_responses + .insert(NetUrl::new(url.to_string()), (status, body.to_string())); + } + pub fn add_propfind_error(&mut self, url: &str, error: &str) { + self.propfind_errors + .insert(NetUrl::new(url.to_string()), error.to_string()); + } + fn save_request(&self, method: RequestMethod, url: &str, body: RequestBody) { + unsafe { + self.requests + .lock() + .unwrap_unchecked() + .push(SavedRequest::new(method, url, body)); + } + } + + #[tracing::instrument(skip_all)] + fn call( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + tracing::info!("MockNetworkEnv::call({:?})", net_request); + let method = net_request.method(); + let url = net_request.url(); + let body = net_request.body(); + let response_type = net_request.response_type(); + self.save_request(method, url, body.clone()); + let errors = match method { + RequestMethod::Get => &self.get_errors, + RequestMethod::Post => &self.post_errors, + RequestMethod::Put => &self.put_errors, + RequestMethod::Patch => &self.patch_errors, + RequestMethod::Propfind => &self.propfind_errors, + RequestMethod::Delete => &self.delete_errors, + }; + if let Some(error) = errors.get(url) { + event!( + Level::INFO, + "MockNetworkEnv::{}({}) -> error: {}", + method, + **url, + error + ); + Err(NetworkError::RequestError( + method, + StatusCode::INTERNAL_SERVER_ERROR, + url.clone(), + )) + } else { + let responses = match method { + RequestMethod::Get => &self.get_responses, + RequestMethod::Post => &self.post_responses, + RequestMethod::Put => &self.put_responses, + RequestMethod::Patch => &self.patch_responses, + RequestMethod::Propfind => &self.propfind_responses, + RequestMethod::Delete => &self.delete_responses, + }; + let (status, response) = responses.get(url).ok_or_else(|| { + tracing::error!(?method, ?url, "unexpected request"); + NetworkError::MockError(method, StatusCode::NOT_IMPLEMENTED, url.clone()) + })?; + if status.is_client_error() || status.is_server_error() { + event!( + Level::INFO, + "MockNetworkEnv::{}({}) -> error: {}", + method, + url, + response + ); + Err(NetworkError::RequestError(method, *status, url.clone())) + } else { + event!( + Level::INFO, + "MockNetworkEnv::{}({}) -> response: {}", + method, + url, + response + ); + let response_body: Option = match response_type { + ResponseType::Json => Some(serde_json::from_str(response)?), + ResponseType::Xml => Some(serde_xml_rs::from_str(response)?), + ResponseType::None => None, + ResponseType::Text => { + // use call_string() instead + Err(NetworkError::UnexpectedMockRequest { + method, + request_url: url.clone(), + })? + } + }; + Ok(NetResponse::new(method, url, *status, response_body)) + } + } + } + + fn call_string(&self, net_request: NetRequest) -> Result, NetworkError> { + let method = net_request.method(); + let url = net_request.url(); + let body = net_request.body(); + + match body { + RequestBody::String(_) => Ok(()), + RequestBody::None => Ok(()), + _ => Err(NetworkError::InvalidRequestBody), + }?; + + self.save_request(method, url, body.clone()); + event!(Level::INFO, "MockNetworkEnv::{}({})", method, url); + self.check_for_error(method, url).map_or_else( + || self.as_response(method, url, &net_request), + |error| as_server_error(method, url, error), + ) + } + + fn as_response( + &self, + method: RequestMethod, + url: &NetUrl, + net_request: &NetRequest, + ) -> Result, NetworkError> { + event!(Level::INFO, "url: {}", url); + let (status, response) = self.check_for_response(method, url).ok_or_else(|| { + NetworkError::MockError(method, StatusCode::NOT_IMPLEMENTED, url.clone()) + })?; + event!(Level::INFO, "status: {:?}", status); + let response_body = match net_request.response_type() { + ResponseType::None => None, + _ => { + let response_body: String = response.to_string(); + event!(Level::INFO, "response_body: {:?}", response_body); + Some(response_body) + } + }; + Ok(NetResponse::new(method, url, *status, response_body)) + } + + fn check_for_response( + &self, + method: RequestMethod, + url: &NetUrl, + ) -> Option<&(StatusCode, String)> { + (match method { + RequestMethod::Get => &self.get_responses, + RequestMethod::Post => &self.post_responses, + RequestMethod::Put => &self.put_responses, + RequestMethod::Patch => &self.patch_responses, + RequestMethod::Propfind => &self.propfind_responses, + RequestMethod::Delete => &self.delete_responses, + }) + .get(url) + } + + fn check_for_error(&self, method: RequestMethod, url: &NetUrl) -> Option<&String> { + (match method { + RequestMethod::Get => &self.get_errors, + RequestMethod::Post => &self.post_errors, + RequestMethod::Put => &self.put_errors, + RequestMethod::Patch => &self.patch_errors, + RequestMethod::Propfind => &self.propfind_errors, + RequestMethod::Delete => &self.delete_errors, + }) + .get(url) + } +} + +fn as_server_error( + method: RequestMethod, + url: &NetUrl, + error: &String, +) -> Result, NetworkError> { + event!( + Level::INFO, + "MockNetworkEnv::{}({}) -> error: {}", + method, + url, + error + ); + Err(NetworkError::RequestError( + method, + StatusCode::INTERNAL_SERVER_ERROR, + url.clone(), + )) +} +impl Default for MockNetwork { + fn default() -> Self { + Self::new() + } +} +impl From for Network { + fn from(mock: MockNetwork) -> Self { + Self::Mock(mock) + } +} + +#[async_trait::async_trait] +impl NetworkTrait for MockNetwork { + async fn get( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + assert_eq!( + net_request.method(), + RequestMethod::Get, + "get method must be RequestMethod::Get" + ); + self.call(net_request) + } + + async fn get_string( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + assert_eq!( + net_request.method(), + RequestMethod::Get, + "get_string method must be RequestMethod::Get" + ); + assert_eq!( + net_request.response_type(), + ResponseType::Text, + "get_string response_type must be ResponseType::Text" + ); + self.call_string(net_request) + } + + async fn post_json( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + assert_eq!( + net_request.method(), + RequestMethod::Post, + "post method must be RequestMethod::Post" + ); + assert!( + matches!(net_request.body(), RequestBody::Json(_)), + "request body must be RequestBody::Json" + ); + self.call(net_request) + } + + async fn post_string( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + assert_eq!( + net_request.method(), + RequestMethod::Post, + "post_string method must be RequestMethod::Post" + ); + assert_eq!( + net_request.response_type(), + ResponseType::Text, + "post_string response_type must be ResponseType::Text" + ); + self.call_string(net_request) + } + + async fn put_json( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + assert_eq!( + net_request.method(), + RequestMethod::Put, + "put method must be RequestMethod::Put" + ); + assert!( + matches!(net_request.body(), RequestBody::Json(_)), + "request body must be RequestBody::Json" + ); + self.call(net_request) + } + + async fn put_string( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + assert_eq!( + net_request.method(), + RequestMethod::Put, + "put_string method must be RequestMethod::Put" + ); + assert_eq!( + net_request.response_type(), + ResponseType::Text, + "put_string response_type must be ResponseType::Text" + ); + self.call_string(net_request) + } + + async fn patch_json( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + assert_eq!( + net_request.method(), + RequestMethod::Patch, + "patch method must be RequestMethod::Patch" + ); + assert!( + matches!(net_request.body(), RequestBody::Json(_)), + "request body must be RequestBody::Json" + ); + self.call(net_request) + } + + #[tracing::instrument(skip(self))] + async fn delete(&self, net_request: NetRequest) -> Result, NetworkError> { + assert_eq!( + net_request.method(), + RequestMethod::Delete, + "delete method must be RequestMethod::Delete" + ); + assert_eq!( + net_request.response_type(), + ResponseType::None, + "delete response_type must be ResponseType::None" + ); + self.call(net_request) + } + + async fn propfind( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + assert_eq!( + net_request.method(), + RequestMethod::Propfind, + "propfind method must be RequestMethod::Propfind" + ); + assert_eq!( + net_request.response_type(), + ResponseType::Xml, + "propfind response_type must be ResponseType::Xml" + ); + assert_eq!( + net_request.body(), + &RequestBody::None, + "delete body must be RequestBody::None" + ); + self.call(net_request) + } + + async fn propfind_string( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + assert_eq!( + net_request.method(), + RequestMethod::Propfind, + "propfind_string method must be RequestMethod::Propfind" + ); + assert_eq!( + net_request.response_type(), + ResponseType::Text, + "propfind_string response_type must be ResponseType::Text" + ); + assert_eq!( + net_request.body(), + &RequestBody::None, + "delete body must be RequestBody::None" + ); + self.call_string(net_request) + } +} + +#[cfg(test)] +mod tests { + + use crate::network::{NetResponse, NetworkError, RequestMethod}; + + use super::*; + + use reqwest::StatusCode; + + use pretty_assertions::assert_eq; + use serde_json::json; + use tokio_test::block_on; + + #[test_log::test] + fn test_mock_network_env_get() -> Result<(), NetworkError> { + let mut net = MockNetwork::new(); + net.add_get_response("https://httpbin.org", StatusCode::OK, r#"{"foo": "bar"}"#); + let net_request = NetRequest::get(NetUrl::new("https://httpbin.org".into())).build(); + let response: NetResponse> = block_on(net.get(net_request))?; + assert_eq!( + response + .response_body() + .and_then(|body| body.get("foo").cloned()), + Some("bar".to_string()) + ); + assert_eq!( + net.requests(), + vec![SavedRequest::new( + RequestMethod::Get, + "https://httpbin.org", + RequestBody::None + )] + ); + Ok(()) + } + + #[test_log::test] + fn test_mock_network_env_get_error() -> Result<(), NetworkError> { + let mut net = MockNetwork::new(); + net.add_get_error("https://httpbin.org", "error"); + let net_request = NetRequest::get(NetUrl::new("https://httpbin.org".into())).build(); + let result: Result>, NetworkError> = + block_on(net.get(net_request)); + assert!(result.is_err()); + assert_eq!( + net.requests(), + vec![SavedRequest::new( + RequestMethod::Get, + "https://httpbin.org", + RequestBody::None + )] + ); + Ok(()) + } + + #[test_log::test] + fn test_mock_network_env_get_string() -> Result<(), NetworkError> { + let mut net = MockNetwork::new(); + net.add_get_response("https://httpbin.org", StatusCode::OK, r#"{"foo":"bar"}"#); + let net_request = NetRequest::get(NetUrl::new("https://httpbin.org".into())) + .response_type(ResponseType::Text) + .build(); + let response: NetResponse = block_on(net.get_string(net_request))?; + assert_eq!( + response.response_body(), + Some(json!({"foo":"bar"}).to_string()) + ); + assert_eq!( + net.requests(), + vec![SavedRequest::new( + RequestMethod::Get, + "https://httpbin.org", + RequestBody::None + )] + ); + Ok(()) + } + + #[test_log::test] + fn test_mock_network_env_get_string_error() { + let mut net = MockNetwork::new(); + net.add_get_error("https://httpbin.org", "error"); + let net_request = NetRequest::get(NetUrl::new("https://httpbin.org".into())) + .response_type(ResponseType::Text) + .build(); + let result: Result, NetworkError> = + block_on(net.get_string(net_request)); + assert!(result.is_err()); + assert_eq!( + net.requests(), + vec![SavedRequest::new( + RequestMethod::Get, + "https://httpbin.org", + RequestBody::None + )] + ); + } + + #[test_log::test] + fn test_mock_network_env_post() -> Result<(), NetworkError> { + let mut net = MockNetwork::new(); + net.add_post_response("https://httpbin.org", StatusCode::OK, r#"{"foo": "bar"}"#); + let net_request = NetRequest::post(NetUrl::new("https://httpbin.org".into())) + .json_body(json!({}))? + .build(); + let response: NetResponse> = block_on(net.post_json(net_request))?; + assert_eq!( + response + .response_body() + .and_then(|body| body.get("foo").cloned()), + Some("bar".to_string()) + ); + assert_eq!( + net.requests(), + vec![SavedRequest::new( + RequestMethod::Post, + "https://httpbin.org", + RequestBody::Json(json!({})) + )] + ); + Ok(()) + } + + #[test_log::test] + fn test_mock_network_env_post_error() -> Result<(), NetworkError> { + let mut net = MockNetwork::new(); + net.add_post_error("https://httpbin.org", "error"); + let net_request = NetRequest::post(NetUrl::new("https://httpbin.org".into())) + .json_body(json!({}))? + .build(); + let result: Result>, NetworkError> = + block_on(net.post_json(net_request)); + assert!(result.is_err()); + assert_eq!( + net.requests(), + vec![SavedRequest::new( + RequestMethod::Post, + "https://httpbin.org", + RequestBody::Json(json!({})) + )] + ); + Ok(()) + } + + #[test_log::test] + fn test_mock_network_env_put_json() -> Result<(), NetworkError> { + let mut net = MockNetwork::new(); + net.add_put_response("https://httpbin.org", StatusCode::OK, r#"{"foo": "bar"}"#); + let net_request = NetRequest::put(NetUrl::new("https://httpbin.org".into())) + .json_body(json!({}))? + .build(); + let response: NetResponse> = block_on(net.put_json(net_request))?; + assert_eq!( + response + .response_body() + .and_then(|body| body.get("foo").cloned()), + Some("bar".to_string()) + ); + assert_eq!( + net.requests(), + vec![SavedRequest::new( + RequestMethod::Put, + "https://httpbin.org", + RequestBody::Json(json!({})) + )] + ); + Ok(()) + } + + #[test_log::test] + fn test_mock_network_env_put_json_error() -> Result<(), NetworkError> { + let mut net = MockNetwork::new(); + net.add_put_error("https://httpbin.org", "error"); + let net_request = NetRequest::put(NetUrl::new("https://httpbin.org".into())) + .json_body(json!({}))? + .build(); + let result: Result>, NetworkError> = + block_on(net.put_json(net_request)); + assert!(result.is_err()); + assert_eq!( + net.requests(), + vec![SavedRequest::new( + RequestMethod::Put, + "https://httpbin.org", + RequestBody::Json(json!({})) + )] + ); + Ok(()) + } + + #[test_log::test] + fn test_mock_network_env_put_string() -> Result<(), NetworkError> { + let mut net = MockNetwork::new(); + net.add_put_response("https://httpbin.org", StatusCode::OK, r#"{"foo":"bar"}"#); + let net_request = NetRequest::put(NetUrl::new("https://httpbin.org".into())) + .string_body("PLAIN-TEXT".to_string()) + .build(); + let response: NetResponse = block_on(net.put_string(net_request))?; + assert_eq!( + response.response_body(), + Some(json!({"foo":"bar"}).to_string()) + ); + assert_eq!( + net.requests(), + vec![SavedRequest::new( + RequestMethod::Put, + "https://httpbin.org", + RequestBody::String("PLAIN-TEXT".to_string()), + )] + ); + Ok(()) + } + + #[test_log::test] + fn test_mock_network_env_put_string_error() { + let mut net = MockNetwork::new(); + net.add_put_error("https://httpbin.org", "error"); + let net_request = NetRequest::put(NetUrl::new("https://httpbin.org".into())) + .string_body("PLAIN-TEXT".to_string()) + .build(); + let result: Result, NetworkError> = + block_on(net.put_string(net_request)); + assert!(result.is_err()); + assert_eq!( + net.requests(), + vec![SavedRequest::new( + RequestMethod::Put, + "https://httpbin.org", + RequestBody::String("PLAIN-TEXT".to_string()) + )] + ); + } + + #[derive(Debug, PartialEq, Eq, serde::Deserialize)] + struct PropfindTestResponse { + pub foo: String, + } + + #[test_log::test] + fn test_mock_network_env_propfind() -> Result<(), NetworkError> { + let mut net = MockNetwork::new(); + net.add_propfind_response( + "https://caldav.org", + StatusCode::OK, + r#"bar"#, + ); + let net_request = NetRequest::propfind(NetUrl::new("https://caldav.org".into())) + .response_type(ResponseType::Xml) + .build(); + let response: NetResponse = block_on(net.propfind(net_request))?; + assert_eq!( + response.response_body().map(|body| body.foo), + Some("bar".to_string()) + ); + assert_eq!( + net.requests(), + vec![SavedRequest::new( + RequestMethod::Propfind, + "https://caldav.org", + RequestBody::None + )] + ); + Ok(()) + } + + #[test_log::test] + fn test_mock_network_env_propfind_string() -> Result<(), NetworkError> { + let mut net = MockNetwork::new(); + net.add_propfind_response( + "https://caldav.org", + StatusCode::OK, + r#"bar"#, + ); + let net_request = NetRequest::propfind(NetUrl::new("https://caldav.org".into())) + .response_type(ResponseType::Text) + .build(); + let response: NetResponse = block_on(net.propfind_string(net_request))?; + assert_eq!( + response.response_body(), + Some("bar".to_string()) + ); + assert_eq!( + net.requests(), + vec![SavedRequest::new( + RequestMethod::Propfind, + "https://caldav.org", + RequestBody::None + )] + ); + Ok(()) + } + + #[test_log::test] + fn test_mock_network_env_propfind_error() -> Result<(), NetworkError> { + let mut net = MockNetwork::new(); + net.add_propfind_error("https://caldav.org", "error"); + let net_request = NetRequest::propfind(NetUrl::new("https://caldav.org".into())) + .response_type(ResponseType::Xml) + .build(); + let result: Result, NetworkError> = + block_on(net.propfind(net_request)); + assert!(result.is_err()); + assert_eq!( + net.requests(), + vec![SavedRequest::new( + RequestMethod::Propfind, + "https://caldav.org", + RequestBody::None + )] + ); + Ok(()) + } + + #[test_log::test] + fn test_mock_network_env_propfind_string_error() -> Result<(), NetworkError> { + let mut net = MockNetwork::new(); + net.add_propfind_error("https://caldav.org", "error"); + let net_request = NetRequest::propfind(NetUrl::new("https://caldav.org".into())) + .response_type(ResponseType::Text) + .build(); + let result: Result, NetworkError> = + block_on(net.propfind_string(net_request)); + assert!(result.is_err()); + assert_eq!( + net.requests(), + vec![SavedRequest::new( + RequestMethod::Propfind, + "https://caldav.org", + RequestBody::None + )] + ); + Ok(()) + } +} diff --git a/src/network/mod.rs b/src/network/mod.rs new file mode 100644 index 0000000..495d1ce --- /dev/null +++ b/src/network/mod.rs @@ -0,0 +1,36 @@ +mod mock; +mod net_auth; +mod net_request; +mod net_request_headers; +mod net_response; +mod network_env; +mod network_error; +mod real; +mod request_body; +mod request_method; +mod response_type; +mod saved_request; + +pub use mock::MockNetwork; +pub use net_auth::NetAuth; +pub use net_auth::NetAuthPassword; +pub use net_auth::NetAuthUsername; +pub use net_request::NetRequest; +pub use net_request::NetRequestLogging; +pub use net_request::NetUrl; +pub use net_request_headers::NetRequestHeaders; +pub use net_response::NetResponse; +pub use network_env::Network; +pub use network_error::NetworkError; +pub use real::RealNetwork; +pub use request_body::RequestBody; +pub use request_method::RequestMethod; +pub use response_type::ResponseType; +pub use saved_request::SavedRequest; + +pub use reqwest::header::HeaderMap; +pub use reqwest::Error as ReqwestError; +pub use reqwest::StatusCode; +pub use serde_json::json; + +pub type NetworkResult = Result; diff --git a/src/network/net_auth.rs b/src/network/net_auth.rs new file mode 100644 index 0000000..0f24546 --- /dev/null +++ b/src/network/net_auth.rs @@ -0,0 +1,66 @@ +use std::fmt::{Display, Formatter}; + +#[derive(Debug, Clone)] +pub struct Password(secrecy::SecretString); +impl Password { + pub fn new(password: String) -> Self { + Self(secrecy::SecretString::new(password)) + } + pub fn expose_password(&self) -> &str { + secrecy::ExposeSecret::expose_secret(&self.0) + } +} + +#[derive(Debug, Clone)] +pub struct NetAuthUsername(String); +impl NetAuthUsername { + pub const fn new(username: String) -> Self { + Self(username) + } + pub fn as_str(&self) -> &str { + &self.0 + } +} +impl Display for NetAuthUsername { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// "Password for HTTP authentication"); +#[derive(Debug, Clone)] +pub struct NetAuthPassword(Password); +impl NetAuthPassword { + pub fn new(password: String) -> Self { + Self(Password::new(password)) + } + pub fn expose_password(&self) -> &str { + self.0.expose_password() + } + // pub const fn as_str(&self) -> &str { + // "********" + // } +} +impl From for NetAuthPassword { + fn from(password: Password) -> Self { + Self(password) + } +} +// new_type_display!(NetAuthPassword); + +#[derive(Debug, Clone)] +pub struct NetAuth { + username: NetAuthUsername, + password: NetAuthPassword, +} +impl NetAuth { + pub const fn new(username: NetAuthUsername, password: NetAuthPassword) -> Self { + Self { username, password } + } + pub const fn username(&self) -> &NetAuthUsername { + &self.username + } + pub const fn password(&self) -> &NetAuthPassword { + &self.password + } +} diff --git a/src/network/net_request.rs b/src/network/net_request.rs new file mode 100644 index 0000000..af66679 --- /dev/null +++ b/src/network/net_request.rs @@ -0,0 +1,462 @@ +use std::fmt::Display; +use std::fmt::Formatter; +use std::ops::Deref; + +use crate::network::HeaderMap; +use crate::network::NetAuth; +use crate::network::RequestBody; +use crate::network::RequestMethod; +use crate::network::ResponseType; + +use super::NetRequestHeaders; + +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)] +pub struct NetUrl(String); +impl NetUrl { + pub const fn new(url: String) -> Self { + Self(url) + } + pub fn as_str(&self) -> &str { + &self.0 + } +} +impl Deref for NetUrl { + type Target = String; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl Display for NetUrl { + fn fmt(&self, f: &mut Formatter) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum NetRequestLogging { + None, + Request, + Response, + Both, +} +impl Default for NetRequestLogging { + fn default() -> Self { + Self::None + } +} + +#[derive(Debug)] +pub struct NetRequest { + method: RequestMethod, + url: NetUrl, + body: RequestBody, + response_type: ResponseType, + auth: Option, + headers: NetRequestHeaders, + log: NetRequestLogging, +} +impl NetRequest { + pub fn get(net_url: NetUrl) -> NetRequestBuilder { + NetRequestBuilder::default() + .url(net_url) + .header("Accept", "application/json") + } + pub fn post(net_url: NetUrl) -> NetRequestBuilder { + NetRequestBuilder::default() + .method(RequestMethod::Post) + .url(net_url) + } + pub fn put(net_url: NetUrl) -> NetRequestBuilder { + NetRequestBuilder::default() + .method(RequestMethod::Put) + .url(net_url) + } + + pub fn patch(net_url: NetUrl) -> NetRequestBuilder { + NetRequestBuilder::default() + .method(RequestMethod::Patch) + .url(net_url) + } + + pub fn delete(net_url: NetUrl) -> NetRequestBuilder { + NetRequestBuilder::default() + .method(RequestMethod::Delete) + .response_type(ResponseType::None) + .url(net_url) + } + + pub fn propfind(net_url: NetUrl) -> NetRequestBuilder { + NetRequestBuilder::default() + .method(RequestMethod::Propfind) + .response_type(ResponseType::None) + .url(net_url) + } + + const fn new( + method: RequestMethod, + url: NetUrl, + headers: NetRequestHeaders, + body: RequestBody, + response_type: ResponseType, + auth: Option, + log: NetRequestLogging, + ) -> Self { + Self { + method, + url, + headers, + body, + response_type, + auth, + log, + } + } + pub fn as_trace(&self) -> String { + format!( + "{} {}", + self.method, + self.url.as_str().chars().take(90).collect::() + ) + } + pub const fn method(&self) -> RequestMethod { + self.method + } + pub const fn url(&self) -> &NetUrl { + &self.url + } + pub const fn body(&self) -> &RequestBody { + &self.body + } + pub const fn response_type(&self) -> ResponseType { + self.response_type + } + pub const fn log(&self) -> NetRequestLogging { + self.log + } + pub fn auth(&self) -> Option { + self.auth.clone() + } + pub fn headers(&self) -> HeaderMap { + self.headers + .clone() + .try_into() + .unwrap_or_else(|_| HeaderMap::new()) + } + pub fn as_curl(&self) -> Result { + let mut curl = format!("curl -X {} {}", self.method, *self.url); + if let Some(accept) = &self.response_type.accept() { + if self.headers.get("Accept").is_none() { + curl.push_str(&format!(" -H 'Accept: {}'", accept)); + } + } + let mut headers = vec![]; + for (key, value) in self.headers.iter() { + headers.push(format!(" -H '{}: {}'", key, value)); + } + headers.sort(); + for header in headers { + curl.push_str(&header); + } + if let Some(auth) = &self.auth() { + curl.push_str(&format!( + " -u {}:{}", + auth.username(), + auth.password().expose_password() + )); + } + if self.method == RequestMethod::Post || self.method == RequestMethod::Put { + let body = String::try_from(&self.body)?; + curl.push_str(&format!(" -d '{}'", body)); + } + Ok(curl) + } +} + +#[derive(Default)] +pub struct NetRequestBuilder { + method: RequestMethod, + url: NetUrl, + headers: NetRequestHeaders, + body: RequestBody, + response_type: ResponseType, + auth: Option, + log: NetRequestLogging, +} +impl NetRequestBuilder { + pub fn build(self) -> NetRequest { + NetRequest::new( + self.method, + self.url, + self.headers, + self.body, + self.response_type, + self.auth, + self.log, + ) + } + + pub fn method(mut self, method: RequestMethod) -> Self { + assert_ne!(method, RequestMethod::default()); + self.method = method; + if self.method == RequestMethod::Get || self.method == RequestMethod::Delete { + self.body = RequestBody::None; + } + self + } + + fn url(mut self, url: NetUrl) -> Self { + self.url = url; + self + } + + pub fn header(mut self, key: &str, value: &str) -> Self { + self.headers = self.headers.with(key, value); + self + } + + pub fn headers(mut self, new_headers: NetRequestHeaders) -> Self { + assert_ne!(new_headers, NetRequestHeaders::default()); + for (key, value) in new_headers.iter() { + self.headers = self.headers.with(key, value); + } + self + } + + pub fn body(mut self, body: RequestBody) -> Self { + match body { + RequestBody::Json(_) => { + self.headers = self.headers.with("Content-Type", "application/json") + } + RequestBody::Xml(_) => { + self.headers = self.headers.with("Content-Type", "application/xml") + } + _ => (), + } + self.body = body; + self + } + + pub fn string_body(mut self, body: String) -> Self { + self.body = RequestBody::String(body); + self.header("Content-Type", "text/plain") + .response_type(ResponseType::Text) + } + + pub fn json_body(mut self, body: T) -> Result { + self.body = RequestBody::json(body)?; + Ok(self.header("Content-Type", "application/json")) + } + + pub fn xml_body(mut self, body: &str) -> Self { + self.body = RequestBody::Xml(body.to_string()); + self.header("Content-Type", "application/xml") + } + + pub fn response_type(mut self, response_type: ResponseType) -> Self { + assert_ne!(response_type, ResponseType::default()); + self.response_type = response_type; + match response_type.accept() { + Some(accept) => self.header("Accept", accept.as_str()), + None => { + self.headers.remove("Accept"); + self + } + } + } + + pub fn auth(mut self, auth: NetAuth) -> Self { + self.auth = Some(auth); + self + } + + pub const fn log(mut self, log: NetRequestLogging) -> Self { + self.log = log; + self + } +} + +#[cfg(test)] +mod tests { + use assert2::let_assert; + use serde_json::json; + + use crate::network::net_auth::{NetAuthPassword, NetAuthUsername}; + + use super::*; + + #[test_log::test] + fn test_as_curl_no_auth() { + let request = NetRequest::get(NetUrl("https://httpbin.org/get".to_string())).build(); + let_assert!(Ok(curl) = request.as_curl()); + assert_eq!( + curl, + "curl -X GET https://httpbin.org/get -H 'Accept: application/json'".to_string() + ); + } + #[test_log::test] + fn test_as_curl_auth() { + let request = NetRequest::get(NetUrl("https://httpbin.org/get".to_string())) + .auth(NetAuth::new( + NetAuthUsername::new("user".into()), + NetAuthPassword::new("pass".into()), + )) + .build(); + let_assert!(Ok(curl) = request.as_curl()); + assert_eq!( + curl, + "curl -X GET https://httpbin.org/get -H 'Accept: application/json' -u user:pass" + .to_string() + ); + } + #[test_log::test] + fn test_as_curl_no_auth_post_json() -> Result<(), serde_json::Error> { + let request = NetRequest::post(NetUrl("https://httpbin.org/post".to_string())) + .json_body(json!({ + "args": {}, + "url": "https://httpbin.org/post", + }))? + .build(); + let_assert!(Ok(curl) = request.as_curl()); + assert_eq!( + curl, + "curl -X POST https://httpbin.org/post -H 'Accept: application/json' -H 'Content-Type: application/json' -d '{\"args\":{},\"url\":\"https://httpbin.org/post\"}'".to_string() + ); + Ok(()) + } + #[test_log::test] + fn test_as_curl_no_auth_post_text() { + let request = NetRequest::post(NetUrl("https://httpbin.org/post".to_string())) + .string_body("body".to_string()) + .build(); + let_assert!(Ok(curl) = request.as_curl()); + assert_eq!( + curl, + "curl -X POST https://httpbin.org/post -H 'Accept: text/plain' -H 'Content-Type: text/plain' -d 'body'".to_string() + ); + } + + #[test_log::test] + fn test_as_curl_no_auth_put() -> Result<(), serde_json::Error> { + let request = NetRequest::put(NetUrl("https://httpbin.org/put".to_string())) + .json_body(json!({ + "args": {}, + "url": "https://httpbin.org/put", + }))? + .build(); + let_assert!(Ok(curl) = request.as_curl()); + assert_eq!( + curl, + "curl -X PUT https://httpbin.org/put -H 'Accept: application/json' -H 'Content-Type: application/json' -d '{\"args\":{},\"url\":\"https://httpbin.org/put\"}'".to_string() + ); + Ok(()) + } + + #[test_log::test] + fn test_as_curl_no_auth_delete() { + let request = NetRequest::delete(NetUrl("https://httpbin.org/delete".to_string())).build(); + let_assert!(Ok(curl) = request.as_curl()); + assert_eq!( + curl, + "curl -X DELETE https://httpbin.org/delete".to_string() + ); + } + + #[test_log::test] + fn test_as_curl_no_auth_put_xml() { + let request = NetRequest::put(NetUrl("https://httpbin.org/put".to_string())) + .xml_body("") + .build(); + let_assert!(Ok(curl) = request.as_curl()); + assert_eq!( + curl, + "curl -X PUT https://httpbin.org/put -H 'Accept: application/json' -H 'Content-Type: application/xml' -d ''" + .to_string() + ); + } + + #[test_log::test] + fn test_as_curl_accept_json() { + let request = NetRequest::get(NetUrl("https://httpbin.org/get".to_string())).build(); + let_assert!(Ok(curl) = request.as_curl()); + assert_eq!( + curl, + "curl -X GET https://httpbin.org/get -H 'Accept: application/json'".to_string() + ); + } + + #[test_log::test] + fn test_as_curl_accept_xml() { + let request = NetRequest::get(NetUrl("https://httpbin.org/get".to_string())) + .response_type(ResponseType::Xml) + .build(); + let_assert!(Ok(curl) = request.as_curl()); + assert_eq!( + curl, + "curl -X GET https://httpbin.org/get -H 'Accept: application/xml'".to_string() + ); + } + + #[test_log::test] + fn test_as_curl_accept_text() { + let request = NetRequest::get(NetUrl("https://httpbin.org/get".to_string())) + .response_type(ResponseType::Text) + .build(); + let_assert!(Ok(curl) = request.as_curl()); + assert_eq!( + curl, + "curl -X GET https://httpbin.org/get -H 'Accept: text/plain'".to_string() + ); + } + + #[test_log::test] + fn text_as_curl_accept_none() { + let request = NetRequest::get(NetUrl("https://httpbin.org/get".to_string())) + .response_type(ResponseType::None) + .build(); + let_assert!(Ok(curl) = request.as_curl()); + assert_eq!(curl, "curl -X GET https://httpbin.org/get".to_string()); + } + + #[test_log::test] + fn test_as_curl_with_headers() { + let request = NetRequest::get(NetUrl("https://httpbin.org/get".to_string())) + .header("header1", "value1") + .header("header2", "value2") + .build(); + let_assert!(Ok(curl) = request.as_curl()); + assert!(curl.contains("-H 'header1: value1'")); + assert!(curl.contains("-H 'header2: value2'")); + assert!(curl.starts_with("curl -X GET https://httpbin.org/get")); + } + + #[test_log::test] + fn test_net_request_headers() { + let request = NetRequest::get(NetUrl("https://httpbin.org/get".to_string())) + .header("header1", "value1") + .header("header2", "value2") + .build(); + let headers = request.headers(); + assert!(headers.len() >= 2); + let_assert!(Some(value1) = headers.get("header1")); + assert_eq!(value1, "value1"); + let_assert!(Some(value2) = headers.get("header2")); + assert_eq!(value2, "value2"); + } + + #[test_log::test] + fn test_as_curl_propfind() { + let request = + NetRequest::propfind(NetUrl("https://httpbin.org/propfind".to_string())).build(); + let_assert!(Ok(curl) = request.as_curl()); + assert_eq!( + curl, + "curl -X PROPFIND https://httpbin.org/propfind".to_string() + ); + } + + #[test_log::test] + fn test_as_trace() { + let request = NetRequest::get(NetUrl("https://httpbin.org/get".to_string())).build(); + assert_eq!(request.as_trace(), "GET https://httpbin.org/get"); + } +} diff --git a/src/network/net_request_headers.rs b/src/network/net_request_headers.rs new file mode 100644 index 0000000..a851e87 --- /dev/null +++ b/src/network/net_request_headers.rs @@ -0,0 +1,102 @@ +use std::{ + collections::HashMap, + ops::{Deref, DerefMut}, +}; + +use crate::network::HeaderMap; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct NetRequestHeaders(HashMap); +impl NetRequestHeaders { + pub fn new() -> Self { + Self::default() //(HashMap::new()) + } + pub fn with(mut self, key: &str, value: &str) -> Self { + self.0.insert(key.into(), value.into()); + self + } +} +impl From for NetRequestHeaders +where + T: Into>, +{ + fn from(x: T) -> Self { + Self(x.into()) + } +} +impl TryFrom for HeaderMap { + type Error = http::Error; + fn try_from(x: NetRequestHeaders) -> Result { + Self::try_from(&x.0) + } +} +impl Deref for NetRequestHeaders { + type Target = HashMap; + fn deref(&self) -> &Self::Target { + &self.0 + } +} +impl DerefMut for NetRequestHeaders { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +#[cfg(test)] +mod tests { + + use assert2::let_assert; + use pretty_assertions::assert_eq; + + use super::*; + + #[test_log::test] + fn test_net_request_headers_from_hash_map() { + let nrh = NetRequestHeaders::from( + [("a", "b"), ("c", "d")] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect::>(), + ); + assert_eq!(nrh.len(), 2); + let_assert!(Some(a) = nrh.get("a")); + assert_eq!(a, "b"); + let_assert!(Some(c) = nrh.get("c")); + assert_eq!(c, "d"); + } + + #[test_log::test] + fn test_net_request_headers_with_new_entry() { + let nrh = NetRequestHeaders::from( + [("a", "b"), ("c", "d")] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect::>(), + ) + .with("e", "f"); + assert_eq!(nrh.len(), 3); + let_assert!(Some(a) = nrh.get("a")); + assert_eq!(a, "b"); + let_assert!(Some(c) = nrh.get("c")); + assert_eq!(c, "d"); + let_assert!(Some(e) = nrh.get("e")); + assert_eq!(e, "f"); + } + + #[test_log::test] + fn test_net_request_headers_try_into_header_map() { + let result: Result = NetRequestHeaders::from( + [("a", "b"), ("c", "d")] + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect::>(), + ) + .try_into(); + let_assert!(Ok(hm) = result); + assert_eq!(hm.len(), 2); + let_assert!(Some(a) = hm.get("a")); + assert_eq!(a, "b"); + let_assert!(Some(c) = hm.get("c")); + assert_eq!(c, "d"); + } +} diff --git a/src/network/net_response.rs b/src/network/net_response.rs new file mode 100644 index 0000000..c92b5de --- /dev/null +++ b/src/network/net_response.rs @@ -0,0 +1,38 @@ +use crate::network::StatusCode; + +use super::{net_request::NetUrl, RequestMethod}; + +#[derive(Debug)] +pub struct NetResponse { + method: RequestMethod, + request_url: NetUrl, + status_code: StatusCode, + response_body: Option, +} +impl NetResponse { + pub fn new( + method: RequestMethod, + request_url: &NetUrl, + status_code: StatusCode, + response_body: Option, + ) -> Self { + Self { + method, + request_url: request_url.clone(), + status_code, + response_body, + } + } + pub const fn method(&self) -> &RequestMethod { + &self.method + } + pub const fn request_url(&self) -> &NetUrl { + &self.request_url + } + pub const fn status_code(&self) -> StatusCode { + self.status_code + } + pub fn response_body(self) -> Option { + self.response_body + } +} diff --git a/src/network/network_env.rs b/src/network/network_env.rs new file mode 100644 index 0000000..3dc42fc --- /dev/null +++ b/src/network/network_env.rs @@ -0,0 +1,174 @@ +use async_trait::async_trait; +use serde::de::DeserializeOwned; + +use crate::network::{MockNetwork, NetRequest, NetworkError, RealNetwork, SavedRequest}; + +use super::NetResponse; + +#[derive(Debug, Clone)] +pub enum Network { + Mock(MockNetwork), + Real(RealNetwork), +} +impl Network { + pub fn new_mock() -> Self { + Self::Mock(MockNetwork::default()) + } + + pub fn new_real() -> Self { + Self::Real(RealNetwork::default()) + } + + pub async fn get( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + match self { + Self::Mock(mock) => mock.get(net_request).await, + Self::Real(real) => real.get(net_request).await, + } + } + + pub async fn get_string( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + match self { + Self::Mock(mock) => mock.get_string(net_request).await, + Self::Real(real) => real.get_string(net_request).await, + } + } + + pub async fn post_json( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + match self { + Self::Mock(mock) => mock.post_json(net_request).await, + Self::Real(real) => real.post_json(net_request).await, + } + } + + pub async fn post_string( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + match self { + Self::Mock(mock) => mock.post_string(net_request).await, + Self::Real(real) => real.post_string(net_request).await, + } + } + + pub async fn put_json( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + match self { + Self::Mock(mock) => mock.put_json(net_request).await, + Self::Real(real) => real.put_json(net_request).await, + } + } + + pub async fn put_string( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + match self { + Self::Mock(mock) => mock.put_string(net_request).await, + Self::Real(real) => real.put_string(net_request).await, + } + } + + pub async fn patch_json( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + match self { + Self::Mock(mock) => mock.patch_json(net_request).await, + Self::Real(real) => real.patch_json(net_request).await, + } + } + + pub async fn delete(&self, net_request: NetRequest) -> Result, NetworkError> { + match self { + Self::Mock(mock) => mock.delete(net_request).await, + Self::Real(real) => real.delete(net_request).await, + } + } + + pub async fn propfind( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + match self { + Self::Mock(mock) => mock.propfind(net_request).await, + Self::Real(real) => real.propfind(net_request).await, + } + } + + pub async fn propfind_string( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + match self { + Self::Mock(mock) => mock.propfind_string(net_request).await, + Self::Real(real) => real.propfind_string(net_request).await, + } + } + + pub fn mocked_requests(&self) -> Option> { + match self { + Self::Mock(mock) => Some(mock.requests()), + Self::Real(_) => None, + } + } +} +#[async_trait] +pub(super) trait NetworkTrait: Sync + Send + Clone + std::fmt::Debug { + async fn get( + &self, + net_request: NetRequest, + ) -> Result, NetworkError>; + + async fn get_string( + &self, + net_request: NetRequest, + ) -> Result, NetworkError>; + + async fn post_json( + &self, + net_request: NetRequest, + ) -> Result, NetworkError>; + + async fn post_string( + &self, + net_request: NetRequest, + ) -> Result, NetworkError>; + + async fn put_json( + &self, + net_request: NetRequest, + ) -> Result, NetworkError>; + + async fn put_string( + &self, + net_request: NetRequest, + ) -> Result, NetworkError>; + + async fn patch_json( + &self, + net_request: NetRequest, + ) -> Result, NetworkError>; + + async fn delete(&self, net_request: NetRequest) -> Result, NetworkError>; + + async fn propfind( + &self, + net_request: NetRequest, + ) -> Result, NetworkError>; + + async fn propfind_string( + &self, + net_request: NetRequest, + ) -> Result, NetworkError>; +} diff --git a/src/network/network_error.rs b/src/network/network_error.rs new file mode 100644 index 0000000..7e80f63 --- /dev/null +++ b/src/network/network_error.rs @@ -0,0 +1,56 @@ +#[derive(thiserror::Error, Debug)] +pub enum NetworkError { + #[error(transparent)] + Reqwest(#[from] crate::network::ReqwestError), + + #[error(transparent)] + SerdeJson(#[from] serde_json::Error), + + #[error(transparent)] + SerdeXml(#[from] serde_xml_rs::Error), + + #[error(transparent)] + HttpMethod(#[from] http::method::InvalidMethod), + + #[error(transparent)] + Http(#[from] http::Error), + + #[error("{0} failed: {1} ({2})")] + RequestFailed(super::RequestMethod, super::StatusCode, super::NetUrl), + + #[error("{0} failed: {1} ({2})")] + MockError(super::RequestMethod, super::StatusCode, super::NetUrl), + + #[error("missing response body")] + MissingResponseBody, + + #[error("unexpected mock request: {method:?} {request_url}")] + UnexpectedMockRequest { + method: super::RequestMethod, + request_url: super::NetUrl, + }, + + #[error("{0} failed: {1} ({2})")] + RequestError(super::RequestMethod, super::StatusCode, super::NetUrl), + + #[error("invalid response type")] + InvalidResponseType, + + #[error("invalid request body")] + InvalidRequestBody, + + #[error("response body is empty")] + EmptyResponseBody, +} + +#[cfg(test)] +mod tests { + use super::NetworkError; + + #[test] + const fn test_network_error_is_send() { + const fn assert_send() {} + assert_send::(); + assert_send::>(); + } +} diff --git a/src/network/real.rs b/src/network/real.rs new file mode 100644 index 0000000..e84d5da --- /dev/null +++ b/src/network/real.rs @@ -0,0 +1,612 @@ +use reqwest::RequestBuilder; +use serde::de::DeserializeOwned; +use tracing::{event, Level}; + +use super::{ + network_env::NetworkTrait, NetAuth, NetRequest, NetRequestLogging, NetResponse, Network, + NetworkError, RequestMethod, ResponseType, StatusCode, +}; + +trait WithAuthentiction { + fn auth(self, auth: Option) -> reqwest::RequestBuilder; +} +impl WithAuthentiction for reqwest::RequestBuilder { + fn auth(self, auth: Option) -> reqwest::RequestBuilder { + if let Some(auth) = auth { + self.basic_auth( + auth.username().to_string(), + Some(auth.password().expose_password()), + ) + } else { + self + } + } +} + +trait WithResponseType { + fn response_type(self, response_type: ResponseType) -> Self; +} +impl WithResponseType for reqwest::RequestBuilder { + fn response_type(self, response_type: ResponseType) -> Self { + match response_type { + ResponseType::Json => self.header(reqwest::header::ACCEPT, "application/json"), + ResponseType::Xml => self.header(reqwest::header::ACCEPT, "application/xml"), + ResponseType::Text => self.header(reqwest::header::ACCEPT, "text/plain"), + ResponseType::None => self, + } + } +} + +#[derive(Debug, Clone)] +pub struct RealNetwork { + client: reqwest::Client, +} +impl RealNetwork { + #[cfg(not(tarpaulin_include))] + pub fn new() -> Self { + Self { + client: reqwest::Client::new(), + } + } + + async fn send( + &self, + request: RequestBuilder, + net_request: NetRequest, + ) -> Result, NetworkError> { + let response = request.send().await?; + let status_code = response.status(); + let text = response.text().await?; + if status_code.is_success() { + tracing::trace!(status = ?status_code); + } else { + tracing::error!("Request failed"); + self.log_request(&net_request); + self.log_response(&net_request, &status_code, text.as_str()); + return Err(NetworkError::RequestFailed( + net_request.method(), + status_code, + net_request.url().clone(), + )); + } + match net_request.response_type() { + ResponseType::Json => serde_json::from_str(text.as_str()) + .map(Some) + .map_err(NetworkError::from), + ResponseType::Xml => serde_xml_rs::from_str(text.as_str()) + .map(Some) + .map_err(NetworkError::from), + ResponseType::Text => { + panic!("text response type not implemented - use send_for_text(...) instead") + } + ResponseType::None => Ok(None), + } + .map(|response_body| { + match net_request.log() { + NetRequestLogging::None => (), + NetRequestLogging::Request => self.log_request(&net_request), + NetRequestLogging::Response => { + self.log_response(&net_request, &status_code, text.as_str()); + } + NetRequestLogging::Both => { + self.log_request(&net_request); + self.log_response(&net_request, &status_code, text.as_str()); + } + }; + NetResponse::new( + net_request.method(), + net_request.url(), + status_code, + response_body, + ) + }) + } + + async fn send_for_text( + &self, + request: RequestBuilder, + net_request: NetRequest, + ) -> Result, NetworkError> { + let response = request.send().await?; + let status_code = response.status(); + if status_code.is_success() { + tracing::trace!(status = ?status_code); + } else { + tracing::error!(status = ?status_code, request = ?net_request); + } + match net_request.response_type() { + ResponseType::Text => { + let text = response.text().await?; + Ok(Some(text)) + } + _ => panic!("text response type not implemented - use send(...) instead"), + } + .map(|response_body| { + NetResponse::new( + net_request.method(), + net_request.url(), + status_code, + response_body, + ) + }) + } + + fn log_request(&self, net_request: &NetRequest) { + tracing::info!(?net_request, "RealNetworkEnv::request"); + } + + fn log_response( + &self, + net_request: &NetRequest, + status_code: &StatusCode, + response_body: &str, + ) { + tracing::info!( + ?net_request, + status = ?status_code, + ?response_body, + "RealNetworkEnv::response" + ); + } +} + +impl Default for RealNetwork { + fn default() -> Self { + Self::new() + } +} +impl From for Network { + fn from(real: RealNetwork) -> Self { + Self::Real(real) + } +} + +#[cfg(not(tarpaulin_include))] +#[async_trait::async_trait] +impl NetworkTrait for RealNetwork { + #[tracing::instrument(skip_all, fields(request = %net_request.as_trace()), level = "trace")] + async fn get( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + tracing::info!("RealNetworkEnv::get({:?})", net_request); + let url = net_request.url(); + let auth = net_request.auth(); + let response_type = net_request.response_type(); + let headers = net_request.headers(); + + let request = self + .client + .get(url.to_string()) + .auth(auth) + .response_type(response_type) + .headers(headers); + self.send(request, net_request).await + } + + #[tracing::instrument(skip_all, fields(request = %net_request.as_trace()), level = "trace")] + async fn post_json( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + // event!(Level::INFO, "RealNetworkEnv::post_json({:?})", net_request); + let url = net_request.url(); + let body = net_request.body(); + let auth = net_request.auth(); + let response_type = net_request.response_type(); + let headers = net_request.headers(); + + let body = String::try_from(body)?; + + let request = self + .client + .post(url.to_string()) + .body(body) + .auth(auth) + .response_type(response_type) + .headers(headers); + self.send(request, net_request).await + } + + #[tracing::instrument(skip_all, fields(request = %net_request.as_trace()), level = "trace")] + async fn post_string( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + // event!(Level::INFO, "RealNetworkEnv::post_string({:?})", net_request); + let url = net_request.url(); + let body = net_request.body(); + let auth = net_request.auth(); + let response_type = net_request.response_type(); + let headers = net_request.headers(); + + let body = String::try_from(body)?; + + let request = self + .client + .post(url.to_string()) + .body(body) + .auth(auth) + .response_type(response_type) + .headers(headers); + + self.send_for_text(request, net_request).await + } + + #[tracing::instrument(skip_all, fields(request = %net_request.as_trace()), level = "trace")] + async fn put_json( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + // event!(Level::INFO, "RealNetworkEnv::put_json({:?})", net_request); + let url = net_request.url(); + let body = net_request.body(); + let auth = net_request.auth(); + let response_type = net_request.response_type(); + let headers = net_request.headers(); + + let body = String::try_from(body)?; + + let request = self + .client + .put(url.to_string()) + .body(body) + .auth(auth) + .response_type(response_type) + .headers(headers); + + self.send(request, net_request).await + } + + #[tracing::instrument(skip_all, fields(request = %net_request.as_trace()), level = "trace")] + async fn put_string( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + // event!(Level::INFO, "RealNetworkEnv::put_string({:?})", net_request); + let url = net_request.url(); + let body = net_request.body(); + let auth = net_request.auth(); + let response_type = net_request.response_type(); + let headers = net_request.headers(); + + let body = String::try_from(body)?; + + let request = self + .client + .put(url.to_string()) + .body(body) + .auth(auth) + .response_type(response_type) + .headers(headers); + + self.send_for_text(request, net_request).await + } + + #[tracing::instrument(skip_all, fields(request = %net_request.as_trace()), level = "trace")] + async fn patch_json( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + // event!(Level::INFO, "RealNetworkEnv::put_json({:?})", net_request); + let url = net_request.url(); + let body = net_request.body(); + let auth = net_request.auth(); + let response_type = net_request.response_type(); + let headers = net_request.headers(); + + let body = String::try_from(body)?; + + let request = self + .client + .patch(url.to_string()) + .body(body) + .auth(auth) + .response_type(response_type) + .headers(headers); + + self.send(request, net_request).await + } + + #[tracing::instrument(skip_all, fields(request = %net_request.as_trace()), level = "trace")] + async fn delete(&self, net_request: NetRequest) -> Result, NetworkError> { + // event!(Level::INFO, "RealNetworkEnv::delete({:?})", net_request); + let url = net_request.url(); + let auth = net_request.auth(); + let response_type = net_request.response_type(); + let headers = net_request.headers(); + + let request = self + .client + .delete(url.to_string()) + .auth(auth) + .response_type(response_type) + .headers(headers); + + self.send(request, net_request).await + } + + #[tracing::instrument(skip_all, fields(request = %net_request.as_trace()), level = "trace")] + async fn propfind( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + // event!(Level::INFO, "RealNetworkEnv::propfind({:?})", net_request); + let url = net_request.url(); + let auth = net_request.auth(); + let response_type = net_request.response_type(); + let headers = net_request.headers(); + + let request = self + .client + .request(reqwest::Method::from_bytes(b"PROPFIND")?, url.to_string()) + .auth(auth) + .response_type(response_type) + .headers(headers); + + let response = request.send().await?; + + let status_code = response.status(); + event!(Level::TRACE, status = %status_code); + let body = match response_type { + ResponseType::Xml => serde_xml_rs::from_str(response.text().await?.as_str()), + _ => Err(NetworkError::InvalidResponseType)?, + }; + body.map_err(Into::into).map(|response_body| { + NetResponse::new(RequestMethod::Propfind, url, status_code, response_body) + }) + } + + #[tracing::instrument(skip_all, fields(request = %net_request.as_trace()), level = "trace")] + async fn get_string( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + // event!(Level::INFO, "RealNetworkEnv::get_string({:?})", net_request); + let url = net_request.url(); + let auth = net_request.auth(); + let response_type = net_request.response_type(); + let headers = net_request.headers(); + + let request = self + .client + .get(url.to_string()) + .auth(auth) + .response_type(response_type) + .headers(headers); + + self.send_for_text(request, net_request).await + } + + #[tracing::instrument(skip_all, fields(request = %net_request.as_trace()), level = "trace")] + async fn propfind_string( + &self, + net_request: NetRequest, + ) -> Result, NetworkError> { + // event!(Level::INFO, "RealNetworkEnv::propfind_string({:?})", net_request); + let url = net_request.url(); + let auth = net_request.auth(); + let response_type = net_request.response_type(); + let headers = net_request.headers(); + + let request = self + .client + .request(reqwest::Method::from_bytes(b"PROPFIND")?, url.to_string()) + .auth(auth) + .response_type(response_type) + .headers(headers); + + self.send_for_text(request, net_request).await + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + use crate::network::{ + net_auth::{NetAuthPassword, NetAuthUsername}, + NetUrl, NetworkError, StatusCode, + }; + + use assert2::let_assert; + use pretty_assertions::assert_eq; + use std::collections::HashMap; + use tokio_test::block_on; + + #[derive(Debug, PartialEq, Eq, serde::Deserialize)] + pub struct GetResponse { + pub args: HashMap, + pub url: String, + } + + #[derive(Debug, PartialEq, Eq, serde::Deserialize)] + pub struct PostResponse { + pub args: HashMap, + pub url: String, + pub data: String, + } + + #[derive(Debug, PartialEq, Eq, serde::Deserialize)] + pub struct PutResponse { + pub args: HashMap, + pub url: String, + pub data: String, + } + + #[test_log::test] + fn test_with_authentication_none() { + let client = reqwest::Client::new(); + let request = client.get("https://httpbin.org/get").auth(None); + let_assert!(Ok(build) = request.build()); + assert!(build.headers().is_empty()); + } + + #[test_log::test] + fn test_with_authentication_some() { + let client = reqwest::Client::new(); + let request = client + .get("https://httpbin.org/get") + .auth(Some(NetAuth::new( + NetAuthUsername::new("user".into()), + NetAuthPassword::new("pass".into()), + ))); + let_assert!(Ok(build) = request.build()); + let headers = build.headers(); + let_assert!(Some(authorization) = headers.get(reqwest::header::AUTHORIZATION)); + assert_eq!(authorization, "Basic dXNlcjpwYXNz"); + } + + #[test_log::test] + fn test_with_response_type_json() { + let client = reqwest::Client::new(); + let request = client + .get("https://httpbin.org/get") + .response_type(ResponseType::Json); + let_assert!(Ok(request) = request.build()); + let headers = request.headers(); + let_assert!(Some(accept) = headers.get(reqwest::header::ACCEPT)); + assert_eq!(accept, "application/json"); + } + + #[test_log::test] + fn test_with_response_type_xml() { + let client = reqwest::Client::new(); + let request = client + .get("https://httpbin.org/get") + .response_type(ResponseType::Xml); + let_assert!(Ok(request) = request.build()); + let headers = request.headers(); + let_assert!(Some(accept) = headers.get(reqwest::header::ACCEPT)); + assert_eq!(accept, "application/xml"); + } + + #[test_log::test] + fn test_with_response_type_text() { + let client = reqwest::Client::new(); + let request = client + .get("https://httpbin.org/get") + .response_type(ResponseType::Text); + let_assert!(Ok(request) = request.build()); + let headers = request.headers(); + let_assert!(Some(accept) = headers.get(reqwest::header::ACCEPT)); + assert_eq!(accept, "text/plain"); + } + + #[test_log::test] + fn test_with_response_type_none() { + let client = reqwest::Client::new(); + let request = client + .get("https://httpbin.org/get") + .response_type(ResponseType::None); + let_assert!(Ok(request) = request.build()); + let headers = request.headers(); + assert!(headers.get(reqwest::header::ACCEPT).is_none()); + } + + #[test_log::test] + #[ignore] + fn test_real_network_env_get() -> Result<(), NetworkError> { + let env = RealNetwork::new(); + let net_request = + NetRequest::get(NetUrl::new("https://httpbin.org/get?arg=baz".into())).build(); + let response: NetResponse = block_on(env.get(net_request))?; + let_assert!(Some(body) = response.response_body()); + assert_eq!( + body.args.get("arg"), + Some(&"baz".to_string()), + "args from body" + ); + Ok(()) + } + + #[test_log::test] + #[ignore] + fn test_real_network_env_get_error() { + let env = RealNetwork::new(); + let net_request = + NetRequest::get(NetUrl::new("https://httpbin.org/status/400".into())).build(); + let result: Result, NetworkError> = block_on(env.get(net_request)); + assert!(result.is_err(), "response is not a String"); + } + + #[test_log::test] + #[ignore] + fn test_real_network_env_post_json() -> Result<(), NetworkError> { + let env = RealNetwork::new(); + let body = serde_json::json!({"foo":"bar"}); + let net_request = NetRequest::post(NetUrl::new("https://httpbin.org/post?arg=baz".into())) + .json_body(body)? + .build(); + let response: NetResponse = block_on(env.post_json(net_request))?; + let_assert!(Some(body) = response.response_body()); + assert_eq!( + body.args.get("arg"), + Some(&"baz".to_string()), + "args from body" + ); + assert_eq!(body.data, "{\"foo\":\"bar\"}".to_string(), "data from body"); + Ok(()) + } + + #[test_log::test] + #[ignore] + fn test_real_network_env_post_json_error() -> Result<(), NetworkError> { + let env = RealNetwork::new(); + let net_request = + NetRequest::post(NetUrl::new("https://httpbin.org/status/400".into())).build(); + let response: Result, NetworkError> = + block_on(env.post_json(net_request)); + match response { + Ok(_) => panic!("expected error"), + Err(e) => match e { + NetworkError::MockError(method, status, _url) => { + assert_eq!(method, RequestMethod::Post); + assert_eq!(status, StatusCode::BAD_REQUEST) + } + _ => panic!("unexpected error type"), + }, + } + Ok(()) + } + + #[test_log::test] + #[ignore] + fn test_real_network_env_put() -> Result<(), NetworkError> { + let env = RealNetwork::new(); + let body = serde_json::json!({"foo":"bar"}); + let net_request = NetRequest::put(NetUrl::new("https://httpbin.org/put?arg=baz".into())) + .json_body(body)? + .build(); + let response: NetResponse = block_on(env.put_json(net_request))?; + let_assert!(Some(body) = response.response_body()); + assert_eq!( + body.args.get("arg"), + Some(&"baz".to_string()), + "args from body" + ); + assert_eq!(body.data, "{\"foo\":\"bar\"}".to_string(), "data from body"); + Ok(()) + } + + #[test_log::test] + #[ignore] + fn test_real_network_env_put_error() { + let env = RealNetwork::new(); + let net_request = + NetRequest::put(NetUrl::new("https://httpbin.org/status/400".into())).build(); + let result: Result, NetworkError> = block_on(env.put_json(net_request)); + assert!(result.is_err(), "response is not a String"); + } + + #[test_log::test] + #[ignore] + fn test_real_network_env_delete() { + let env = RealNetwork::new(); + let net_request = + NetRequest::delete(NetUrl::new("https://httpbin.org/delete?arg=baz".into())).build(); + let result = block_on(env.delete(net_request)); + assert!(result.is_ok()); + } +} diff --git a/src/network/request_body.rs b/src/network/request_body.rs new file mode 100644 index 0000000..bd2a8a8 --- /dev/null +++ b/src/network/request_body.rs @@ -0,0 +1,86 @@ +use serde::Serialize; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RequestBody { + None, + String(String), + Json(serde_json::Value), + Xml(String), // no separate type for XML, so store as string +} +impl Default for RequestBody { + fn default() -> Self { + Self::None + } +} +impl TryFrom<&RequestBody> for String { + type Error = serde_json::Error; + fn try_from(value: &RequestBody) -> Result { + match value { + RequestBody::None => Ok("".to_string()), + RequestBody::String(s) => Ok(s.to_string()), + RequestBody::Json(json) => serde_json::to_string(&json), + RequestBody::Xml(s) => Ok(s.to_string()), + } + } +} +impl RequestBody { + pub fn json(source: T) -> Result { + serde_json::to_value(source).map(Self::Json) + } + pub fn content_type(&self) -> Option { + match self { + Self::None => None, + Self::String(_) => Some("text/plain".to_string()), + Self::Json(_) => Some("application/json".to_string()), + Self::Xml(_) => Some("application/xml".to_string()), + } + } +} + +#[cfg(test)] +mod tests { + + use assert2::let_assert; + use pretty_assertions::assert_eq; + use serde_json::json; + + use super::*; + + #[test_log::test] + fn test_request_body_json() { + let_assert!(Ok(json) = RequestBody::json("hello")); + assert_eq!( + json, + RequestBody::Json(serde_json::Value::String("hello".to_string())) + ); + } + + #[test_log::test] + fn test_request_body_content_type() { + assert_eq!(RequestBody::None.content_type(), None); + assert_eq!( + RequestBody::String("".to_string()).content_type(), + Some("text/plain".to_string()) + ); + assert_eq!( + RequestBody::Json(json!("")).content_type(), + Some("application/json".to_string()) + ); + assert_eq!( + RequestBody::Xml("".to_string()).content_type(), + Some("application/xml".to_string()) + ); + } + + #[test_log::test] + fn test_request_body_try_from() { + let_assert!(Ok(value) = String::try_from(&RequestBody::None)); + assert_eq!(value, ""); + let_assert!(Ok(value) = String::try_from(&RequestBody::String("hello".to_string()))); + assert_eq!(value, "hello"); + let_assert!(Ok(value) = String::try_from(&RequestBody::Json(json!("hello")))); + assert_eq!(value, "\"hello\""); + let_assert!(Ok(value) = String::try_from(&RequestBody::Xml("hello".to_string()))); + assert_eq!(value, "hello"); + } +} diff --git a/src/network/request_method.rs b/src/network/request_method.rs new file mode 100644 index 0000000..4461c63 --- /dev/null +++ b/src/network/request_method.rs @@ -0,0 +1,26 @@ +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum RequestMethod { + Get, + Post, + Put, + Patch, + Delete, + Propfind, +} +impl Default for RequestMethod { + fn default() -> Self { + Self::Get + } +} +impl std::fmt::Display for RequestMethod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Get => write!(f, "GET"), + Self::Post => write!(f, "POST"), + Self::Put => write!(f, "PUT"), + Self::Patch => write!(f, "PATCH"), + Self::Delete => write!(f, "DELETE"), + Self::Propfind => write!(f, "PROPFIND"), + } + } +} diff --git a/src/network/response_type.rs b/src/network/response_type.rs new file mode 100644 index 0000000..c855f29 --- /dev/null +++ b/src/network/response_type.rs @@ -0,0 +1,22 @@ +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum ResponseType { + Json, + Xml, + None, + Text, +} +impl ResponseType { + pub fn accept(&self) -> Option { + match self { + Self::Json => Some("application/json".to_string()), + Self::Text => Some("text/plain".to_string()), + Self::Xml => Some("application/xml".to_string()), + Self::None => None, + } + } +} +impl Default for ResponseType { + fn default() -> Self { + Self::Json + } +} diff --git a/src/network/saved_request.rs b/src/network/saved_request.rs new file mode 100644 index 0000000..e2ef412 --- /dev/null +++ b/src/network/saved_request.rs @@ -0,0 +1,113 @@ +use super::{RequestBody, RequestMethod}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SavedRequest { + method: RequestMethod, + url: String, + body: RequestBody, +} +impl SavedRequest { + pub fn new(method: RequestMethod, url: &str, body: RequestBody) -> Self { + Self { + method, + url: url.to_string(), + body, + } + } + pub const fn method(&self) -> RequestMethod { + self.method + } + pub fn url(&self) -> &str { + &self.url + } + pub const fn body(&self) -> &RequestBody { + &self.body + } +} + +#[cfg(test)] +mod tests { + use assert2::let_assert; + + use super::*; + + #[test_log::test] + fn test_saved_request() { + let request = + SavedRequest::new(RequestMethod::Get, "https://httpbin.org", RequestBody::None); + assert_eq!(request.method, RequestMethod::Get); + assert_eq!(request.url, "https://httpbin.org"); + assert_eq!(request.body, RequestBody::None); + } + + #[test_log::test] + fn test_saved_request_body() { + let request = SavedRequest::new( + RequestMethod::Get, + "https://httpbin.org", + RequestBody::String("body".to_string()), + ); + let_assert!(RequestBody::String(body) = request.body()); + assert_eq!(body, "body"); + } + + #[test_log::test] + fn test_saved_request_eq() { + let request1 = SavedRequest::new( + RequestMethod::Get, + "https://httpbin.org", + RequestBody::String("body".to_string()), + ); + let request2 = SavedRequest::new( + RequestMethod::Get, + "https://httpbin.org", + RequestBody::String("body".to_string()), + ); + assert_eq!(request1, request2); + } + + #[test_log::test] + fn test_saved_request_ne_method() { + let request1 = SavedRequest::new( + RequestMethod::Get, + "https://httpbin.org", + RequestBody::String("body".to_string()), + ); + let request2 = SavedRequest::new( + RequestMethod::Post, + "https://httpbin.org", + RequestBody::String("body".to_string()), + ); + assert_ne!(request1, request2); + } + + #[test_log::test] + fn test_saved_request_ne_url() { + let request1 = SavedRequest::new( + RequestMethod::Get, + "https://httpbin.org", + RequestBody::String("body".to_string()), + ); + let request2 = SavedRequest::new( + RequestMethod::Get, + "https://httpbin.org2", + RequestBody::String("body".to_string()), + ); + assert_ne!(request1, request2); + } + + #[test_log::test] + fn test_saved_request_ne_body() { + let request1 = SavedRequest::new( + RequestMethod::Get, + "https://httpbin.org", + RequestBody::String("body".to_string()), + ); + let request2 = SavedRequest::new( + RequestMethod::Get, + "https://httpbin.org", + RequestBody::String("body2".to_string()), + ); + assert_ne!(request1, request2); + } +}