pavex/request/body/
url_encoded.rs

1use crate::request::RequestHead;
2use crate::request::body::BufferedBody;
3use crate::request::body::errors::{
4    ExtractUrlEncodedBodyError, MissingUrlEncodedContentType, UrlEncodedBodyDeserializationError,
5    UrlEncodedContentTypeMismatch,
6};
7use http::HeaderMap;
8use pavex_macros::methods;
9use serde::Deserialize;
10
11#[doc(alias = "UrlEncoded")]
12#[doc(alias = "Form")]
13#[doc(alias = "FormBody")]
14#[doc(alias = "PercentEncoded")]
15#[doc(alias = "PercentEncodedBody")]
16#[derive(Debug)]
17/// Parse a URL-encoded request body, such as a web form.
18///
19/// # Guide
20///
21/// Check out the [relevant section](https://pavex.dev/docs/guide/request_data/body/url_encoded/)
22/// of Pavex's guide for a thorough introduction to `UrlEncodedBody`.
23///
24/// # Example
25///
26/// ```rust
27/// use pavex::request::body::UrlEncodedBody;
28///
29/// // You must derive `serde::Deserialize` for the type you want to extract,
30/// // in this case `HomeListing`.
31/// #[derive(serde::Deserialize)]
32/// pub struct HomeListing {
33///     address: String,
34///     price: u64,
35/// }
36///
37/// // The `UrlEncodedBody` extractor deserializes the request body into
38/// // the type you specified—`HomeListing` in this case.
39/// pub fn get_home(body: &UrlEncodedBody<HomeListing>) -> String {
40///     format!(
41///         "The home you want to sell for {} is located at {}",
42///         body.0.price,
43///         body.0.address
44///     )
45/// }
46/// ```
47pub struct UrlEncodedBody<T>(pub T);
48
49#[methods]
50impl<T> UrlEncodedBody<T> {
51    #[request_scoped(pavex = crate, id = "URL_ENCODED_BODY_EXTRACT")]
52    pub fn extract<'head, 'body>(
53        request_head: &'head RequestHead,
54        buffered_body: &'body BufferedBody,
55    ) -> Result<Self, ExtractUrlEncodedBodyError>
56    where
57        T: Deserialize<'body>,
58    {
59        check_urlencoded_content_type(&request_head.headers)?;
60        parse(buffered_body.bytes.as_ref()).map(UrlEncodedBody)
61    }
62}
63
64/// Check that the `Content-Type` header is set to `application/x-www-form-urlencoded`.
65///
66/// Return an error otherwise.
67fn check_urlencoded_content_type(headers: &HeaderMap) -> Result<(), ExtractUrlEncodedBodyError> {
68    let Some(content_type) = headers.get(http::header::CONTENT_TYPE) else {
69        return Err(MissingUrlEncodedContentType.into());
70    };
71    let Ok(content_type) = content_type.to_str() else {
72        return Err(MissingUrlEncodedContentType.into());
73    };
74
75    let Ok(mime) = content_type.parse::<mime::Mime>() else {
76        return Err(UrlEncodedContentTypeMismatch {
77            actual: content_type.to_string(),
78        }
79        .into());
80    };
81
82    let is_urlencoded_content_type =
83        mime.type_() == mime::APPLICATION && mime.subtype() == mime::WWW_FORM_URLENCODED;
84    if !is_urlencoded_content_type {
85        return Err(UrlEncodedContentTypeMismatch {
86            actual: content_type.to_string(),
87        }
88        .into());
89    };
90    Ok(())
91}
92
93/// Parse bytes into `T`.
94fn parse<'a, T>(bytes: &'a [u8]) -> Result<T, ExtractUrlEncodedBodyError>
95where
96    T: Deserialize<'a>,
97{
98    serde_html_form::from_bytes(bytes)
99        .map_err(|e| UrlEncodedBodyDeserializationError { source: e })
100        .map_err(ExtractUrlEncodedBodyError::DeserializationError)
101}
102
103#[cfg(test)]
104mod tests {
105    use crate::request::body::UrlEncodedBody;
106    use std::borrow::Cow;
107
108    #[test]
109    fn test_parse() {
110        #[derive(serde::Deserialize, Debug, PartialEq)]
111        struct Home<'a> {
112            home_id: u32,
113            home_price: f64,
114            home_name: Cow<'a, str>,
115        }
116
117        let query = "home_id=1&home_price=0.1&home_name=Hi%20there";
118        let expected = Home {
119            home_id: 1,
120            home_price: 0.1,
121            home_name: Cow::Borrowed("Hi there"),
122        };
123        let actual: Home = crate::request::body::url_encoded::parse(query.as_bytes()).unwrap();
124        assert_eq!(expected, actual);
125    }
126
127    #[test]
128    fn missing_content_type() {
129        let headers = http::HeaderMap::new();
130        let err = super::check_urlencoded_content_type(&headers).unwrap_err();
131        insta::assert_snapshot!(err, @"The `Content-Type` header is missing. This endpoint expects requests with a `Content-Type` header set to `application/x-www-form-urlencoded`");
132        insta::assert_debug_snapshot!(err, @r###"
133        MissingContentType(
134            MissingUrlEncodedContentType,
135        )
136        "###);
137    }
138
139    #[test]
140    fn content_type_is_not_valid_mime() {
141        let mut headers = http::HeaderMap::new();
142        headers.insert(http::header::CONTENT_TYPE, "hello world".parse().unwrap());
143
144        let err = super::check_urlencoded_content_type(&headers).unwrap_err();
145        insta::assert_snapshot!(err, @"The `Content-Type` header was set to `hello world`. This endpoint expects requests with a `Content-Type` header set to `application/x-www-form-urlencoded`");
146        insta::assert_debug_snapshot!(err, @r###"
147        ContentTypeMismatch(
148            UrlEncodedContentTypeMismatch {
149                actual: "hello world",
150            },
151        )
152        "###);
153    }
154
155    #[test]
156    fn content_type_is_not_form() {
157        let mut headers = http::HeaderMap::new();
158        headers.insert(
159            http::header::CONTENT_TYPE,
160            "application/json".parse().unwrap(),
161        );
162
163        let err = super::check_urlencoded_content_type(&headers).unwrap_err();
164        insta::assert_snapshot!(err, @"The `Content-Type` header was set to `application/json`. This endpoint expects requests with a `Content-Type` header set to `application/x-www-form-urlencoded`");
165        insta::assert_debug_snapshot!(err, @r###"
166        ContentTypeMismatch(
167            UrlEncodedContentTypeMismatch {
168                actual: "application/json",
169            },
170        )
171        "###);
172    }
173
174    #[test]
175    fn content_type_is_form() {
176        let mut headers = http::HeaderMap::new();
177        headers.insert(
178            http::header::CONTENT_TYPE,
179            "application/x-www-form-urlencoded".parse().unwrap(),
180        );
181
182        let outcome = super::check_urlencoded_content_type(&headers);
183        assert!(outcome.is_ok());
184    }
185
186    #[test]
187    fn form_content_type_with_charset() {
188        let mut headers = http::HeaderMap::new();
189        headers.insert(
190            http::header::CONTENT_TYPE,
191            "application/x-www-form-urlencoded; charset=utf-8"
192                .parse()
193                .unwrap(),
194        );
195
196        let outcome = super::check_urlencoded_content_type(&headers);
197        assert!(outcome.is_ok());
198    }
199
200    #[test]
201    /// Let's check the error quality when the request body is missing
202    /// a required field.
203    fn missing_form_field() {
204        // Arrange
205        #[derive(serde::Deserialize, Debug)]
206        #[allow(dead_code)]
207        struct BodySchema {
208            name: String,
209            surname: String,
210            age: u8,
211        }
212
213        let mut headers = http::HeaderMap::new();
214        headers.insert(
215            http::header::CONTENT_TYPE,
216            "application/x-www-form-urlencoded".parse().unwrap(),
217        );
218        let request_head = crate::request::RequestHead {
219            headers,
220            method: http::Method::POST,
221            version: http::Version::HTTP_11,
222            target: "/".parse().unwrap(),
223        };
224        let body = "name=John%20Doe&age=43".to_string();
225
226        // Act
227        let buffered_body = crate::request::body::BufferedBody { bytes: body.into() };
228        let outcome: Result<UrlEncodedBody<BodySchema>, _> =
229            UrlEncodedBody::extract(&request_head, &buffered_body);
230
231        // Assert
232        let err = outcome.unwrap_err();
233        insta::assert_snapshot!(err, @r###"
234        Failed to deserialize the body as a urlencoded form.
235        missing field `surname`
236        "###);
237        insta::assert_debug_snapshot!(err, @r###"
238        DeserializationError(
239            UrlEncodedBodyDeserializationError {
240                source: Error(
241                    "missing field `surname`",
242                ),
243            },
244        )
245        "###);
246    }
247}