Skip to main content

backbone_core/
extractors.rs

1//! Request body extractors.
2//!
3//! [`JsonOrForm`] accepts a request body as EITHER `application/json` OR
4//! `application/x-www-form-urlencoded` (defaulting to JSON when the
5//! `Content-Type` is absent/unknown), so endpoints built with
6//! [`crate::http::BackboneCrudHandler`] — and any handler using it — work with
7//! both content types. Drop-in replacement for `Json(x): Json<T>` /
8//! `Form(x): Form<T>` in handler signatures and route closures.
9//!
10//! A body that cannot be read as `T` is refused with the standard JSON error envelope
11//! ([`ApiResponse`] with `success: false`), never axum's plain-text rejection: malformed JSON
12//! is a 400 with a `malformed_body` violation, and a well-formed body with a wrong or missing
13//! value is a 422 whose violation names the field (`lines/3/amount`).
14//!
15//! [`StrictJsonOrForm`] additionally refuses a JSON body carrying a key `T` does not take
16//! (422, code `unknown_field`, the key as path): serde drops such a key silently, and the
17//! caller's edit with it.
18
19use axum::{
20    async_trait,
21    body::Bytes,
22    extract::{FromRequest, Request},
23    http::{header::CONTENT_TYPE, StatusCode},
24    response::{IntoResponse, Response},
25    Json,
26};
27use serde::de::DeserializeOwned;
28
29use crate::http::ApiResponse;
30use crate::violation::Violation;
31
32/// Body extractor that accepts JSON or url-encoded form data. Keys the target type does not
33/// take are ignored; see [`StrictJsonOrForm`] for the variant that refuses them.
34pub struct JsonOrForm<T>(pub T);
35
36/// [`JsonOrForm`] that refuses a JSON body carrying a key the target type does not take.
37///
38/// The keys a record's own response carries but no write takes — `id`, `metadata` and the
39/// `created_at` / `updated_at` / `deleted_at` timestamps, in either casing — are tolerated, so
40/// a client that sends back the record it read is not refused for them. Form bodies stay
41/// lenient: a browser form posts its buttons and hidden inputs too.
42pub struct StrictJsonOrForm<T>(pub T);
43
44#[async_trait]
45impl<S, T> FromRequest<S> for JsonOrForm<T>
46where
47    S: Send + Sync,
48    T: DeserializeOwned + 'static,
49{
50    type Rejection = Response;
51
52    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
53        decode_body(req, state, false).await.map(Self)
54    }
55}
56
57#[async_trait]
58impl<S, T> FromRequest<S> for StrictJsonOrForm<T>
59where
60    S: Send + Sync,
61    T: DeserializeOwned + 'static,
62{
63    type Rejection = Response;
64
65    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
66        decode_body(req, state, true).await.map(Self)
67    }
68}
69
70/// Keys a client may echo back from a record it read although no write takes them.
71const ECHOED_KEYS: &[&str] = &[
72    "id",
73    "metadata",
74    "created_at",
75    "updated_at",
76    "deleted_at",
77    "createdAt",
78    "updatedAt",
79    "deletedAt",
80];
81
82async fn decode_body<S, T>(req: Request, state: &S, strict: bool) -> Result<T, Response>
83where
84    S: Send + Sync,
85    T: DeserializeOwned,
86{
87    let is_form = req
88        .headers()
89        .get(CONTENT_TYPE)
90        .and_then(|v| v.to_str().ok())
91        .map(|ct| ct.starts_with("application/x-www-form-urlencoded"))
92        .unwrap_or(false);
93    let bytes = Bytes::from_request(req, state).await.map_err(|rejection| {
94        let status = rejection.status();
95        refusal(
96            status,
97            rejection.body_text(),
98            Violation::new("", "unreadable_body", "The request body could not be read"),
99        )
100    })?;
101
102    if is_form {
103        return serde_urlencoded::from_bytes(&bytes).map_err(|e| {
104            let message = e.to_string();
105            refusal(
106                StatusCode::UNPROCESSABLE_ENTITY,
107                format!("invalid form body: {message}"),
108                Violation::new("", "invalid_value", message),
109            )
110        });
111    }
112    decode_json(&bytes, strict)
113}
114
115/// A body refused before it reached the service: its status, sentence and violations.
116pub(crate) struct Refusal {
117    status: StatusCode,
118    error: String,
119    violations: Vec<Violation>,
120}
121
122impl Refusal {
123    fn new(status: StatusCode, error: String, violations: Vec<Violation>) -> Self {
124        Self {
125            status,
126            error,
127            violations,
128        }
129    }
130
131    /// Place the refusal on item `index` of a batch body.
132    pub(crate) fn at_item(mut self, index: usize) -> Self {
133        for v in &mut self.violations {
134            v.path = crate::violation::batch_item_path(index, &v.path);
135        }
136        self.error = format!("item {index}: {}", self.error);
137        self
138    }
139
140    pub(crate) fn into_response(self) -> Response {
141        (
142            self.status,
143            Json(ApiResponse::<()>::rejected(self.error, self.violations)),
144        )
145            .into_response()
146    }
147}
148
149/// Read `bytes` as JSON into `T`, refusing with the field the failure is at; with `strict`,
150/// also refusing a key `T` does not take (other than [`ECHOED_KEYS`]).
151// A rejection is a `Response`, as every axum extractor's is; it is built once per refused body.
152#[allow(clippy::result_large_err)]
153pub(crate) fn decode_json<T: DeserializeOwned>(bytes: &[u8], strict: bool) -> Result<T, Response> {
154    decode_json_or_refusal(bytes, strict).map_err(Refusal::into_response)
155}
156
157/// [`decode_json`], with the refusal still open to be placed on a batch item.
158pub(crate) fn decode_json_or_refusal<T: DeserializeOwned>(
159    bytes: &[u8],
160    strict: bool,
161) -> Result<T, Refusal> {
162    let mut ignored: Vec<String> = Vec::new();
163    let mut de = serde_json::Deserializer::from_slice(bytes);
164    let decoded: Result<T, serde_path_to_error::Error<serde_json::Error>> = if strict {
165        let mut record = |path: serde_ignored::Path<'_>| {
166            if let Some(path) = ignored_key(&path) {
167                ignored.push(path);
168            }
169        };
170        let tracked = serde_ignored::Deserializer::new(&mut de, &mut record);
171        serde_path_to_error::deserialize(tracked)
172    } else {
173        serde_path_to_error::deserialize(&mut de)
174    };
175    let value = match decoded {
176        Ok(value) => value,
177        Err(e) => {
178            let path = path_segments(e.path());
179            return Err(json_refusal(path, e.into_inner()));
180        }
181    };
182    // Trailing characters after the value are a malformed body, not a value to keep.
183    if let Err(e) = de.end() {
184        return Err(json_refusal(Vec::new(), e));
185    }
186    if !ignored.is_empty() {
187        ignored.sort();
188        ignored.dedup();
189        let violations = ignored
190            .iter()
191            .map(|path| {
192                let key = path.rsplit('/').next().unwrap_or(path);
193                Violation::new(
194                    path.clone(),
195                    "unknown_field",
196                    format!("unknown_field: `{key}` is not a field this write accepts"),
197                )
198            })
199            .collect::<Vec<_>>();
200        let sentence = violations
201            .iter()
202            .map(|v| v.message.as_str())
203            .collect::<Vec<_>>()
204            .join("; ");
205        return Err(Refusal::new(
206            StatusCode::UNPROCESSABLE_ENTITY,
207            format!("validation failed: {sentence}"),
208            violations,
209        ));
210    }
211    Ok(value)
212}
213
214/// The slash path of a key serde ignored, unless it is one a client may echo back.
215fn ignored_key(path: &serde_ignored::Path<'_>) -> Option<String> {
216    let mut segments = Vec::new();
217    collect_segments(path, &mut segments);
218    let echoed = segments.len() <= 2
219        && segments
220            .last()
221            .is_some_and(|(key, is_key)| *is_key && ECHOED_KEYS.contains(&key.as_str()))
222        && (segments.len() == 1 || !segments[0].1);
223    if echoed || segments.is_empty() {
224        return None;
225    }
226    Some(
227        segments
228            .into_iter()
229            .map(|(s, _)| s)
230            .collect::<Vec<_>>()
231            .join("/"),
232    )
233}
234
235/// Flatten an ignored-key path into `(segment, is_map_key)` from the root down.
236fn collect_segments(path: &serde_ignored::Path<'_>, out: &mut Vec<(String, bool)>) {
237    use serde_ignored::Path;
238    match path {
239        Path::Root => {}
240        Path::Seq { parent, index } => {
241            collect_segments(parent, out);
242            out.push((index.to_string(), false));
243        }
244        Path::Map { parent, key } => {
245            collect_segments(parent, out);
246            out.push((key.clone(), true));
247        }
248        Path::Some { parent }
249        | Path::NewtypeStruct { parent }
250        | Path::NewtypeVariant { parent } => collect_segments(parent, out),
251    }
252}
253
254/// The segments of the path serde failed at, root first.
255fn path_segments(path: &serde_path_to_error::Path) -> Vec<String> {
256    path.iter()
257        .filter_map(|segment| match segment {
258            serde_path_to_error::Segment::Seq { index } => Some(index.to_string()),
259            serde_path_to_error::Segment::Map { key } => Some(key.clone()),
260            serde_path_to_error::Segment::Enum { variant } => Some(variant.clone()),
261            serde_path_to_error::Segment::Unknown => None,
262        })
263        .collect()
264}
265
266/// The refusal for a JSON body serde could not read at `path`.
267fn json_refusal(mut path: Vec<String>, inner: serde_json::Error) -> Refusal {
268    use serde_json::error::Category;
269    let message = without_position(&inner.to_string());
270    match inner.classify() {
271        Category::Syntax | Category::Eof | Category::Io => Refusal::new(
272            StatusCode::BAD_REQUEST,
273            format!("malformed JSON body: {message}"),
274            vec![Violation::new(
275                "",
276                "malformed_body",
277                format!("The request body is not valid JSON: {message}"),
278            )],
279        ),
280        Category::Data => {
281            let (code, text) = match missing_field(&message) {
282                Some(field) => {
283                    path.push(field.to_string());
284                    ("required", format!("{field} is required"))
285                }
286                None => ("invalid_value", message.clone()),
287            };
288            let path = path.join("/");
289            let sentence = if path.is_empty() {
290                text.clone()
291            } else {
292                format!("{path}: {text}")
293            };
294            Refusal::new(
295                StatusCode::UNPROCESSABLE_ENTITY,
296                format!("validation failed: {sentence}"),
297                vec![Violation::new(path, code, text)],
298            )
299        }
300    }
301}
302
303/// The field a serde "missing field `x`" message names.
304fn missing_field(message: &str) -> Option<&str> {
305    message.strip_prefix("missing field `")?.split('`').next()
306}
307
308/// Drop serde_json's " at line N column M" suffix: the client has no use for byte positions
309/// in a body it built as an object.
310fn without_position(message: &str) -> String {
311    match message.rfind(" at line ") {
312        Some(at) => message[..at].to_string(),
313        None => message.to_string(),
314    }
315}
316
317fn refusal(status: StatusCode, error: String, violation: Violation) -> Response {
318    (
319        status,
320        Json(ApiResponse::<()>::rejected(error, vec![violation])),
321    )
322        .into_response()
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use serde::Deserialize;
329
330    #[derive(Debug, Deserialize)]
331    #[serde(rename_all = "camelCase")]
332    #[allow(dead_code)]
333    struct Line {
334        amount: i64,
335        #[serde(alias = "unit_price", default)]
336        unit_price: Option<i64>,
337    }
338
339    #[derive(Debug, Deserialize)]
340    #[allow(dead_code)]
341    struct Doc {
342        name: String,
343        #[serde(default)]
344        lines: Vec<Line>,
345    }
346
347    async fn body_of(r: Response) -> (StatusCode, serde_json::Value) {
348        let status = r.status();
349        let bytes = axum::body::to_bytes(r.into_body(), usize::MAX)
350            .await
351            .unwrap();
352        (status, serde_json::from_slice(&bytes).unwrap())
353    }
354
355    #[tokio::test]
356    async fn malformed_json_is_a_400_envelope() {
357        let (status, body) = body_of(decode_json::<Doc>(b"{\"name\": ", false).unwrap_err()).await;
358        assert_eq!(status, StatusCode::BAD_REQUEST);
359        assert_eq!(body["success"], false);
360        assert_eq!(body["violations"][0]["code"], "malformed_body");
361    }
362
363    #[tokio::test]
364    async fn a_wrong_value_names_its_field() {
365        let raw = br#"{"name":"a","lines":[{"amount":1},{"amount":"x"}]}"#;
366        let (status, body) = body_of(decode_json::<Doc>(raw, false).unwrap_err()).await;
367        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
368        assert_eq!(body["violations"][0]["path"], "lines/1/amount");
369        assert_eq!(body["violations"][0]["code"], "invalid_value");
370    }
371
372    #[tokio::test]
373    async fn a_missing_field_is_required_on_that_field() {
374        let (status, body) =
375            body_of(decode_json::<Doc>(br#"{"lines":[]}"#, false).unwrap_err()).await;
376        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
377        assert_eq!(body["violations"][0]["path"], "name");
378        assert_eq!(body["violations"][0]["code"], "required");
379    }
380
381    #[tokio::test]
382    async fn strict_refuses_a_key_the_type_does_not_take() {
383        let raw = br#"{"name":"a","colour":"red","lines":[{"amount":1,"discount":2}]}"#;
384        assert!(
385            decode_json::<Doc>(raw, false).is_ok(),
386            "lenient decoding ignores the key"
387        );
388        let (status, body) = body_of(decode_json::<Doc>(raw, true).unwrap_err()).await;
389        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
390        let paths: Vec<&str> = body["violations"]
391            .as_array()
392            .unwrap()
393            .iter()
394            .map(|v| v["path"].as_str().unwrap())
395            .collect();
396        assert_eq!(paths, vec!["colour", "lines/0/discount"]);
397        assert_eq!(body["violations"][0]["code"], "unknown_field");
398    }
399
400    #[test]
401    fn strict_tolerates_what_a_response_echoes_and_both_casings() {
402        let raw = br#"{"id":"x","metadata":{},"createdAt":"t","name":"a","lines":[{"amount":1,"unitPrice":2},{"amount":1,"unit_price":3}]}"#;
403        assert!(decode_json::<Doc>(raw, true).is_ok());
404        // Echoed keys are tolerated on each item of a batch too.
405        assert!(decode_json::<Vec<Doc>>(br#"[{"id":"x","name":"a"}]"#, true).is_ok());
406        let err = decode_json::<Vec<Doc>>(br#"[{"name":"a"},{"name":"b","bogus":1}]"#, true);
407        assert!(err.is_err());
408    }
409}