Skip to main content

jerrycan_validate/
lib.rs

1//! Validation: the `Validate` trait, the `Valid<T>` extractor (422 with
2//! structured `details` on violation), and the `OpenApi` extension serving the
3//! platform-generated document. `derive(Validate)` with rule attributes is a
4//! contract-v1 candidate (the design schema has no field constraints yet).
5#![forbid(unsafe_code)]
6
7use jerrycan_core::{App, Error, Extension, FromRequest, IntoResponse, RequestCtx, Result};
8use serde::Serialize;
9
10/// One field-level problem, rendered into the 422 `details` array.
11#[derive(Debug, Clone, Serialize)]
12pub struct Violation {
13    pub field: String,
14    pub message: String,
15}
16
17impl Violation {
18    pub fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
19        Self {
20            field: field.into(),
21            message: message.into(),
22        }
23    }
24}
25
26/// Types that can check their own invariants after extraction.
27pub trait Validate {
28    fn validate(&self) -> std::result::Result<(), Vec<Violation>>;
29}
30
31/// Transparent: validating a wrapper validates the payload.
32impl<T: Validate> Validate for jerrycan_core::Json<T> {
33    fn validate(&self) -> std::result::Result<(), Vec<Violation>> {
34        self.0.validate()
35    }
36}
37
38/// Extract-then-validate: `Valid(Json(todo)): Valid<Json<NewTodo>>`.
39/// Violations become `422 JC0422` with a structured `details` array.
40pub struct Valid<T>(pub T);
41
42impl<T> FromRequest for Valid<T>
43where
44    T: FromRequest + Validate,
45{
46    async fn from_request(ctx: &mut RequestCtx) -> Result<Self> {
47        let inner = T::from_request(ctx).await?;
48        match inner.validate() {
49            Ok(()) => Ok(Valid(inner)),
50            Err(violations) => Err(Error::unprocessable("validation failed")
51                .with_details(serde_json::to_value(violations).expect("violations serialize"))),
52        }
53    }
54}
55
56/// Serves a pre-generated OpenAPI document at `GET /openapi.json`.
57/// The platform generates the document from design.json (tool-owned).
58pub struct OpenApi {
59    document: &'static str,
60}
61
62impl OpenApi {
63    pub fn new(document: &'static str) -> Self {
64        Self { document }
65    }
66}
67
68impl Extension for OpenApi {
69    fn register(self, app: App) -> App {
70        let doc = self.document;
71        app.route(
72            "/openapi.json",
73            jerrycan_core::get(move || async move { http_json(doc) }),
74        )
75    }
76}
77
78/// Build an application/json response from a static string without
79/// double-encoding (Json<&str> would quote it).
80fn http_json(body: &'static str) -> jerrycan_core::Response {
81    let mut response = body.into_response();
82    response.headers_mut().insert(
83        jerrycan_core::http::header::CONTENT_TYPE,
84        jerrycan_core::http::HeaderValue::from_static("application/json"),
85    );
86    response
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92    use jerrycan_core::{Json, get, post};
93    use serde::Deserialize;
94
95    #[derive(Deserialize, Serialize)]
96    struct NewTodo {
97        title: String,
98    }
99
100    impl Validate for NewTodo {
101        fn validate(&self) -> std::result::Result<(), Vec<Violation>> {
102            if self.title.trim().is_empty() {
103                return Err(vec![Violation::new("title", "must not be empty")]);
104            }
105            Ok(())
106        }
107    }
108
109    async fn create(Valid(Json(todo)): Valid<Json<NewTodo>>) -> Json<NewTodo> {
110        Json(todo)
111    }
112
113    #[tokio::test]
114    async fn valid_payloads_pass_through() {
115        let t = App::new().route("/todos", post(create)).into_test();
116        let res = t
117            .post_json(
118                "/todos",
119                &NewTodo {
120                    title: "ship".into(),
121                },
122            )
123            .await;
124        assert_eq!(res.status(), jerrycan_core::http::StatusCode::OK);
125    }
126
127    #[tokio::test]
128    async fn violations_become_422_with_structured_details() {
129        let t = App::new().route("/todos", post(create)).into_test();
130        let res = t
131            .post_json(
132                "/todos",
133                &NewTodo {
134                    title: "   ".into(),
135                },
136            )
137            .await;
138        assert_eq!(
139            res.status(),
140            jerrycan_core::http::StatusCode::UNPROCESSABLE_ENTITY
141        );
142        let body: serde_json::Value = res.json();
143        assert_eq!(body["code"], "JC0422");
144        assert_eq!(body["details"][0]["field"], "title");
145        assert_eq!(body["details"][0]["message"], "must not be empty");
146    }
147
148    #[tokio::test]
149    async fn openapi_extension_serves_the_document() {
150        let t = App::new()
151            .extend(OpenApi::new(
152                r#"{"openapi":"3.1.0","info":{"title":"demo"}}"#,
153            ))
154            .route("/x", get(|| async { "x" }))
155            .into_test();
156        let res = t.get("/openapi.json").await;
157        assert_eq!(res.status(), jerrycan_core::http::StatusCode::OK);
158        assert_eq!(res.headers()["content-type"], "application/json");
159        let doc: serde_json::Value = res.json();
160        assert_eq!(doc["openapi"], "3.1.0");
161    }
162}