pavex/request/body/
json.rs1use crate::request::RequestHead;
2use http::HeaderMap;
3use pavex_macros::methods;
4use serde::Deserialize;
5
6use super::{
7 buffered_body::BufferedBody,
8 errors::{
9 ExtractJsonBodyError, JsonContentTypeMismatch, JsonDeserializationError,
10 MissingJsonContentType,
11 },
12};
13
14#[doc(alias = "Json")]
15#[derive(Debug)]
16pub struct JsonBody<T>(pub T);
47
48#[methods]
49impl<T> JsonBody<T> {
50 #[request_scoped(pavex = crate, id = "JSON_BODY_EXTRACT")]
64 pub fn extract<'head, 'body>(
65 request_head: &'head RequestHead,
66 buffered_body: &'body BufferedBody,
67 ) -> Result<Self, ExtractJsonBodyError>
68 where
69 T: Deserialize<'body>,
70 {
71 check_json_content_type(&request_head.headers)?;
72 let mut deserializer = serde_json::Deserializer::from_slice(buffered_body.bytes.as_ref());
73 let body = serde_path_to_error::deserialize(&mut deserializer)
74 .map_err(|e| JsonDeserializationError { source: e })?;
75 Ok(JsonBody(body))
76 }
77}
78
79fn check_json_content_type(headers: &HeaderMap) -> Result<(), ExtractJsonBodyError> {
84 let Some(content_type) = headers.get(http::header::CONTENT_TYPE) else {
85 return Err(MissingJsonContentType.into());
86 };
87 let Ok(content_type) = content_type.to_str() else {
88 return Err(MissingJsonContentType.into());
89 };
90
91 let Ok(mime) = content_type.parse::<mime::Mime>() else {
92 return Err(JsonContentTypeMismatch {
93 actual: content_type.to_string(),
94 }
95 .into());
96 };
97
98 let is_json_content_type = mime.type_() == "application"
99 && (mime.subtype() == "json" || mime.suffix().is_some_and(|name| name == "json"));
100 if !is_json_content_type {
101 return Err(JsonContentTypeMismatch {
102 actual: content_type.to_string(),
103 }
104 .into());
105 }
106 Ok(())
107}
108
109#[cfg(test)]
110mod tests {
111 use crate::request::body::JsonBody;
112
113 #[test]
114 fn missing_content_type() {
115 let headers = http::HeaderMap::new();
116 let err = super::check_json_content_type(&headers).unwrap_err();
117 insta::assert_snapshot!(err, @"The `Content-Type` header is missing. This endpoint expects requests with a `Content-Type` header set to `application/json`, or another `application/*+json` MIME type");
118 insta::assert_debug_snapshot!(err, @r###"
119 MissingContentType(
120 MissingJsonContentType,
121 )
122 "###);
123 }
124
125 #[test]
126 fn content_type_is_not_valid_mime() {
127 let mut headers = http::HeaderMap::new();
128 headers.insert(http::header::CONTENT_TYPE, "hello world".parse().unwrap());
129
130 let err = super::check_json_content_type(&headers).unwrap_err();
131 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/json`, or another `application/*+json` MIME type");
132 insta::assert_debug_snapshot!(err, @r###"
133 ContentTypeMismatch(
134 JsonContentTypeMismatch {
135 actual: "hello world",
136 },
137 )
138 "###);
139 }
140
141 #[test]
142 fn content_type_is_not_json() {
143 let mut headers = http::HeaderMap::new();
144 headers.insert(
145 http::header::CONTENT_TYPE,
146 "application/xml".parse().unwrap(),
147 );
148
149 let err = super::check_json_content_type(&headers).unwrap_err();
150 insta::assert_snapshot!(err, @"The `Content-Type` header was set to `application/xml`. This endpoint expects requests with a `Content-Type` header set to `application/json`, or another `application/*+json` MIME type");
151 insta::assert_debug_snapshot!(err, @r###"
152 ContentTypeMismatch(
153 JsonContentTypeMismatch {
154 actual: "application/xml",
155 },
156 )
157 "###);
158 }
159
160 #[test]
161 fn content_type_is_json() {
162 let mut headers = http::HeaderMap::new();
163 headers.insert(
164 http::header::CONTENT_TYPE,
165 "application/json".parse().unwrap(),
166 );
167
168 let outcome = super::check_json_content_type(&headers);
169 assert!(outcome.is_ok());
170 }
171
172 #[test]
173 fn content_type_has_json_suffix() {
174 let mut headers = http::HeaderMap::new();
175 headers.insert(
176 http::header::CONTENT_TYPE,
177 "application/hal+json".parse().unwrap(),
178 );
179
180 let outcome = super::check_json_content_type(&headers);
181 assert!(outcome.is_ok());
182 }
183
184 #[test]
185 fn json_content_type_with_charset() {
186 let mut headers = http::HeaderMap::new();
187 headers.insert(
188 http::header::CONTENT_TYPE,
189 "application/json; charset=utf-8".parse().unwrap(),
190 );
191
192 let outcome = super::check_json_content_type(&headers);
193 assert!(outcome.is_ok());
194 }
195
196 #[test]
197 fn missing_json_field() {
200 #[derive(serde::Deserialize, Debug)]
202 #[allow(dead_code)]
203 struct BodySchema {
204 name: String,
205 surname: String,
206 age: u8,
207 }
208
209 let mut headers = http::HeaderMap::new();
210 headers.insert(
211 http::header::CONTENT_TYPE,
212 "application/json; charset=utf-8".parse().unwrap(),
213 );
214 let request_head = crate::request::RequestHead {
215 headers,
216 method: http::Method::GET,
217 target: "/".parse().unwrap(),
218 version: http::Version::HTTP_11,
219 };
220 let body = serde_json::json!({
221 "name": "John Doe",
222 "age": 43,
223 });
224
225 let buffered_body = crate::request::body::BufferedBody {
227 bytes: serde_json::to_vec(&body).unwrap().into(),
228 };
229 let outcome: Result<JsonBody<BodySchema>, _> =
230 JsonBody::extract(&request_head, &buffered_body);
231
232 let err = outcome.unwrap_err();
234 insta::assert_snapshot!(err, @r###"
235 Failed to deserialize the body as a JSON document.
236 missing field `surname` at line 1 column 28
237 "###);
238 insta::assert_debug_snapshot!(err, @r###"
239 DeserializationError(
240 JsonDeserializationError {
241 source: Error {
242 path: Path {
243 segments: [],
244 },
245 original: Error("missing field `surname`", line: 1, column: 28),
246 },
247 },
248 )
249 "###);
250 }
251}