diff --git a/README.md b/README.md index e69904e..461695e 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ file system and network operations. The Filesystem module offers a clean abstraction over `std::fs`, the standard file system operations. For comprehensive documentation and usage examples, -please refer to the . +please refer to . ### Key Filesystem Features: diff --git a/src/lib.rs b/src/lib.rs index fd7df0d..a75dcc0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,68 @@ -// +//! # kxio +//! +//! `kxio` is a Rust library that provides injectable `FileSystem` and `Network` +//! resources to enhance the testability of your code. By abstracting system-level +//! interactions, `kxio` enables easier mocking and testing of code that relies on +//! file system and network operations. +//! +//! ## Features +//! +//! - Filesystem Abstraction +//! - Network Abstraction +//! - Enhanced Testability +//! +//! ## Filesystem +//! +//! The Filesystem module offers a clean abstraction over `std::fs`, the standard +//! file system operations. For comprehensive documentation and usage examples, +//! please refer to . +//! +//! ### Key Filesystem Features: +//! +//! - File reading and writing +//! - Directory operations +//! - File metadata access +//! - Fluent API for operations like `.reader().bytes()` +//! +//! ## Network +//! +//! The Network module offers a testable interface over the `reqwest` crate. For +//! comprehensive documentation and usage examples, please refer to +//! +//! +//! ## Getting Started +//! +//! Add `kxio` to your `Cargo.toml`: +//! +//! ```toml +//! [dependencies] +//! kxio = "x.y.z" +//! ``` +//! +//! ## Usage +//! +//! See the example [get.rs](https://git.kemitix.net/kemitix/kxio/src/branch/main/examples/get.rs) for an annotated example on how to use the `kxio` library. +//! It covers both the `net` and `fs` modules. +//! +//! ## Development +//! +//! - The project uses [Cargo Mutants](https://crates.io/crates/cargo-mutants) for mutation testing. +//! - [ForgeJo Actions](https://forgejo.org/docs/next/user/actions/) are used for continuous testing and linting. +//! +//! ## Contributing +//! +//! Contributions are welcome! Please check our [issue tracker](https://git.kemitix.net/kemitix/kxio/issues) for open tasks or +//! submit your own ideas. +//! +//! ## License +//! +//! This project is licensed under the terms specified in the `LICENSE` file in the +//! repository root. +//! +//! --- +//! +//! For more information, bug reports, or feature requests, please visit our [repository](https://git.kemitix.net/kemitix/kxio). + pub mod fs; pub mod net; mod result; diff --git a/src/net/mod.rs b/src/net/mod.rs index 1161184..2970da6 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -149,6 +149,148 @@ //! //! The module uses a custom [Result] type that wraps `core::result::Result` with the custom [Error] enum, //! allowing for specific error handling related to network operations. +//! Provides a testable interface over the [reqwest] crate. +//! +//! ## Overview +//! +//! The `net` module provides a testable interface for network operations. +//! It includes implementations for both real network interactions and mocked network operations for testing purposes. +//! +//! ## Key methods and types: +//! +//! - [kxio::net::new()][new()]: Creates a new `Net` instance +//! - [kxio::net::mock()][mock()]: Creates a new `MockNet` instance for use in tests +//! - [Error]: enum for network-related errors +//! - [Result]: an alias for `core::result::Result` +//! - [Net]: struct for real and mocked network operations +//! - [MockNet]: struct for defining behaviours of mocked network operations +//! +//! ## Usage +//! +//! Write your program to take a reference to [Net]. +//! +//! Use the [Net::client] functionto create a [reqwest::RequestBuilder] which you should then pass to the [Net::send] method. +//! This is rather than building the request and calling its own `send` method, doing so would result in the network request being sent, even under-test. +//! +//! ```rust +//! use kxio::net; +//! async fn get_example(net: &net::Net) -> net::Result<()> { +//! let response = net.send(net.client().get("https://example.com")).await?; +//! ///... +//! Ok(()) +//! } +//! ``` +//! +//! ### Real Network Operations +//! +//! In your production code you will want to make real network requests. +//! +//! Construct a [Net] using [kxio::net::new()][new()]. Then pass as a reference to your program. +//! +//! ```rust +//! use kxio::net; +//! # #[tokio::main] +//! # async fn main() -> net::Result<()> { +//! let net = net::new(); +//! +//! get_example(&net).await?; +//! # Ok(()) +//! # } +//! # async fn get_example(net: &net::Net) -> net::Result<()> {Ok(())} +//! ``` +//! +//! ### Mocked Network Operations +//! +//! In your tests you will want to mock your network requests and responses. +//! +//! Construct a [MockNet] using [kxio::net::mock()][mock()]. +//! +//! ```rust +//! use kxio::net; +//! let mock_net = net::mock(); +//! ``` +//! +//! Create a [reqwest::Client] using [MockNet::client()]. +//! +//! ```rust +//! # let mock_net = kxio::net::mock(); +//! let client = mock_net.client(); +//! // this is the same as: +//! let client = reqwest::Client::new(); +//! ``` +//! +//! Define the expected responses for each request, using the [MockNet::on], +//! that you expect you program to make during the test. You can choose what each request should be +//! matched against. The default is to the match when both the Method and Url are the same. +//! +//! ```rust +//! use kxio::net; +//! use kxio::net::MatchOn; +//! +//! # #[tokio::main] +//! # async fn main() -> net::Result<()> { +//! # let mock_net = net::mock(); +//! # let client = mock_net.client(); +//! mock_net.on(client.get("https://example.com")) +//! .match_on(vec![ +//! MatchOn::Url, +//! MatchOn::Method +//! ]) +//! .respond_with_body(mock_net.response().status(200).body("Mocked response"))?; +//! # mock_net.reset(); +//! # Ok(()) +//! # } +//! ``` +//! +//! All [MatchOn] options: +//! - [MatchOn::Method] (default) +//! - [MatchOn::Url] (default) +//! - [MatchOn::Headers] +//! - [MatchOn::Body]. +//! +//! Once you have defined all your expected responses, convert the [MockNet] into a [Net]. +//! +//! ```rust +//! # use kxio::net; +//! # let mock_net = net::mock(); +//! let net: net::Net = mock_net.into(); +//! // or +//! # let mock_net = net::mock(); +//! let net = net::Net::from(mock_net); +//! ``` +//! +//! Now you can pass a reference to `net` to your program. +//! +//! ```rust +//! # use kxio::net; +//! # #[tokio::main] +//! # async fn main() -> net::Result<()> { +//! # let mock_net = net::mock(); +//! # let net = net::Net::from(mock_net); +//! get_example(&net).await?; +//! # Ok(()) +//! # } +//! # async fn get_example(net: &net::Net) -> net::Result<()> {Ok(())} +//! ``` +//! +//! When your test is finished, the [MockNet] will check that all the expected requests were +//! actually made. If there were any missed, then the test will [panic]. +//! +//! If you don't want this to happen, then call [MockNet::reset] before your test finishes. +//! You will need to recover the [MockNet] from the [Net]. +//! +//! ```rust +//! # use kxio::net; +//! # let mock_net = net::mock(); +//! # let net = net::Net::from(mock_net); +//! let mock_net = kxio::net::MockNet::try_from(net).expect("recover mock"); +//! mock_net.reset(); +//! ```` +//! +//! ## Error Handling +//! +//! The module uses a custom [Result] type that wraps `core::result::Result` with the custom [Error] enum, +//! allowing for specific error handling related to network operations. mod result; mod system; diff --git a/src/net/result.rs b/src/net/result.rs index 18295e4..0742be6 100644 --- a/src/net/result.rs +++ b/src/net/result.rs @@ -1,15 +1,32 @@ // use derive_more::derive::From; -/// Represents a error accessing the network. +/// The Errors that may occur within [kxio::net][crate::net]. #[derive(Debug, From, derive_more::Display)] pub enum Error { + /// The Errors that may occur when processing a `Request`. + /// + /// Note: Errors may include the full URL used to make the `Request`. If the URL + /// contains sensitive information (e.g. an API key as a query parameter), be + /// sure to remove it ([`without_url`](reqwest::Error::without_url)) Reqwest(reqwest::Error), + + /// The Errors that may occur when processing a `Request`. + /// + /// The cause has been converted to a String. Request(String), + + /// There was network request that doesn't match any that were expected #[display("Unexpected request: {0}", 0.to_string())] UnexpectedMockRequest(reqwest::Request), + + /// There was an error accessing the list of expected requests. RwLockLocked, + + /// There was an error making a network request. Http(http::Error), + + /// Attempted to extract a [MockNet][super::MockNet] from a [Net][super::Net] that does not contain one. NetIsNotAMock, } impl std::error::Error for Error {} @@ -22,7 +39,7 @@ impl Clone for Error { } } -/// Represents a success or a failure. +/// Represents a success or a failure within [kxio::net][crate::net]. /// /// Any failure is related to `std::io`, a Path Traversal /// (i.e. trying to escape the base of the `FileSystem`), diff --git a/src/net/system.rs b/src/net/system.rs index 6374e74..04bfc83 100644 --- a/src/net/system.rs +++ b/src/net/system.rs @@ -5,26 +5,41 @@ use reqwest::Client; use super::{Error, Result}; +/// Marker trait used to identify whether a [Net] is mocked or not. pub trait NetType {} +/// Marker struct that indicates that a [Net] is a mock. #[derive(Debug)] pub struct Mocked; impl NetType for Mocked {} +/// Market struct that indicates that a [Net] is not mocked. #[derive(Debug)] pub struct Unmocked; impl NetType for Unmocked {} +/// A list of planned requests and responses type Plans = Vec; +/// The different ways to match a request. #[derive(Debug, PartialEq, Eq)] pub enum MatchOn { + /// The request must have a specific HTTP Request Method. Method, + + /// The request must have a specific URL. Url, + + /// The request must have a specify HTTP Body. Body, + + /// The request must have a specific set of HTTP Headers. Headers, } +/// A planned request and the response to return +/// +/// Contains a list of the criteria that a request must meet before being considered a match. #[derive(Debug)] struct Plan { request: reqwest::Request, @@ -39,7 +54,7 @@ pub struct Net { mock: Option>, } impl Net { - // constructors + /// Creates a new unmocked [Net] for creating real network requests. pub(super) const fn new() -> Self { Self { inner: InnerNet::::new(), @@ -47,6 +62,7 @@ impl Net { } } + /// Creats a new [MockNet] for use in tests. pub(super) const fn mock() -> MockNet { MockNet { inner: InnerNet::::new(), @@ -54,11 +70,39 @@ impl Net { } } impl Net { - // public interface + /// Helper to create a default [reqwest::Client]. + /// + /// # Example + /// + /// ```rust + /// # use kxio::net::Result; + /// let net = kxio::net::new(); + /// let client = net.client(); + /// let request = client.get("https://hyper.rs"); + /// ``` pub fn client(&self) -> reqwest::Client { Default::default() } + /// Constructs the Request and sends it to the target URL, returning a + /// future Response. + /// + /// # Errors + /// + /// This method fails if there was an error while sending request, + /// redirect loop was detected or redirect limit was exhausted. + /// + /// # Example + /// + /// ```no_run + /// # use kxio::net::Result; + /// # async fn run() -> Result<()> { + /// let net = kxio::net::new(); + /// let request = net.client().get("https://hyper.rs"); + /// let response = net.send(request).await?; + /// # Ok(()) + /// # } + /// ``` pub async fn send( &self, request: impl Into, @@ -88,27 +132,105 @@ impl TryFrom for MockNet { } } +/// A struct for defining the expected requests and their responses that should be made +/// during a test. +/// +/// When the [MockNet] goes out of scope it will verify that all expected requests were consumed, +/// otherwise it will `panic`. +/// +/// # Example +/// +/// ```rust +/// # use kxio::net::Result; +/// # fn run() -> Result<()> { +/// let mock_net = kxio::net::mock(); +/// let client = mock_net.client(); +/// // define an expected requet, and the response that should be returned +/// mock_net.on(client.get("https://hyper.rs")) +/// .respond_with_body(mock_net.response().status(200).body("Ok"))?; +/// let net: kxio::net::Net = mock_net.into(); +/// // use 'net' in your program, by passing it as a reference +/// +/// // In some rare cases you don't want to assert that all expected requests were made. +/// // You should recover the `MockNet` from the `Net` and `MockNet::reset` it. +/// let mock_net = kxio::net::MockNet::try_from(net)?; +/// mock_net.reset(); // only if explicitly needed +/// # Ok(()) +/// # } +/// ``` #[derive(Debug)] pub struct MockNet { inner: InnerNet, } impl MockNet { + /// Helper to create a default [reqwest::Client]. + /// + /// # Example + /// + /// ```rust + /// let mock_net = kxio::net::mock(); + /// let client = mock_net.client(); + /// let request = client.get("https://hyper.rs"); + /// ``` pub fn client(&self) -> Client { Default::default() } + + /// Specify an expected request. + /// + /// # Example + /// + /// ```rust + /// # use kxio::net::Result; + /// # fn run() -> Result<()> { + /// let mock_net = kxio::net::mock(); + /// let client = mock_net.client(); + /// mock_net.on(client.get("https://hyper.rs")) + /// .respond_with_body(mock_net.response().status(200).body("Ok"))?; + /// # Ok(()) + /// # } + /// ``` pub fn on(&self, request: impl Into) -> OnRequest { self.inner.on(request) } + /// Creates a [http::response::Builder] to be extended and returned by a mocked network request. pub fn response(&self) -> http::response::Builder { Default::default() } + + /// Constructs the request and matches it against the expected requests. + /// + /// The first on it finds where it matches against its criteria will be taken ("consumed") and + /// its response returned. + /// + /// The order the expected requests are scanned is the order they were declared. As each + /// expected request is successfully matched against, it is removed and can't be matched + /// against again. pub async fn send( &self, request: impl Into, ) -> Result { self.inner.send(request).await } + + /// Clears all the expected requests and responses from the [MockNet]. + /// + /// When the [MockNet] goes out of scope it will assert that all expected requests and + /// responses were consumed. If there are any left unconsumed, then it will `panic`. + /// + /// # Example + /// + /// ```rust + /// # use kxio::net::Result; + /// # fn run() -> Result<()> { + /// # let mock_net = kxio::net::mock(); + /// # let net: kxio::net::Net = mock_net.into(); + /// let mock_net = kxio::net::MockNet::try_from(net)?; + /// mock_net.reset(); // only if explicitly needed + /// # Ok(()) + /// # } + /// ``` pub fn reset(&self) -> Result<()> { self.inner.reset() } @@ -124,6 +246,9 @@ impl From for Net { } } +/// Part of the inner workings of [Net] and [MockNet]. +/// +/// Holds the list of expected requests for [MockNet]. #[derive(Debug)] pub struct InnerNet { _type: PhantomData, @@ -240,6 +365,7 @@ impl Drop for InnerNet { } } +/// Intermediate struct used while declaring an expected request and its response. pub enum OnRequest<'net> { Valid { net: &'net InnerNet, @@ -249,6 +375,14 @@ pub enum OnRequest<'net> { Error(super::Error), } impl<'net> OnRequest<'net> { + /// Specify the criteria that a request should be compared against. + /// + /// Given an candidate request, it will be matched against the current request if it has the + /// same HTTP Method and Url by default. i.e. if this method is not called. + /// + /// All criteria must be met for the match to be made. + /// + /// Calling this method replaces the default criteria with the new criteria. pub fn match_on(self, match_on: Vec) -> Self { if let OnRequest::Valid { net, @@ -265,6 +399,12 @@ impl<'net> OnRequest<'net> { self } } + + /// Constructs the response to be returned when a request matched the criteria. + /// + /// The response will have an empty HTTP Body. + /// + /// Each request and response can only be matched once each. pub fn respond(self, response: impl Into) -> Result<()> { match self { OnRequest::Valid { @@ -275,6 +415,10 @@ impl<'net> OnRequest<'net> { OnRequest::Error(error) => Err(error), } } + + /// Constructs the response to be returned when a request matched the criteria. + /// + /// Each request and response can only be matched once each. pub fn respond_with_body( self, body_result: std::result::Result, http::Error>,