Skip to main content

cognee_http_server/middleware/
validation.rs

1//! Custom JSON extractor that emits `ApiError::Validation` on deserialization
2//! failure instead of axum's default 422/400.
3//!
4//! Use `middleware::validation::Json<T>` instead of `axum::Json<T>` in handlers
5//! that need the Python-shaped error envelope.
6
7use axum::{
8    body::Bytes,
9    extract::{FromRequest, FromRequestParts, Request},
10    http::{header, request::Parts},
11};
12use serde::de::DeserializeOwned;
13use serde_json::json;
14
15use crate::error::{ApiError, ValidationDetails};
16
17// ─── LoginForm extractor ──────────────────────────────────────────────────────
18
19/// Path-scoped `Form<T>` extractor for `POST /api/v1/auth/login`.
20///
21/// Maps any deserialization failure to `ApiError::LoginBadCredentials`
22/// (the `{"detail":"LOGIN_BAD_CREDENTIALS"}` shape) instead of the
23/// generic `ValidationDetails` array.  Only use this on the login handler —
24/// applying it elsewhere would suppress structured validation errors on
25/// `/register` etc.
26///
27/// Python reference: the custom `RequestValidationError` handler in
28/// `client.py:165-176` overrides 422 → 400 with `LOGIN_BAD_CREDENTIALS`
29/// specifically for `/api/v1/auth/login`.
30pub struct LoginForm<T>(pub T);
31
32impl<T, S> FromRequest<S> for LoginForm<T>
33where
34    T: DeserializeOwned,
35    S: Send + Sync,
36{
37    type Rejection = ApiError;
38
39    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
40        match axum::extract::Form::<T>::from_request(req, state).await {
41            Ok(axum::extract::Form(value)) => Ok(LoginForm(value)),
42            Err(_) => Err(ApiError::LoginBadCredentials),
43        }
44    }
45}
46
47/// Drop-in replacement for `axum::Json` that converts `serde_json` parse errors
48/// into `ApiError::Validation` with the Python-shaped `{detail: [...], body: ...}`
49/// envelope.
50pub struct Json<T>(pub T);
51
52impl<T, S> FromRequest<S> for Json<T>
53where
54    T: DeserializeOwned,
55    S: Send + Sync,
56{
57    type Rejection = ApiError;
58
59    async fn from_request(req: Request, state: &S) -> Result<Self, Self::Rejection> {
60        // Check content-type header
61        let content_type = req
62            .headers()
63            .get(header::CONTENT_TYPE)
64            .and_then(|v| v.to_str().ok())
65            .unwrap_or_default()
66            .to_lowercase();
67
68        if !content_type.contains("application/json") {
69            return Err(ApiError::Validation(ValidationDetails {
70                detail: json!([{
71                    "loc": ["headers", "content-type"],
72                    "msg": "content-type must be application/json",
73                    "type": "value_error"
74                }]),
75                body: None,
76            }));
77        }
78
79        // Read the raw body bytes
80        let bytes = Bytes::from_request(req, state).await.map_err(|e| {
81            ApiError::Validation(ValidationDetails {
82                detail: json!([{"loc": ["body"], "msg": e.to_string(), "type": "read_error"}]),
83                body: None,
84            })
85        })?;
86
87        // Try to parse the body as the target type
88        match serde_json::from_slice::<T>(&bytes) {
89            Ok(value) => Ok(Json(value)),
90            Err(err) => {
91                // Try to capture the raw body for debugging
92                let raw_body = serde_json::from_slice::<serde_json::Value>(&bytes).ok();
93                Err(ApiError::Validation(ValidationDetails {
94                    detail: json!([{
95                        "loc": ["body"],
96                        "msg": err.to_string(),
97                        "type": "value_error.json_parse"
98                    }]),
99                    body: raw_body,
100                }))
101            }
102        }
103    }
104}
105
106// ─── Query extractor (E-09 / Decision 9) ─────────────────────────────────────
107
108/// Query-string extractor that maps `serde_urlencoded` failures to
109/// `ApiError::Validation` with the same Python-shaped `{detail: [...], body: ...}`
110/// envelope used by [`Json<T>`].
111///
112/// Sibling to [`Json<T>`] for query-string parameters. Lands as project-wide
113/// infrastructure per Decision 9 (acknowledged divergence D-1) and is owned by
114/// E-09 — every later v2 task with query-param validation needs reuses it.
115///
116/// On parse failure the extractor:
117///   - sets HTTP status to 400 (Python's global 422→400 override applies);
118///   - best-effort extracts the field name from the `serde_urlencoded` error
119///     message and emits `loc = ["query", "<field>"]`. Falls back to
120///     `loc = ["query"]` when the field cannot be determined.
121///   - sets `type = "value_error"`.
122///
123/// Re-exported as `ValidatedQuery` at the module root.
124pub struct Query<T>(pub T);
125
126impl<T, S> FromRequestParts<S> for Query<T>
127where
128    T: DeserializeOwned,
129    S: Send + Sync,
130{
131    type Rejection = ApiError;
132
133    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
134        let raw = parts.uri.query().unwrap_or("");
135        // Use `serde_path_to_error` to recover the path of the offending field
136        // since `serde_urlencoded` does not include the field name in its
137        // error messages for typed deserialization failures (e.g. unknown
138        // enum variants on a `#[serde(rename = ...)]` field).
139        let de = serde_urlencoded::Deserializer::new(form_urlencoded::parse(raw.as_bytes()));
140        match serde_path_to_error::deserialize::<_, T>(de) {
141            Ok(value) => Ok(Query(value)),
142            Err(err) => {
143                let path = err.path().to_string();
144                let inner_msg = err.into_inner().to_string();
145                let loc = if path.is_empty() || path == "." {
146                    json!(["query"])
147                } else {
148                    // `serde_path_to_error` returns dotted paths like
149                    // `order_by` for top-level fields. Take the leaf segment.
150                    let leaf = path.rsplit('.').next().unwrap_or(path.as_str());
151                    json!(["query", leaf])
152                };
153                Err(ApiError::Validation(ValidationDetails {
154                    detail: json!([{
155                        "loc": loc,
156                        "msg": inner_msg,
157                        "type": "value_error"
158                    }]),
159                    body: None,
160                }))
161            }
162        }
163    }
164}
165
166// ─── Re-exports ──────────────────────────────────────────────────────────────
167
168/// Re-export of [`Query`] for handlers/tests that prefer the unambiguous name
169/// over the bare `Query` (which can clash with `axum::extract::Query`).
170pub use Query as ValidatedQuery;
171
172// ─── Unit tests ──────────────────────────────────────────────────────────────
173
174#[cfg(test)]
175#[allow(
176    clippy::unwrap_used,
177    clippy::expect_used,
178    reason = "test code — panics are acceptable failures"
179)]
180mod tests {
181    use super::*;
182    use axum::{
183        Router,
184        body::{Body, to_bytes},
185        http::{Request, StatusCode},
186        routing::{get, post},
187    };
188    use serde::Deserialize;
189    use tower::ServiceExt;
190
191    #[derive(Deserialize)]
192    struct Payload {
193        name: String,
194    }
195
196    async fn handler(Json(p): Json<Payload>) -> String {
197        p.name
198    }
199
200    fn app() -> Router {
201        Router::new().route("/", post(handler))
202    }
203
204    #[tokio::test]
205    async fn test_missing_required_field_yields_validation_error() {
206        let req = Request::builder()
207            .method("POST")
208            .uri("/")
209            .header("content-type", "application/json")
210            .body(Body::from(r#"{"other": "value"}"#))
211            .expect("request");
212
213        let resp = app().oneshot(req).await.expect("response");
214        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
215
216        let bytes = to_bytes(resp.into_body(), usize::MAX).await.expect("bytes");
217        let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
218
219        // detail must be an array with at least one entry
220        assert!(body["detail"].is_array(), "detail should be array: {body}");
221    }
222
223    #[tokio::test]
224    async fn test_wrong_content_type_yields_validation_error() {
225        let req = Request::builder()
226            .method("POST")
227            .uri("/")
228            .header("content-type", "text/plain")
229            .body(Body::from(r#"{"name": "test"}"#))
230            .expect("request");
231
232        let resp = app().oneshot(req).await.expect("response");
233        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
234    }
235
236    #[tokio::test]
237    async fn test_valid_json_succeeds() {
238        let req = Request::builder()
239            .method("POST")
240            .uri("/")
241            .header("content-type", "application/json")
242            .body(Body::from(r#"{"name": "alice"}"#))
243            .expect("request");
244
245        let resp = app().oneshot(req).await.expect("response");
246        assert_eq!(resp.status(), StatusCode::OK);
247    }
248
249    // ── ValidatedQuery<T> tests (E-09 / Decision 9) ────────────────────────
250
251    #[derive(Deserialize, Default)]
252    enum TestOrderBy {
253        #[default]
254        #[serde(rename = "last_activity_at")]
255        LastActivityAt,
256        #[serde(rename = "started_at")]
257        StartedAt,
258    }
259
260    #[derive(Deserialize)]
261    struct TestQuery {
262        #[serde(default)]
263        order_by: TestOrderBy,
264        #[serde(default = "default_limit")]
265        limit: u32,
266    }
267
268    fn default_limit() -> u32 {
269        50
270    }
271
272    async fn query_handler(Query(q): Query<TestQuery>) -> String {
273        format!(
274            "limit={} ord={}",
275            q.limit,
276            matches!(q.order_by, TestOrderBy::LastActivityAt)
277        )
278    }
279
280    fn query_app() -> Router {
281        Router::new().route("/", get(query_handler))
282    }
283
284    #[tokio::test]
285    async fn valid_query_succeeds() {
286        let req = Request::builder()
287            .method("GET")
288            .uri("/?order_by=started_at&limit=42")
289            .body(Body::empty())
290            .expect("request");
291
292        let resp = query_app().oneshot(req).await.expect("response");
293        assert_eq!(resp.status(), StatusCode::OK);
294    }
295
296    #[tokio::test]
297    async fn unknown_order_by_returns_400_with_python_envelope() {
298        let req = Request::builder()
299            .method("GET")
300            .uri("/?order_by=banana")
301            .body(Body::empty())
302            .expect("request");
303
304        let resp = query_app().oneshot(req).await.expect("response");
305        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
306        let bytes = to_bytes(resp.into_body(), usize::MAX).await.expect("bytes");
307        let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
308
309        assert!(body["detail"].is_array(), "detail should be array: {body}");
310        let entry = &body["detail"][0];
311        let loc = entry["loc"].as_array().expect("loc array");
312        assert_eq!(loc[0], "query");
313        // Best-effort field name; should resolve to `order_by`.
314        assert_eq!(loc[1], "order_by", "loc should target order_by: {body}");
315        let ty = entry["type"].as_str().expect("type str");
316        assert!(
317            ty.ends_with("value_error"),
318            "type should be value_error: {ty}"
319        );
320    }
321
322    #[tokio::test]
323    async fn out_of_range_limit_returns_400_with_python_envelope() {
324        // u32 deserialization rejects negative values; this asserts the
325        // envelope shape on parse failures driven by serde_urlencoded.
326        let req = Request::builder()
327            .method("GET")
328            .uri("/?limit=-1")
329            .body(Body::empty())
330            .expect("request");
331
332        let resp = query_app().oneshot(req).await.expect("response");
333        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
334        let bytes = to_bytes(resp.into_body(), usize::MAX).await.expect("bytes");
335        let body: serde_json::Value = serde_json::from_slice(&bytes).expect("json");
336
337        assert!(body["detail"].is_array());
338        let entry = &body["detail"][0];
339        let loc = entry["loc"].as_array().expect("loc array");
340        assert_eq!(loc[0], "query");
341        assert_eq!(loc[1], "limit", "loc should target limit: {body}");
342        let ty = entry["type"].as_str().expect("type str");
343        assert!(ty.ends_with("value_error"));
344    }
345}