elif_openapi/
endpoints.rs

1/*!
2Endpoint discovery and analysis for OpenAPI generation.
3
4This module provides functionality to discover API endpoints from elif.rs framework
5components and extract their metadata for documentation generation.
6*/
7
8use crate::{
9    error::{OpenApiError, OpenApiResult},
10    generator::{ParameterInfo, RouteMetadata},
11};
12use regex::Regex;
13use std::collections::HashMap;
14
15/// Endpoint metadata extracted from framework components
16#[derive(Debug, Clone)]
17pub struct EndpointMetadata {
18    /// Controller name
19    pub controller: String,
20    /// Method name
21    pub method: String,
22    /// HTTP verb
23    pub verb: String,
24    /// Path pattern
25    pub path: String,
26    /// Documentation comments
27    pub documentation: Option<String>,
28    /// Parameters
29    pub parameters: Vec<EndpointParameter>,
30    /// Return type
31    pub return_type: Option<String>,
32    /// Attributes/annotations
33    pub attributes: HashMap<String, String>,
34}
35
36/// Parameter extracted from endpoint
37#[derive(Debug, Clone)]
38pub struct EndpointParameter {
39    /// Parameter name
40    pub name: String,
41    /// Parameter type
42    pub param_type: String,
43    /// Parameter source (path, query, body, header)
44    pub source: ParameterSource,
45    /// Optional flag
46    pub optional: bool,
47    /// Documentation
48    pub documentation: Option<String>,
49}
50
51/// Source of parameter data
52#[derive(Debug, Clone, PartialEq)]
53pub enum ParameterSource {
54    Path,
55    Query,
56    Body,
57    Header,
58    Cookie,
59}
60
61/// Endpoint discovery service
62pub struct EndpointDiscovery {
63    /// Path parameter regex
64    path_param_regex: Regex,
65}
66
67impl EndpointDiscovery {
68    /// Create new endpoint discovery service
69    pub fn new() -> OpenApiResult<Self> {
70        Ok(Self {
71            path_param_regex: Regex::new(r"\{([^}]+)\}").map_err(|e| {
72                OpenApiError::route_discovery_error(format!("Failed to compile regex: {}", e))
73            })?,
74        })
75    }
76
77    /// Discover endpoints from controller metadata
78    pub fn discover_endpoints(
79        &self,
80        controllers: &[ControllerInfo],
81    ) -> OpenApiResult<Vec<RouteMetadata>> {
82        let mut routes = Vec::new();
83
84        for controller in controllers {
85            for endpoint in &controller.endpoints {
86                let route = self.convert_endpoint_to_route(controller, endpoint)?;
87                routes.push(route);
88            }
89        }
90
91        Ok(routes)
92    }
93
94    /// Convert endpoint metadata to route metadata
95    fn convert_endpoint_to_route(
96        &self,
97        controller: &ControllerInfo,
98        endpoint: &EndpointMetadata,
99    ) -> OpenApiResult<RouteMetadata> {
100        // Extract path parameters
101        let path_params = self.extract_path_parameters(&endpoint.path)?;
102
103        // Build parameters list
104        let mut parameters = Vec::new();
105
106        // Add path parameters
107        for param_name in &path_params {
108            if let Some(endpoint_param) = endpoint
109                .parameters
110                .iter()
111                .find(|p| &p.name == param_name && p.source == ParameterSource::Path)
112            {
113                parameters.push(ParameterInfo {
114                    name: param_name.clone(),
115                    location: "path".to_string(),
116                    param_type: endpoint_param.param_type.clone(),
117                    description: endpoint_param.documentation.clone(),
118                    required: true, // Path parameters are always required
119                    example: None,
120                });
121            } else {
122                // Default path parameter if not found in endpoint metadata
123                parameters.push(ParameterInfo {
124                    name: param_name.clone(),
125                    location: "path".to_string(),
126                    param_type: "string".to_string(),
127                    description: None,
128                    required: true,
129                    example: None,
130                });
131            }
132        }
133
134        // Add query parameters
135        for endpoint_param in &endpoint.parameters {
136            if endpoint_param.source == ParameterSource::Query {
137                parameters.push(ParameterInfo {
138                    name: endpoint_param.name.clone(),
139                    location: "query".to_string(),
140                    param_type: endpoint_param.param_type.clone(),
141                    description: endpoint_param.documentation.clone(),
142                    required: !endpoint_param.optional,
143                    example: None,
144                });
145            }
146        }
147
148        // Add header parameters
149        for endpoint_param in &endpoint.parameters {
150            if endpoint_param.source == ParameterSource::Header {
151                parameters.push(ParameterInfo {
152                    name: endpoint_param.name.clone(),
153                    location: "header".to_string(),
154                    param_type: endpoint_param.param_type.clone(),
155                    description: endpoint_param.documentation.clone(),
156                    required: !endpoint_param.optional,
157                    example: None,
158                });
159            }
160        }
161
162        // Determine request schema
163        let request_schema = endpoint
164            .parameters
165            .iter()
166            .find(|p| p.source == ParameterSource::Body)
167            .map(|p| p.param_type.clone());
168
169        // Build response schemas
170        let mut response_schemas = HashMap::new();
171        if let Some(return_type) = &endpoint.return_type {
172            if return_type != "()" && return_type != "ElifResponse" {
173                response_schemas.insert("200".to_string(), return_type.clone());
174            }
175        }
176
177        // Extract attributes
178        let summary = endpoint
179            .attributes
180            .get("summary")
181            .or_else(|| endpoint.attributes.get("description"))
182            .cloned();
183
184        let description = endpoint
185            .documentation
186            .clone()
187            .or_else(|| endpoint.attributes.get("description").cloned());
188
189        let operation_id = Some(format!(
190            "{}{}",
191            controller.name.to_lowercase(),
192            capitalize(&endpoint.method)
193        ));
194
195        let tags = vec![controller.name.clone()];
196
197        // Determine security requirements
198        let security = if endpoint.attributes.contains_key("requires_auth") {
199            vec!["bearerAuth".to_string()]
200        } else {
201            Vec::new()
202        };
203
204        let deprecated = endpoint
205            .attributes
206            .get("deprecated")
207            .map(|v| v == "true")
208            .unwrap_or(false);
209
210        // Construct full path by joining base_path and endpoint path
211        let full_path = self.join_paths(controller.base_path.as_deref(), &endpoint.path);
212
213        Ok(RouteMetadata {
214            method: endpoint.verb.clone(),
215            path: full_path,
216            summary,
217            description,
218            operation_id,
219            tags,
220            request_schema,
221            response_schemas,
222            parameters,
223            security,
224            deprecated,
225        })
226    }
227
228    /// Robustly join base path and endpoint path to prevent double slashes or missing slashes
229    fn join_paths(&self, base_path: Option<&str>, endpoint_path: &str) -> String {
230        match base_path {
231            Some(base) => {
232                // Ensure base path starts with /
233                let base = if base.starts_with('/') {
234                    base.to_string()
235                } else {
236                    format!("/{}", base)
237                };
238
239                // Ensure endpoint path starts with /
240                let endpoint = if endpoint_path.starts_with('/') {
241                    endpoint_path.to_string()
242                } else {
243                    format!("/{}", endpoint_path)
244                };
245
246                // Remove trailing slash from base if present
247                let base = base.trim_end_matches('/');
248
249                // Combine paths, avoiding double slashes
250                if endpoint == "/" {
251                    base.to_string()
252                } else {
253                    format!("{}{}", base, endpoint)
254                }
255            }
256            None => {
257                // Ensure endpoint path starts with /
258                if endpoint_path.starts_with('/') {
259                    endpoint_path.to_string()
260                } else {
261                    format!("/{}", endpoint_path)
262                }
263            }
264        }
265    }
266
267    /// Extract path parameters from a path pattern
268    fn extract_path_parameters(&self, path: &str) -> OpenApiResult<Vec<String>> {
269        let mut parameters = Vec::new();
270
271        for caps in self.path_param_regex.captures_iter(path) {
272            if let Some(param) = caps.get(1) {
273                parameters.push(param.as_str().to_string());
274            }
275        }
276
277        Ok(parameters)
278    }
279
280    /// Extract endpoint metadata from source code (simplified implementation)
281    pub fn extract_from_source(&self, source_code: &str) -> OpenApiResult<Vec<EndpointMetadata>> {
282        // This is a simplified implementation
283        // In a real implementation, you would parse the Rust AST
284        let mut endpoints = Vec::new();
285
286        // Look for route attribute patterns
287        let route_regex = Regex::new(r#"#\[route\((\w+),\s*"([^"]+)"\)\]"#).map_err(|e| {
288            OpenApiError::route_discovery_error(format!("Failed to compile route regex: {}", e))
289        })?;
290
291        // Look for function definitions
292        let fn_regex = Regex::new(r"pub\s+async\s+fn\s+(\w+)").map_err(|e| {
293            OpenApiError::route_discovery_error(format!("Failed to compile function regex: {}", e))
294        })?;
295
296        for route_match in route_regex.captures_iter(source_code) {
297            if let (Some(verb), Some(path)) = (route_match.get(1), route_match.get(2)) {
298                // Find the next function after this route
299                let route_end = route_match.get(0).unwrap().end();
300                let remaining_code = &source_code[route_end..];
301
302                if let Some(fn_match) = fn_regex.find(remaining_code) {
303                    let fn_name = fn_regex
304                        .captures(&remaining_code[fn_match.start()..])
305                        .and_then(|caps| caps.get(1))
306                        .map(|m| m.as_str().to_string())
307                        .unwrap_or_else(|| "unknown".to_string());
308
309                    endpoints.push(EndpointMetadata {
310                        controller: "Unknown".to_string(),
311                        method: fn_name,
312                        verb: verb.as_str().to_uppercase(),
313                        path: path.as_str().to_string(),
314                        documentation: None,
315                        parameters: Vec::new(),
316                        return_type: Some("ElifResponse".to_string()),
317                        attributes: HashMap::new(),
318                    });
319                }
320            }
321        }
322
323        Ok(endpoints)
324    }
325}
326
327/// Controller information for endpoint discovery
328#[derive(Debug, Clone)]
329pub struct ControllerInfo {
330    /// Controller name
331    pub name: String,
332    /// Base path prefix
333    pub base_path: Option<String>,
334    /// Endpoints in this controller
335    pub endpoints: Vec<EndpointMetadata>,
336    /// Controller attributes
337    pub attributes: HashMap<String, String>,
338}
339
340impl ControllerInfo {
341    /// Create new controller info
342    pub fn new(name: &str) -> Self {
343        Self {
344            name: name.to_string(),
345            base_path: None,
346            endpoints: Vec::new(),
347            attributes: HashMap::new(),
348        }
349    }
350
351    /// Add endpoint to controller
352    pub fn add_endpoint(mut self, endpoint: EndpointMetadata) -> Self {
353        self.endpoints.push(endpoint);
354        self
355    }
356
357    /// Set base path
358    pub fn with_base_path(mut self, base_path: &str) -> Self {
359        self.base_path = Some(base_path.to_string());
360        self
361    }
362
363    /// Add attribute
364    pub fn with_attribute(mut self, key: &str, value: &str) -> Self {
365        self.attributes.insert(key.to_string(), value.to_string());
366        self
367    }
368}
369
370impl EndpointMetadata {
371    /// Create new endpoint metadata
372    pub fn new(method: &str, verb: &str, path: &str) -> Self {
373        Self {
374            controller: "Unknown".to_string(),
375            method: method.to_string(),
376            verb: verb.to_string(),
377            path: path.to_string(),
378            documentation: None,
379            parameters: Vec::new(),
380            return_type: None,
381            attributes: HashMap::new(),
382        }
383    }
384
385    /// Add parameter
386    pub fn with_parameter(mut self, parameter: EndpointParameter) -> Self {
387        self.parameters.push(parameter);
388        self
389    }
390
391    /// Set return type
392    pub fn with_return_type(mut self, return_type: &str) -> Self {
393        self.return_type = Some(return_type.to_string());
394        self
395    }
396
397    /// Add attribute
398    pub fn with_attribute(mut self, key: &str, value: &str) -> Self {
399        self.attributes.insert(key.to_string(), value.to_string());
400        self
401    }
402
403    /// Set documentation
404    pub fn with_documentation(mut self, doc: &str) -> Self {
405        self.documentation = Some(doc.to_string());
406        self
407    }
408}
409
410impl EndpointParameter {
411    /// Create new endpoint parameter
412    pub fn new(name: &str, param_type: &str, source: ParameterSource) -> Self {
413        Self {
414            name: name.to_string(),
415            param_type: param_type.to_string(),
416            source,
417            optional: false,
418            documentation: None,
419        }
420    }
421
422    /// Make parameter optional
423    pub fn optional(mut self) -> Self {
424        self.optional = true;
425        self
426    }
427
428    /// Add documentation
429    pub fn with_documentation(mut self, doc: &str) -> Self {
430        self.documentation = Some(doc.to_string());
431        self
432    }
433}
434
435/// Helper function to capitalize first letter
436fn capitalize(s: &str) -> String {
437    let mut chars = s.chars();
438    match chars.next() {
439        None => String::new(),
440        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use super::*;
447
448    #[test]
449    fn test_endpoint_discovery_creation() {
450        let discovery = EndpointDiscovery::new().unwrap();
451        assert!(discovery.path_param_regex.is_match("{id}"));
452    }
453
454    #[test]
455    fn test_path_parameter_extraction() {
456        let discovery = EndpointDiscovery::new().unwrap();
457
458        let params = discovery
459            .extract_path_parameters("/users/{id}/posts/{post_id}")
460            .unwrap();
461        assert_eq!(params, vec!["id", "post_id"]);
462
463        let no_params = discovery.extract_path_parameters("/users").unwrap();
464        assert!(no_params.is_empty());
465    }
466
467    #[test]
468    fn test_endpoint_metadata_creation() {
469        let endpoint = EndpointMetadata::new("index", "GET", "/users")
470            .with_return_type("Vec<User>")
471            .with_attribute("summary", "List all users")
472            .with_parameter(
473                EndpointParameter::new("limit", "Option<i32>", ParameterSource::Query).optional(),
474            );
475
476        assert_eq!(endpoint.method, "index");
477        assert_eq!(endpoint.verb, "GET");
478        assert_eq!(endpoint.path, "/users");
479        assert_eq!(endpoint.return_type, Some("Vec<User>".to_string()));
480        assert_eq!(endpoint.parameters.len(), 1);
481        assert_eq!(
482            endpoint.attributes.get("summary"),
483            Some(&"List all users".to_string())
484        );
485    }
486
487    #[test]
488    fn test_controller_info_creation() {
489        let controller = ControllerInfo::new("Users")
490            .with_base_path("/api/v1")
491            .add_endpoint(EndpointMetadata::new("index", "GET", "/users"))
492            .add_endpoint(EndpointMetadata::new("show", "GET", "/users/{id}"));
493
494        assert_eq!(controller.name, "Users");
495        assert_eq!(controller.base_path, Some("/api/v1".to_string()));
496        assert_eq!(controller.endpoints.len(), 2);
497    }
498
499    #[test]
500    fn test_route_metadata_conversion() {
501        let discovery = EndpointDiscovery::new().unwrap();
502
503        let controller = ControllerInfo::new("Users");
504        let endpoint = EndpointMetadata::new("show", "GET", "/users/{id}")
505            .with_return_type("User")
506            .with_parameter(EndpointParameter::new("id", "i32", ParameterSource::Path))
507            .with_attribute("summary", "Get user by ID");
508
509        let route = discovery
510            .convert_endpoint_to_route(&controller, &endpoint)
511            .unwrap();
512
513        assert_eq!(route.method, "GET");
514        assert_eq!(route.path, "/users/{id}");
515        assert_eq!(route.summary, Some("Get user by ID".to_string()));
516        assert_eq!(route.tags, vec!["Users".to_string()]);
517        assert_eq!(route.parameters.len(), 1);
518        assert_eq!(route.parameters[0].name, "id");
519        assert_eq!(route.parameters[0].location, "path");
520        assert!(route.parameters[0].required);
521    }
522
523    #[test]
524    fn test_path_joining_robust_logic() {
525        let discovery = EndpointDiscovery::new().unwrap();
526
527        // Test normal case: base with leading slash, endpoint with leading slash
528        assert_eq!(
529            discovery.join_paths(Some("/api/v1"), "/users"),
530            "/api/v1/users"
531        );
532
533        // Test case: base without leading slash, endpoint with leading slash
534        assert_eq!(
535            discovery.join_paths(Some("api/v1"), "/users"),
536            "/api/v1/users"
537        );
538
539        // Test case: base with leading slash, endpoint without leading slash
540        assert_eq!(
541            discovery.join_paths(Some("/api/v1"), "users"),
542            "/api/v1/users"
543        );
544
545        // Test case: base without leading slash, endpoint without leading slash
546        assert_eq!(
547            discovery.join_paths(Some("api/v1"), "users"),
548            "/api/v1/users"
549        );
550
551        // Test case: base with trailing slash, endpoint with leading slash
552        assert_eq!(
553            discovery.join_paths(Some("/api/v1/"), "/users"),
554            "/api/v1/users"
555        );
556
557        // Test case: base with trailing slash, endpoint without leading slash
558        assert_eq!(
559            discovery.join_paths(Some("/api/v1/"), "users"),
560            "/api/v1/users"
561        );
562
563        // Test case: root endpoint path
564        assert_eq!(discovery.join_paths(Some("/api/v1"), "/"), "/api/v1");
565
566        // Test case: no base path, endpoint with leading slash
567        assert_eq!(discovery.join_paths(None, "/users"), "/users");
568
569        // Test case: no base path, endpoint without leading slash
570        assert_eq!(discovery.join_paths(None, "users"), "/users");
571
572        // Test edge cases
573        assert_eq!(discovery.join_paths(Some("/"), "/users"), "/users");
574        assert_eq!(discovery.join_paths(Some("/api"), "/"), "/api");
575
576        // Test complex paths
577        assert_eq!(
578            discovery.join_paths(Some("/api/v1"), "/users/{id}/posts"),
579            "/api/v1/users/{id}/posts"
580        );
581        assert_eq!(
582            discovery.join_paths(Some("api/v1/"), "/users/{id}"),
583            "/api/v1/users/{id}"
584        );
585    }
586
587    #[test]
588    fn test_route_metadata_conversion_with_base_path() {
589        let discovery = EndpointDiscovery::new().unwrap();
590
591        let controller = ControllerInfo::new("Users").with_base_path("/api/v1");
592
593        let endpoint = EndpointMetadata::new("show", "GET", "/users/{id}")
594            .with_parameter(EndpointParameter::new("id", "i32", ParameterSource::Path));
595
596        let route = discovery
597            .convert_endpoint_to_route(&controller, &endpoint)
598            .unwrap();
599
600        // Verify path joining worked correctly
601        assert_eq!(route.path, "/api/v1/users/{id}");
602        assert_eq!(route.method, "GET");
603    }
604}