pavex/request/body/
json.rs

1use 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)]
16/// Parse the body of an incoming request as JSON.
17///
18/// # Guide
19///
20/// Check out the [relevant section](https://pavex.dev/docs/guide/request_data/body/json/)
21/// of Pavex's guide for a thorough introduction to `JsonBody`.
22///
23/// # Example
24///
25/// ```rust
26/// use pavex::request::body::JsonBody;
27///
28/// // You must derive `serde::Deserialize` for the type you want to extract,
29/// // in this case `HomeListing`.
30/// #[derive(serde::Deserialize)]
31/// pub struct HomeListing {
32///     address: String,
33///     price: u64,
34/// }
35///
36/// // The `Json` extractor deserializes the request body into
37/// // the type you specified—`HomeListing` in this case.
38/// pub fn get_home(body: &JsonBody<HomeListing>) -> String {
39///     format!(
40///         "The home you want to sell for {} is located at {}",
41///         body.0.price,
42///         body.0.address
43///     )
44/// }
45/// ```
46pub struct JsonBody<T>(pub T);
47
48#[methods]
49impl<T> JsonBody<T> {
50    /// The default constructor for [`JsonBody`].
51    ///
52    /// The extraction can fail for a number of reasons:
53    ///
54    /// - the `Content-Type` is missing
55    /// - the `Content-Type` header is not set to `application/json` or another `application/*+json` MIME type
56    /// - the request body is not a valid JSON document
57    ///
58    /// In all of the above cases, an [`ExtractJsonBodyError`] is returned.
59    // # Implementation notes
60    //
61    // We are using two separate lifetimes here to make it clear to the compiler
62    // that `JsonBody` doesn't borrow from `RequestHead`.
63    #[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
79/// Check that the `Content-Type` header is set to `application/json`, or another
80/// `application/*+json` MIME type.
81///
82/// Return an error otherwise.
83fn 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    /// Let's check the error quality when the request body is missing
198    /// a required field.
199    fn missing_json_field() {
200        // Arrange
201        #[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        // Act
226        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        // Assert
233        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}