1#![forbid(unsafe_code)]
6
7use jerrycan_core::{App, Error, Extension, FromRequest, IntoResponse, RequestCtx, Result};
8use serde::Serialize;
9
10#[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
26pub trait Validate {
28 fn validate(&self) -> std::result::Result<(), Vec<Violation>>;
29}
30
31impl<T: Validate> Validate for jerrycan_core::Json<T> {
33 fn validate(&self) -> std::result::Result<(), Vec<Violation>> {
34 self.0.validate()
35 }
36}
37
38pub 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
56pub 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
78fn 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}