Skip to main content

ranvier_openapi/
lib.rs

1use std::collections::{BTreeMap, HashMap};
2
3use http::Method;
4use ranvier_core::Schematic;
5use ranvier_http::{FromRequest, HttpIngress, HttpRouteDescriptor, IntoResponse};
6use schemars::{JsonSchema, schema_for};
7use serde::{Deserialize, Serialize};
8use serde_json::{Value, json};
9
10#[derive(Clone, Debug, Serialize, Deserialize)]
11pub struct OpenApiDocument {
12    pub openapi: String,
13    pub info: OpenApiInfo,
14    pub paths: BTreeMap<String, OpenApiPathItem>,
15}
16
17#[derive(Clone, Debug, Serialize, Deserialize)]
18pub struct OpenApiInfo {
19    pub title: String,
20    pub version: String,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub description: Option<String>,
23}
24
25#[derive(Clone, Debug, Serialize, Deserialize, Default)]
26pub struct OpenApiPathItem {
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub get: Option<OpenApiOperation>,
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub post: Option<OpenApiOperation>,
31    #[serde(skip_serializing_if = "Option::is_none")]
32    pub put: Option<OpenApiOperation>,
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub delete: Option<OpenApiOperation>,
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub patch: Option<OpenApiOperation>,
37    #[serde(skip_serializing_if = "Option::is_none")]
38    pub options: Option<OpenApiOperation>,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub head: Option<OpenApiOperation>,
41}
42
43impl OpenApiPathItem {
44    fn set_operation(&mut self, method: &Method, operation: OpenApiOperation) {
45        match *method {
46            Method::GET => self.get = Some(operation),
47            Method::POST => self.post = Some(operation),
48            Method::PUT => self.put = Some(operation),
49            Method::DELETE => self.delete = Some(operation),
50            Method::PATCH => self.patch = Some(operation),
51            Method::OPTIONS => self.options = Some(operation),
52            Method::HEAD => self.head = Some(operation),
53            _ => {}
54        }
55    }
56}
57
58#[derive(Clone, Debug, Serialize, Deserialize)]
59pub struct OpenApiOperation {
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub summary: Option<String>,
62    #[serde(rename = "operationId", skip_serializing_if = "Option::is_none")]
63    pub operation_id: Option<String>,
64    #[serde(skip_serializing_if = "Vec::is_empty", default)]
65    pub parameters: Vec<OpenApiParameter>,
66    #[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")]
67    pub request_body: Option<OpenApiRequestBody>,
68    pub responses: BTreeMap<String, OpenApiResponse>,
69    #[serde(rename = "x-ranvier", skip_serializing_if = "Option::is_none")]
70    pub x_ranvier: Option<Value>,
71}
72
73#[derive(Clone, Debug, Serialize, Deserialize)]
74pub struct OpenApiParameter {
75    pub name: String,
76    #[serde(rename = "in")]
77    pub location: String,
78    pub required: bool,
79    pub schema: Value,
80}
81
82#[derive(Clone, Debug, Serialize, Deserialize)]
83pub struct OpenApiRequestBody {
84    pub required: bool,
85    pub content: BTreeMap<String, OpenApiMediaType>,
86}
87
88#[derive(Clone, Debug, Serialize, Deserialize)]
89pub struct OpenApiResponse {
90    pub description: String,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub content: Option<BTreeMap<String, OpenApiMediaType>>,
93}
94
95#[derive(Clone, Debug, Serialize, Deserialize)]
96pub struct OpenApiMediaType {
97    pub schema: Value,
98}
99
100#[derive(Clone, Debug)]
101struct OperationPatch {
102    summary: Option<String>,
103    request_schema: Option<Value>,
104    response_schema: Option<Value>,
105}
106
107impl OperationPatch {
108    fn apply(self, operation: &mut OpenApiOperation) {
109        if let Some(summary) = self.summary {
110            operation.summary = Some(summary);
111        }
112        if let Some(schema) = self.request_schema {
113            let mut content = BTreeMap::new();
114            content.insert("application/json".to_string(), OpenApiMediaType { schema });
115            operation.request_body = Some(OpenApiRequestBody {
116                required: true,
117                content,
118            });
119        }
120        if let Some(schema) = self.response_schema {
121            let mut content = BTreeMap::new();
122            content.insert("application/json".to_string(), OpenApiMediaType { schema });
123
124            let response =
125                operation
126                    .responses
127                    .entry("200".to_string())
128                    .or_insert(OpenApiResponse {
129                        description: "Successful response".to_string(),
130                        content: None,
131                    });
132            response.content = Some(content);
133        }
134    }
135}
136
137#[derive(Clone, Debug)]
138struct SchematicMetadata {
139    id: String,
140    name: String,
141    node_count: usize,
142    edge_count: usize,
143}
144
145impl From<&Schematic> for SchematicMetadata {
146    fn from(value: &Schematic) -> Self {
147        Self {
148            id: value.id.clone(),
149            name: value.name.clone(),
150            node_count: value.nodes.len(),
151            edge_count: value.edges.len(),
152        }
153    }
154}
155
156/// OpenAPI generator bound to a set of ingress route descriptors.
157#[derive(Clone, Debug)]
158pub struct OpenApiGenerator {
159    routes: Vec<HttpRouteDescriptor>,
160    title: String,
161    version: String,
162    description: Option<String>,
163    patches: HashMap<String, OperationPatch>,
164    schematic: Option<SchematicMetadata>,
165}
166
167impl OpenApiGenerator {
168    pub fn from_descriptors(routes: Vec<HttpRouteDescriptor>) -> Self {
169        Self {
170            routes,
171            title: "Ranvier API".to_string(),
172            version: "0.1.0".to_string(),
173            description: None,
174            patches: HashMap::new(),
175            schematic: None,
176        }
177    }
178
179    pub fn from_ingress<R>(ingress: &HttpIngress<R>) -> Self
180    where
181        R: ranvier_core::transition::ResourceRequirement + Clone + Send + Sync + 'static,
182    {
183        Self::from_descriptors(ingress.route_descriptors())
184    }
185
186    pub fn title(mut self, title: impl Into<String>) -> Self {
187        self.title = title.into();
188        self
189    }
190
191    pub fn version(mut self, version: impl Into<String>) -> Self {
192        self.version = version.into();
193        self
194    }
195
196    pub fn description(mut self, description: impl Into<String>) -> Self {
197        self.description = Some(description.into());
198        self
199    }
200
201    pub fn with_schematic(mut self, schematic: &Schematic) -> Self {
202        self.schematic = Some(SchematicMetadata::from(schematic));
203        self
204    }
205
206    pub fn summary(
207        mut self,
208        method: Method,
209        path_pattern: impl AsRef<str>,
210        summary: impl Into<String>,
211    ) -> Self {
212        let key = operation_key(&method, path_pattern.as_ref());
213        let patch = self.patches.entry(key).or_insert(OperationPatch {
214            summary: None,
215            request_schema: None,
216            response_schema: None,
217        });
218        patch.summary = Some(summary.into());
219        self
220    }
221
222    pub fn json_request_schema<T>(mut self, method: Method, path_pattern: impl AsRef<str>) -> Self
223    where
224        T: JsonSchema,
225    {
226        let key = operation_key(&method, path_pattern.as_ref());
227        let patch = self.patches.entry(key).or_insert(OperationPatch {
228            summary: None,
229            request_schema: None,
230            response_schema: None,
231        });
232        patch.request_schema = Some(schema_value::<T>());
233        self
234    }
235
236    /// Register JSON request schema using a `FromRequest` implementor type.
237    pub fn json_request_schema_from_extractor<T>(
238        self,
239        method: Method,
240        path_pattern: impl AsRef<str>,
241    ) -> Self
242    where
243        T: FromRequest + JsonSchema,
244    {
245        self.json_request_schema::<T>(method, path_pattern)
246    }
247
248    pub fn json_response_schema<T>(mut self, method: Method, path_pattern: impl AsRef<str>) -> Self
249    where
250        T: JsonSchema,
251    {
252        let key = operation_key(&method, path_pattern.as_ref());
253        let patch = self.patches.entry(key).or_insert(OperationPatch {
254            summary: None,
255            request_schema: None,
256            response_schema: None,
257        });
258        patch.response_schema = Some(schema_value::<T>());
259        self
260    }
261
262    /// Register JSON response schema using an `IntoResponse` implementor type.
263    pub fn json_response_schema_from_into_response<T>(
264        self,
265        method: Method,
266        path_pattern: impl AsRef<str>,
267    ) -> Self
268    where
269        T: IntoResponse + JsonSchema,
270    {
271        self.json_response_schema::<T>(method, path_pattern)
272    }
273
274    pub fn build(self) -> OpenApiDocument {
275        let mut paths = BTreeMap::new();
276
277        for route in self.routes {
278            let (openapi_path, parameters) = normalize_path(route.path_pattern());
279            let default_summary = format!("{} {}", route.method(), openapi_path);
280            let operation_id = format!(
281                "{}_{}",
282                route.method().as_str().to_ascii_lowercase(),
283                route
284                    .path_pattern()
285                    .trim_matches('/')
286                    .replace(['/', ':', '*'], "_")
287                    .trim_matches('_')
288            );
289
290            let mut operation = OpenApiOperation {
291                summary: Some(default_summary),
292                operation_id: if operation_id.is_empty() {
293                    None
294                } else {
295                    Some(operation_id)
296                },
297                parameters,
298                request_body: None,
299                responses: BTreeMap::from([(
300                    "200".to_string(),
301                    OpenApiResponse {
302                        description: "Successful response".to_string(),
303                        content: None,
304                    },
305                )]),
306                x_ranvier: self.schematic.as_ref().map(|metadata| {
307                    json!({
308                        "schematic_id": metadata.id,
309                        "schematic_name": metadata.name,
310                        "node_count": metadata.node_count,
311                        "edge_count": metadata.edge_count,
312                        "route_pattern": route.path_pattern(),
313                    })
314                }),
315            };
316
317            if let Some(patch) = self
318                .patches
319                .get(&operation_key(route.method(), route.path_pattern()))
320            {
321                patch.clone().apply(&mut operation);
322            }
323
324            paths
325                .entry(openapi_path)
326                .or_insert_with(OpenApiPathItem::default)
327                .set_operation(route.method(), operation);
328        }
329
330        OpenApiDocument {
331            openapi: "3.0.3".to_string(),
332            info: OpenApiInfo {
333                title: self.title,
334                version: self.version,
335                description: self.description,
336            },
337            paths,
338        }
339    }
340
341    pub fn build_json(self) -> Value {
342        serde_json::to_value(self.build()).expect("openapi document should serialize")
343    }
344
345    pub fn build_pretty_json(self) -> String {
346        serde_json::to_string_pretty(&self.build()).expect("openapi document should serialize")
347    }
348}
349
350pub fn swagger_ui_html(spec_url: &str, title: &str) -> String {
351    format!(
352        r#"<!doctype html>
353<html lang="en">
354<head>
355  <meta charset="utf-8" />
356  <meta name="viewport" content="width=device-width,initial-scale=1" />
357  <title>{title}</title>
358  <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
359</head>
360<body>
361  <div id="swagger-ui"></div>
362  <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
363  <script>
364    window.ui = SwaggerUIBundle({{
365      url: '{spec_url}',
366      dom_id: '#swagger-ui',
367      deepLinking: true,
368      presets: [SwaggerUIBundle.presets.apis]
369    }});
370  </script>
371</body>
372</html>"#
373    )
374}
375
376fn operation_key(method: &Method, path_pattern: &str) -> String {
377    format!("{} {}", method.as_str(), path_pattern)
378}
379
380fn normalize_path(path_pattern: &str) -> (String, Vec<OpenApiParameter>) {
381    if path_pattern == "/" {
382        return ("/".to_string(), Vec::new());
383    }
384
385    let mut params = Vec::new();
386    let mut segments = Vec::new();
387
388    for segment in path_pattern
389        .trim_matches('/')
390        .split('/')
391        .filter(|segment| !segment.is_empty())
392    {
393        if let Some(name) = segment
394            .strip_prefix(':')
395            .or_else(|| segment.strip_prefix('*'))
396        {
397            let normalized_name = if name.is_empty() { "path" } else { name };
398            segments.push(format!("{{{normalized_name}}}"));
399            params.push(OpenApiParameter {
400                name: normalized_name.to_string(),
401                location: "path".to_string(),
402                required: true,
403                schema: json!({"type": "string"}),
404            });
405            continue;
406        }
407
408        segments.push(segment.to_string());
409    }
410
411    (format!("/{}", segments.join("/")), params)
412}
413
414fn schema_value<T>() -> Value
415where
416    T: JsonSchema,
417{
418    serde_json::to_value(schema_for!(T)).expect("json schema should serialize")
419}
420
421pub mod prelude {
422    pub use crate::{OpenApiDocument, OpenApiGenerator, swagger_ui_html};
423}
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use schemars::JsonSchema;
429
430    #[derive(JsonSchema)]
431    #[allow(dead_code)]
432    struct CreateUserRequest {
433        email: String,
434    }
435
436    #[derive(JsonSchema)]
437    #[allow(dead_code)]
438    struct CreateUserResponse {
439        id: String,
440    }
441
442    #[test]
443    fn normalize_path_converts_param_and_wildcard_segments() {
444        let (path, params) = normalize_path("/users/:id/files/*path");
445        assert_eq!(path, "/users/{id}/files/{path}");
446        assert_eq!(params.len(), 2);
447        assert_eq!(params[0].name, "id");
448        assert_eq!(params[1].name, "path");
449    }
450
451    #[test]
452    fn generator_builds_paths_from_route_descriptors() {
453        let doc = OpenApiGenerator::from_descriptors(vec![
454            HttpRouteDescriptor::new(Method::GET, "/users/:id"),
455            HttpRouteDescriptor::new(Method::POST, "/users"),
456        ])
457        .title("Users API")
458        .version("0.7.0")
459        .build();
460
461        assert_eq!(doc.info.title, "Users API");
462        assert!(doc.paths.contains_key("/users/{id}"));
463        assert!(doc.paths.contains_key("/users"));
464        assert!(doc.paths["/users/{id}"].get.is_some());
465        assert!(doc.paths["/users"].post.is_some());
466    }
467
468    #[test]
469    fn generator_applies_json_request_response_schema_overrides() {
470        let doc = OpenApiGenerator::from_descriptors(vec![HttpRouteDescriptor::new(
471            Method::POST,
472            "/users",
473        )])
474        .json_request_schema::<CreateUserRequest>(Method::POST, "/users")
475        .json_response_schema::<CreateUserResponse>(Method::POST, "/users")
476        .summary(Method::POST, "/users", "Create a user")
477        .build();
478
479        let operation = doc.paths["/users"].post.as_ref().expect("post operation");
480        assert_eq!(operation.summary.as_deref(), Some("Create a user"));
481        assert!(operation.request_body.is_some());
482        assert!(
483            operation.responses["200"]
484                .content
485                .as_ref()
486                .expect("response content")
487                .contains_key("application/json")
488        );
489    }
490
491    #[test]
492    fn swagger_html_contains_spec_url() {
493        let html = swagger_ui_html("/openapi.json", "API Docs");
494        assert!(html.contains("/openapi.json"));
495        assert!(html.contains("SwaggerUIBundle"));
496    }
497}