allframe_core/router/
docs.rs

1//! Documentation serving helpers
2//!
3//! This module provides helpers for serving API documentation.
4//! The actual HTTP serving is left to the user's choice of framework
5//! (Axum, Actix, etc.), but this module provides the data and formatting.
6
7use crate::router::Router;
8
9/// Documentation configuration
10///
11/// Configures how API documentation should be served.
12#[derive(Debug, Clone)]
13pub struct DocsConfig {
14    /// Path where documentation will be served (e.g., "/docs")
15    pub path: String,
16    /// API title
17    pub title: String,
18    /// API version
19    pub version: String,
20    /// Optional API description
21    pub description: Option<String>,
22}
23
24impl DocsConfig {
25    /// Create a new documentation configuration
26    pub fn new(
27        path: impl Into<String>,
28        title: impl Into<String>,
29        version: impl Into<String>,
30    ) -> Self {
31        Self {
32            path: path.into(),
33            title: title.into(),
34            version: version.into(),
35            description: None,
36        }
37    }
38
39    /// Set the API description
40    pub fn with_description(mut self, description: impl Into<String>) -> Self {
41        self.description = Some(description.into());
42        self
43    }
44
45    /// Get the OpenAPI spec path
46    pub fn openapi_path(&self) -> String {
47        format!("{}/openapi.json", self.path.trim_end_matches('/'))
48    }
49}
50
51impl Router {
52    /// Get documentation configuration helper
53    ///
54    /// Returns a DocsConfig that can be used to serve documentation.
55    pub fn docs_config(&self, path: &str, title: &str, version: &str) -> DocsConfig {
56        DocsConfig::new(path, title, version)
57    }
58
59    /// Generate OpenAPI JSON for serving at /docs/openapi.json
60    ///
61    /// This is a convenience method that generates the OpenAPI spec
62    /// in JSON format ready to be served via HTTP.
63    pub fn openapi_json(&self, title: &str, version: &str) -> String {
64        let spec = self.to_openapi(title, version);
65        serde_json::to_string_pretty(&spec).unwrap_or_else(|_| "{}".to_string())
66    }
67
68    /// Generate OpenAPI JSON with description
69    pub fn openapi_json_with_description(
70        &self,
71        title: &str,
72        version: &str,
73        description: &str,
74    ) -> String {
75        let spec = self.to_openapi_with_description(title, version, description);
76        serde_json::to_string_pretty(&spec).unwrap_or_else(|_| "{}".to_string())
77    }
78
79    /// Generate a basic HTML page for documentation
80    ///
81    /// Returns a simple HTML page that can serve as a landing page
82    /// for API documentation. In production, you'd want to use
83    /// a proper documentation UI like Scalar or Swagger UI.
84    pub fn docs_html(&self, config: &DocsConfig) -> String {
85        let openapi_path = config.openapi_path();
86
87        format!(
88            r#"<!DOCTYPE html>
89<html lang="en">
90<head>
91    <meta charset="UTF-8">
92    <meta name="viewport" content="width=device-width, initial-scale=1.0">
93    <title>{title} - API Documentation</title>
94    <style>
95        body {{
96            font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
97            max-width: 800px;
98            margin: 40px auto;
99            padding: 20px;
100            line-height: 1.6;
101        }}
102        h1 {{ color: #333; }}
103        .info {{ background: #f5f5f5; padding: 20px; border-radius: 8px; }}
104        .links {{ margin-top: 30px; }}
105        .links a {{
106            display: inline-block;
107            margin: 10px 10px 10px 0;
108            padding: 10px 20px;
109            background: #007bff;
110            color: white;
111            text-decoration: none;
112            border-radius: 4px;
113        }}
114        .links a:hover {{ background: #0056b3; }}
115    </style>
116</head>
117<body>
118    <h1>{title}</h1>
119    <div class="info">
120        <p><strong>Version:</strong> {version}</p>
121        {description}
122    </div>
123    <div class="links">
124        <a href="{openapi_path}">OpenAPI Specification</a>
125    </div>
126    <p>
127        <small>Built with <a href="https://github.com/all-source-os/all-frame">AllFrame</a></small>
128    </p>
129</body>
130</html>"#,
131            title = config.title,
132            version = config.version,
133            description = config
134                .description
135                .as_ref()
136                .map(|d| format!("<p>{}</p>", d))
137                .unwrap_or_default(),
138            openapi_path = openapi_path
139        )
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use serde_json::Value;
146
147    use super::*;
148
149    #[test]
150    fn test_docs_config_creation() {
151        let config = DocsConfig::new("/docs", "My API", "1.0.0");
152
153        assert_eq!(config.path, "/docs");
154        assert_eq!(config.title, "My API");
155        assert_eq!(config.version, "1.0.0");
156        assert_eq!(config.description, None);
157    }
158
159    #[test]
160    fn test_docs_config_with_description() {
161        let config = DocsConfig::new("/docs", "My API", "1.0.0").with_description("A great API");
162
163        assert_eq!(config.description, Some("A great API".to_string()));
164    }
165
166    #[test]
167    fn test_openapi_path() {
168        let config = DocsConfig::new("/docs", "API", "1.0");
169        assert_eq!(config.openapi_path(), "/docs/openapi.json");
170    }
171
172    #[test]
173    fn test_openapi_path_with_trailing_slash() {
174        let config = DocsConfig::new("/docs/", "API", "1.0");
175        assert_eq!(config.openapi_path(), "/docs/openapi.json");
176    }
177
178    #[tokio::test]
179    async fn test_router_docs_config() {
180        let router = Router::new();
181        let config = router.docs_config("/api-docs", "Test API", "2.0.0");
182
183        assert_eq!(config.path, "/api-docs");
184        assert_eq!(config.title, "Test API");
185        assert_eq!(config.version, "2.0.0");
186    }
187
188    #[tokio::test]
189    async fn test_openapi_json() {
190        let mut router = Router::new();
191        router.get("/users", || async { "Users".to_string() });
192
193        let json = router.openapi_json("Test API", "1.0.0");
194
195        assert!(json.contains("\"openapi\": \"3.1.0\""));
196        assert!(json.contains("\"title\": \"Test API\""));
197        assert!(json.contains("\"/users\""));
198    }
199
200    #[tokio::test]
201    async fn test_openapi_json_with_description() {
202        let router = Router::new();
203        let json = router.openapi_json_with_description("Test API", "1.0.0", "A test API");
204
205        assert!(json.contains("\"description\": \"A test API\""));
206    }
207
208    #[tokio::test]
209    async fn test_docs_html() {
210        let router = Router::new();
211        let config = DocsConfig::new("/docs", "My API", "1.0.0");
212        let html = router.docs_html(&config);
213
214        assert!(html.contains("<title>My API - API Documentation</title>"));
215        assert!(html.contains("Version:</strong> 1.0.0"));
216        assert!(html.contains("href=\"/docs/openapi.json\""));
217    }
218
219    #[tokio::test]
220    async fn test_docs_html_with_description() {
221        let router = Router::new();
222        let config = DocsConfig::new("/docs", "My API", "1.0.0").with_description("A great API");
223        let html = router.docs_html(&config);
224
225        assert!(html.contains("A great API"));
226    }
227
228    #[tokio::test]
229    async fn test_docs_html_contains_allframe_link() {
230        let router = Router::new();
231        let config = DocsConfig::new("/docs", "API", "1.0");
232        let html = router.docs_html(&config);
233
234        assert!(html.contains("AllFrame"));
235        assert!(html.contains("github.com/all-source-os/all-frame"));
236    }
237
238    #[tokio::test]
239    async fn test_openapi_json_is_valid_json() {
240        let mut router = Router::new();
241        router.get("/test", || async { "Test".to_string() });
242
243        let json = router.openapi_json("API", "1.0");
244        let parsed: Value = serde_json::from_str(&json).expect("Should be valid JSON");
245
246        assert_eq!(parsed["openapi"], "3.1.0");
247        assert_eq!(parsed["info"]["title"], "API");
248    }
249}