Skip to main content

nidus_openapi/
route.rs

1use std::borrow::Cow;
2
3use nidus_http::router::RouteMetadata;
4use nidus_http::{StatusCode, error::RoutePathError};
5use serde_json::{Value, json};
6use utoipa::ToSchema;
7
8use crate::path::{openapi_path, openapi_path_parameters, operation_id};
9
10/// OpenAPI route metadata.
11#[derive(Clone, Debug)]
12pub struct OpenApiRoute {
13    method: Cow<'static, str>,
14    path: String,
15    path_parameters: Vec<String>,
16    summary: Option<String>,
17    tags: Vec<String>,
18    response_status: StatusCode,
19    request_schema: Option<String>,
20    response_schema: Option<String>,
21    guards: Vec<String>,
22    pipes: Vec<String>,
23    validates: bool,
24}
25
26impl OpenApiRoute {
27    /// Creates GET route metadata.
28    pub fn get(path: impl Into<String>) -> Self {
29        Self::try_get(path).unwrap_or_else(|error| panic!("{error}"))
30    }
31
32    /// Tries to create GET route metadata.
33    pub fn try_get(path: impl Into<String>) -> Result<Self, RoutePathError> {
34        Self::try_new("get", path)
35    }
36
37    /// Creates POST route metadata.
38    pub fn post(path: impl Into<String>) -> Self {
39        Self::try_post(path).unwrap_or_else(|error| panic!("{error}"))
40    }
41
42    /// Tries to create POST route metadata.
43    pub fn try_post(path: impl Into<String>) -> Result<Self, RoutePathError> {
44        Self::try_new("post", path)
45    }
46
47    /// Creates PUT route metadata.
48    pub fn put(path: impl Into<String>) -> Self {
49        Self::try_put(path).unwrap_or_else(|error| panic!("{error}"))
50    }
51
52    /// Tries to create PUT route metadata.
53    pub fn try_put(path: impl Into<String>) -> Result<Self, RoutePathError> {
54        Self::try_new("put", path)
55    }
56
57    /// Creates PATCH route metadata.
58    pub fn patch(path: impl Into<String>) -> Self {
59        Self::try_patch(path).unwrap_or_else(|error| panic!("{error}"))
60    }
61
62    /// Tries to create PATCH route metadata.
63    pub fn try_patch(path: impl Into<String>) -> Result<Self, RoutePathError> {
64        Self::try_new("patch", path)
65    }
66
67    /// Creates DELETE route metadata.
68    pub fn delete(path: impl Into<String>) -> Self {
69        Self::try_delete(path).unwrap_or_else(|error| panic!("{error}"))
70    }
71
72    /// Tries to create DELETE route metadata.
73    pub fn try_delete(path: impl Into<String>) -> Result<Self, RoutePathError> {
74        Self::try_new("delete", path)
75    }
76
77    /// Sets the route summary.
78    pub fn summary(mut self, summary: impl Into<String>) -> Self {
79        self.summary = Some(summary.into());
80        self
81    }
82
83    /// Adds an OpenAPI tag to this operation.
84    pub fn tag(mut self, tag: impl Into<String>) -> Self {
85        self.tags.push(tag.into());
86        self
87    }
88
89    /// Sets the successful response status for this operation.
90    pub fn response_status(mut self, status: StatusCode) -> Self {
91        self.response_status = status;
92        self
93    }
94
95    /// Sets the JSON request body schema reference.
96    pub fn request_schema<T>(self) -> Self
97    where
98        T: ToSchema,
99    {
100        self.request_schema_ref(T::name())
101    }
102
103    /// Sets the successful JSON response schema reference.
104    pub fn response_schema<T>(self) -> Self
105    where
106        T: ToSchema,
107    {
108        self.response_schema_ref(T::name())
109    }
110
111    pub(crate) fn method(&self) -> &str {
112        self.method.as_ref()
113    }
114
115    pub(crate) fn path(&self) -> &str {
116        &self.path
117    }
118
119    pub(crate) fn try_from_route_metadata(
120        metadata: &RouteMetadata,
121    ) -> Result<Self, RoutePathError> {
122        Self::try_from_route_metadata_at_path(metadata, metadata.path())
123    }
124
125    pub(crate) fn try_from_route_metadata_at_path(
126        metadata: &RouteMetadata,
127        path: impl AsRef<str>,
128    ) -> Result<Self, RoutePathError> {
129        let path = openapi_path(path.as_ref())?;
130        let path_parameters = openapi_path_parameters(&path);
131        let mut route = Self::new(openapi_method(metadata.method()), path, path_parameters);
132        if let Some(summary) = metadata.summary() {
133            route = route.summary(summary);
134        }
135        for tag in metadata.tags() {
136            route = route.tag(*tag);
137        }
138        if let Some(status) = metadata.response_status() {
139            route = route.response_status(status);
140        }
141        if let Some(schema) = metadata.request_schema() {
142            route = route.request_schema_ref(schema);
143        }
144        if let Some(schema) = metadata.response_schema() {
145            route = route.response_schema_ref(schema);
146        }
147        route.guards = metadata
148            .guards()
149            .iter()
150            .map(|guard| (*guard).to_owned())
151            .collect();
152        route.pipes = metadata
153            .pipes()
154            .iter()
155            .map(|pipe| (*pipe).to_owned())
156            .collect();
157        route.validates = metadata.validates();
158        Ok(route)
159    }
160
161    pub(crate) fn to_json_value(&self) -> Value {
162        let mut success_response = json!({
163            "description": "Success"
164        });
165        if let Some(schema) = &self.response_schema {
166            success_response["content"] = json!({
167                "application/json": {
168                    "schema": {
169                        "$ref": format!("#/components/schemas/{schema}")
170                    }
171                }
172            });
173        }
174
175        let mut responses = serde_json::Map::new();
176        responses.insert(self.response_status.as_u16().to_string(), success_response);
177
178        // O-1: advertise the error statuses a route can actually return, derived
179        // from its declared guards and validation, so clients can discover them.
180        if !self.guards.is_empty() {
181            responses.insert("401".to_owned(), json!({ "description": "Unauthorized" }));
182            responses.insert("403".to_owned(), json!({ "description": "Forbidden" }));
183        }
184        if self.validates {
185            responses.insert(
186                "422".to_owned(),
187                json!({ "description": "Validation failed" }),
188            );
189        }
190
191        let mut operation = json!({
192            "operationId": operation_id(&self.method, &self.path),
193            "responses": responses
194        });
195
196        if let Some(summary) = &self.summary {
197            operation["summary"] = json!(summary);
198        }
199        if !self.tags.is_empty() {
200            operation["tags"] = json!(self.tags);
201        }
202        if let Some(schema) = &self.request_schema {
203            operation["requestBody"] = json!({
204                "required": true,
205                "content": {
206                    "application/json": {
207                        "schema": {
208                            "$ref": format!("#/components/schemas/{schema}")
209                        }
210                    }
211                }
212            });
213        }
214        if !self.path_parameters.is_empty() {
215            operation["parameters"] = json!(
216                self.path_parameters
217                    .iter()
218                    .map(|name| {
219                        json!({
220                            "name": name,
221                            "in": "path",
222                            "required": true,
223                            "schema": {
224                                "type": "string"
225                            }
226                        })
227                    })
228                    .collect::<Vec<_>>()
229            );
230        }
231        if !self.guards.is_empty() {
232            operation["x-nidus-guards"] = json!(self.guards);
233        }
234        if !self.pipes.is_empty() {
235            operation["x-nidus-pipes"] = json!(self.pipes);
236        }
237        if self.validates {
238            operation["x-nidus-validates"] = json!(true);
239        }
240
241        operation
242    }
243
244    fn request_schema_ref(mut self, schema: impl Into<String>) -> Self {
245        self.request_schema = Some(schema.into());
246        self
247    }
248
249    fn response_schema_ref(mut self, schema: impl Into<String>) -> Self {
250        self.response_schema = Some(schema.into());
251        self
252    }
253
254    fn new(
255        method: impl Into<Cow<'static, str>>,
256        path: impl Into<String>,
257        path_parameters: Vec<String>,
258    ) -> Self {
259        Self {
260            method: method.into(),
261            path: path.into(),
262            path_parameters,
263            summary: None,
264            tags: Vec::new(),
265            response_status: StatusCode::OK,
266            request_schema: None,
267            response_schema: None,
268            guards: Vec::new(),
269            pipes: Vec::new(),
270            validates: false,
271        }
272    }
273
274    fn try_new(
275        method: impl Into<Cow<'static, str>>,
276        path: impl Into<String>,
277    ) -> Result<Self, RoutePathError> {
278        let path = path.into();
279        let path = openapi_path(&path)?;
280        let path_parameters = openapi_path_parameters(&path);
281        Ok(Self::new(method, path, path_parameters))
282    }
283}
284
285fn openapi_method(method: &'static str) -> Cow<'static, str> {
286    match method {
287        "GET" | "get" => Cow::Borrowed("get"),
288        "POST" | "post" => Cow::Borrowed("post"),
289        "PUT" | "put" => Cow::Borrowed("put"),
290        "PATCH" | "patch" => Cow::Borrowed("patch"),
291        "DELETE" | "delete" => Cow::Borrowed("delete"),
292        method => Cow::Owned(method.to_ascii_lowercase()),
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn fixed_route_methods_are_borrowed() {
302        let routes = [
303            (OpenApiRoute::get("/"), "get"),
304            (OpenApiRoute::post("/"), "post"),
305            (OpenApiRoute::put("/"), "put"),
306            (OpenApiRoute::patch("/"), "patch"),
307            (OpenApiRoute::delete("/"), "delete"),
308        ];
309
310        for (route, expected) in routes {
311            assert_eq!(route.method(), expected);
312            assert!(matches!(route.method, Cow::Borrowed(_)));
313        }
314    }
315
316    #[test]
317    fn route_metadata_borrows_supported_methods_and_owns_fallbacks() {
318        let supported =
319            OpenApiRoute::try_from_route_metadata(&RouteMetadata::new("GET", "/")).unwrap();
320        let fallback =
321            OpenApiRoute::try_from_route_metadata(&RouteMetadata::new("OPTIONS", "/")).unwrap();
322
323        assert!(matches!(supported.method, Cow::Borrowed("get")));
324        assert!(matches!(fallback.method, Cow::Owned(ref method) if method == "options"));
325    }
326}