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/// A write body decoded to `T` (strictly, as [`StrictJsonOrForm`]), with the top-level keys it
45/// names beside it — the keys a field permission may refuse a caller who cannot hold the field
46/// ([`crate::http`]'s `field_permissions` gate). JSON keys come from the parsed document; form
47/// bodies are flat, so their keys are the encoded pairs'.
48pub struct WriteBody<T>(pub T, pub Vec<String>);
49
50#[async_trait]
51impl<S, T> FromRequest<S> for JsonOrForm<T>
52where
53    S: Send + Sync,
54    T: DeserializeOwned + 'static,
55{
56    type Rejection = Response;
57
58    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
59        decode_body(req, state, false).await.map(Self)
60    }
61}
62
63#[async_trait]
64impl<S, T> FromRequest<S> for StrictJsonOrForm<T>
65where
66    S: Send + Sync,
67    T: DeserializeOwned + 'static,
68{
69    type Rejection = Response;
70
71    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
72        decode_body(req, state, true).await.map(Self)
73    }
74}
75
76#[async_trait]
77impl<S, T> FromRequest<S> for WriteBody<T>
78where
79    S: Send + Sync,
80    T: DeserializeOwned + 'static,
81{
82    type Rejection = Response;
83
84    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
85        let is_form = req
86            .headers()
87            .get(CONTENT_TYPE)
88            .and_then(|v| v.to_str().ok())
89            .map(|ct| ct.starts_with("application/x-www-form-urlencoded"))
90            .unwrap_or(false);
91        if is_form {
92            // A form write is flat: the encoded pairs' names are the keys the body names, and
93            // the body decodes leniently as a form does.
94            let bytes = Bytes::from_request(req, state).await.map_err(|rejection| {
95                let status = rejection.status();
96                refusal(
97                    status,
98                    rejection.body_text(),
99                    Violation::new("", "unreadable_body", "The request body could not be read"),
100                )
101            })?;
102            let keys = serde_urlencoded::from_bytes::<Vec<(String, String)>>(&bytes)
103                .map(|pairs| pairs.into_iter().map(|(k, _)| k).collect())
104                .unwrap_or_default();
105            let value: T = serde_urlencoded::from_bytes(&bytes).map_err(|e| {
106                let message = e.to_string();
107                refusal(
108                    StatusCode::UNPROCESSABLE_ENTITY,
109                    format!("invalid form body: {message}"),
110                    Violation::new("", "invalid_value", message),
111                )
112            })?;
113            return Ok(Self(value, keys));
114        }
115        let bytes = Bytes::from_request(req, state).await.map_err(|rejection| {
116            let status = rejection.status();
117            refusal(
118                status,
119                rejection.body_text(),
120                Violation::new("", "unreadable_body", "The request body could not be read"),
121            )
122        })?;
123        // Parse once for the keys (the strict unknown-key check below runs on the typed decode;
124        // a key serde drops would be a body the strict form already refuses).
125        let keys = serde_json::from_slice::<serde_json::Value>(&bytes)
126            .ok()
127            .and_then(|v| match v {
128                serde_json::Value::Object(m) => Some(m.keys().cloned().collect()),
129                serde_json::Value::Array(a) => Some(
130                    a.into_iter()
131                        .filter_map(|item| {
132                            item.as_object()
133                                .map(|m| m.keys().cloned().collect::<Vec<_>>())
134                        })
135                        .flatten()
136                        .collect(),
137                ),
138                _ => None,
139            })
140            .unwrap_or_default();
141        let value = decode_json(&bytes, true)?;
142        Ok(Self(value, keys))
143    }
144}
145
146/// Keys a client may echo back from a record it read although no write takes them.
147const ECHOED_KEYS: &[&str] = &[
148    "id",
149    "metadata",
150    "created_at",
151    "updated_at",
152    "deleted_at",
153    "createdAt",
154    "updatedAt",
155    "deletedAt",
156];
157
158async fn decode_body<S, T>(req: Request, state: &S, strict: bool) -> Result<T, Response>
159where
160    S: Send + Sync,
161    T: DeserializeOwned,
162{
163    let is_form = req
164        .headers()
165        .get(CONTENT_TYPE)
166        .and_then(|v| v.to_str().ok())
167        .map(|ct| ct.starts_with("application/x-www-form-urlencoded"))
168        .unwrap_or(false);
169    let bytes = Bytes::from_request(req, state).await.map_err(|rejection| {
170        let status = rejection.status();
171        refusal(
172            status,
173            rejection.body_text(),
174            Violation::new("", "unreadable_body", "The request body could not be read"),
175        )
176    })?;
177
178    if is_form {
179        return serde_urlencoded::from_bytes(&bytes).map_err(|e| {
180            let message = e.to_string();
181            refusal(
182                StatusCode::UNPROCESSABLE_ENTITY,
183                format!("invalid form body: {message}"),
184                Violation::new("", "invalid_value", message),
185            )
186        });
187    }
188    decode_json(&bytes, strict)
189}
190
191/// A body refused before it reached the service: its status, sentence and violations.
192pub(crate) struct Refusal {
193    status: StatusCode,
194    error: String,
195    violations: Vec<Violation>,
196}
197
198impl Refusal {
199    fn new(status: StatusCode, error: String, violations: Vec<Violation>) -> Self {
200        Self {
201            status,
202            error,
203            violations,
204        }
205    }
206
207    /// Place the refusal on item `index` of a batch body.
208    pub(crate) fn at_item(mut self, index: usize) -> Self {
209        for v in &mut self.violations {
210            v.path = crate::violation::batch_item_path(index, &v.path);
211        }
212        self.error = format!("item {index}: {}", self.error);
213        self
214    }
215
216    pub(crate) fn into_response(self) -> Response {
217        (
218            self.status,
219            Json(ApiResponse::<()>::rejected(self.error, self.violations)),
220        )
221            .into_response()
222    }
223}
224
225/// Read `bytes` as JSON into `T`, refusing with the field the failure is at; with `strict`,
226/// also refusing a key `T` does not take (other than [`ECHOED_KEYS`]).
227// A rejection is a `Response`, as every axum extractor's is; it is built once per refused body.
228#[allow(clippy::result_large_err)]
229pub(crate) fn decode_json<T: DeserializeOwned>(bytes: &[u8], strict: bool) -> Result<T, Response> {
230    decode_json_or_refusal(bytes, strict).map_err(Refusal::into_response)
231}
232
233/// [`decode_json`], with the refusal still open to be placed on a batch item.
234pub(crate) fn decode_json_or_refusal<T: DeserializeOwned>(
235    bytes: &[u8],
236    strict: bool,
237) -> Result<T, Refusal> {
238    let mut ignored: Vec<String> = Vec::new();
239    let mut de = serde_json::Deserializer::from_slice(bytes);
240    let decoded: Result<T, serde_path_to_error::Error<serde_json::Error>> = if strict {
241        let mut record = |path: serde_ignored::Path<'_>| {
242            if let Some(path) = ignored_key(&path) {
243                ignored.push(path);
244            }
245        };
246        let tracked = serde_ignored::Deserializer::new(&mut de, &mut record);
247        serde_path_to_error::deserialize(tracked)
248    } else {
249        serde_path_to_error::deserialize(&mut de)
250    };
251    let value = match decoded {
252        Ok(value) => value,
253        Err(e) => {
254            let path = path_segments(e.path());
255            return Err(json_refusal(path, e.into_inner()));
256        }
257    };
258    // Trailing characters after the value are a malformed body, not a value to keep.
259    if let Err(e) = de.end() {
260        return Err(json_refusal(Vec::new(), e));
261    }
262    if !ignored.is_empty() {
263        ignored.sort();
264        ignored.dedup();
265        let violations = ignored
266            .iter()
267            .map(|path| {
268                let key = path.rsplit('/').next().unwrap_or(path);
269                Violation::new(
270                    path.clone(),
271                    "unknown_field",
272                    format!("unknown_field: `{key}` is not a field this write accepts"),
273                )
274            })
275            .collect::<Vec<_>>();
276        let sentence = violations
277            .iter()
278            .map(|v| v.message.as_str())
279            .collect::<Vec<_>>()
280            .join("; ");
281        return Err(Refusal::new(
282            StatusCode::UNPROCESSABLE_ENTITY,
283            format!("validation failed: {sentence}"),
284            violations,
285        ));
286    }
287    Ok(value)
288}
289
290/// The slash path of a key serde ignored, unless it is one a client may echo back.
291fn ignored_key(path: &serde_ignored::Path<'_>) -> Option<String> {
292    let mut segments = Vec::new();
293    collect_segments(path, &mut segments);
294    let echoed = segments.len() <= 2
295        && segments
296            .last()
297            .is_some_and(|(key, is_key)| *is_key && ECHOED_KEYS.contains(&key.as_str()))
298        && (segments.len() == 1 || !segments[0].1);
299    if echoed || segments.is_empty() {
300        return None;
301    }
302    Some(
303        segments
304            .into_iter()
305            .map(|(s, _)| s)
306            .collect::<Vec<_>>()
307            .join("/"),
308    )
309}
310
311/// Flatten an ignored-key path into `(segment, is_map_key)` from the root down.
312fn collect_segments(path: &serde_ignored::Path<'_>, out: &mut Vec<(String, bool)>) {
313    use serde_ignored::Path;
314    match path {
315        Path::Root => {}
316        Path::Seq { parent, index } => {
317            collect_segments(parent, out);
318            out.push((index.to_string(), false));
319        }
320        Path::Map { parent, key } => {
321            collect_segments(parent, out);
322            out.push((key.clone(), true));
323        }
324        Path::Some { parent }
325        | Path::NewtypeStruct { parent }
326        | Path::NewtypeVariant { parent } => collect_segments(parent, out),
327    }
328}
329
330/// The segments of the path serde failed at, root first.
331fn path_segments(path: &serde_path_to_error::Path) -> Vec<String> {
332    path.iter()
333        .filter_map(|segment| match segment {
334            serde_path_to_error::Segment::Seq { index } => Some(index.to_string()),
335            serde_path_to_error::Segment::Map { key } => Some(key.clone()),
336            serde_path_to_error::Segment::Enum { variant } => Some(variant.clone()),
337            serde_path_to_error::Segment::Unknown => None,
338        })
339        .collect()
340}
341
342/// The refusal for a JSON body serde could not read at `path`.
343fn json_refusal(mut path: Vec<String>, inner: serde_json::Error) -> Refusal {
344    use serde_json::error::Category;
345    let message = without_position(&inner.to_string());
346    match inner.classify() {
347        Category::Syntax | Category::Eof | Category::Io => Refusal::new(
348            StatusCode::BAD_REQUEST,
349            format!("malformed JSON body: {message}"),
350            vec![Violation::new(
351                "",
352                "malformed_body",
353                format!("The request body is not valid JSON: {message}"),
354            )],
355        ),
356        Category::Data => {
357            let (code, text) = match missing_field(&message) {
358                Some(field) => {
359                    path.push(field.to_string());
360                    ("required", format!("{field} is required"))
361                }
362                None => ("invalid_value", message.clone()),
363            };
364            let path = path.join("/");
365            let sentence = if path.is_empty() {
366                text.clone()
367            } else {
368                format!("{path}: {text}")
369            };
370            Refusal::new(
371                StatusCode::UNPROCESSABLE_ENTITY,
372                format!("validation failed: {sentence}"),
373                vec![Violation::new(path, code, text)],
374            )
375        }
376    }
377}
378
379/// The field a serde "missing field `x`" message names.
380fn missing_field(message: &str) -> Option<&str> {
381    message.strip_prefix("missing field `")?.split('`').next()
382}
383
384/// Drop serde_json's " at line N column M" suffix: the client has no use for byte positions
385/// in a body it built as an object.
386fn without_position(message: &str) -> String {
387    match message.rfind(" at line ") {
388        Some(at) => message[..at].to_string(),
389        None => message.to_string(),
390    }
391}
392
393fn refusal(status: StatusCode, error: String, violation: Violation) -> Response {
394    (
395        status,
396        Json(ApiResponse::<()>::rejected(error, vec![violation])),
397    )
398        .into_response()
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404    use serde::Deserialize;
405
406    #[derive(Debug, Deserialize)]
407    #[serde(rename_all = "camelCase")]
408    #[allow(dead_code)]
409    struct Line {
410        amount: i64,
411        #[serde(alias = "unit_price", default)]
412        unit_price: Option<i64>,
413    }
414
415    #[derive(Debug, Deserialize)]
416    #[allow(dead_code)]
417    struct Doc {
418        name: String,
419        #[serde(default)]
420        lines: Vec<Line>,
421    }
422
423    async fn body_of(r: Response) -> (StatusCode, serde_json::Value) {
424        let status = r.status();
425        let bytes = axum::body::to_bytes(r.into_body(), usize::MAX)
426            .await
427            .unwrap();
428        (status, serde_json::from_slice(&bytes).unwrap())
429    }
430
431    #[tokio::test]
432    async fn malformed_json_is_a_400_envelope() {
433        let (status, body) = body_of(decode_json::<Doc>(b"{\"name\": ", false).unwrap_err()).await;
434        assert_eq!(status, StatusCode::BAD_REQUEST);
435        assert_eq!(body["success"], false);
436        assert_eq!(body["violations"][0]["code"], "malformed_body");
437    }
438
439    #[tokio::test]
440    async fn a_wrong_value_names_its_field() {
441        let raw = br#"{"name":"a","lines":[{"amount":1},{"amount":"x"}]}"#;
442        let (status, body) = body_of(decode_json::<Doc>(raw, false).unwrap_err()).await;
443        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
444        assert_eq!(body["violations"][0]["path"], "lines/1/amount");
445        assert_eq!(body["violations"][0]["code"], "invalid_value");
446    }
447
448    #[tokio::test]
449    async fn a_missing_field_is_required_on_that_field() {
450        let (status, body) =
451            body_of(decode_json::<Doc>(br#"{"lines":[]}"#, false).unwrap_err()).await;
452        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
453        assert_eq!(body["violations"][0]["path"], "name");
454        assert_eq!(body["violations"][0]["code"], "required");
455    }
456
457    #[tokio::test]
458    async fn strict_refuses_a_key_the_type_does_not_take() {
459        let raw = br#"{"name":"a","colour":"red","lines":[{"amount":1,"discount":2}]}"#;
460        assert!(
461            decode_json::<Doc>(raw, false).is_ok(),
462            "lenient decoding ignores the key"
463        );
464        let (status, body) = body_of(decode_json::<Doc>(raw, true).unwrap_err()).await;
465        assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY);
466        let paths: Vec<&str> = body["violations"]
467            .as_array()
468            .unwrap()
469            .iter()
470            .map(|v| v["path"].as_str().unwrap())
471            .collect();
472        assert_eq!(paths, vec!["colour", "lines/0/discount"]);
473        assert_eq!(body["violations"][0]["code"], "unknown_field");
474    }
475
476    #[test]
477    fn strict_tolerates_what_a_response_echoes_and_both_casings() {
478        let raw = br#"{"id":"x","metadata":{},"createdAt":"t","name":"a","lines":[{"amount":1,"unitPrice":2},{"amount":1,"unit_price":3}]}"#;
479        assert!(decode_json::<Doc>(raw, true).is_ok());
480        // Echoed keys are tolerated on each item of a batch too.
481        assert!(decode_json::<Vec<Doc>>(br#"[{"id":"x","name":"a"}]"#, true).is_ok());
482        let err = decode_json::<Vec<Doc>>(br#"[{"name":"a"},{"name":"b","bogus":1}]"#, true);
483        assert!(err.is_err());
484    }
485}