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 #[serde(skip_serializing_if = "Option::is_none")]
16 pub components: Option<OpenApiComponents>,
17}
18
19#[derive(Clone, Debug, Serialize, Deserialize, Default)]
21pub struct OpenApiComponents {
22 #[serde(rename = "securitySchemes", skip_serializing_if = "BTreeMap::is_empty", default)]
23 pub security_schemes: BTreeMap<String, SecurityScheme>,
24 #[serde(skip_serializing_if = "BTreeMap::is_empty", default)]
25 pub schemas: BTreeMap<String, Value>,
26}
27
28#[derive(Clone, Debug, Serialize, Deserialize)]
30pub struct SecurityScheme {
31 #[serde(rename = "type")]
32 pub scheme_type: String,
33 #[serde(skip_serializing_if = "Option::is_none")]
34 pub scheme: Option<String>,
35 #[serde(rename = "bearerFormat", skip_serializing_if = "Option::is_none")]
36 pub bearer_format: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub description: Option<String>,
39}
40
41#[derive(Clone, Debug, Serialize, Deserialize)]
42pub struct OpenApiInfo {
43 pub title: String,
44 pub version: String,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub description: Option<String>,
47}
48
49#[derive(Clone, Debug, Serialize, Deserialize, Default)]
50pub struct OpenApiPathItem {
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub get: Option<OpenApiOperation>,
53 #[serde(skip_serializing_if = "Option::is_none")]
54 pub post: Option<OpenApiOperation>,
55 #[serde(skip_serializing_if = "Option::is_none")]
56 pub put: Option<OpenApiOperation>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 pub delete: Option<OpenApiOperation>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 pub patch: Option<OpenApiOperation>,
61 #[serde(skip_serializing_if = "Option::is_none")]
62 pub options: Option<OpenApiOperation>,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub head: Option<OpenApiOperation>,
65}
66
67impl OpenApiPathItem {
68 fn set_operation(&mut self, method: &Method, operation: OpenApiOperation) {
69 match *method {
70 Method::GET => self.get = Some(operation),
71 Method::POST => self.post = Some(operation),
72 Method::PUT => self.put = Some(operation),
73 Method::DELETE => self.delete = Some(operation),
74 Method::PATCH => self.patch = Some(operation),
75 Method::OPTIONS => self.options = Some(operation),
76 Method::HEAD => self.head = Some(operation),
77 _ => {}
78 }
79 }
80}
81
82#[derive(Clone, Debug, Serialize, Deserialize)]
83pub struct OpenApiOperation {
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub summary: Option<String>,
86 #[serde(rename = "operationId", skip_serializing_if = "Option::is_none")]
87 pub operation_id: Option<String>,
88 #[serde(skip_serializing_if = "Vec::is_empty", default)]
89 pub parameters: Vec<OpenApiParameter>,
90 #[serde(rename = "requestBody", skip_serializing_if = "Option::is_none")]
91 pub request_body: Option<OpenApiRequestBody>,
92 pub responses: BTreeMap<String, OpenApiResponse>,
93 #[serde(rename = "x-ranvier", skip_serializing_if = "Option::is_none")]
94 pub x_ranvier: Option<Value>,
95}
96
97#[derive(Clone, Debug, Serialize, Deserialize)]
98pub struct OpenApiParameter {
99 pub name: String,
100 #[serde(rename = "in")]
101 pub location: String,
102 pub required: bool,
103 pub schema: Value,
104}
105
106#[derive(Clone, Debug, Serialize, Deserialize)]
107pub struct OpenApiRequestBody {
108 pub required: bool,
109 pub content: BTreeMap<String, OpenApiMediaType>,
110}
111
112#[derive(Clone, Debug, Serialize, Deserialize)]
113pub struct OpenApiResponse {
114 pub description: String,
115 #[serde(skip_serializing_if = "Option::is_none")]
116 pub content: Option<BTreeMap<String, OpenApiMediaType>>,
117}
118
119#[derive(Clone, Debug, Serialize, Deserialize)]
120pub struct OpenApiMediaType {
121 pub schema: Value,
122}
123
124#[derive(Clone, Debug)]
125struct OperationPatch {
126 summary: Option<String>,
127 request_schema: Option<Value>,
128 response_schema: Option<Value>,
129}
130
131impl OperationPatch {
132 fn apply(self, operation: &mut OpenApiOperation) {
133 if let Some(summary) = self.summary {
134 operation.summary = Some(summary);
135 }
136 if let Some(schema) = self.request_schema {
137 let mut content = BTreeMap::new();
138 content.insert("application/json".to_string(), OpenApiMediaType { schema });
139 operation.request_body = Some(OpenApiRequestBody {
140 required: true,
141 content,
142 });
143 }
144 if let Some(schema) = self.response_schema {
145 let mut content = BTreeMap::new();
146 content.insert("application/json".to_string(), OpenApiMediaType { schema });
147
148 let response =
149 operation
150 .responses
151 .entry("200".to_string())
152 .or_insert(OpenApiResponse {
153 description: "Successful response".to_string(),
154 content: None,
155 });
156 response.content = Some(content);
157 }
158 }
159}
160
161#[derive(Clone, Debug)]
162struct SchematicMetadata {
163 id: String,
164 name: String,
165 node_count: usize,
166 edge_count: usize,
167}
168
169impl From<&Schematic> for SchematicMetadata {
170 fn from(value: &Schematic) -> Self {
171 Self {
172 id: value.id.clone(),
173 name: value.name.clone(),
174 node_count: value.nodes.len(),
175 edge_count: value.edges.len(),
176 }
177 }
178}
179
180#[derive(Clone, Debug)]
182pub struct OpenApiGenerator {
183 routes: Vec<HttpRouteDescriptor>,
184 title: String,
185 version: String,
186 description: Option<String>,
187 patches: HashMap<String, OperationPatch>,
188 schematic: Option<SchematicMetadata>,
189 bearer_auth: bool,
190 problem_detail_errors: bool,
191}
192
193impl OpenApiGenerator {
194 pub fn from_descriptors(routes: Vec<HttpRouteDescriptor>) -> Self {
195 Self {
196 routes,
197 title: "Ranvier API".to_string(),
198 version: "0.1.0".to_string(),
199 description: None,
200 patches: HashMap::new(),
201 schematic: None,
202 bearer_auth: false,
203 problem_detail_errors: false,
204 }
205 }
206
207 pub fn from_ingress<R>(ingress: &HttpIngress<R>) -> Self
208 where
209 R: ranvier_core::transition::ResourceRequirement + Clone + Send + Sync + 'static,
210 {
211 Self::from_descriptors(ingress.route_descriptors())
212 }
213
214 pub fn title(mut self, title: impl Into<String>) -> Self {
215 self.title = title.into();
216 self
217 }
218
219 pub fn version(mut self, version: impl Into<String>) -> Self {
220 self.version = version.into();
221 self
222 }
223
224 pub fn description(mut self, description: impl Into<String>) -> Self {
225 self.description = Some(description.into());
226 self
227 }
228
229 pub fn with_schematic(mut self, schematic: &Schematic) -> Self {
230 self.schematic = Some(SchematicMetadata::from(schematic));
231 self
232 }
233
234 pub fn summary(
235 mut self,
236 method: Method,
237 path_pattern: impl AsRef<str>,
238 summary: impl Into<String>,
239 ) -> Self {
240 let key = operation_key(&method, path_pattern.as_ref());
241 let patch = self.patches.entry(key).or_insert(OperationPatch {
242 summary: None,
243 request_schema: None,
244 response_schema: None,
245 });
246 patch.summary = Some(summary.into());
247 self
248 }
249
250 pub fn json_request_schema<T>(mut self, method: Method, path_pattern: impl AsRef<str>) -> Self
251 where
252 T: JsonSchema,
253 {
254 let key = operation_key(&method, path_pattern.as_ref());
255 let patch = self.patches.entry(key).or_insert(OperationPatch {
256 summary: None,
257 request_schema: None,
258 response_schema: None,
259 });
260 patch.request_schema = Some(schema_value::<T>());
261 self
262 }
263
264 pub fn json_request_schema_from_extractor<T>(
266 self,
267 method: Method,
268 path_pattern: impl AsRef<str>,
269 ) -> Self
270 where
271 T: FromRequest + JsonSchema,
272 {
273 self.json_request_schema::<T>(method, path_pattern)
274 }
275
276 pub fn json_response_schema<T>(mut self, method: Method, path_pattern: impl AsRef<str>) -> Self
277 where
278 T: JsonSchema,
279 {
280 let key = operation_key(&method, path_pattern.as_ref());
281 let patch = self.patches.entry(key).or_insert(OperationPatch {
282 summary: None,
283 request_schema: None,
284 response_schema: None,
285 });
286 patch.response_schema = Some(schema_value::<T>());
287 self
288 }
289
290 pub fn json_response_schema_from_into_response<T>(
292 self,
293 method: Method,
294 path_pattern: impl AsRef<str>,
295 ) -> Self
296 where
297 T: IntoResponse + JsonSchema,
298 {
299 self.json_response_schema::<T>(method, path_pattern)
300 }
301
302 pub fn with_bearer_auth(mut self) -> Self {
307 self.bearer_auth = true;
308 self
309 }
310
311 pub fn with_problem_detail_errors(mut self) -> Self {
316 self.problem_detail_errors = true;
317 self
318 }
319
320 pub fn build(self) -> OpenApiDocument {
321 let mut paths = BTreeMap::new();
322
323 for route in self.routes {
324 let (openapi_path, parameters) = normalize_path(route.path_pattern());
325 let default_summary = format!("{} {}", route.method(), openapi_path);
326 let operation_id = format!(
327 "{}_{}",
328 route.method().as_str().to_ascii_lowercase(),
329 route
330 .path_pattern()
331 .trim_matches('/')
332 .replace(['/', ':', '*'], "_")
333 .trim_matches('_')
334 );
335
336 let mut operation = OpenApiOperation {
337 summary: Some(default_summary),
338 operation_id: if operation_id.is_empty() {
339 None
340 } else {
341 Some(operation_id)
342 },
343 parameters,
344 request_body: None,
345 responses: BTreeMap::from([(
346 "200".to_string(),
347 OpenApiResponse {
348 description: "Successful response".to_string(),
349 content: None,
350 },
351 )]),
352 x_ranvier: self.schematic.as_ref().map(|metadata| {
353 json!({
354 "schematic_id": metadata.id,
355 "schematic_name": metadata.name,
356 "node_count": metadata.node_count,
357 "edge_count": metadata.edge_count,
358 "route_pattern": route.path_pattern(),
359 })
360 }),
361 };
362
363 if let Some(patch) = self
364 .patches
365 .get(&operation_key(route.method(), route.path_pattern()))
366 {
367 patch.clone().apply(&mut operation);
368 }
369
370 if self.problem_detail_errors {
372 let problem_ref = json!({"$ref": "#/components/schemas/ProblemDetail"});
373 let mut problem_content = BTreeMap::new();
374 problem_content.insert(
375 "application/problem+json".to_string(),
376 OpenApiMediaType {
377 schema: problem_ref,
378 },
379 );
380
381 for (code, desc) in [
382 ("400", "Bad Request"),
383 ("404", "Not Found"),
384 ("500", "Internal Server Error"),
385 ] {
386 operation.responses.entry(code.to_string()).or_insert(
387 OpenApiResponse {
388 description: desc.to_string(),
389 content: Some(problem_content.clone()),
390 },
391 );
392 }
393 }
394
395 paths
396 .entry(openapi_path)
397 .or_insert_with(OpenApiPathItem::default)
398 .set_operation(route.method(), operation);
399 }
400
401 let mut components = OpenApiComponents::default();
403
404 if self.bearer_auth {
405 components.security_schemes.insert(
406 "bearerAuth".to_string(),
407 SecurityScheme {
408 scheme_type: "http".to_string(),
409 scheme: Some("bearer".to_string()),
410 bearer_format: Some("JWT".to_string()),
411 description: Some("Bearer token authentication".to_string()),
412 },
413 );
414 }
415
416 if self.problem_detail_errors {
417 components.schemas.insert(
418 "ProblemDetail".to_string(),
419 json!({
420 "type": "object",
421 "description": "RFC 7807 Problem Detail",
422 "properties": {
423 "type": { "type": "string", "description": "URI reference identifying the problem type" },
424 "title": { "type": "string", "description": "Short human-readable summary" },
425 "status": { "type": "integer", "description": "HTTP status code" },
426 "detail": { "type": "string", "description": "Human-readable explanation" },
427 "instance": { "type": "string", "description": "URI reference identifying the specific occurrence" }
428 },
429 "required": ["type", "title", "status"]
430 }),
431 );
432 }
433
434 let has_components = !components.security_schemes.is_empty()
435 || !components.schemas.is_empty();
436
437 OpenApiDocument {
438 openapi: "3.0.3".to_string(),
439 info: OpenApiInfo {
440 title: self.title,
441 version: self.version,
442 description: self.description,
443 },
444 paths,
445 components: if has_components {
446 Some(components)
447 } else {
448 None
449 },
450 }
451 }
452
453 pub fn build_json(self) -> Value {
454 serde_json::to_value(self.build()).expect("openapi document should serialize")
455 }
456
457 pub fn build_pretty_json(self) -> String {
458 serde_json::to_string_pretty(&self.build()).expect("openapi document should serialize")
459 }
460}
461
462pub fn swagger_ui_html(spec_url: &str, title: &str) -> String {
463 format!(
464 r#"<!doctype html>
465<html lang="en">
466<head>
467 <meta charset="utf-8" />
468 <meta name="viewport" content="width=device-width,initial-scale=1" />
469 <title>{title}</title>
470 <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
471</head>
472<body>
473 <div id="swagger-ui"></div>
474 <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
475 <script>
476 window.ui = SwaggerUIBundle({{
477 url: '{spec_url}',
478 dom_id: '#swagger-ui',
479 deepLinking: true,
480 presets: [SwaggerUIBundle.presets.apis]
481 }});
482 </script>
483</body>
484</html>"#
485 )
486}
487
488fn operation_key(method: &Method, path_pattern: &str) -> String {
489 format!("{} {}", method.as_str(), path_pattern)
490}
491
492fn normalize_path(path_pattern: &str) -> (String, Vec<OpenApiParameter>) {
493 if path_pattern == "/" {
494 return ("/".to_string(), Vec::new());
495 }
496
497 let mut params = Vec::new();
498 let mut segments = Vec::new();
499
500 for segment in path_pattern
501 .trim_matches('/')
502 .split('/')
503 .filter(|segment| !segment.is_empty())
504 {
505 if let Some(name) = segment
506 .strip_prefix(':')
507 .or_else(|| segment.strip_prefix('*'))
508 {
509 let normalized_name = if name.is_empty() { "path" } else { name };
510 segments.push(format!("{{{normalized_name}}}"));
511 params.push(OpenApiParameter {
512 name: normalized_name.to_string(),
513 location: "path".to_string(),
514 required: true,
515 schema: json!({"type": "string"}),
516 });
517 continue;
518 }
519
520 segments.push(segment.to_string());
521 }
522
523 (format!("/{}", segments.join("/")), params)
524}
525
526fn schema_value<T>() -> Value
527where
528 T: JsonSchema,
529{
530 serde_json::to_value(schema_for!(T)).expect("json schema should serialize")
531}
532
533pub mod prelude {
534 pub use crate::{
535 OpenApiComponents, OpenApiDocument, OpenApiGenerator, SecurityScheme, swagger_ui_html,
536 };
537}
538
539#[cfg(test)]
540mod tests {
541 use super::*;
542 use schemars::JsonSchema;
543
544 #[derive(JsonSchema)]
545 #[allow(dead_code)]
546 struct CreateUserRequest {
547 email: String,
548 }
549
550 #[derive(JsonSchema)]
551 #[allow(dead_code)]
552 struct CreateUserResponse {
553 id: String,
554 }
555
556 #[test]
557 fn normalize_path_converts_param_and_wildcard_segments() {
558 let (path, params) = normalize_path("/users/:id/files/*path");
559 assert_eq!(path, "/users/{id}/files/{path}");
560 assert_eq!(params.len(), 2);
561 assert_eq!(params[0].name, "id");
562 assert_eq!(params[1].name, "path");
563 }
564
565 #[test]
566 fn generator_builds_paths_from_route_descriptors() {
567 let doc = OpenApiGenerator::from_descriptors(vec![
568 HttpRouteDescriptor::new(Method::GET, "/users/:id"),
569 HttpRouteDescriptor::new(Method::POST, "/users"),
570 ])
571 .title("Users API")
572 .version("0.7.0")
573 .build();
574
575 assert_eq!(doc.info.title, "Users API");
576 assert!(doc.paths.contains_key("/users/{id}"));
577 assert!(doc.paths.contains_key("/users"));
578 assert!(doc.paths["/users/{id}"].get.is_some());
579 assert!(doc.paths["/users"].post.is_some());
580 }
581
582 #[test]
583 fn generator_applies_json_request_response_schema_overrides() {
584 let doc = OpenApiGenerator::from_descriptors(vec![HttpRouteDescriptor::new(
585 Method::POST,
586 "/users",
587 )])
588 .json_request_schema::<CreateUserRequest>(Method::POST, "/users")
589 .json_response_schema::<CreateUserResponse>(Method::POST, "/users")
590 .summary(Method::POST, "/users", "Create a user")
591 .build();
592
593 let operation = doc.paths["/users"].post.as_ref().expect("post operation");
594 assert_eq!(operation.summary.as_deref(), Some("Create a user"));
595 assert!(operation.request_body.is_some());
596 assert!(
597 operation.responses["200"]
598 .content
599 .as_ref()
600 .expect("response content")
601 .contains_key("application/json")
602 );
603 }
604
605 #[test]
606 fn swagger_html_contains_spec_url() {
607 let html = swagger_ui_html("/openapi.json", "API Docs");
608 assert!(html.contains("/openapi.json"));
609 assert!(html.contains("SwaggerUIBundle"));
610 }
611
612 #[test]
615 fn bearer_auth_adds_security_scheme() {
616 let doc = OpenApiGenerator::from_descriptors(vec![HttpRouteDescriptor::new(
617 Method::GET,
618 "/users",
619 )])
620 .with_bearer_auth()
621 .build();
622
623 let components = doc.components.expect("should have components");
624 let scheme = components
625 .security_schemes
626 .get("bearerAuth")
627 .expect("should have bearerAuth");
628 assert_eq!(scheme.scheme_type, "http");
629 assert_eq!(scheme.scheme.as_deref(), Some("bearer"));
630 assert_eq!(scheme.bearer_format.as_deref(), Some("JWT"));
631 }
632
633 #[test]
634 fn no_bearer_auth_means_no_components() {
635 let doc = OpenApiGenerator::from_descriptors(vec![HttpRouteDescriptor::new(
636 Method::GET,
637 "/users",
638 )])
639 .build();
640
641 assert!(doc.components.is_none());
642 }
643
644 #[test]
645 fn problem_detail_adds_error_responses() {
646 let doc = OpenApiGenerator::from_descriptors(vec![HttpRouteDescriptor::new(
647 Method::GET,
648 "/users",
649 )])
650 .with_problem_detail_errors()
651 .build();
652
653 let op = doc.paths["/users"].get.as_ref().unwrap();
654 assert!(op.responses.contains_key("400"));
655 assert!(op.responses.contains_key("404"));
656 assert!(op.responses.contains_key("500"));
657
658 let r404 = &op.responses["404"];
659 assert_eq!(r404.description, "Not Found");
660 let content = r404.content.as_ref().unwrap();
661 assert!(content.contains_key("application/problem+json"));
662 }
663
664 #[test]
665 fn problem_detail_schema_in_components() {
666 let doc = OpenApiGenerator::from_descriptors(vec![HttpRouteDescriptor::new(
667 Method::GET,
668 "/users",
669 )])
670 .with_problem_detail_errors()
671 .build();
672
673 let components = doc.components.expect("should have components");
674 let schema = components
675 .schemas
676 .get("ProblemDetail")
677 .expect("should have ProblemDetail schema");
678 assert_eq!(schema["type"], "object");
679 assert!(schema["properties"]["type"].is_object());
680 assert!(schema["properties"]["title"].is_object());
681 assert!(schema["properties"]["status"].is_object());
682 }
683
684 #[test]
685 fn problem_detail_references_schema() {
686 let doc = OpenApiGenerator::from_descriptors(vec![HttpRouteDescriptor::new(
687 Method::POST,
688 "/orders",
689 )])
690 .with_problem_detail_errors()
691 .build();
692
693 let op = doc.paths["/orders"].post.as_ref().unwrap();
694 let content_500 = op.responses["500"].content.as_ref().unwrap();
695 let schema = &content_500["application/problem+json"].schema;
696 assert_eq!(schema["$ref"], "#/components/schemas/ProblemDetail");
697 }
698
699 #[test]
700 fn multiple_routes_all_get_error_responses() {
701 let doc = OpenApiGenerator::from_descriptors(vec![
702 HttpRouteDescriptor::new(Method::GET, "/users"),
703 HttpRouteDescriptor::new(Method::POST, "/users"),
704 HttpRouteDescriptor::new(Method::DELETE, "/users/:id"),
705 ])
706 .with_problem_detail_errors()
707 .build();
708
709 let get_op = doc.paths["/users"].get.as_ref().unwrap();
711 let post_op = doc.paths["/users"].post.as_ref().unwrap();
712 let delete_op = doc.paths["/users/{id}"].delete.as_ref().unwrap();
713
714 assert!(get_op.responses.contains_key("400"));
715 assert!(post_op.responses.contains_key("404"));
716 assert!(delete_op.responses.contains_key("500"));
717 }
718
719 #[test]
720 fn bearer_auth_and_problem_detail_combined() {
721 let doc = OpenApiGenerator::from_descriptors(vec![HttpRouteDescriptor::new(
722 Method::GET,
723 "/protected",
724 )])
725 .with_bearer_auth()
726 .with_problem_detail_errors()
727 .build();
728
729 let components = doc.components.expect("should have components");
730 assert!(components.security_schemes.contains_key("bearerAuth"));
731 assert!(components.schemas.contains_key("ProblemDetail"));
732 }
733
734 #[test]
735 fn bearer_auth_serializes_in_json() {
736 let doc = OpenApiGenerator::from_descriptors(vec![HttpRouteDescriptor::new(
737 Method::GET,
738 "/api",
739 )])
740 .with_bearer_auth()
741 .build_json();
742
743 let schemes = &doc["components"]["securitySchemes"];
744 assert_eq!(schemes["bearerAuth"]["type"], "http");
745 assert_eq!(schemes["bearerAuth"]["scheme"], "bearer");
746 assert_eq!(schemes["bearerAuth"]["bearerFormat"], "JWT");
747 }
748}