Skip to main content

soma_http_api/
response.rs

1//! Reusable JSON response envelope and error-body helpers.
2
3use axum::{
4    extract::rejection::JsonRejection,
5    http::StatusCode,
6    response::{IntoResponse, Response},
7    Json,
8};
9use serde::Serialize;
10
11use crate::problem::ErrorBody;
12
13/// Render a JSON body extraction failure as a response.
14///
15/// `413 Payload Too Large` when the body exceeded the configured limit,
16/// `400 Bad Request` for every other rejection (missing/invalid content
17/// type, malformed JSON, etc.).
18pub fn json_rejection_response(error: JsonRejection) -> Response {
19    let status = if error.status() == StatusCode::PAYLOAD_TOO_LARGE {
20        StatusCode::PAYLOAD_TOO_LARGE
21    } else {
22        StatusCode::BAD_REQUEST
23    };
24    ErrorBody::new(error.to_string()).into_response_with_status(status)
25}
26
27/// Render a `400 Bad Request` with a generic validation error body.
28pub fn validation_error_response(message: impl Into<String>) -> Response {
29    ErrorBody::new("validation_error")
30        .with_message(message)
31        .into_response_with_status(StatusCode::BAD_REQUEST)
32}
33
34/// Render any `Serialize` payload as a JSON response with the given status.
35pub fn json_response(status: StatusCode, body: impl Serialize) -> Response {
36    (status, Json(body)).into_response()
37}
38
39/// Map a Soma `ApplicationError.code` value to the HTTP status it renders
40/// as.
41///
42/// This is the single, shared classification used across every REST/HTTP
43/// product surface that emits `ApplicationError` bodies (`soma-api`,
44/// `soma-palette`, ...). Product-surface crates must not depend on one
45/// another (see `xtask check-architecture`), so this table lives here
46/// rather than being duplicated per surface or exposed by one surface for
47/// another to import. Callers pass the plain `code` string rather than the
48/// `ApplicationError` type itself — that type lives in `soma-application`
49/// (a `crates/soma/*` product crate), and `shared/*` crates must never
50/// depend on `crates/soma/*` or `apps/*`.
51pub fn application_error_status(code: &str) -> StatusCode {
52    match code {
53        "unknown_action" | "surface_not_exposed" | "upstream_missing" | "unknown_upstream" => {
54            StatusCode::NOT_FOUND
55        }
56        "insufficient_scope" | "capability_denied" | "admin_required" | "not_exposed" => {
57            StatusCode::FORBIDDEN
58        }
59        "input_too_large" | "response_too_large" => StatusCode::PAYLOAD_TOO_LARGE,
60        "input_schema_failed"
61        | "confirmation_required"
62        | "invalid_param"
63        | "spawn_validation_failed"
64        | "upstream_exists"
65        | "invalid_config" => StatusCode::BAD_REQUEST,
66        "unsupported_transport" => StatusCode::NOT_IMPLEMENTED,
67        "gateway_reloading"
68        | "store_not_mounted"
69        | "oauth_runtime_error"
70        | "not_routable"
71        | "upstream_connect_failed"
72        | "upstream_call_failed"
73        | "engine_unavailable" => StatusCode::SERVICE_UNAVAILABLE,
74        _ => StatusCode::INTERNAL_SERVER_ERROR,
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use axum::{
81        body::{to_bytes, Body},
82        extract::DefaultBodyLimit,
83        http::Request,
84        routing::post,
85        Router,
86    };
87    use tower::ServiceExt;
88
89    use super::*;
90
91    #[tokio::test]
92    async fn validation_error_response_is_bad_request_with_message() {
93        let response = validation_error_response("name is required");
94        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
95        let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
96        let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
97        assert_eq!(value["error"], "validation_error");
98        assert_eq!(value["message"], "name is required");
99    }
100
101    #[tokio::test]
102    async fn json_response_serializes_status_and_body() {
103        let response = json_response(StatusCode::CREATED, serde_json::json!({"ok": true}));
104        assert_eq!(response.status(), StatusCode::CREATED);
105        let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
106        let value: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
107        assert_eq!(value["ok"], true);
108    }
109
110    #[test]
111    fn application_error_status_maps_known_codes() {
112        assert_eq!(
113            application_error_status("unknown_action"),
114            StatusCode::NOT_FOUND
115        );
116        assert_eq!(
117            application_error_status("capability_denied"),
118            StatusCode::FORBIDDEN
119        );
120        assert_eq!(
121            application_error_status("input_too_large"),
122            StatusCode::PAYLOAD_TOO_LARGE
123        );
124        assert_eq!(
125            application_error_status("invalid_param"),
126            StatusCode::BAD_REQUEST
127        );
128        assert_eq!(
129            application_error_status("unsupported_transport"),
130            StatusCode::NOT_IMPLEMENTED
131        );
132        assert_eq!(
133            application_error_status("engine_unavailable"),
134            StatusCode::SERVICE_UNAVAILABLE
135        );
136    }
137
138    #[test]
139    fn application_error_status_defaults_to_internal_server_error() {
140        assert_eq!(
141            application_error_status("something_unmapped"),
142            StatusCode::INTERNAL_SERVER_ERROR
143        );
144    }
145
146    /// Drive a real oversized-body rejection through a minimal Axum router
147    /// (rather than hand-constructing a `JsonRejection`, which is
148    /// `#[non_exhaustive]`) to cover `json_rejection_response`'s
149    /// `PAYLOAD_TOO_LARGE` branch — the only conditional in this function,
150    /// otherwise untested at this crate's layer (see `apps/soma/tests/
151    /// api_routes.rs::oversized_body_returns_413` for the equivalent
152    /// full-router integration test this unit test complements).
153    #[tokio::test]
154    async fn json_rejection_response_maps_oversized_body_to_413() {
155        async fn handler(body: Result<Json<serde_json::Value>, JsonRejection>) -> Response {
156            match body {
157                Ok(_) => StatusCode::OK.into_response(),
158                Err(error) => json_rejection_response(error),
159            }
160        }
161
162        let app = Router::new()
163            .route("/", post(handler))
164            .layer(DefaultBodyLimit::max(16));
165        let request = Request::builder()
166            .method("POST")
167            .uri("/")
168            .header("content-type", "application/json")
169            .body(Body::from(vec![b'x'; 100]))
170            .unwrap();
171
172        let response = app.oneshot(request).await.unwrap();
173
174        assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
175    }
176
177    #[tokio::test]
178    async fn json_rejection_response_maps_malformed_json_to_400() {
179        async fn handler(body: Result<Json<serde_json::Value>, JsonRejection>) -> Response {
180            match body {
181                Ok(_) => StatusCode::OK.into_response(),
182                Err(error) => json_rejection_response(error),
183            }
184        }
185
186        let app = Router::new().route("/", post(handler));
187        let request = Request::builder()
188            .method("POST")
189            .uri("/")
190            .header("content-type", "application/json")
191            .body(Body::from("not json"))
192            .unwrap();
193
194        let response = app.oneshot(request).await.unwrap();
195
196        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
197    }
198}