Skip to main content

nidus_openapi/
route.rs

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