pavex/request/body/
url_encoded.rs1use 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)]
17pub 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
64fn 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
93fn 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 fn missing_form_field() {
204 #[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 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 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}