diff --git a/src/matchers.rs b/src/matchers.rs index 62f688a..5ca9028 100644 --- a/src/matchers.rs +++ b/src/matchers.rs @@ -1004,6 +1004,216 @@ impl Match for QueryParamIsMissingMatcher { !request.url.query_pairs().any(|(k, _)| k == self.0) } } + +#[derive(Debug)] +/// Match **exactly** the form url encoded field of a request. +/// +/// ### Example: +/// ```rust +/// use wiremock::{MockServer, Mock, ResponseTemplate}; +/// use wiremock::matchers::form_url_encoded; +/// use url::form_urlencoded; +/// +/// #[async_std::main] +/// async fn main() { +/// // Arrange +/// let mock_server = MockServer::start().await; +/// +/// Mock::given(form_url_encoded("hello", "world")) +/// .respond_with(ResponseTemplate::new(200)) +/// .mount(&mock_server) +/// .await; +/// +/// let form = form_urlencoded::Serializer::new(String::new()) +/// .append_pair("hello", "world") +/// .finish(); +/// +/// let client = reqwest::Client::new(); +/// +/// // Act +/// let status = client.post(&mock_server.uri()) +/// .body(form) +/// .send() +/// .await +/// .unwrap() +/// .status(); +/// +/// // Assert +/// assert_eq!(status, 200); +/// } +/// ``` +pub struct FormUrlEncodedExactMatcher(String, String); + +impl FormUrlEncodedExactMatcher { + /// Specify the expected value for a field inside the form url encoded body. + pub fn new, V: Into>(key: K, value: V) -> Self { + let key = key.into(); + let value = value.into(); + Self(key, value) + } +} + +/// Shorthand for [`FormUrlEncodedExactMatcher::new`]. +pub fn form_url_encoded(key: K, value: V) -> FormUrlEncodedExactMatcher +where + K: Into, + V: Into, +{ + FormUrlEncodedExactMatcher::new(key, value) +} + +impl Match for FormUrlEncodedExactMatcher { + fn matches(&self, request: &Request) -> bool { + request + .body_form_urlencoded() + .any(|(k, v)| k == self.0 && v == self.1) + } +} + +#[derive(Debug)] +/// Match when the form url encoded body contains the specified value as a substring. +/// +/// ### Example: +/// ```rust +/// use wiremock::{MockServer, Mock, ResponseTemplate}; +/// use wiremock::matchers::form_url_encoded_contains; +/// use url::form_urlencoded; +/// +/// #[async_std::main] +/// async fn main() { +/// // Arrange +/// let mock_server = MockServer::start().await; +/// +/// // It matches since "world" is a substring of "some_world". +/// Mock::given(form_url_encoded_contains("hello", "world")) +/// .respond_with(ResponseTemplate::new(200)) +/// .mount(&mock_server) +/// .await; +/// +/// let form = form_urlencoded::Serializer::new(String::new()) +/// .append_pair("hello", "some_world") +/// .finish(); +/// +/// let client = reqwest::Client::new(); +/// +/// // Act +/// let status = client.post(&mock_server.uri()) +/// .body(form) +/// .send() +/// .await +/// .unwrap() +/// .status(); +/// +/// // Assert +/// assert_eq!(status, 200); +/// } +/// ``` +pub struct FormUrlEncodedContainsMatcher(String, String); + +impl FormUrlEncodedContainsMatcher { + /// Specify the key value pair that the form url encoded body should contain. + pub fn new, V: Into>(key: K, value: V) -> Self { + let key = key.into(); + let value = value.into(); + Self(key, value) + } +} + +/// Shorthand for [`FormUrlEncodedContainsMatcher::new`]. +pub fn form_url_encoded_contains(key: K, value: V) -> FormUrlEncodedContainsMatcher +where + K: Into, + V: Into, +{ + FormUrlEncodedContainsMatcher::new(key, value) +} + +impl Match for FormUrlEncodedContainsMatcher { + fn matches(&self, request: &Request) -> bool { + request + .body_form_urlencoded() + .any(|(k, v)| k == self.0 && v.contains(self.1.as_str())) + } +} + +#[derive(Debug)] +/// Only match requests that do **not** contain a specified form url encoded field. +/// +/// ### Example: +/// ```rust +/// use wiremock::{MockServer, Mock, ResponseTemplate}; +/// use wiremock::matchers::{method, form_url_encoded_field_is_missing}; +/// use url::form_urlencoded; +/// +/// #[async_std::main] +/// async fn main() { +/// // Arrange +/// let mock_server = MockServer::start().await; +/// +/// Mock::given(method("POST")) +/// .and(form_url_encoded_field_is_missing("unexpected")) +/// .respond_with(ResponseTemplate::new(200)) +/// .mount(&mock_server) +/// .await; +/// +/// let form = form_urlencoded::Serializer::new(String::new()) +/// .append_pair("hello", "world") +/// .finish(); +/// +/// let client = reqwest::Client::new(); +/// +/// // Act +/// let ok_status = client.post(mock_server.uri().to_string()) +/// .body(form) +/// .send() +/// .await +/// .unwrap() +/// .status(); +/// +/// // Assert +/// assert_eq!(ok_status, 200); +/// +/// let form = form_urlencoded::Serializer::new(String::new()) +/// .append_pair("unexpected", "foo") +/// .finish(); +/// +/// let client = reqwest::Client::new(); +/// +/// // Act +/// let err_status = client.post(mock_server.uri()) +/// .body(form) +/// .send() +/// .await +/// .unwrap().status(); +/// +/// // Assert +/// assert_eq!(err_status, 404); +/// } +/// ``` +pub struct FormUrlEncodedFieldIsMissingMatcher(String); + +impl FormUrlEncodedFieldIsMissingMatcher { + /// Specify the form field that is expected to not exist. + pub fn new>(key: K) -> Self { + let key = key.into(); + Self(key) + } +} + +/// Shorthand for [`FormUrlEncodedFieldIsMissingMatcher::new`]. +pub fn form_url_encoded_field_is_missing(key: K) -> FormUrlEncodedFieldIsMissingMatcher +where + K: Into, +{ + FormUrlEncodedFieldIsMissingMatcher::new(key) +} + +impl Match for FormUrlEncodedFieldIsMissingMatcher { + fn matches(&self, request: &Request) -> bool { + !request.body_form_urlencoded().any(|(k, _)| k == self.0) + } +} + /// Match an incoming request if its body is encoded as JSON and can be deserialized /// according to the specified schema. /// diff --git a/src/request.rs b/src/request.rs index 14d5aa7..8326d5b 100644 --- a/src/request.rs +++ b/src/request.rs @@ -3,7 +3,7 @@ use std::fmt; use http::{HeaderMap, Method}; use http_body_util::BodyExt; use serde::de::DeserializeOwned; -use url::Url; +use url::{form_urlencoded, Url}; pub const BODY_PRINT_LIMIT: usize = 10_000; @@ -48,6 +48,10 @@ impl Request { serde_json::from_slice(&self.body) } + pub fn body_form_urlencoded(&self) -> form_urlencoded::Parse<'_> { + form_urlencoded::parse(&self.body) + } + pub(crate) async fn from_hyper(request: hyper::Request) -> Request { let (parts, body) = request.into_parts(); let url = match parts.uri.authority() { diff --git a/tests/mocks.rs b/tests/mocks.rs index 7ab4b05..c2aef30 100644 --- a/tests/mocks.rs +++ b/tests/mocks.rs @@ -7,7 +7,11 @@ use std::io::ErrorKind; use std::iter; use std::net::TcpStream; use std::time::Duration; -use wiremock::matchers::{body_json, body_partial_json, method, path, PathExactMatcher}; +use url::form_urlencoded; +use wiremock::matchers::{ + body_json, body_partial_json, form_url_encoded, form_url_encoded_field_is_missing, method, + path, PathExactMatcher, +}; use wiremock::{Mock, MockServer, Request, ResponseTemplate}; #[async_std::test] @@ -228,6 +232,82 @@ async fn body_json_partial_matches_a_part_of_response_json() { assert_eq!(response.status(), StatusCode::OK); } +#[async_std::test] +async fn body_form_matches_independent_of_key_ordering() { + let body = form_urlencoded::Serializer::new(String::new()) + .append_pair("b", "2") + .append_pair("a", "1") + .finish(); + + let mock_server = MockServer::start().await; + let response = ResponseTemplate::new(200); + let mock = Mock::given(method("POST")) + .and(form_url_encoded("a", "1")) + .and(form_url_encoded("b", "2")) + .respond_with(response); + mock_server.register(mock).await; + + let client = reqwest::Client::new(); + + // Act + let response = client + .post(mock_server.uri()) + .body(body) + .send() + .await + .unwrap(); + + // Assert + assert_eq!(response.status(), StatusCode::OK); +} + +#[async_std::test] +async fn body_form_partial_matches() { + let body: String = form_urlencoded::Serializer::new(String::new()) + .append_pair("a", "1") + .append_pair("b", "2") + .finish(); + + let mock_server = MockServer::start().await; + let response = ResponseTemplate::new(200); + let mock = Mock::given(method("POST")) + .and(form_url_encoded_field_is_missing("c")) + .respond_with(response); + mock_server.register(mock).await; + + let client = reqwest::Client::new(); + + // Act + let response = client + .post(mock_server.uri()) + .body(body) + .send() + .await + .unwrap(); + + // Assert + assert_eq!(response.status(), StatusCode::OK); + + let body: String = form_urlencoded::Serializer::new(String::new()) + .append_pair("a", "1") + .append_pair("b", "2") + .append_pair("c", "unexpected") + .finish(); + + let client = reqwest::Client::new(); + + // Act + let err_response = client + .post(mock_server.uri()) + .body(body) + .send() + .await + .unwrap(); + + // Assert + assert_eq!(err_response.status(), StatusCode::NOT_FOUND); +} + #[should_panic(expected = "\ Wiremock can't match the path `abcd?` because it contains a `?`. You must use `wiremock::matchers::query_param` to match on query parameters (the part of the path after the `?`).")] #[async_std::test]