Skip to main content

soma_http_api/
json.rs

1//! Generic "parse a JSON body, or fall back to a default when the client
2//! sent none" helper — the shape every REST handler in this workspace that
3//! accepts an optional body already needs.
4
5use axum::{extract::rejection::JsonRejection, response::Response, Json};
6use serde::de::DeserializeOwned;
7
8use crate::response::json_rejection_response;
9
10/// Outcome of extracting an optional JSON body: either the parsed (or
11/// defaulted) value, or a response to return immediately because extraction
12/// failed for a reason other than "no body was sent".
13pub enum JsonBodyOutcome<T> {
14    Params(T),
15    Response(Response),
16}
17
18/// Parse `body`, or call `default` when the client omitted the JSON content
19/// type and `allow_missing` is set. Any other rejection short-circuits with
20/// a rendered error response.
21pub fn json_body_or_else<T>(
22    body: Result<Json<T>, JsonRejection>,
23    allow_missing: bool,
24    default: impl FnOnce() -> T,
25) -> JsonBodyOutcome<T>
26where
27    T: DeserializeOwned,
28{
29    match body {
30        Ok(Json(value)) => JsonBodyOutcome::Params(value),
31        Err(JsonRejection::MissingJsonContentType(_)) if allow_missing => {
32            JsonBodyOutcome::Params(default())
33        }
34        Err(error) => JsonBodyOutcome::Response(json_rejection_response(error)),
35    }
36}
37
38#[cfg(test)]
39mod tests {
40    use axum::{
41        body::Body,
42        extract::FromRequest,
43        http::{Request, StatusCode},
44    };
45    use serde_json::Value;
46
47    use super::*;
48
49    /// Drive a real extraction failure through Axum's own `FromRequest`
50    /// machinery rather than constructing `JsonRejection` variants by hand —
51    /// `MissingJsonContentType` is `#[non_exhaustive]` and not constructible
52    /// outside axum-core.
53    async fn extract_json(request: Request<Body>) -> Result<Json<Value>, JsonRejection> {
54        Json::<Value>::from_request(request, &()).await
55    }
56
57    #[tokio::test]
58    async fn missing_body_uses_default_when_allowed() {
59        let request = Request::builder()
60            .method("POST")
61            .uri("/")
62            .body(Body::empty())
63            .unwrap();
64        let body = extract_json(request).await;
65        match json_body_or_else(body, true, || serde_json::json!({})) {
66            JsonBodyOutcome::Params(value) => assert_eq!(value, serde_json::json!({})),
67            JsonBodyOutcome::Response(_) => panic!("expected defaulted params"),
68        }
69    }
70
71    #[tokio::test]
72    async fn missing_body_is_rejected_when_not_allowed() {
73        let request = Request::builder()
74            .method("POST")
75            .uri("/")
76            .body(Body::empty())
77            .unwrap();
78        let body = extract_json(request).await;
79        match json_body_or_else(body, false, || serde_json::json!({})) {
80            JsonBodyOutcome::Response(response) => {
81                assert_eq!(response.status(), StatusCode::BAD_REQUEST);
82            }
83            JsonBodyOutcome::Params(_) => panic!("expected a rejection response"),
84        }
85    }
86
87    #[tokio::test]
88    async fn parsed_body_passes_through() {
89        let request = Request::builder()
90            .method("POST")
91            .uri("/")
92            .header("content-type", "application/json")
93            .body(Body::from(r#"{"a":1}"#))
94            .unwrap();
95        let body = extract_json(request).await;
96        match json_body_or_else(body, true, || serde_json::json!({})) {
97            JsonBodyOutcome::Params(value) => assert_eq!(value, serde_json::json!({"a": 1})),
98            JsonBodyOutcome::Response(_) => panic!("expected parsed params"),
99        }
100    }
101}