Skip to main content

sz_orm_swagger/
lib.rs

1//! # SZ-ORM Swagger — OpenAPI/Swagger 规范生成
2//!
3//! 提供 OpenAPI 3.0 规范的构建与序列化,支持路径、方法、参数与响应定义,
4//! 输出可被 Swagger UI 等工具直接消费。
5//!
6//! ## 主要类型
7//!
8//! - [`OpenAPISpec`] — 规范根对象
9//! - [`PathInfo`] — 单个 (path, method) 操作描述
10
11use serde::{Deserialize, Serialize};
12use std::collections::HashMap;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct OpenAPISpec {
16    pub paths: HashMap<String, serde_json::Value>,
17    pub info: serde_json::Value,
18}
19
20impl OpenAPISpec {
21    pub fn to_json_string(&self) -> String {
22        serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
23    }
24}
25
26/// Describes a single (path, method) operation in an OpenAPI spec.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct PathInfo {
29    pub method: String,
30    pub summary: String,
31    pub parameters: Vec<serde_json::Value>,
32    pub responses: HashMap<String, serde_json::Value>,
33}
34
35impl PathInfo {
36    pub fn new(method: &str, summary: &str) -> Self {
37        Self {
38            method: method.to_string(),
39            summary: summary.to_string(),
40            parameters: vec![],
41            responses: HashMap::new(),
42        }
43    }
44
45    pub fn with_response(mut self, code: &str, desc: &str) -> Self {
46        self.responses
47            .insert(code.to_string(), serde_json::json!({ "description": desc }));
48        self
49    }
50
51    pub fn with_parameter(mut self, param: serde_json::Value) -> Self {
52        self.parameters.push(param);
53        self
54    }
55}
56
57pub struct OpenAPIGenerator {
58    paths: Vec<(String, PathInfo)>,
59    info: serde_json::Value,
60}
61
62impl OpenAPIGenerator {
63    pub fn new() -> Self {
64        Self {
65            paths: vec![],
66            info: serde_json::json!({
67                "title": "API",
68                "version": "1.0.0",
69                "description": "Generated by sz-orm-swagger"
70            }),
71        }
72    }
73
74    pub fn with_info(mut self, info: serde_json::Value) -> Self {
75        self.info = info;
76        self
77    }
78
79    /// Register a (path, method) operation. Multiple methods on the same path
80    /// are merged into a single paths entry.
81    pub fn register_path(&mut self, path: &str, info: PathInfo) -> &mut Self {
82        self.paths.push((path.to_string(), info));
83        self
84    }
85
86    /// Generate an OpenAPISpec from the registered paths.
87    pub fn generate(&self) -> OpenAPISpec {
88        let mut paths: HashMap<String, serde_json::Value> = HashMap::new();
89        for (path, info) in &self.paths {
90            let method = info.method.to_lowercase();
91            let entry = paths
92                .entry(path.clone())
93                .or_insert_with(|| serde_json::json!({}));
94            entry[method] = serde_json::json!({
95                "summary": info.summary,
96                "parameters": info.parameters,
97                "responses": info.responses
98            });
99        }
100        OpenAPISpec {
101            paths,
102            info: self.info.clone(),
103        }
104    }
105}
106
107impl Default for OpenAPIGenerator {
108    fn default() -> Self {
109        Self::new()
110    }
111}
112
113pub struct SwaggerUi {
114    mount_path: String,
115    spec: Option<OpenAPISpec>,
116}
117
118impl SwaggerUi {
119    pub fn new(path: &str) -> Self {
120        Self {
121            mount_path: path.to_string(),
122            spec: None,
123        }
124    }
125
126    pub fn with_spec(mut self, spec: OpenAPISpec) -> Self {
127        self.spec = Some(spec);
128        self
129    }
130
131    pub fn mount(&self) -> String {
132        format!("{}docs", self.mount_path)
133    }
134
135    /// Render a real, self-contained Swagger UI HTML page that loads the
136    /// swagger-ui-dist CDN assets and embeds the spec JSON inline.
137    pub fn render_html(&self) -> String {
138        let spec_json = match &self.spec {
139            Some(s) => s.to_json_string(),
140            None => serde_json::json!({
141                "openapi": "3.0.0",
142                "info": { "title": "API", "version": "1.0.0" },
143                "paths": {}
144            })
145            .to_string(),
146        };
147        let mount = self.mount();
148        format!(
149            r#"<!DOCTYPE html>
150<html lang="en">
151<head>
152    <meta charset="UTF-8">
153    <title>Swagger UI</title>
154    <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@4.19.0/swagger-ui.css">
155</head>
156<body>
157    <div id="swagger-ui"></div>
158    <script src="https://unpkg.com/swagger-ui-dist@4.19.0/swagger-ui-bundle.js"></script>
159    <script src="https://unpkg.com/swagger-ui-dist@4.19.0/swagger-ui-standalone-preset.js"></script>
160    <script>
161        const spec = {spec};
162        window.onload = () => {{
163            SwaggerUIBundle({{
164                spec: spec,
165                dom_id: '#swagger-ui',
166                url: '{mount}/openapi.json',
167                presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
168                layout: 'StandaloneLayout'
169            }});
170        }};
171    </script>
172</body>
173</html>"#,
174            spec = spec_json,
175            mount = mount
176        )
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn test_gen_empty_has_no_paths() {
186        let s = OpenAPIGenerator::new().generate();
187        assert!(s.paths.is_empty());
188        // Info should still be present
189        assert_eq!(s.info["title"], "API");
190    }
191
192    #[test]
193    fn test_register_and_generate_single_path() {
194        let mut g = OpenAPIGenerator::new();
195        g.register_path(
196            "/users",
197            PathInfo::new("GET", "List users").with_response("200", "OK"),
198        );
199        let spec = g.generate();
200        let users = spec.paths.get("/users").expect("/users should exist");
201        let get = users.get("get").expect("GET method should exist");
202        assert_eq!(get["summary"], "List users");
203        assert!(get["responses"]["200"].is_object());
204    }
205
206    #[test]
207    fn test_register_multiple_methods_same_path() {
208        let mut g = OpenAPIGenerator::new();
209        g.register_path(
210            "/users",
211            PathInfo::new("GET", "List users").with_response("200", "OK"),
212        );
213        g.register_path(
214            "/users",
215            PathInfo::new("POST", "Create user").with_response("201", "Created"),
216        );
217        let spec = g.generate();
218        assert_eq!(spec.paths.len(), 1, "only one /users path key");
219        let users = spec.paths.get("/users").unwrap();
220        assert!(users.get("get").is_some());
221        assert!(users.get("post").is_some());
222        assert_eq!(users["get"]["summary"], "List users");
223        assert_eq!(users["post"]["summary"], "Create user");
224        assert_eq!(users["post"]["responses"]["201"]["description"], "Created");
225    }
226
227    #[test]
228    fn test_register_multiple_paths() {
229        let mut g = OpenAPIGenerator::new();
230        g.register_path("/users", PathInfo::new("GET", "List users"));
231        g.register_path("/orders", PathInfo::new("GET", "List orders"));
232        g.register_path(
233            "/items/{id}",
234            PathInfo::new("GET", "Get item").with_response("404", "Not found"),
235        );
236        let spec = g.generate();
237        assert_eq!(spec.paths.len(), 3);
238        assert!(spec.paths.contains_key("/users"));
239        assert!(spec.paths.contains_key("/orders"));
240        assert!(spec.paths.contains_key("/items/{id}"));
241    }
242
243    #[test]
244    fn test_ui_mount() {
245        let ui = SwaggerUi::new("/api");
246        assert_eq!(ui.mount(), "/apidocs");
247    }
248
249    #[test]
250    fn test_ui_html_contains_cdn_and_bundle() {
251        let ui = SwaggerUi::new("/api").with_spec(OpenAPIGenerator::new().generate());
252        let html = ui.render_html();
253        assert!(html.contains("swagger-ui-dist"));
254        assert!(html.contains("swagger-ui.css"));
255        assert!(html.contains("swagger-ui-bundle.js"));
256        assert!(html.contains("SwaggerUIBundle"));
257        assert!(html.contains("id=\"swagger-ui\""));
258        assert!(html.contains("<!DOCTYPE html>"));
259    }
260
261    #[test]
262    fn test_ui_html_embeds_spec_content() {
263        let mut g = OpenAPIGenerator::new();
264        g.register_path(
265            "/items",
266            PathInfo::new("GET", "List items").with_response("200", "OK"),
267        );
268        let ui = SwaggerUi::new("/api").with_spec(g.generate());
269        let html = ui.render_html();
270        // Spec content should be embedded inline
271        assert!(html.contains("/items"));
272        assert!(html.contains("List items"));
273        assert!(html.contains("\"get\""));
274    }
275
276    #[test]
277    fn test_ui_html_without_spec_uses_default_spec() {
278        let ui = SwaggerUi::new("/api");
279        let html = ui.render_html();
280        // Default spec should still produce valid HTML with a basic spec
281        assert!(html.contains("swagger-ui"));
282        assert!(html.contains("\"openapi\""));
283    }
284
285    #[test]
286    fn test_spec_to_json_string_is_valid_json() {
287        let mut g = OpenAPIGenerator::new();
288        g.register_path("/users", PathInfo::new("GET", "List users"));
289        let spec = g.generate();
290        let json = spec.to_json_string();
291        let parsed: serde_json::Value = serde_json::from_str(&json).expect("should parse");
292        assert!(parsed["paths"]["/users"]["get"].is_object());
293    }
294
295    #[test]
296    fn test_path_info_builder() {
297        let p = PathInfo::new("PUT", "Update user")
298            .with_response("200", "OK")
299            .with_response("404", "Not found")
300            .with_parameter(serde_json::json!({"name": "id", "in": "path"}));
301        assert_eq!(p.method, "PUT");
302        assert_eq!(p.responses.len(), 2);
303        assert_eq!(p.parameters.len(), 1);
304    }
305}