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#[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 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 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}