kxio/src/net/system.rs

432 lines
12 KiB
Rust
Raw Normal View History

//
use std::{
cell::RefCell, collections::HashMap, marker::PhantomData, ops::Deref, rc::Rc, sync::Arc,
};
use derive_more::derive::{Display, From};
use http::{Method, StatusCode};
use reqwest::Client;
use tokio::sync::Mutex;
use url::Url;
use crate::net::{Request, RequestBuilder, Response};
use super::{Error, Result};
2024-11-09 21:07:43 +00:00
/// A list of planned requests and responses
type Plans = Vec<Plan>;
2024-11-09 21:07:43 +00:00
/// 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 {
match_request: Vec<MatchRequest>,
response: reqwest::Response,
}
impl Plan {
fn matches(&self, request: &Request) -> bool {
self.match_request.iter().all(|criteria| match criteria {
MatchRequest::Method(method) => request.method() == method,
MatchRequest::Url(uri) => request.url() == uri,
MatchRequest::Header { name, value } => {
request
.headers()
.iter()
.any(|(request_header_name, request_header_value)| {
let Ok(request_header_value) = request_header_value.to_str() else {
return false;
};
request_header_name.as_str() == name && request_header_value == value
})
}
MatchRequest::Body(body) => {
request.body().and_then(reqwest::Body::as_bytes) == Some(body)
}
})
}
}
2024-11-10 17:11:54 +00:00
/// An abstraction for the network
#[derive(Debug, Clone, Default)]
pub struct Net {
plans: Option<Arc<Mutex<RefCell<Plans>>>>,
}
impl Net {
2024-11-09 21:07:43 +00:00
/// Creates a new unmocked [Net] for creating real network requests.
pub(super) const fn new() -> Self {
2024-11-11 07:44:43 +00:00
Self { plans: None }
}
}
impl Net {
/// Helper to create a default [Client].
2024-11-09 21:07:43 +00:00
///
/// # 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) -> Client {
Default::default()
}
2024-11-09 21:07:43 +00:00
/// 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<RequestBuilder>) -> Result<Response> {
2024-11-11 07:44:43 +00:00
let Some(plans) = &self.plans else {
return request.into().send().await.map_err(Error::from);
};
let request = request.into().build()?;
let index = plans
.lock()
.await
.deref()
.borrow()
.iter()
.position(|plan| plan.matches(&request));
2024-11-11 07:44:43 +00:00
match index {
Some(i) => {
let response = plans.lock().await.borrow_mut().remove(i).response;
2024-11-11 07:44:43 +00:00
Ok(response)
}
None => Err(Error::UnexpectedMockRequest(request)),
2024-11-10 17:11:54 +00:00
}
}
}
impl MockNet {
pub async fn try_from(net: Net) -> std::result::Result<Self, super::Error> {
2024-11-11 07:44:43 +00:00
match &net.plans {
Some(plans) => Ok(MockNet {
plans: Rc::new(RefCell::new(plans.lock().await.take())),
2024-11-11 07:44:43 +00:00
}),
None => Err(super::Error::NetIsNotAMock),
}
}
}
2024-11-09 21:07:43 +00:00
/// 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;
/// use kxio::net::StatusCode;
/// # #[tokio::main]
/// # async fn run() -> Result<()> {
2024-11-09 21:07:43 +00:00
/// 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().get("https://hyper.rs")
/// .respond(StatusCode::OK).body("Ok");
2024-11-09 21:07:43 +00:00
/// 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).await?;
2024-11-09 21:07:43 +00:00
/// mock_net.reset(); // only if explicitly needed
/// # Ok(())
/// # }
/// ```
#[derive(Debug, Clone, Default)]
pub struct MockNet {
plans: Rc<RefCell<Plans>>,
}
impl MockNet {
/// Helper to create a default [Client].
2024-11-09 21:07:43 +00:00
///
/// # 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()
}
2024-11-09 21:07:43 +00:00
/// Specify an expected request.
///
/// # Example
///
/// ```rust
/// use kxio::net::StatusCode;
2024-11-09 21:07:43 +00:00
/// # use kxio::net::Result;
/// # fn run() -> Result<()> {
/// let mock_net = kxio::net::mock();
/// let client = mock_net.client();
/// mock_net.on().get("https://hyper.rs")
/// .respond(StatusCode::OK).body("Ok");
2024-11-09 21:07:43 +00:00
/// # Ok(())
/// # }
/// ```
#[must_use]
pub fn on(&self) -> WhenRequest<WhenBuildRequest> {
WhenRequest::new(self)
2024-11-11 07:44:43 +00:00
}
fn _when(&self, plan: Plan) {
self.plans.borrow_mut().push(plan);
}
2024-11-09 21:07:43 +00:00
/// 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;
/// # #[tokio::main]
/// # async fn run() -> Result<()> {
2024-11-09 21:07:43 +00:00
/// # let mock_net = kxio::net::mock();
/// # let net: kxio::net::Net = mock_net.into();
/// let mock_net = kxio::net::MockNet::try_from(net).await?;
2024-11-09 21:07:43 +00:00
/// mock_net.reset(); // only if explicitly needed
/// # Ok(())
/// # }
/// ```
2024-11-11 07:44:43 +00:00
pub fn reset(&self) {
self.plans.take();
}
}
impl From<MockNet> for Net {
fn from(mock_net: MockNet) -> Self {
Self {
// keep the original `inner` around to allow it's Drop impelmentation to run when we go
// out of scope at the end of the test
plans: Some(Arc::new(Mutex::new(RefCell::new(mock_net.plans.take())))),
}
}
}
2024-11-11 07:44:43 +00:00
impl Drop for MockNet {
fn drop(&mut self) {
assert!(self.plans.borrow().is_empty())
}
}
2024-11-11 07:44:43 +00:00
impl Drop for Net {
fn drop(&mut self) {
2024-11-11 07:44:43 +00:00
if let Some(plans) = &self.plans {
assert!(plans.try_lock().expect("lock plans").take().is_empty())
2024-11-11 07:44:43 +00:00
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MatchRequest {
Method(Method),
Url(Url),
Header { name: String, value: String },
Body(bytes::Bytes),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RespondWith {
Status(StatusCode),
Header { name: String, value: String },
Body(bytes::Bytes),
}
2024-11-09 21:07:43 +00:00
#[derive(Clone, Debug, Display, From)]
pub enum MockError {
#[display("url parse: {}", 0)]
UrlParse(#[from] url::ParseError),
}
impl std::error::Error for MockError {}
pub trait WhenState {}
pub struct WhenBuildRequest;
impl WhenState for WhenBuildRequest {}
pub struct WhenBuildResponse;
impl WhenState for WhenBuildResponse {}
#[derive(Debug, Clone)]
pub struct WhenRequest<'net, State>
where
State: WhenState,
{
_state: PhantomData<State>,
net: &'net MockNet,
match_on: Vec<MatchRequest>,
respond_with: Vec<RespondWith>,
error: Option<MockError>,
}
2024-11-09 21:07:43 +00:00
impl<'net> WhenRequest<'net, WhenBuildRequest> {
fn new(net: &'net MockNet) -> Self {
Self {
_state: PhantomData,
net,
match_on: vec![],
respond_with: vec![],
error: None,
}
}
#[must_use]
pub fn get(self, url: impl Into<String>) -> Self {
self._url(Method::GET, url)
}
#[must_use]
pub fn post(self, url: impl Into<String>) -> Self {
self._url(Method::POST, url)
}
#[must_use]
pub fn put(self, url: impl Into<String>) -> Self {
self._url(Method::PUT, url)
}
#[must_use]
pub fn delete(self, url: impl Into<String>) -> Self {
self._url(Method::DELETE, url)
}
#[must_use]
pub fn head(self, url: impl Into<String>) -> Self {
self._url(Method::HEAD, url)
}
#[must_use]
pub fn patch(self, url: impl Into<String>) -> Self {
self._url(Method::PATCH, url)
}
fn _url(mut self, method: http::Method, url: impl Into<String>) -> Self {
self.match_on.push(MatchRequest::Method(method));
match Url::parse(&url.into()) {
Ok(url) => {
self.match_on.push(MatchRequest::Url(url));
}
Err(err) => {
self.error.replace(err.into());
}
}
self
}
#[must_use]
pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
self.match_on.push(MatchRequest::Header {
name: name.into(),
value: value.into(),
});
self
}
#[must_use]
pub fn body(mut self, body: impl Into<bytes::Bytes>) -> Self {
self.match_on.push(MatchRequest::Body(body.into()));
self
}
#[must_use]
pub fn respond(self, status: StatusCode) -> WhenRequest<'net, WhenBuildResponse> {
WhenRequest::<WhenBuildResponse> {
_state: PhantomData,
net: self.net,
match_on: self.match_on,
respond_with: vec![RespondWith::Status(status)],
error: self.error,
}
}
}
impl<'net> WhenRequest<'net, WhenBuildResponse> {
#[must_use]
pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
let name = name.into();
let value = value.into();
self.respond_with.push(RespondWith::Header { name, value });
self
}
#[must_use]
pub fn headers(mut self, headers: impl Into<HashMap<String, String>>) -> Self {
let h: HashMap<String, String> = headers.into();
for (name, value) in h.into_iter() {
self.respond_with.push(RespondWith::Header { name, value });
}
self
}
pub fn body(mut self, body: impl Into<bytes::Bytes>) {
self.respond_with.push(RespondWith::Body(body.into()));
self.mock().expect("valid mock");
}
pub fn mock(self) -> Result<()> {
if let Some(error) = self.error {
return Err(crate::net::Error::InvalidMock(error));
}
let mut builder = http::response::Builder::default();
let mut response_body = None;
for part in self.respond_with {
builder = match part {
RespondWith::Status(status) => builder.status(status),
RespondWith::Header { name, value } => builder.header(name, value),
RespondWith::Body(body) => {
response_body.replace(body);
builder
}
}
}
let Some(body) = response_body else {
return Err(crate::net::Error::MockResponseHasNoBody);
};
let response = builder.body(body)?;
self.net._when(Plan {
match_request: self.match_on,
response: response.into(),
});
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn is_normal<T: Sized + Send + Sync + Unpin>() {}
#[test]
fn normal_types() {
is_normal::<Net>();
// is_normal::<MockNet>(); // only used in test setup - no need to be Send or Sync
is_normal::<MatchRequest>();
is_normal::<Plan>();
// is_normal::<WhenRequest>(); // only used in test setup - no need to be Send or Sync
}
}