allframe_core/router/
openapi.rs

1//! OpenAPI 3.1 specification generation
2//!
3//! This module provides functionality to generate OpenAPI 3.1 specifications
4//! from router metadata. This enables automatic API documentation for REST
5//! endpoints.
6
7use serde_json::{json, Value};
8
9use crate::router::{RouteMetadata, Router};
10
11/// OpenAPI server configuration
12///
13/// Represents a server that the API can be accessed from.
14/// Used in the "Try It" functionality to make actual API calls.
15#[derive(Debug, Clone)]
16pub struct OpenApiServer {
17    /// Server URL (e.g., `https://api.example.com`)
18    pub url: String,
19    /// Optional description (e.g., "Production server")
20    pub description: Option<String>,
21}
22
23impl OpenApiServer {
24    /// Create a new server configuration
25    pub fn new(url: impl Into<String>) -> Self {
26        Self {
27            url: url.into(),
28            description: None,
29        }
30    }
31
32    /// Set the server description
33    pub fn with_description(mut self, description: impl Into<String>) -> Self {
34        self.description = Some(description.into());
35        self
36    }
37}
38
39/// OpenAPI specification generator
40///
41/// Generates OpenAPI 3.1 compliant specifications from router metadata.
42pub struct OpenApiGenerator {
43    title: String,
44    version: String,
45    description: Option<String>,
46    servers: Vec<OpenApiServer>,
47}
48
49impl OpenApiGenerator {
50    /// Create a new OpenAPI generator
51    pub fn new(title: impl Into<String>, version: impl Into<String>) -> Self {
52        Self {
53            title: title.into(),
54            version: version.into(),
55            description: None,
56            servers: vec![],
57        }
58    }
59
60    /// Set the API description
61    pub fn with_description(mut self, description: impl Into<String>) -> Self {
62        self.description = Some(description.into());
63        self
64    }
65
66    /// Add a server URL
67    ///
68    /// Servers are used by the "Try It" functionality to make actual API calls.
69    ///
70    /// # Example
71    ///
72    /// ```rust
73    /// use allframe_core::router::openapi::OpenApiGenerator;
74    ///
75    /// let generator = OpenApiGenerator::new("API", "1.0.0")
76    ///     .with_server("http://localhost:3000", Some("Local development"));
77    /// ```
78    pub fn with_server(
79        mut self,
80        url: impl Into<String>,
81        description: Option<impl Into<String>>,
82    ) -> Self {
83        let mut server = OpenApiServer::new(url);
84        if let Some(desc) = description {
85            server = server.with_description(desc);
86        }
87        self.servers.push(server);
88        self
89    }
90
91    /// Add multiple servers
92    pub fn with_servers(mut self, servers: Vec<OpenApiServer>) -> Self {
93        self.servers = servers;
94        self
95    }
96
97    /// Generate OpenAPI specification from router
98    pub fn generate(&self, router: &Router) -> Value {
99        let mut spec = json!({
100            "openapi": "3.1.0",
101            "info": {
102                "title": self.title,
103                "version": self.version,
104            },
105            "paths": {}
106        });
107
108        // Add description if present
109        if let Some(ref desc) = self.description {
110            spec["info"]["description"] = Value::String(desc.clone());
111        }
112
113        // Add servers if present (required for "Try It" functionality)
114        if !self.servers.is_empty() {
115            let servers: Vec<Value> = self
116                .servers
117                .iter()
118                .map(|s| {
119                    let mut server = json!({ "url": s.url });
120                    if let Some(ref desc) = s.description {
121                        server["description"] = Value::String(desc.clone());
122                    }
123                    server
124                })
125                .collect();
126            spec["servers"] = Value::Array(servers);
127        }
128
129        // Build paths from routes
130        let paths = self.build_paths(router.routes());
131        spec["paths"] = paths;
132
133        spec
134    }
135
136    fn build_paths(&self, routes: &[RouteMetadata]) -> Value {
137        let mut paths = serde_json::Map::new();
138
139        for route in routes {
140            // Only process REST routes
141            if route.protocol != "rest" {
142                continue;
143            }
144
145            let path_item = paths.entry(route.path.clone()).or_insert_with(|| json!({}));
146
147            let method = route.method.to_lowercase();
148            let operation = self.build_operation(route);
149
150            if let Value::Object(ref mut map) = path_item {
151                map.insert(method, operation);
152            }
153        }
154
155        Value::Object(paths)
156    }
157
158    fn build_operation(&self, route: &RouteMetadata) -> Value {
159        let mut operation = json!({
160            "responses": {
161                "200": {
162                    "description": "Successful response"
163                }
164            }
165        });
166
167        // Add description if present
168        if let Some(ref desc) = route.description {
169            operation["description"] = Value::String(desc.clone());
170        }
171
172        // Add request body if schema present
173        if let Some(ref schema) = route.request_schema {
174            operation["requestBody"] = json!({
175                "required": true,
176                "content": {
177                    "application/json": {
178                        "schema": schema
179                    }
180                }
181            });
182        }
183
184        // Add response schema if present
185        if let Some(ref schema) = route.response_schema {
186            operation["responses"]["200"]["content"] = json!({
187                "application/json": {
188                    "schema": schema
189                }
190            });
191        }
192
193        operation
194    }
195}
196
197impl Router {
198    /// Generate OpenAPI 3.1 specification
199    ///
200    /// This is a convenience method that creates an OpenAPI specification
201    /// for all REST routes registered with this router.
202    pub fn to_openapi(&self, title: &str, version: &str) -> Value {
203        OpenApiGenerator::new(title, version).generate(self)
204    }
205
206    /// Generate OpenAPI 3.1 specification with description
207    pub fn to_openapi_with_description(
208        &self,
209        title: &str,
210        version: &str,
211        description: &str,
212    ) -> Value {
213        OpenApiGenerator::new(title, version)
214            .with_description(description)
215            .generate(self)
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::router::RouteMetadata;
223
224    #[tokio::test]
225    async fn test_openapi_generator_basic() {
226        let generator = OpenApiGenerator::new("Test API", "1.0.0");
227        let router = Router::new();
228
229        let spec = generator.generate(&router);
230
231        assert_eq!(spec["openapi"], "3.1.0");
232        assert_eq!(spec["info"]["title"], "Test API");
233        assert_eq!(spec["info"]["version"], "1.0.0");
234        assert!(spec["paths"].is_object());
235    }
236
237    #[tokio::test]
238    async fn test_openapi_with_description() {
239        let generator = OpenApiGenerator::new("Test API", "1.0.0").with_description("A test API");
240        let router = Router::new();
241
242        let spec = generator.generate(&router);
243
244        assert_eq!(spec["info"]["description"], "A test API");
245    }
246
247    #[tokio::test]
248    async fn test_openapi_single_route() {
249        let mut router = Router::new();
250        router.get("/users", || async { "Users".to_string() });
251
252        let spec = router.to_openapi("Test API", "1.0.0");
253
254        assert!(spec["paths"]["/users"].is_object());
255        assert!(spec["paths"]["/users"]["get"].is_object());
256        assert!(spec["paths"]["/users"]["get"]["responses"]["200"].is_object());
257    }
258
259    #[tokio::test]
260    async fn test_openapi_multiple_routes() {
261        let mut router = Router::new();
262        router.get("/users", || async { "List".to_string() });
263        router.post("/users", || async { "Create".to_string() });
264        router.get("/posts", || async { "Posts".to_string() });
265
266        let spec = router.to_openapi("Test API", "1.0.0");
267
268        assert!(spec["paths"]["/users"]["get"].is_object());
269        assert!(spec["paths"]["/users"]["post"].is_object());
270        assert!(spec["paths"]["/posts"]["get"].is_object());
271    }
272
273    #[tokio::test]
274    async fn test_openapi_route_with_description() {
275        let mut router = Router::new();
276        let metadata =
277            RouteMetadata::new("/users", "GET", "rest").with_description("Get all users");
278        router.add_route(metadata);
279
280        let spec = router.to_openapi("Test API", "1.0.0");
281
282        assert_eq!(
283            spec["paths"]["/users"]["get"]["description"],
284            "Get all users"
285        );
286    }
287
288    #[tokio::test]
289    async fn test_openapi_route_with_request_schema() {
290        let mut router = Router::new();
291        let request_schema = serde_json::json!({
292            "type": "object",
293            "properties": {
294                "name": {"type": "string"}
295            }
296        });
297
298        let metadata = RouteMetadata::new("/users", "POST", "rest")
299            .with_request_schema(request_schema.clone());
300        router.add_route(metadata);
301
302        let spec = router.to_openapi("Test API", "1.0.0");
303
304        assert_eq!(
305            spec["paths"]["/users"]["post"]["requestBody"]["content"]["application/json"]["schema"],
306            request_schema
307        );
308    }
309
310    #[tokio::test]
311    async fn test_openapi_route_with_response_schema() {
312        let mut router = Router::new();
313        let response_schema = serde_json::json!({
314            "type": "object",
315            "properties": {
316                "id": {"type": "string"},
317                "name": {"type": "string"}
318            }
319        });
320
321        let metadata = RouteMetadata::new("/users", "GET", "rest")
322            .with_response_schema(response_schema.clone());
323        router.add_route(metadata);
324
325        let spec = router.to_openapi("Test API", "1.0.0");
326
327        assert_eq!(
328            spec["paths"]["/users"]["get"]["responses"]["200"]["content"]["application/json"]
329                ["schema"],
330            response_schema
331        );
332    }
333
334    #[tokio::test]
335    async fn test_openapi_filters_non_rest_routes() {
336        let mut router = Router::new();
337        router.add_route(RouteMetadata::new("/users", "GET", "rest"));
338        router.add_route(RouteMetadata::new("users", "query", "graphql"));
339        router.add_route(RouteMetadata::new("UserService", "unary", "grpc"));
340
341        let spec = router.to_openapi("Test API", "1.0.0");
342
343        // Only REST route should be in spec
344        assert!(spec["paths"]["/users"].is_object());
345        assert!(spec["paths"]["users"].is_null());
346        assert!(spec["paths"]["UserService"].is_null());
347    }
348
349    #[tokio::test]
350    async fn test_router_to_openapi_convenience_method() {
351        let mut router = Router::new();
352        router.get("/test", || async { "Test".to_string() });
353
354        let spec = router.to_openapi("My API", "2.0.0");
355
356        assert_eq!(spec["info"]["title"], "My API");
357        assert_eq!(spec["info"]["version"], "2.0.0");
358        assert!(spec["paths"]["/test"]["get"].is_object());
359    }
360
361    #[tokio::test]
362    async fn test_router_to_openapi_with_description() {
363        let mut router = Router::new();
364        router.get("/test", || async { "Test".to_string() });
365
366        let spec = router.to_openapi_with_description("My API", "2.0.0", "A great API");
367
368        assert_eq!(spec["info"]["description"], "A great API");
369    }
370}