diff --git a/mocktail-tests/tests/examples/http_unary.rs b/mocktail-tests/tests/examples/http_unary.rs index 0ee1b13..876c207 100644 --- a/mocktail-tests/tests/examples/http_unary.rs +++ b/mocktail-tests/tests/examples/http_unary.rs @@ -184,3 +184,31 @@ async fn test_unary_headers() -> Result<(), Error> { Ok(()) } + +#[tokio::test] +async fn test_unary_form_body() -> Result<(), Error> { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post() + .header("content-type", "application/x-www-form-urlencoded") + .form(FormBody::new().field("name", "world")); + then.text("hello world"); + }); + + let server = MockServer::new_http("form").with_mocks(mocks); + server.start().await?; + + let client = reqwest::Client::builder().http2_prior_knowledge().build()?; + + let response = client + .post(server.url("/form")) + .form(&[("name", "world")]) + .send() + .await?; + + assert_eq!(response.status(), http::StatusCode::OK); + let res = response.text().await?; + assert_eq!(res, "hello world"); + + Ok(()) +} diff --git a/mocktail/Cargo.toml b/mocktail/Cargo.toml index 6ce7366..3d4fb98 100644 --- a/mocktail/Cargo.toml +++ b/mocktail/Cargo.toml @@ -28,6 +28,7 @@ prost = "0.14" rand = "0.9" serde = "1" serde_json = "1" +serde_urlencoded = "0.7" thiserror = "2" tokio = "1" tokio-stream = "0" diff --git a/mocktail/src/form.rs b/mocktail/src/form.rs new file mode 100644 index 0000000..70d89a6 --- /dev/null +++ b/mocktail/src/form.rs @@ -0,0 +1,50 @@ +use crate::body::Body; + +/// The body of a mock request or response as a form. +#[derive(Debug, Default, Clone, PartialEq, PartialOrd)] +pub struct FormBody { + fields: Vec<(String, String)>, +} + +impl FormBody { + /// Creates a new form. + pub fn new() -> Self { + Self { fields: Vec::new() } + } + + /// Creates an empty form. + pub fn empty() -> Self { + Self::default() + } + + /// Adds a field to the form. + pub fn field(self, name: impl Into, value: impl Into) -> Self { + let mut fields = self.fields; + + fields.push((name.into(), value.into())); + + Self { fields } + } + + /// Converts the form body to a URL-encoded string. + pub fn url_encoded(&self) -> String { + self.fields + .iter() + .map(|(k, v)| format!("{k}={v}")) + .collect::>() + .join("&") + } +} + +impl PartialEq for FormBody { + fn eq(&self, other: &Body) -> bool { + let mut body_copy = other.clone(); + let other_form_body = + serde_urlencoded::from_bytes::>(&body_copy.as_bytes()); + + match other_form_body { + Ok(other) => self.fields == other, + Err(_) => false, + } + } +} diff --git a/mocktail/src/lib.rs b/mocktail/src/lib.rs index add8bdc..5ecd143 100644 --- a/mocktail/src/lib.rs +++ b/mocktail/src/lib.rs @@ -1,5 +1,6 @@ #![doc = include_str!("../README.md")] pub mod body; +pub mod form; mod headers; pub use headers::Headers; pub mod matchers; @@ -18,6 +19,7 @@ pub use status::{Code, StatusCode}; pub mod prelude { pub use crate::{ body::Body, + form::FormBody, headers::Headers, matchers::*, mock::Mock, diff --git a/mocktail/src/matchers.rs b/mocktail/src/matchers.rs index 7b34946..d4a5cd7 100644 --- a/mocktail/src/matchers.rs +++ b/mocktail/src/matchers.rs @@ -2,7 +2,7 @@ use std::{any::Any, borrow::Cow, cmp::Ordering}; use super::{body::Body, headers::Headers, request::Request}; -use crate::request::Method; +use crate::{form::FormBody, request::Method}; /// A matcher. pub trait Matcher: std::fmt::Debug + Send + Sync + 'static + AsMatcherEq { @@ -97,6 +97,23 @@ pub fn body(body: Body) -> BodyMatcher { BodyMatcher(body) } +/// Form body matcher. +#[derive(Debug, PartialEq, PartialOrd)] +pub struct FormMatcher(FormBody); + +impl Matcher for FormMatcher { + fn name(&self) -> &str { + "form_body" + } + fn matches(&self, req: &Request) -> bool { + self.0 == req.body + } +} + +pub fn form(body: FormBody) -> FormMatcher { + FormMatcher(body) +} + /// Headers matcher. #[derive(Debug, PartialEq, PartialOrd)] pub struct HeadersMatcher(Headers); diff --git a/mocktail/src/mock_builder/when.rs b/mocktail/src/mock_builder/when.rs index 30d9559..98458a7 100644 --- a/mocktail/src/mock_builder/when.rs +++ b/mocktail/src/mock_builder/when.rs @@ -5,9 +5,9 @@ use bytes::Bytes; use crate::{ body::Body, + form::FormBody, headers::{HeaderName, HeaderValue, Headers}, - matchers, - matchers::Matcher, + matchers::{self, Matcher}, request::Method, }; @@ -66,6 +66,12 @@ impl When { self } + /// Form Body. + pub fn form(self, form_body: FormBody) -> Self { + self.push(matchers::form(form_body)); + self + } + /// Headers. /// /// Cannonicalizes passed in header name and values to ensure matching is done in a