feat(net): mock requests based on query parameters
All checks were successful
Test / build (map[name:nightly]) (push) Successful in 7m10s
Test / build (map[name:stable]) (push) Successful in 13m3s
Release Please / Release-plz (push) Successful in 1m54s

Changed the way URLs were matched, by breaking them down into their component parts, so that we can match query parameters when they are speciifed in either the URL string, or via the `query` method, or both.
This commit is contained in:
Paul Campbell 2024-12-01 20:58:05 +00:00
parent eb761b0973
commit 41973abe18
2 changed files with 298 additions and 22 deletions

View file

@ -34,9 +34,11 @@ struct Plan {
} }
impl Plan { impl Plan {
fn matches(&self, request: &Request) -> bool { fn matches(&self, request: &Request) -> bool {
let url = request.url();
self.match_request.iter().all(|criteria| match criteria { self.match_request.iter().all(|criteria| match criteria {
MatchRequest::Method(method) => request.method() == http::Method::from(method), MatchRequest::Body(body) => {
MatchRequest::Url(uri) => request.url() == uri, request.body().and_then(reqwest::Body::as_bytes) == Some(body)
}
MatchRequest::Header { name, value } => { MatchRequest::Header { name, value } => {
request request
.headers() .headers()
@ -48,8 +50,17 @@ impl Plan {
request_header_name.as_str() == name && request_header_value == value request_header_name.as_str() == name && request_header_value == value
}) })
} }
MatchRequest::Body(body) => { MatchRequest::Method(method) => request.method() == http::Method::from(method),
request.body().and_then(reqwest::Body::as_bytes) == Some(body) MatchRequest::Scheme(scheme) => url.scheme() == scheme,
MatchRequest::Host(host) => url.host_str() == Some(host),
MatchRequest::Path(path) => url.path() == path,
MatchRequest::Fragment(fragment) => url.fragment() == Some(fragment),
MatchRequest::Query { name, value } => {
url.query_pairs()
.into_iter()
.any(|(request_query_name, request_query_value)| {
request_query_name.as_ref() == name && request_query_value.as_ref() == value
})
} }
}) })
} }
@ -226,6 +237,7 @@ pub struct ReqBuilder<'net> {
url: String, url: String,
method: NetMethod, method: NetMethod,
headers: Vec<(String, String)>, headers: Vec<(String, String)>,
query: Vec<(String, String)>,
body: Option<Bytes>, body: Option<Bytes>,
} }
impl<'net> ReqBuilder<'net> { impl<'net> ReqBuilder<'net> {
@ -236,6 +248,7 @@ impl<'net> ReqBuilder<'net> {
url: url.into(), url: url.into(),
method, method,
headers: vec![], headers: vec![],
query: vec![],
body: None, body: None,
} }
} }
@ -365,14 +378,28 @@ impl<'net> ReqBuilder<'net> {
/// ``` /// ```
pub async fn send(self) -> Result<Response> { pub async fn send(self) -> Result<Response> {
let client = self.net.client(); let client = self.net.client();
// URL
let mut url = self.url;
// Query Parameters
if !self.query.is_empty() {
url.push('?');
for (i, (name, value)) in self.query.into_iter().enumerate() {
if i > 0 {
url.push('&');
}
url.push_str(&name);
url.push('=');
url.push_str(&value);
}
}
// Method // Method
let mut req = match self.method { let mut req = match self.method {
NetMethod::Delete => client.delete(self.url), NetMethod::Delete => client.delete(url),
NetMethod::Get => client.get(self.url), NetMethod::Get => client.get(url),
NetMethod::Head => client.head(self.url), NetMethod::Head => client.head(url),
NetMethod::Patch => client.patch(self.url), NetMethod::Patch => client.patch(url),
NetMethod::Post => client.post(self.url), NetMethod::Post => client.post(url),
NetMethod::Put => client.put(self.url), NetMethod::Put => client.put(url),
}; };
// Headers // Headers
for (name, value) in self.headers.into_iter() { for (name, value) in self.headers.into_iter() {
@ -406,6 +433,13 @@ impl<'net> ReqBuilder<'net> {
self.body = Some(bytes.into()); self.body = Some(bytes.into());
self self
} }
/// Add query parameter
#[must_use]
pub fn query(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.query.push((key.into(), value.into()));
self
}
} }
/// A struct for defining the expected requests and their responses that should be made /// A struct for defining the expected requests and their responses that should be made
@ -456,6 +490,10 @@ impl MockNet {
/// Specify an expected request. /// Specify an expected request.
/// ///
/// When specifying multiple requests to be matched, always specify the more specific case
/// first as they are matched in the order speciifed. Once a match has been made, it is removed
/// and will not match a second time.
///
/// # Example /// # Example
/// ///
/// ```rust /// ```rust
@ -541,18 +579,26 @@ fn panic_with_unused_plans(unused: Vec<Plan>) {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum MatchRequest { pub enum MatchRequest {
Method(NetMethod),
Url(Url),
Header { name: String, value: String },
Body(bytes::Bytes), Body(bytes::Bytes),
Fragment(String),
Header { name: String, value: String },
Host(String),
Method(NetMethod),
Path(String),
Query { name: String, value: String },
Scheme(String),
} }
impl Display for MatchRequest { impl Display for MatchRequest {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { match self {
Self::Method(method) => write!(f, "{method}"),
Self::Url(url) => write!(f, "{url}"),
Self::Header { name, value } => write!(f, "({name}: {value})"),
Self::Body(body) => write!(f, "Body: {body:?}"), Self::Body(body) => write!(f, "Body: {body:?}"),
Self::Fragment(fragment) => write!(f, "#{fragment}"),
Self::Header { name, value } => write!(f, "({name}: {value})"),
Self::Host(host) => write!(f, "@{host}"),
Self::Method(method) => write!(f, "{method}"),
Self::Path(path) => write!(f, "/{path}"),
Self::Query { name, value } => write!(f, "?{name}={value})"),
Self::Scheme(scheme) => write!(f, "{scheme}://"),
} }
} }
} }
@ -642,7 +688,34 @@ impl<'net> WhenRequest<'net, WhenBuildRequest> {
self.match_on.push(MatchRequest::Method(method)); self.match_on.push(MatchRequest::Method(method));
match Url::parse(&url.into()) { match Url::parse(&url.into()) {
Ok(url) => { Ok(url) => {
self.match_on.push(MatchRequest::Url(url)); // scheme
self.match_on
.push(MatchRequest::Scheme(url.scheme().into()));
// usernmae
// password
// if url.has_authority() {
// // : requires basic auth
// self = self.header(http::header::AUTHORIZATION.to_string(), "TODO");
// }
// host
if url.has_host() {
if let Some(host) = url.host_str() {
self.match_on.push(MatchRequest::Host(host.into()));
}
}
// path
self.match_on.push(MatchRequest::Path(url.path().into()));
// fragment
if let Some(fragment) = url.fragment() {
self.match_on.push(MatchRequest::Fragment(fragment.into()));
}
// query
url.query_pairs().into_iter().for_each(|(key, value)| {
self.match_on.push(MatchRequest::Query {
name: key.into(),
value: value.into(),
})
});
} }
Err(err) => { Err(err) => {
self.error.replace(err.into()); self.error.replace(err.into());
@ -651,6 +724,15 @@ impl<'net> WhenRequest<'net, WhenBuildRequest> {
self self
} }
/// Specifies a query parameter key/value pair thta the mock will match against.
#[must_use]
pub fn query(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
let name = name.into();
let value = value.into();
self.match_on.push(MatchRequest::Query { name, value });
self
}
/// Specifies a header that the mock will match against. /// Specifies a header that the mock will match against.
/// ///
/// Any request that does not have this header will not match the mock. /// Any request that does not have this header will not match the mock.

View file

@ -1,5 +1,3 @@
use std::collections::HashMap;
use http::StatusCode; use http::StatusCode;
// //
use kxio::net::{Error, MockNet, Net}; use kxio::net::{Error, MockNet, Net};
@ -12,20 +10,45 @@ async fn test_get_url() {
let mock_net = kxio::net::mock(); let mock_net = kxio::net::mock();
let url = "https://www.example.com"; let url = "https://www.example.com";
let url_alpha = format!("{url}/alpha");
let url_beta = format!("{url}/beta");
mock_net
.on()
.get(&url_alpha)
.respond(StatusCode::OK)
.body("Get OK alpha")
.expect("mock alpha");
mock_net
.on()
.get(&url_beta)
.respond(StatusCode::OK)
.body("Get OK beta")
.expect("mock beta");
mock_net mock_net
.on() .on()
.get(url) .get(url)
.respond(StatusCode::OK) .respond(StatusCode::OK)
.header("foo", "bar")
.headers(HashMap::new())
.body("Get OK") .body("Get OK")
.expect("mock"); .expect("mock");
let net = Net::from(mock_net);
//when //when
let response = Net::from(mock_net).get(url).send().await.expect("response"); let response_alpha = net.get(url_alpha).send().await.expect("response alpha");
let response_beta = net.get(url_beta).send().await.expect("response beta");
let response = net.get(url).send().await.expect("response");
//then //then
assert_eq!(response_alpha.status(), http::StatusCode::OK);
assert_eq!(
response_alpha.bytes().await.expect("response body alpha"),
"Get OK alpha"
);
assert_eq!(response_beta.status(), http::StatusCode::OK);
assert_eq!(
response_beta.bytes().await.expect("response body beta"),
"Get OK beta"
);
assert_eq!(response.status(), http::StatusCode::OK); assert_eq!(response.status(), http::StatusCode::OK);
assert_eq!(response.bytes().await.expect("response body"), "Get OK"); assert_eq!(response.bytes().await.expect("response body"), "Get OK");
} }
@ -152,6 +175,27 @@ async fn test_patch_url() {
assert_eq!(response.bytes().await.expect("response body"), "patch OK"); assert_eq!(response.bytes().await.expect("response body"), "patch OK");
} }
// #[tokio::test]
// async fn test_get_auth_url() {
// //given
// let net = kxio::net::mock();
//
// let url = "https://user:pass@www.example.com";
//
// net.on()
// .get(url)
// .respond(StatusCode::OK)
// .body("post OK")
// .expect("mock");
//
// //when
// let response = Net::from(net).get(url).send().await.expect("reponse");
//
// //then
// assert_eq!(response.status(), http::StatusCode::OK);
// assert_eq!(response.bytes().await.expect("response body"), "post OK");
// }
#[tokio::test] #[tokio::test]
async fn test_get_wrong_url() { async fn test_get_wrong_url() {
//given //given
@ -337,3 +381,153 @@ async fn test_unused_post_as_mocknet() {
//then //then
// Drop implementation for mock_net should panic // Drop implementation for mock_net should panic
} }
#[tokio::test]
async fn test_get_url_with_fragment() {
//given
let net = kxio::net::mock();
let client = net.client();
let url = "https://www.example.com#test";
net.on()
.get(url)
.respond(StatusCode::OK)
.body("post OK")
.expect("mock");
//when
let response = Net::from(net).send(client.get(url)).await.expect("reponse");
//then
assert_eq!(response.status(), http::StatusCode::OK);
assert_eq!(response.bytes().await.expect("response body"), "post OK");
}
#[tokio::test]
async fn test_get_with_query_parameters() {
//given
let mock_net = kxio::net::mock();
let url = "https://www.example.com/path";
mock_net
.on()
.get(url)
.query("key-1", "value-1")
.respond(StatusCode::OK)
.body("with query parameters 1/1")
.expect("mock");
mock_net
.on()
.get(url)
.query("key-1", "value-2")
.respond(StatusCode::OK)
.body("with query parameters 1/2")
.expect("mock");
mock_net
.on()
.get(url)
.query("key-2", "value-2")
.respond(StatusCode::OK)
.body("with query parameters 2/2")
.expect("mock");
mock_net
.on()
.get(url)
.respond(StatusCode::OK)
.body("sans query parameters")
.expect("mock");
let net = Net::from(mock_net);
//when
// The order of 12 nad 11 should be in that order to ensure we test the discrimination of the
// query value when the keys are the same
let response_with_12 = net
.get(url)
.query("key-1", "value-2")
.send()
.await
.expect("response with qp 1/2");
let response_with_11 = net
.get(url)
.query("key-1", "value-1")
.send()
.await
.expect("response with qp 1/1");
let response_with_22 = net
.get(url)
.query("key-2", "value-2")
.send()
.await
.expect("response with qp 2/2");
let response_sans_qp = net.get(url).send().await.expect("response sans qp");
//then
assert_eq!(
response_with_11.bytes().await.expect("with qp 1/1 body"),
"with query parameters 1/1"
);
assert_eq!(
response_with_12.bytes().await.expect("with qp 1/2 body"),
"with query parameters 1/2"
);
assert_eq!(
response_with_22.bytes().await.expect("with qp 2/2 body"),
"with query parameters 2/2"
);
assert_eq!(
response_sans_qp.bytes().await.expect("sans qp body"),
"sans query parameters"
);
}
#[tokio::test]
async fn test_get_with_duplicate_query_keys() {
//given
let mock_net = kxio::net::mock();
let url = "https://www.example.com/path";
mock_net
.on()
.get(url)
.query("key", "value-1")
.query("key", "value-2")
.respond(StatusCode::OK)
.body("key:value-1,value-2")
.expect("mock");
mock_net
.on()
.get(url)
.query("key", "value-3")
.query("key", "value-4")
.respond(StatusCode::OK)
.body("key:value-3,value-4")
.expect("mock");
let net = Net::from(mock_net);
//when
let response_a = net
.get(url)
.query("key", "value-2")
.query("key", "value-1")
.send()
.await
.expect("response a");
let response_b = net
.get(url)
.query("key", "value-3")
.query("key", "value-4")
.send()
.await
.expect("response b");
//then
assert_eq!(
response_a.bytes().await.expect("response a bytes"),
"key:value-1,value-2"
);
assert_eq!(
response_b.bytes().await.expect("response b bytes"),
"key:value-3,value-4"
);
}