sz-orm-swagger 1.0.0

OpenAPI/Swagger spec generator: path/method/parameter/response builder, self-contained Swagger UI HTML rendering
Documentation
//! # SZ-ORM Swagger — OpenAPI/Swagger 规范生成
//!
//! 提供 OpenAPI 3.0 规范的构建与序列化,支持路径、方法、参数与响应定义,
//! 输出可被 Swagger UI 等工具直接消费。
//!
//! ## 主要类型
//!
//! - [`OpenAPISpec`] — 规范根对象
//! - [`PathInfo`] — 单个 (path, method) 操作描述

use serde::{Deserialize, Serialize};
use std::collections::HashMap;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenAPISpec {
    pub paths: HashMap<String, serde_json::Value>,
    pub info: serde_json::Value,
}

impl OpenAPISpec {
    pub fn to_json_string(&self) -> String {
        serde_json::to_string_pretty(self).unwrap_or_else(|_| "{}".to_string())
    }
}

/// Describes a single (path, method) operation in an OpenAPI spec.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathInfo {
    pub method: String,
    pub summary: String,
    pub parameters: Vec<serde_json::Value>,
    pub responses: HashMap<String, serde_json::Value>,
}

impl PathInfo {
    pub fn new(method: &str, summary: &str) -> Self {
        Self {
            method: method.to_string(),
            summary: summary.to_string(),
            parameters: vec![],
            responses: HashMap::new(),
        }
    }

    pub fn with_response(mut self, code: &str, desc: &str) -> Self {
        self.responses
            .insert(code.to_string(), serde_json::json!({ "description": desc }));
        self
    }

    pub fn with_parameter(mut self, param: serde_json::Value) -> Self {
        self.parameters.push(param);
        self
    }
}

pub struct OpenAPIGenerator {
    paths: Vec<(String, PathInfo)>,
    info: serde_json::Value,
}

impl OpenAPIGenerator {
    pub fn new() -> Self {
        Self {
            paths: vec![],
            info: serde_json::json!({
                "title": "API",
                "version": "1.0.0",
                "description": "Generated by sz-orm-swagger"
            }),
        }
    }

    pub fn with_info(mut self, info: serde_json::Value) -> Self {
        self.info = info;
        self
    }

    /// Register a (path, method) operation. Multiple methods on the same path
    /// are merged into a single paths entry.
    pub fn register_path(&mut self, path: &str, info: PathInfo) -> &mut Self {
        self.paths.push((path.to_string(), info));
        self
    }

    /// Generate an OpenAPISpec from the registered paths.
    pub fn generate(&self) -> OpenAPISpec {
        let mut paths: HashMap<String, serde_json::Value> = HashMap::new();
        for (path, info) in &self.paths {
            let method = info.method.to_lowercase();
            let entry = paths
                .entry(path.clone())
                .or_insert_with(|| serde_json::json!({}));
            entry[method] = serde_json::json!({
                "summary": info.summary,
                "parameters": info.parameters,
                "responses": info.responses
            });
        }
        OpenAPISpec {
            paths,
            info: self.info.clone(),
        }
    }
}

impl Default for OpenAPIGenerator {
    fn default() -> Self {
        Self::new()
    }
}

pub struct SwaggerUi {
    mount_path: String,
    spec: Option<OpenAPISpec>,
}

impl SwaggerUi {
    pub fn new(path: &str) -> Self {
        Self {
            mount_path: path.to_string(),
            spec: None,
        }
    }

    pub fn with_spec(mut self, spec: OpenAPISpec) -> Self {
        self.spec = Some(spec);
        self
    }

    pub fn mount(&self) -> String {
        format!("{}docs", self.mount_path)
    }

    /// Render a real, self-contained Swagger UI HTML page that loads the
    /// swagger-ui-dist CDN assets and embeds the spec JSON inline.
    pub fn render_html(&self) -> String {
        let spec_json = match &self.spec {
            Some(s) => s.to_json_string(),
            None => serde_json::json!({
                "openapi": "3.0.0",
                "info": { "title": "API", "version": "1.0.0" },
                "paths": {}
            })
            .to_string(),
        };
        let mount = self.mount();
        format!(
            r#"<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Swagger UI</title>
    <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@4.19.0/swagger-ui.css">
</head>
<body>
    <div id="swagger-ui"></div>
    <script src="https://unpkg.com/swagger-ui-dist@4.19.0/swagger-ui-bundle.js"></script>
    <script src="https://unpkg.com/swagger-ui-dist@4.19.0/swagger-ui-standalone-preset.js"></script>
    <script>
        const spec = {spec};
        window.onload = () => {{
            SwaggerUIBundle({{
                spec: spec,
                dom_id: '#swagger-ui',
                url: '{mount}/openapi.json',
                presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
                layout: 'StandaloneLayout'
            }});
        }};
    </script>
</body>
</html>"#,
            spec = spec_json,
            mount = mount
        )
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_gen_empty_has_no_paths() {
        let s = OpenAPIGenerator::new().generate();
        assert!(s.paths.is_empty());
        // Info should still be present
        assert_eq!(s.info["title"], "API");
    }

    #[test]
    fn test_register_and_generate_single_path() {
        let mut g = OpenAPIGenerator::new();
        g.register_path(
            "/users",
            PathInfo::new("GET", "List users").with_response("200", "OK"),
        );
        let spec = g.generate();
        let users = spec.paths.get("/users").expect("/users should exist");
        let get = users.get("get").expect("GET method should exist");
        assert_eq!(get["summary"], "List users");
        assert!(get["responses"]["200"].is_object());
    }

    #[test]
    fn test_register_multiple_methods_same_path() {
        let mut g = OpenAPIGenerator::new();
        g.register_path(
            "/users",
            PathInfo::new("GET", "List users").with_response("200", "OK"),
        );
        g.register_path(
            "/users",
            PathInfo::new("POST", "Create user").with_response("201", "Created"),
        );
        let spec = g.generate();
        assert_eq!(spec.paths.len(), 1, "only one /users path key");
        let users = spec.paths.get("/users").unwrap();
        assert!(users.get("get").is_some());
        assert!(users.get("post").is_some());
        assert_eq!(users["get"]["summary"], "List users");
        assert_eq!(users["post"]["summary"], "Create user");
        assert_eq!(users["post"]["responses"]["201"]["description"], "Created");
    }

    #[test]
    fn test_register_multiple_paths() {
        let mut g = OpenAPIGenerator::new();
        g.register_path("/users", PathInfo::new("GET", "List users"));
        g.register_path("/orders", PathInfo::new("GET", "List orders"));
        g.register_path(
            "/items/{id}",
            PathInfo::new("GET", "Get item").with_response("404", "Not found"),
        );
        let spec = g.generate();
        assert_eq!(spec.paths.len(), 3);
        assert!(spec.paths.contains_key("/users"));
        assert!(spec.paths.contains_key("/orders"));
        assert!(spec.paths.contains_key("/items/{id}"));
    }

    #[test]
    fn test_ui_mount() {
        let ui = SwaggerUi::new("/api");
        assert_eq!(ui.mount(), "/apidocs");
    }

    #[test]
    fn test_ui_html_contains_cdn_and_bundle() {
        let ui = SwaggerUi::new("/api").with_spec(OpenAPIGenerator::new().generate());
        let html = ui.render_html();
        assert!(html.contains("swagger-ui-dist"));
        assert!(html.contains("swagger-ui.css"));
        assert!(html.contains("swagger-ui-bundle.js"));
        assert!(html.contains("SwaggerUIBundle"));
        assert!(html.contains("id=\"swagger-ui\""));
        assert!(html.contains("<!DOCTYPE html>"));
    }

    #[test]
    fn test_ui_html_embeds_spec_content() {
        let mut g = OpenAPIGenerator::new();
        g.register_path(
            "/items",
            PathInfo::new("GET", "List items").with_response("200", "OK"),
        );
        let ui = SwaggerUi::new("/api").with_spec(g.generate());
        let html = ui.render_html();
        // Spec content should be embedded inline
        assert!(html.contains("/items"));
        assert!(html.contains("List items"));
        assert!(html.contains("\"get\""));
    }

    #[test]
    fn test_ui_html_without_spec_uses_default_spec() {
        let ui = SwaggerUi::new("/api");
        let html = ui.render_html();
        // Default spec should still produce valid HTML with a basic spec
        assert!(html.contains("swagger-ui"));
        assert!(html.contains("\"openapi\""));
    }

    #[test]
    fn test_spec_to_json_string_is_valid_json() {
        let mut g = OpenAPIGenerator::new();
        g.register_path("/users", PathInfo::new("GET", "List users"));
        let spec = g.generate();
        let json = spec.to_json_string();
        let parsed: serde_json::Value = serde_json::from_str(&json).expect("should parse");
        assert!(parsed["paths"]["/users"]["get"].is_object());
    }

    #[test]
    fn test_path_info_builder() {
        let p = PathInfo::new("PUT", "Update user")
            .with_response("200", "OK")
            .with_response("404", "Not found")
            .with_parameter(serde_json::json!({"name": "id", "in": "path"}));
        assert_eq!(p.method, "PUT");
        assert_eq!(p.responses.len(), 2);
        assert_eq!(p.parameters.len(), 1);
    }
}