Skip to main content

zeno_apidoc/
lib.rs

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, // "query", "path", "header"
23    #[serde(skip_serializing_if = "String::is_empty", default)]
24    pub description: String,
25    pub required: bool,
26    pub r#type: String, // e.g. "string", "integer"
27}
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            // Find or create path entry in paths
97            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}