use crate::{
error::{OpenApiError, OpenApiResult},
generator::{ParameterInfo, RouteMetadata},
};
use regex::Regex;
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct EndpointMetadata {
pub controller: String,
pub method: String,
pub verb: String,
pub path: String,
pub documentation: Option<String>,
pub parameters: Vec<EndpointParameter>,
pub return_type: Option<String>,
pub attributes: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub struct EndpointParameter {
pub name: String,
pub param_type: String,
pub source: ParameterSource,
pub optional: bool,
pub documentation: Option<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum ParameterSource {
Path,
Query,
Body,
Header,
Cookie,
}
pub struct EndpointDiscovery {
path_param_regex: Regex,
}
impl EndpointDiscovery {
pub fn new() -> OpenApiResult<Self> {
Ok(Self {
path_param_regex: Regex::new(r"\{([^}]+)\}").map_err(|e| {
OpenApiError::route_discovery_error(format!("Failed to compile regex: {}", e))
})?,
})
}
pub fn discover_endpoints(
&self,
controllers: &[ControllerInfo],
) -> OpenApiResult<Vec<RouteMetadata>> {
let mut routes = Vec::new();
for controller in controllers {
for endpoint in &controller.endpoints {
let route = self.convert_endpoint_to_route(controller, endpoint)?;
routes.push(route);
}
}
Ok(routes)
}
fn convert_endpoint_to_route(
&self,
controller: &ControllerInfo,
endpoint: &EndpointMetadata,
) -> OpenApiResult<RouteMetadata> {
let path_params = self.extract_path_parameters(&endpoint.path)?;
let mut parameters = Vec::new();
for param_name in &path_params {
if let Some(endpoint_param) = endpoint
.parameters
.iter()
.find(|p| &p.name == param_name && p.source == ParameterSource::Path)
{
parameters.push(ParameterInfo {
name: param_name.clone(),
location: "path".to_string(),
param_type: endpoint_param.param_type.clone(),
description: endpoint_param.documentation.clone(),
required: true, example: None,
});
} else {
parameters.push(ParameterInfo {
name: param_name.clone(),
location: "path".to_string(),
param_type: "string".to_string(),
description: None,
required: true,
example: None,
});
}
}
for endpoint_param in &endpoint.parameters {
if endpoint_param.source == ParameterSource::Query {
parameters.push(ParameterInfo {
name: endpoint_param.name.clone(),
location: "query".to_string(),
param_type: endpoint_param.param_type.clone(),
description: endpoint_param.documentation.clone(),
required: !endpoint_param.optional,
example: None,
});
}
}
for endpoint_param in &endpoint.parameters {
if endpoint_param.source == ParameterSource::Header {
parameters.push(ParameterInfo {
name: endpoint_param.name.clone(),
location: "header".to_string(),
param_type: endpoint_param.param_type.clone(),
description: endpoint_param.documentation.clone(),
required: !endpoint_param.optional,
example: None,
});
}
}
let request_schema = endpoint
.parameters
.iter()
.find(|p| p.source == ParameterSource::Body)
.map(|p| p.param_type.clone());
let mut response_schemas = HashMap::new();
if let Some(return_type) = &endpoint.return_type {
if return_type != "()" && return_type != "ElifResponse" {
response_schemas.insert("200".to_string(), return_type.clone());
}
}
let summary = endpoint
.attributes
.get("summary")
.or_else(|| endpoint.attributes.get("description"))
.cloned();
let description = endpoint
.documentation
.clone()
.or_else(|| endpoint.attributes.get("description").cloned());
let operation_id = Some(format!(
"{}{}",
controller.name.to_lowercase(),
capitalize(&endpoint.method)
));
let tags = vec![controller.name.clone()];
let security = if endpoint.attributes.contains_key("requires_auth") {
vec!["bearerAuth".to_string()]
} else {
Vec::new()
};
let deprecated = endpoint
.attributes
.get("deprecated")
.map(|v| v == "true")
.unwrap_or(false);
let full_path = self.join_paths(controller.base_path.as_deref(), &endpoint.path);
Ok(RouteMetadata {
method: endpoint.verb.clone(),
path: full_path,
summary,
description,
operation_id,
tags,
request_schema,
response_schemas,
parameters,
security,
deprecated,
})
}
fn join_paths(&self, base_path: Option<&str>, endpoint_path: &str) -> String {
match base_path {
Some(base) => {
let base = if base.starts_with('/') {
base.to_string()
} else {
format!("/{}", base)
};
let endpoint = if endpoint_path.starts_with('/') {
endpoint_path.to_string()
} else {
format!("/{}", endpoint_path)
};
let base = base.trim_end_matches('/');
if endpoint == "/" {
base.to_string()
} else {
format!("{}{}", base, endpoint)
}
}
None => {
if endpoint_path.starts_with('/') {
endpoint_path.to_string()
} else {
format!("/{}", endpoint_path)
}
}
}
}
fn extract_path_parameters(&self, path: &str) -> OpenApiResult<Vec<String>> {
let mut parameters = Vec::new();
for caps in self.path_param_regex.captures_iter(path) {
if let Some(param) = caps.get(1) {
parameters.push(param.as_str().to_string());
}
}
Ok(parameters)
}
pub fn extract_from_source(&self, source_code: &str) -> OpenApiResult<Vec<EndpointMetadata>> {
let mut endpoints = Vec::new();
let route_regex = Regex::new(r#"#\[route\((\w+),\s*"([^"]+)"\)\]"#).map_err(|e| {
OpenApiError::route_discovery_error(format!("Failed to compile route regex: {}", e))
})?;
let fn_regex = Regex::new(r"pub\s+async\s+fn\s+(\w+)").map_err(|e| {
OpenApiError::route_discovery_error(format!("Failed to compile function regex: {}", e))
})?;
for route_match in route_regex.captures_iter(source_code) {
if let (Some(verb), Some(path)) = (route_match.get(1), route_match.get(2)) {
let route_end = route_match.get(0).unwrap().end();
let remaining_code = &source_code[route_end..];
if let Some(fn_match) = fn_regex.find(remaining_code) {
let fn_name = fn_regex
.captures(&remaining_code[fn_match.start()..])
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().to_string())
.unwrap_or_else(|| "unknown".to_string());
endpoints.push(EndpointMetadata {
controller: "Unknown".to_string(),
method: fn_name,
verb: verb.as_str().to_uppercase(),
path: path.as_str().to_string(),
documentation: None,
parameters: Vec::new(),
return_type: Some("ElifResponse".to_string()),
attributes: HashMap::new(),
});
}
}
}
Ok(endpoints)
}
}
#[derive(Debug, Clone)]
pub struct ControllerInfo {
pub name: String,
pub base_path: Option<String>,
pub endpoints: Vec<EndpointMetadata>,
pub attributes: HashMap<String, String>,
}
impl ControllerInfo {
pub fn new(name: &str) -> Self {
Self {
name: name.to_string(),
base_path: None,
endpoints: Vec::new(),
attributes: HashMap::new(),
}
}
pub fn add_endpoint(mut self, endpoint: EndpointMetadata) -> Self {
self.endpoints.push(endpoint);
self
}
pub fn with_base_path(mut self, base_path: &str) -> Self {
self.base_path = Some(base_path.to_string());
self
}
pub fn with_attribute(mut self, key: &str, value: &str) -> Self {
self.attributes.insert(key.to_string(), value.to_string());
self
}
}
impl EndpointMetadata {
pub fn new(method: &str, verb: &str, path: &str) -> Self {
Self {
controller: "Unknown".to_string(),
method: method.to_string(),
verb: verb.to_string(),
path: path.to_string(),
documentation: None,
parameters: Vec::new(),
return_type: None,
attributes: HashMap::new(),
}
}
pub fn with_parameter(mut self, parameter: EndpointParameter) -> Self {
self.parameters.push(parameter);
self
}
pub fn with_return_type(mut self, return_type: &str) -> Self {
self.return_type = Some(return_type.to_string());
self
}
pub fn with_attribute(mut self, key: &str, value: &str) -> Self {
self.attributes.insert(key.to_string(), value.to_string());
self
}
pub fn with_documentation(mut self, doc: &str) -> Self {
self.documentation = Some(doc.to_string());
self
}
}
impl EndpointParameter {
pub fn new(name: &str, param_type: &str, source: ParameterSource) -> Self {
Self {
name: name.to_string(),
param_type: param_type.to_string(),
source,
optional: false,
documentation: None,
}
}
pub fn optional(mut self) -> Self {
self.optional = true;
self
}
pub fn with_documentation(mut self, doc: &str) -> Self {
self.documentation = Some(doc.to_string());
self
}
}
fn capitalize(s: &str) -> String {
let mut chars = s.chars();
match chars.next() {
None => String::new(),
Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_endpoint_discovery_creation() {
let discovery = EndpointDiscovery::new().unwrap();
assert!(discovery.path_param_regex.is_match("{id}"));
}
#[test]
fn test_path_parameter_extraction() {
let discovery = EndpointDiscovery::new().unwrap();
let params = discovery
.extract_path_parameters("/users/{id}/posts/{post_id}")
.unwrap();
assert_eq!(params, vec!["id", "post_id"]);
let no_params = discovery.extract_path_parameters("/users").unwrap();
assert!(no_params.is_empty());
}
#[test]
fn test_endpoint_metadata_creation() {
let endpoint = EndpointMetadata::new("index", "GET", "/users")
.with_return_type("Vec<User>")
.with_attribute("summary", "List all users")
.with_parameter(
EndpointParameter::new("limit", "Option<i32>", ParameterSource::Query).optional(),
);
assert_eq!(endpoint.method, "index");
assert_eq!(endpoint.verb, "GET");
assert_eq!(endpoint.path, "/users");
assert_eq!(endpoint.return_type, Some("Vec<User>".to_string()));
assert_eq!(endpoint.parameters.len(), 1);
assert_eq!(
endpoint.attributes.get("summary"),
Some(&"List all users".to_string())
);
}
#[test]
fn test_controller_info_creation() {
let controller = ControllerInfo::new("Users")
.with_base_path("/api/v1")
.add_endpoint(EndpointMetadata::new("index", "GET", "/users"))
.add_endpoint(EndpointMetadata::new("show", "GET", "/users/{id}"));
assert_eq!(controller.name, "Users");
assert_eq!(controller.base_path, Some("/api/v1".to_string()));
assert_eq!(controller.endpoints.len(), 2);
}
#[test]
fn test_route_metadata_conversion() {
let discovery = EndpointDiscovery::new().unwrap();
let controller = ControllerInfo::new("Users");
let endpoint = EndpointMetadata::new("show", "GET", "/users/{id}")
.with_return_type("User")
.with_parameter(EndpointParameter::new("id", "i32", ParameterSource::Path))
.with_attribute("summary", "Get user by ID");
let route = discovery
.convert_endpoint_to_route(&controller, &endpoint)
.unwrap();
assert_eq!(route.method, "GET");
assert_eq!(route.path, "/users/{id}");
assert_eq!(route.summary, Some("Get user by ID".to_string()));
assert_eq!(route.tags, vec!["Users".to_string()]);
assert_eq!(route.parameters.len(), 1);
assert_eq!(route.parameters[0].name, "id");
assert_eq!(route.parameters[0].location, "path");
assert!(route.parameters[0].required);
}
#[test]
fn test_path_joining_robust_logic() {
let discovery = EndpointDiscovery::new().unwrap();
assert_eq!(
discovery.join_paths(Some("/api/v1"), "/users"),
"/api/v1/users"
);
assert_eq!(
discovery.join_paths(Some("api/v1"), "/users"),
"/api/v1/users"
);
assert_eq!(
discovery.join_paths(Some("/api/v1"), "users"),
"/api/v1/users"
);
assert_eq!(
discovery.join_paths(Some("api/v1"), "users"),
"/api/v1/users"
);
assert_eq!(
discovery.join_paths(Some("/api/v1/"), "/users"),
"/api/v1/users"
);
assert_eq!(
discovery.join_paths(Some("/api/v1/"), "users"),
"/api/v1/users"
);
assert_eq!(discovery.join_paths(Some("/api/v1"), "/"), "/api/v1");
assert_eq!(discovery.join_paths(None, "/users"), "/users");
assert_eq!(discovery.join_paths(None, "users"), "/users");
assert_eq!(discovery.join_paths(Some("/"), "/users"), "/users");
assert_eq!(discovery.join_paths(Some("/api"), "/"), "/api");
assert_eq!(
discovery.join_paths(Some("/api/v1"), "/users/{id}/posts"),
"/api/v1/users/{id}/posts"
);
assert_eq!(
discovery.join_paths(Some("api/v1/"), "/users/{id}"),
"/api/v1/users/{id}"
);
}
#[test]
fn test_route_metadata_conversion_with_base_path() {
let discovery = EndpointDiscovery::new().unwrap();
let controller = ControllerInfo::new("Users").with_base_path("/api/v1");
let endpoint = EndpointMetadata::new("show", "GET", "/users/{id}")
.with_parameter(EndpointParameter::new("id", "i32", ParameterSource::Path));
let route = discovery
.convert_endpoint_to_route(&controller, &endpoint)
.unwrap();
assert_eq!(route.path, "/api/v1/users/{id}");
assert_eq!(route.method, "GET");
}
}