use crate::openapi::spec::OpenApiSpec;
use crate::Result;
use openapiv3::{Operation, ParameterSchemaOrContent, PathItem, ReferenceOr, Schema};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct RouteInfo {
pub method: String,
pub path: String,
pub operation_id: Option<String>,
pub summary: Option<String>,
pub description: Option<String>,
pub path_params: Vec<String>,
pub query_params: Vec<QueryParamInfo>,
pub request_body_schema: Option<Schema>,
pub responses: HashMap<u16, ResponseInfo>,
pub tags: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct QueryParamInfo {
pub name: String,
pub required: bool,
pub schema: Option<Schema>,
pub description: Option<String>,
}
#[derive(Debug, Clone)]
pub struct ResponseInfo {
pub status_code: u16,
pub description: Option<String>,
pub schema: Option<Schema>,
pub example: Option<serde_json::Value>,
}
pub fn extract_routes(spec: &OpenApiSpec) -> Result<Vec<RouteInfo>> {
let mut routes = Vec::new();
for (path, path_item) in &spec.spec.paths.paths {
if let Some(item) = path_item.as_item() {
if let Some(op) = &item.get {
routes.push(extract_route_info("GET", path, op, item)?);
}
if let Some(op) = &item.post {
routes.push(extract_route_info("POST", path, op, item)?);
}
if let Some(op) = &item.put {
routes.push(extract_route_info("PUT", path, op, item)?);
}
if let Some(op) = &item.delete {
routes.push(extract_route_info("DELETE", path, op, item)?);
}
if let Some(op) = &item.patch {
routes.push(extract_route_info("PATCH", path, op, item)?);
}
if let Some(op) = &item.head {
routes.push(extract_route_info("HEAD", path, op, item)?);
}
if let Some(op) = &item.options {
routes.push(extract_route_info("OPTIONS", path, op, item)?);
}
if let Some(op) = &item.trace {
routes.push(extract_route_info("TRACE", path, op, item)?);
}
}
}
Ok(routes)
}
fn extract_route_info(
method: &str,
path: &str,
operation: &Operation,
_path_item: &PathItem,
) -> Result<RouteInfo> {
let path_params = extract_path_parameters(path);
let mut query_params = Vec::new();
for param_ref in &operation.parameters {
if let Some(openapiv3::Parameter::Query { parameter_data, .. }) = param_ref.as_item() {
let schema =
if let ParameterSchemaOrContent::Schema(schema_ref) = ¶meter_data.format {
schema_ref.as_item().cloned()
} else {
None
};
query_params.push(QueryParamInfo {
name: parameter_data.name.clone(),
required: parameter_data.required,
schema,
description: parameter_data.description.clone(),
});
}
}
let request_body_schema = operation
.request_body
.as_ref()
.and_then(|body_ref| body_ref.as_item())
.and_then(|body| {
body.content
.get("application/json")
.and_then(|content| content.schema.as_ref())
.and_then(|schema_ref| schema_ref.as_item().cloned())
});
let mut responses = HashMap::new();
for (status_code, response_ref) in &operation.responses.responses {
let status = match status_code {
openapiv3::StatusCode::Code(code) => *code,
openapiv3::StatusCode::Range(range) if *range == 2 => 200,
openapiv3::StatusCode::Range(range) if *range == 4 => 400,
openapiv3::StatusCode::Range(range) if *range == 5 => 500,
_ => continue,
};
if let Some(response) = response_ref.as_item() {
let schema = response
.content
.get("application/json")
.and_then(|content| content.schema.as_ref())
.and_then(|schema_ref| schema_ref.as_item().cloned());
let example = response.content.get("application/json").and_then(|content| {
content.example.clone().or_else(|| {
content.examples.iter().next().and_then(|(_, example_ref)| {
example_ref.as_item().and_then(|example_item| example_item.value.clone())
})
})
});
responses.insert(
status,
ResponseInfo {
status_code: status,
description: Some(response.description.clone()),
schema,
example,
},
);
}
}
Ok(RouteInfo {
method: method.to_string(),
path: path.to_string(),
operation_id: operation.operation_id.clone(),
summary: operation.summary.clone(),
description: operation.description.clone(),
path_params,
query_params,
request_body_schema,
responses,
tags: operation.tags.clone(),
})
}
pub fn extract_path_parameters(path: &str) -> Vec<String> {
let mut params = Vec::new();
let mut in_param = false;
let mut current_param = String::new();
for ch in path.chars() {
match ch {
'{' => {
in_param = true;
current_param.clear();
}
'}' => {
if in_param && !current_param.is_empty() {
params.push(current_param.clone());
in_param = false;
}
}
ch if in_param => {
current_param.push(ch);
}
_ => {}
}
}
params
}
pub fn extract_schemas(spec: &OpenApiSpec) -> HashMap<String, Schema> {
let mut schemas = HashMap::new();
if let Some(components) = &spec.spec.components {
if !components.schemas.is_empty() {
for (name, schema_ref) in &components.schemas {
if let ReferenceOr::Item(schema) = schema_ref {
schemas.insert(name.clone(), schema.clone());
}
}
}
}
schemas
}
pub fn schema_to_rust_type(schema: &Schema, schema_name: Option<&str>) -> String {
match &schema.schema_kind {
openapiv3::SchemaKind::Type(openapiv3::Type::String(_)) => "String".to_string(),
openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_)) => "i64".to_string(),
openapiv3::SchemaKind::Type(openapiv3::Type::Number(_)) => "f64".to_string(),
openapiv3::SchemaKind::Type(openapiv3::Type::Boolean(_)) => "bool".to_string(),
openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) => {
let item_type = array_type
.items
.as_ref()
.and_then(|item_ref| item_ref.as_item())
.map(|item_schema| schema_to_rust_type(item_schema, None))
.unwrap_or_else(|| "serde_json::Value".to_string());
format!("Vec<{}>", item_type)
}
openapiv3::SchemaKind::Type(openapiv3::Type::Object(_)) => schema_name
.map(to_pascal_case)
.unwrap_or_else(|| "serde_json::Value".to_string()),
_ => "serde_json::Value".to_string(),
}
}
pub fn to_pascal_case(s: &str) -> String {
s.split(['-', '_', ' '])
.filter(|s| !s.is_empty())
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => {
first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase()
}
}
})
.collect()
}
pub fn to_snake_case(s: &str) -> String {
let mut result = String::new();
let mut prev_lower = false;
for ch in s.chars() {
if ch.is_uppercase() && prev_lower {
result.push('_');
}
result.push(ch.to_lowercase().next().unwrap_or(ch));
prev_lower = ch.is_lowercase() || ch.is_numeric();
}
result
}
pub fn generate_handler_name(route: &RouteInfo) -> String {
if let Some(ref op_id) = route.operation_id {
to_snake_case(op_id)
} else {
let method_lower = route.method.to_lowercase();
let path_part = route
.path
.replace('/', "_")
.replace(['{', '}'], "")
.replace('-', "_")
.trim_matches('_')
.to_string();
format!("{}_{}", method_lower, to_snake_case(&path_part))
}
}
pub fn sanitize_name(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_alphanumeric() || c == '_' {
c
} else {
'_'
}
})
.collect::<String>()
.trim_matches('_')
.to_string()
.to_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_path_parameters() {
assert_eq!(extract_path_parameters("/users"), Vec::<String>::new());
assert_eq!(extract_path_parameters("/users/{id}"), vec!["id"]);
assert_eq!(extract_path_parameters("/users/{id}/posts/{postId}"), vec!["id", "postId"]);
}
#[test]
fn test_to_pascal_case() {
assert_eq!(to_pascal_case("user"), "User");
assert_eq!(to_pascal_case("user_profile"), "UserProfile");
assert_eq!(to_pascal_case("user-profile"), "UserProfile");
assert_eq!(to_pascal_case("get_user_by_id"), "GetUserById");
}
#[test]
fn test_to_snake_case() {
assert_eq!(to_snake_case("User"), "user");
assert_eq!(to_snake_case("UserProfile"), "user_profile");
assert_eq!(to_snake_case("getUserById"), "get_user_by_id");
assert_eq!(to_snake_case("GetUserById"), "get_user_by_id");
}
#[test]
fn test_generate_handler_name() {
let route = RouteInfo {
method: "GET".to_string(),
path: "/users/{id}".to_string(),
operation_id: Some("getUser".to_string()),
summary: None,
description: None,
path_params: vec!["id".to_string()],
query_params: Vec::new(),
request_body_schema: None,
responses: HashMap::new(),
tags: Vec::new(),
};
assert_eq!(generate_handler_name(&route), "get_user");
}
}