1use serde::{Serialize, Deserialize};
2use std::collections::HashMap;
3use std::sync::{OnceLock, RwLock};
4
5#[derive(Serialize, Deserialize, Debug, Clone)]
6pub struct RouteDoc {
7 pub method: String,
8 pub path: String,
9 pub summary: String,
10 pub description: String,
11 pub tags: Vec<String>,
12 #[serde(skip_serializing_if = "Vec::is_empty", default)]
13 pub params: Vec<ParamDoc>,
14 #[serde(skip_serializing_if = "Option::is_none", rename = "requestBody", default)]
15 pub request_body: Option<RequestBodyDoc>,
16 pub responses: HashMap<String, ResponseDoc>,
17}
18
19#[derive(Serialize, Deserialize, Debug, Clone)]
20pub struct ParamDoc {
21 pub name: String,
22 pub r#in: String, #[serde(skip_serializing_if = "String::is_empty", default)]
24 pub description: String,
25 pub required: bool,
26 pub r#type: String, }
28
29#[derive(Serialize, Deserialize, Debug, Clone)]
30pub struct RequestBodyDoc {
31 pub content: HashMap<String, MediaTypeDoc>,
32}
33
34#[derive(Serialize, Deserialize, Debug, Clone)]
35pub struct MediaTypeDoc {
36 pub schema: SchemaDoc,
37}
38
39#[derive(Serialize, Deserialize, Debug, Clone)]
40pub struct SchemaDoc {
41 pub r#type: String,
42 #[serde(skip_serializing_if = "HashMap::is_empty", default)]
43 pub properties: HashMap<String, Property>,
44}
45
46#[derive(Serialize, Deserialize, Debug, Clone)]
47pub struct Property {
48 pub r#type: String,
49}
50
51#[derive(Serialize, Deserialize, Debug, Clone)]
52pub struct ResponseDoc {
53 pub description: String,
54}
55
56pub struct APIRegistry {
57 pub title: RwLock<String>,
58 pub description: RwLock<String>,
59 routes: RwLock<HashMap<String, RouteDoc>>,
60}
61
62impl APIRegistry {
63 pub fn global() -> &'static Self {
64 static INSTANCE: OnceLock<APIRegistry> = OnceLock::new();
65 INSTANCE.get_or_init(|| Self {
66 title: RwLock::new("Zeno API".to_string()),
67 description: RwLock::new("Auto-generated API Documentation".to_string()),
68 routes: RwLock::new(HashMap::new()),
69 })
70 }
71
72 pub fn register(&self, method: &str, path: &str, doc: RouteDoc) {
73 let mut routes = self.routes.write().unwrap();
74 let key = format!("{}:{}", method, path);
75 routes.insert(key, doc);
76 }
77
78 pub fn get_routes(&self) -> Vec<RouteDoc> {
79 let routes = self.routes.read().unwrap();
80 routes.values().cloned().collect()
81 }
82
83 pub fn to_json(&self) -> Result<String, serde_json::Error> {
84 let spec = self.generate_openapi();
85 serde_json::to_string_pretty(&spec)
86 }
87
88 pub fn generate_openapi(&self) -> serde_json::Value {
89 let title = self.title.read().unwrap().clone();
90 let description = self.description.read().unwrap().clone();
91 let routes = self.routes.read().unwrap();
92
93 let mut paths_obj = serde_json::Map::new();
94
95 for route in routes.values() {
96 let path_entry = paths_obj
98 .entry(route.path.clone())
99 .or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
100
101 if let serde_json::Value::Object(methods_map) = path_entry {
102 let method_lower = route.method.to_lowercase();
103
104 let mut operation = serde_json::Map::new();
105 operation.insert("summary".to_string(), serde_json::Value::String(route.summary.clone()));
106 operation.insert("description".to_string(), serde_json::Value::String(route.description.clone()));
107 operation.insert("tags".to_string(), serde_json::to_value(&route.tags).unwrap_or(serde_json::Value::Array(Vec::new())));
108
109 if !route.params.is_empty() {
110 operation.insert("parameters".to_string(), serde_json::to_value(&route.params).unwrap());
111 }
112
113 if let Some(ref body) = route.request_body {
114 operation.insert("requestBody".to_string(), serde_json::to_value(body).unwrap());
115 }
116
117 operation.insert("responses".to_string(), serde_json::to_value(&route.responses).unwrap_or(serde_json::Value::Object(serde_json::Map::new())));
118
119 methods_map.insert(method_lower, serde_json::Value::Object(operation));
120 }
121 }
122
123 serde_json::json!({
124 "openapi": "3.0.0",
125 "info": {
126 "title": title,
127 "version": "1.0.0",
128 "description": description
129 },
130 "paths": paths_obj
131 })
132 }
133}
134
135pub fn swagger_ui_html(swagger_json_url: &str) -> String {
136 format!(
137 r#"<!DOCTYPE html>
138<html lang="en">
139<head>
140 <meta charset="UTF-8">
141 <title>API Documentation</title>
142 <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui.css" />
143</head>
144<body>
145<div id="swagger-ui"></div>
146<script src="https://unpkg.com/swagger-ui-dist@5.9.0/swagger-ui-bundle.js"></script>
147<script>
148window.onload = function() {{
149 window.ui = SwaggerUIBundle({{
150 url: "{}",
151 dom_id: '#swagger-ui',
152 }});
153}};
154</script>
155</body>
156</html>"#,
157 swagger_json_url
158 )
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[test]
166 fn test_apidoc_registration_and_openapi_generation() {
167 let registry = APIRegistry::global();
168
169 let mut responses = HashMap::new();
170 responses.insert("200".to_string(), ResponseDoc {
171 description: "Success".to_string(),
172 });
173
174 registry.register("GET", "/api/v1/ping", RouteDoc {
175 method: "GET".to_string(),
176 path: "/api/v1/ping".to_string(),
177 summary: "Ping endpoint".to_string(),
178 description: "Returns 200 pong".to_string(),
179 tags: vec!["System".to_string()],
180 params: vec![ParamDoc {
181 name: "verbosity".to_string(),
182 r#in: "query".to_string(),
183 description: "Debug verbosity".to_string(),
184 required: false,
185 r#type: "string".to_string(),
186 }],
187 request_body: None,
188 responses,
189 });
190
191 let openapi_json = registry.to_json().unwrap();
192 println!("Generated OpenAPI Spec:\n{}", openapi_json);
193
194 assert!(openapi_json.contains("\"openapi\": \"3.0.0\""));
195 assert!(openapi_json.contains("\"title\": \"Zeno API\""));
196 assert!(openapi_json.contains("\"/api/v1/ping\""));
197 assert!(openapi_json.contains("\"get\""));
198 assert!(openapi_json.contains("\"verbosity\""));
199 assert!(openapi_json.contains("\"System\""));
200 }
201
202 #[test]
203 fn test_swagger_ui_html() {
204 let html = swagger_ui_html("/swagger.json");
205 assert!(html.contains("url: \"/swagger.json\""));
206 assert!(html.contains("https://unpkg.com/swagger-ui-dist"));
207 }
208}