use std::collections::BTreeMap;
use vespera_core::{
openapi::{Info, OpenApi, OpenApiVersion, Server},
route::{HttpMethod, PathItem},
schema::Components,
};
use crate::metadata::CollectedMetadata;
use crate::parser::{
build_operation_from_function, extract_default, extract_field_rename, extract_rename_all,
parse_enum_to_schema, parse_struct_to_schema, rename_field,
};
pub fn generate_openapi_doc_with_metadata(
title: Option<String>,
version: Option<String>,
metadata: &CollectedMetadata,
) -> OpenApi {
let mut paths: BTreeMap<String, PathItem> = BTreeMap::new();
let mut schemas: BTreeMap<String, vespera_core::schema::Schema> = BTreeMap::new();
let mut known_schema_names: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut struct_definitions: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for struct_meta in &metadata.structs {
let schema_name = struct_meta.name.clone();
known_schema_names.insert(schema_name.clone(), schema_name);
struct_definitions.insert(struct_meta.name.clone(), struct_meta.definition.clone());
}
for struct_meta in &metadata.structs {
let parsed = syn::parse_str::<syn::Item>(&struct_meta.definition).unwrap();
let mut schema = match &parsed {
syn::Item::Struct(struct_item) => {
parse_struct_to_schema(struct_item, &known_schema_names, &struct_definitions)
}
syn::Item::Enum(enum_item) => {
parse_enum_to_schema(enum_item, &known_schema_names, &struct_definitions)
}
_ => {
parse_struct_to_schema(
&syn::parse_str(&struct_meta.definition).unwrap(),
&known_schema_names,
&struct_definitions,
)
}
};
if let syn::Item::Struct(struct_item) = &parsed {
let struct_file = metadata
.routes
.iter()
.find_map(|route| {
if let Ok(file_content) = std::fs::read_to_string(&route.file_path) {
let struct_pattern = format!("struct {}", struct_meta.name);
if file_content.contains(&struct_pattern) {
return Some(route.file_path.clone());
}
}
None
})
.or_else(|| {
for route in &metadata.routes {
if let Ok(file_content) = std::fs::read_to_string(&route.file_path) {
let struct_pattern = format!("struct {}", struct_meta.name);
if file_content.contains(&struct_pattern) {
return Some(route.file_path.clone());
}
}
}
metadata.routes.first().map(|r| r.file_path.clone())
});
if let Some(file_path) = struct_file && let Ok(file_content) = std::fs::read_to_string(&file_path) && let Ok(file_ast) = syn::parse_file(&file_content) {
process_default_functions(struct_item, &file_ast, &mut schema);
}
}
let schema_name = struct_meta.name.clone();
schemas.insert(schema_name.clone(), schema);
}
for route_meta in &metadata.routes {
let content = match std::fs::read_to_string(&route_meta.file_path) {
Ok(content) => content,
Err(e) => {
eprintln!(
"Warning: Failed to read file {}: {}",
route_meta.file_path, e
);
continue;
}
};
let file_ast = match syn::parse_file(&content) {
Ok(ast) => ast,
Err(e) => {
eprintln!(
"Warning: Failed to parse file {}: {}",
route_meta.file_path, e
);
continue;
}
};
for item in file_ast.items {
if let syn::Item::Fn(fn_item) = item
&& fn_item.sig.ident == route_meta.function_name
{
let method = HttpMethod::from(route_meta.method.as_str());
let operation = build_operation_from_function(
&fn_item.sig,
&route_meta.path,
&known_schema_names,
&struct_definitions,
route_meta.error_status.as_deref(),
);
let path_item = paths
.entry(route_meta.path.clone())
.or_insert_with(|| PathItem {
get: None,
post: None,
put: None,
patch: None,
delete: None,
head: None,
options: None,
trace: None,
parameters: None,
summary: None,
description: None,
});
path_item.set_operation(method, operation);
break;
}
}
}
OpenApi {
openapi: OpenApiVersion::V3_1_0,
info: Info {
title: title.unwrap_or_else(|| "API".to_string()),
version: version.unwrap_or_else(|| "1.0.0".to_string()),
description: None,
terms_of_service: None,
contact: None,
license: None,
summary: None,
},
servers: Some(vec![Server {
url: "http://localhost:3000".to_string(),
description: None,
variables: None,
}]),
paths,
components: Some(Components {
schemas: if schemas.is_empty() {
None
} else {
Some(schemas)
},
responses: None,
parameters: None,
examples: None,
request_bodies: None,
headers: None,
security_schemes: None,
}),
security: None,
tags: None,
external_docs: None,
}
}
fn process_default_functions(
struct_item: &syn::ItemStruct,
file_ast: &syn::File,
schema: &mut vespera_core::schema::Schema,
) {
use syn::Fields;
use vespera_core::schema::SchemaRef;
let struct_rename_all = extract_rename_all(&struct_item.attrs);
let properties = match &mut schema.properties {
Some(props) => props,
None => return, };
if let Fields::Named(fields_named) = &struct_item.fields {
for field in &fields_named.named {
let default_info = match extract_default(&field.attrs) {
Some(Some(func_name)) => func_name, Some(None) => {
let rust_field_name = field
.ident
.as_ref()
.map(|i| i.to_string())
.unwrap_or_else(|| "unknown".to_string());
let field_name = if let Some(renamed) = extract_field_rename(&field.attrs) {
renamed
} else {
rename_field(&rust_field_name, struct_rename_all.as_deref())
};
if let Some(prop_schema_ref) = properties.get_mut(&field_name)
&& let SchemaRef::Inline(prop_schema) = prop_schema_ref
&& prop_schema.default.is_none()
&& let Some(default_value) = get_type_default(&field.ty)
{
prop_schema.default = Some(default_value);
}
continue;
}
None => continue, };
let func = find_function_in_file(file_ast, &default_info);
if let Some(func_item) = func {
if let Some(default_value) = extract_default_value_from_function(func_item) {
let rust_field_name = field
.ident
.as_ref()
.map(|i| i.to_string())
.unwrap_or_else(|| "unknown".to_string());
let field_name = if let Some(renamed) = extract_field_rename(&field.attrs) {
renamed
} else {
rename_field(&rust_field_name, struct_rename_all.as_deref())
};
if let Some(prop_schema_ref) = properties.get_mut(&field_name)
&& let SchemaRef::Inline(prop_schema) = prop_schema_ref
{
prop_schema.default = Some(default_value);
}
}
}
}
}
}
fn find_function_in_file<'a>(
file_ast: &'a syn::File,
function_name: &str,
) -> Option<&'a syn::ItemFn> {
for item in &file_ast.items {
if let syn::Item::Fn(fn_item) = item
&& fn_item.sig.ident == function_name
{
return Some(fn_item);
}
}
None
}
fn extract_default_value_from_function(func: &syn::ItemFn) -> Option<serde_json::Value> {
for stmt in &func.block.stmts {
if let syn::Stmt::Expr(expr, _) = stmt {
if let Some(value) = extract_value_from_expr(expr) {
return Some(value);
}
if let syn::Expr::Return(ret) = expr
&& let Some(expr) = &ret.expr
&& let Some(value) = extract_value_from_expr(expr)
{
return Some(value);
}
}
}
None
}
fn extract_value_from_expr(expr: &syn::Expr) -> Option<serde_json::Value> {
use syn::{Expr, ExprLit, ExprMacro, Lit};
match expr {
Expr::Lit(ExprLit { lit, .. }) => match lit {
Lit::Str(s) => Some(serde_json::Value::String(s.value())),
Lit::Int(i) => {
if let Ok(val) = i.base10_parse::<i64>() {
Some(serde_json::Value::Number(val.into()))
} else {
None
}
}
Lit::Float(f) => {
if let Ok(val) = f.base10_parse::<f64>() {
Some(serde_json::Value::Number(
serde_json::Number::from_f64(val).unwrap_or(serde_json::Number::from(0)),
))
} else {
None
}
}
Lit::Bool(b) => Some(serde_json::Value::Bool(b.value)),
_ => None,
},
Expr::MethodCall(method_call) => {
if method_call.method == "to_string" {
if let Expr::Lit(ExprLit {
lit: Lit::Str(s), ..
}) = method_call.receiver.as_ref()
{
return Some(serde_json::Value::String(s.value()));
}
if let Some(value) = extract_value_from_expr(method_call.receiver.as_ref()) {
return Some(value);
}
}
None
}
Expr::Macro(ExprMacro { mac, .. }) => {
if mac.path.is_ident("vec") {
return Some(serde_json::Value::Array(vec![]));
}
None
}
_ => None,
}
}
fn get_type_default(ty: &syn::Type) -> Option<serde_json::Value> {
use syn::Type;
match ty {
Type::Path(type_path) => {
if let Some(segment) = type_path.path.segments.last() {
match segment.ident.to_string().as_str() {
"String" => Some(serde_json::Value::String(String::new())),
"i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" => {
Some(serde_json::Value::Number(serde_json::Number::from(0)))
}
"f32" | "f64" => Some(serde_json::Value::Number(
serde_json::Number::from_f64(0.0).unwrap_or(serde_json::Number::from(0)),
)),
"bool" => Some(serde_json::Value::Bool(false)),
_ => None,
}
} else {
None
}
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::metadata::{CollectedMetadata, RouteMetadata, StructMetadata};
use rstest::rstest;
use std::fs;
use std::path::PathBuf;
use tempfile::TempDir;
fn create_temp_file(dir: &TempDir, filename: &str, content: &str) -> PathBuf {
let file_path = dir.path().join(filename);
fs::write(&file_path, content).expect("Failed to write temp file");
file_path
}
#[test]
fn test_generate_openapi_empty_metadata() {
let metadata = CollectedMetadata::new();
let doc = generate_openapi_doc_with_metadata(None, None, &metadata);
assert_eq!(doc.openapi, OpenApiVersion::V3_1_0);
assert_eq!(doc.info.title, "API");
assert_eq!(doc.info.version, "1.0.0");
assert!(doc.paths.is_empty());
assert!(doc.components.as_ref().unwrap().schemas.is_none());
assert_eq!(doc.servers.as_ref().unwrap().len(), 1);
assert_eq!(
doc.servers.as_ref().unwrap()[0].url,
"http://localhost:3000"
);
}
#[rstest]
#[case(None, None, "API", "1.0.0")]
#[case(Some("My API".to_string()), None, "My API", "1.0.0")]
#[case(None, Some("2.0.0".to_string()), "API", "2.0.0")]
#[case(Some("Test API".to_string()), Some("3.0.0".to_string()), "Test API", "3.0.0")]
fn test_generate_openapi_title_version(
#[case] title: Option<String>,
#[case] version: Option<String>,
#[case] expected_title: &str,
#[case] expected_version: &str,
) {
let metadata = CollectedMetadata::new();
let doc = generate_openapi_doc_with_metadata(title, version, &metadata);
assert_eq!(doc.info.title, expected_title);
assert_eq!(doc.info.version, expected_version);
}
#[test]
fn test_generate_openapi_with_route() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let route_content = r#"
pub fn get_users() -> String {
"users".to_string()
}
"#;
let route_file = create_temp_file(&temp_dir, "users.rs", route_content);
let mut metadata = CollectedMetadata::new();
metadata.routes.push(RouteMetadata {
method: "GET".to_string(),
path: "/users".to_string(),
function_name: "get_users".to_string(),
module_path: "test::users".to_string(),
file_path: route_file.to_string_lossy().to_string(),
signature: "fn get_users() -> String".to_string(),
error_status: None,
});
let doc = generate_openapi_doc_with_metadata(None, None, &metadata);
assert!(doc.paths.contains_key("/users"));
let path_item = doc.paths.get("/users").unwrap();
assert!(path_item.get.is_some());
let operation = path_item.get.as_ref().unwrap();
assert_eq!(operation.operation_id, Some("get_users".to_string()));
}
#[test]
fn test_generate_openapi_with_struct() {
let mut metadata = CollectedMetadata::new();
metadata.structs.push(StructMetadata {
name: "User".to_string(),
definition: "struct User { id: i32, name: String }".to_string(),
});
let doc = generate_openapi_doc_with_metadata(None, None, &metadata);
assert!(doc.components.as_ref().unwrap().schemas.is_some());
let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap();
assert!(schemas.contains_key("User"));
}
#[test]
fn test_generate_openapi_with_enum() {
let mut metadata = CollectedMetadata::new();
metadata.structs.push(StructMetadata {
name: "Status".to_string(),
definition: "enum Status { Active, Inactive, Pending }".to_string(),
});
let doc = generate_openapi_doc_with_metadata(None, None, &metadata);
assert!(doc.components.as_ref().unwrap().schemas.is_some());
let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap();
assert!(schemas.contains_key("Status"));
}
#[test]
fn test_generate_openapi_with_enum_with_data() {
let mut metadata = CollectedMetadata::new();
metadata.structs.push(StructMetadata {
name: "Message".to_string(),
definition: "enum Message { Text(String), User { id: i32, name: String } }".to_string(),
});
let doc = generate_openapi_doc_with_metadata(None, None, &metadata);
assert!(doc.components.as_ref().unwrap().schemas.is_some());
let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap();
assert!(schemas.contains_key("Message"));
}
#[test]
fn test_generate_openapi_with_enum_and_route() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let route_content = r#"
pub fn get_status() -> Status {
Status::Active
}
"#;
let route_file = create_temp_file(&temp_dir, "status_route.rs", route_content);
let mut metadata = CollectedMetadata::new();
metadata.structs.push(StructMetadata {
name: "Status".to_string(),
definition: "enum Status { Active, Inactive }".to_string(),
});
metadata.routes.push(RouteMetadata {
method: "GET".to_string(),
path: "/status".to_string(),
function_name: "get_status".to_string(),
module_path: "test::status_route".to_string(),
file_path: route_file.to_string_lossy().to_string(),
signature: "fn get_status() -> Status".to_string(),
error_status: None,
});
let doc = generate_openapi_doc_with_metadata(None, None, &metadata);
assert!(doc.components.as_ref().unwrap().schemas.is_some());
let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap();
assert!(schemas.contains_key("Status"));
assert!(doc.paths.contains_key("/status"));
}
#[test]
#[should_panic(expected = "expected `struct`")]
fn test_generate_openapi_with_fallback_item() {
let mut metadata = CollectedMetadata::new();
metadata.structs.push(StructMetadata {
name: "Config".to_string(),
definition: "const CONFIG: i32 = 42;".to_string(),
});
let _doc = generate_openapi_doc_with_metadata(None, None, &metadata);
}
#[test]
fn test_generate_openapi_with_route_and_struct() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let route_content = r#"
use crate::user::User;
pub fn get_user() -> User {
User { id: 1, name: "Alice".to_string() }
}
"#;
let route_file = create_temp_file(&temp_dir, "user_route.rs", route_content);
let mut metadata = CollectedMetadata::new();
metadata.structs.push(StructMetadata {
name: "User".to_string(),
definition: "struct User { id: i32, name: String }".to_string(),
});
metadata.routes.push(RouteMetadata {
method: "GET".to_string(),
path: "/user".to_string(),
function_name: "get_user".to_string(),
module_path: "test::user_route".to_string(),
file_path: route_file.to_string_lossy().to_string(),
signature: "fn get_user() -> User".to_string(),
error_status: None,
});
let doc = generate_openapi_doc_with_metadata(
Some("Test API".to_string()),
Some("1.0.0".to_string()),
&metadata,
);
assert!(doc.components.as_ref().unwrap().schemas.is_some());
let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap();
assert!(schemas.contains_key("User"));
assert!(doc.paths.contains_key("/user"));
let path_item = doc.paths.get("/user").unwrap();
assert!(path_item.get.is_some());
}
#[test]
fn test_generate_openapi_multiple_routes() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let route1_content = r#"
pub fn get_users() -> String {
"users".to_string()
}
"#;
let route1_file = create_temp_file(&temp_dir, "users.rs", route1_content);
let route2_content = r#"
pub fn create_user() -> String {
"created".to_string()
}
"#;
let route2_file = create_temp_file(&temp_dir, "create_user.rs", route2_content);
let mut metadata = CollectedMetadata::new();
metadata.routes.push(RouteMetadata {
method: "GET".to_string(),
path: "/users".to_string(),
function_name: "get_users".to_string(),
module_path: "test::users".to_string(),
file_path: route1_file.to_string_lossy().to_string(),
signature: "fn get_users() -> String".to_string(),
error_status: None,
});
metadata.routes.push(RouteMetadata {
method: "POST".to_string(),
path: "/users".to_string(),
function_name: "create_user".to_string(),
module_path: "test::create_user".to_string(),
file_path: route2_file.to_string_lossy().to_string(),
signature: "fn create_user() -> String".to_string(),
error_status: None,
});
let doc = generate_openapi_doc_with_metadata(None, None, &metadata);
assert_eq!(doc.paths.len(), 1); let path_item = doc.paths.get("/users").unwrap();
assert!(path_item.get.is_some());
assert!(path_item.post.is_some());
}
#[rstest]
#[case::route_file_read_failure(
None,
Some(RouteMetadata {
method: "GET".to_string(),
path: "/users".to_string(),
function_name: "get_users".to_string(),
module_path: "test::users".to_string(),
file_path: "/nonexistent/route.rs".to_string(),
signature: "fn get_users() -> String".to_string(),
error_status: None,
}),
false, // struct should not be added
false, // route should not be added
)]
#[case::route_file_parse_failure(
None,
Some(RouteMetadata {
method: "GET".to_string(),
path: "/users".to_string(),
function_name: "get_users".to_string(),
module_path: "test::users".to_string(),
file_path: "".to_string(), // Will be set to temp file with invalid syntax
signature: "fn get_users() -> String".to_string(),
error_status: None,
}),
false, // struct should not be added
false, // route should not be added
)]
fn test_generate_openapi_file_errors(
#[case] struct_meta: Option<StructMetadata>,
#[case] route_meta: Option<RouteMetadata>,
#[case] expect_struct: bool,
#[case] expect_route: bool,
) {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let mut metadata = CollectedMetadata::new();
if let Some(struct_m) = struct_meta {
metadata.structs.push(struct_m);
}
if let Some(mut route_m) = route_meta {
if route_m.file_path.is_empty() {
let invalid_file =
create_temp_file(&temp_dir, "invalid_route.rs", "invalid rust syntax {");
route_m.file_path = invalid_file.to_string_lossy().to_string();
}
metadata.routes.push(route_m);
}
let doc = generate_openapi_doc_with_metadata(None, None, &metadata);
if expect_struct {
assert!(doc.components.as_ref().unwrap().schemas.is_some());
let schemas = doc.components.as_ref().unwrap().schemas.as_ref().unwrap();
assert!(schemas.contains_key("User"));
} else if let Some(schemas) = doc.components.as_ref().unwrap().schemas.as_ref() {
assert!(!schemas.contains_key("User"));
}
if expect_route {
assert!(doc.paths.contains_key("/users"));
} else {
assert!(!doc.paths.contains_key("/users"));
}
drop(temp_dir);
}
}