use std::collections::HashMap;
use std::sync::{Arc, LazyLock, Mutex};
use crate::macro_storage::{CrateStorage, SourceIdentified};
use crate::{args, metadata::HeaderParam};
#[derive(Debug, Clone)]
pub struct StoredRouteInfo {
pub fn_name: String,
pub method: Option<String>,
pub custom_path: Option<String>,
pub success_status: Option<u16>,
pub error_status: Option<Vec<u16>>,
pub typed_responses: Option<Vec<(u16, String)>>,
pub tags: Option<Vec<String>>,
pub security: Option<Vec<String>>,
pub headers: Vec<HeaderParam>,
pub operation_id: Option<String>,
pub summary: Option<String>,
pub request_example: Option<serde_json::Value>,
pub response_example: Option<serde_json::Value>,
pub deprecated: bool,
pub description: Option<String>,
pub file_path: Option<String>,
pub fn_sig_str: String,
}
pub static ROUTE_STORAGE: CrateStorage<StoredRouteInfo> =
LazyLock::new(|| Mutex::new(HashMap::new()));
impl SourceIdentified for StoredRouteInfo {
fn fn_name(&self) -> &str {
&self.fn_name
}
fn file_path(&self) -> Option<&str> {
self.file_path.as_deref()
}
}
pub fn register_route(info: StoredRouteInfo) {
crate::macro_storage::register(&ROUTE_STORAGE, info);
}
#[must_use]
pub fn current_crate_routes() -> Arc<Vec<StoredRouteInfo>> {
crate::macro_storage::current_crate_items(&ROUTE_STORAGE)
}
fn extract_error_status_codes(arr: &syn::ExprArray) -> Option<Vec<u16>> {
let codes: Vec<u16> = arr
.elems
.iter()
.filter_map(|elem| {
if let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Int(lit_int),
..
}) = elem
{
lit_int.base10_parse::<u16>().ok()
} else {
None
}
})
.collect();
if codes.is_empty() { None } else { Some(codes) }
}
fn require_string_literal_elems(arr: &syn::ExprArray, attr_name: &str) -> syn::Result<()> {
for elem in &arr.elems {
if !matches!(
elem,
syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(_),
..
})
) {
return Err(syn::Error::new_spanned(
elem,
format!(
"#[route] `{attr_name}` entries must be string literals, e.g. `{attr_name} = [\"name\"]`"
),
));
}
}
Ok(())
}
fn collect_string_literal_values(arr: &syn::ExprArray) -> Vec<String> {
arr.elems
.iter()
.filter_map(|elem| {
if let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(lit_str),
..
}) = elem
{
Some(lit_str.value())
} else {
None
}
})
.collect()
}
fn extract_tag_strings(arr: &syn::ExprArray) -> syn::Result<Option<Vec<String>>> {
require_string_literal_elems(arr, "tags")?;
let tags = collect_string_literal_values(arr);
Ok(if tags.is_empty() { None } else { Some(tags) })
}
fn extract_security_strings(arr: &syn::ExprArray) -> syn::Result<Vec<String>> {
require_string_literal_elems(arr, "security")?;
Ok(collect_string_literal_values(arr))
}
fn parse_example_string(lit: &syn::LitStr) -> serde_json::Value {
let value = lit.value();
serde_json::from_str(&value).unwrap_or(serde_json::Value::String(value))
}
fn extract_typed_responses(arr: &syn::ExprArray) -> Option<Vec<(u16, String)>> {
let responses: Vec<(u16, String)> = arr
.elems
.iter()
.filter_map(|elem| {
let syn::Expr::Tuple(tuple) = elem else {
return None;
};
let status = tuple.elems.first().and_then(|status| {
if let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Int(lit_int),
..
}) = status
{
lit_int.base10_parse::<u16>().ok()
} else {
None
}
})?;
let schema_name = tuple.elems.get(1).and_then(|schema| {
if let syn::Expr::Path(path) = schema {
path.path.segments.last().map(|seg| seg.ident.to_string())
} else {
None
}
})?;
Some((status, schema_name))
})
.collect();
if responses.is_empty() {
None
} else {
Some(responses)
}
}
pub fn validate_route_fn(item_fn: &syn::ItemFn) -> Result<(), syn::Error> {
if !matches!(item_fn.vis, syn::Visibility::Public(_)) {
return Err(syn::Error::new_spanned(
item_fn.sig.fn_token,
"#[route] attribute: function must be public. Add `pub` before `fn`.",
));
}
if item_fn.sig.asyncness.is_none() {
return Err(syn::Error::new_spanned(
item_fn.sig.fn_token,
"#[route] attribute: function must be async. Add `async` before `fn`.",
));
}
Ok(())
}
pub fn process_route_attribute(
attr: proc_macro2::TokenStream,
item: proc_macro2::TokenStream,
) -> syn::Result<proc_macro2::TokenStream> {
let route_args = syn::parse2::<args::RouteArgs>(attr)?;
let item_fn: syn::ItemFn = syn::parse2(item.clone()).map_err(|e| syn::Error::new(e.span(), "#[route] attribute: can only be applied to functions, not other items. Move or remove the attribute."))?;
validate_route_fn(&item_fn)?;
let fn_sig = &item_fn.sig;
let stored = StoredRouteInfo {
fn_name: item_fn.sig.ident.to_string(),
method: route_args.method.as_ref().map(syn::Ident::to_string),
custom_path: route_args.path.as_ref().map(syn::LitStr::value),
success_status: route_args.success_status,
error_status: route_args
.error_status
.as_ref()
.and_then(extract_error_status_codes),
typed_responses: route_args
.responses
.as_ref()
.and_then(extract_typed_responses),
tags: match route_args.tags.as_ref() {
Some(arr) => extract_tag_strings(arr)?,
None => None,
},
security: match route_args.security.as_ref() {
Some(arr) => Some(extract_security_strings(arr)?),
None => None,
},
headers: route_args.headers.unwrap_or_default(),
operation_id: route_args.operation_id.as_ref().map(syn::LitStr::value),
summary: route_args.summary.as_ref().map(syn::LitStr::value),
request_example: route_args
.request_example
.as_ref()
.map(parse_example_string),
response_example: route_args
.response_example
.as_ref()
.map(parse_example_string),
deprecated: route_args.deprecated,
description: route_args
.description
.as_ref()
.map(syn::LitStr::value)
.or_else(|| crate::route::extract_doc_comment(&item_fn.attrs)),
fn_sig_str: quote::quote!(#fn_sig).to_string(),
file_path: proc_macro2::Span::call_site()
.local_file()
.map(|p| p.display().to_string()),
};
register_route(stored);
Ok(item)
}
#[cfg(test)]
mod tests {
use quote::quote;
use rstest::rstest;
use super::*;
#[test]
fn test_validate_route_fn_not_public() {
let item: syn::ItemFn = syn::parse_quote! {
async fn private_handler() -> String {
"test".to_string()
}
};
let result = validate_route_fn(&item);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("function must be public")
);
}
#[test]
fn test_validate_route_fn_not_async() {
let item: syn::ItemFn = syn::parse_quote! {
pub fn sync_handler() -> String {
"test".to_string()
}
};
let result = validate_route_fn(&item);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("function must be async")
);
}
#[test]
fn test_validate_route_fn_valid() {
let item: syn::ItemFn = syn::parse_quote! {
pub async fn valid_handler() -> String {
"test".to_string()
}
};
let result = validate_route_fn(&item);
assert!(result.is_ok());
}
#[test]
fn test_process_route_attribute_valid() {
let attr = quote!(get);
let item = quote!(
pub async fn handler() -> String {
"ok".to_string()
}
);
let result = process_route_attribute(attr, item.clone());
assert!(result.is_ok());
assert_eq!(result.unwrap().to_string(), item.to_string());
}
#[test]
fn test_process_route_attribute_invalid_attr() {
let attr = quote!(invalid_method);
let item = quote!(
pub async fn handler() -> String {
"ok".to_string()
}
);
let result = process_route_attribute(attr, item);
assert!(result.is_err());
}
#[test]
fn test_process_route_attribute_not_function() {
let attr = quote!(get);
let item = quote!(
struct NotAFunction;
);
let result = process_route_attribute(attr, item);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("can only be applied to functions"));
}
#[test]
fn test_process_route_attribute_not_public() {
let attr = quote!(get);
let item = quote!(
async fn private_handler() -> String {
"ok".to_string()
}
);
let result = process_route_attribute(attr, item);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("function must be public"));
}
#[test]
fn test_process_route_attribute_not_async() {
let attr = quote!(get);
let item = quote!(
pub fn sync_handler() -> String {
"ok".to_string()
}
);
let result = process_route_attribute(attr, item);
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(err.contains("function must be async"));
}
#[test]
fn test_process_route_attribute_with_path() {
let attr = quote!(get, path = "/users/{id}");
let item = quote!(
pub async fn get_user() -> String {
"user".to_string()
}
);
let result = process_route_attribute(attr, item);
assert!(result.is_ok());
}
#[test]
fn test_process_route_attribute_with_tags() {
let attr = quote!(post, tags = ["users", "admin"]);
let item = quote!(
pub async fn create_user() -> String {
"created".to_string()
}
);
let result = process_route_attribute(attr, item);
assert!(result.is_ok());
}
#[test]
fn test_process_route_attribute_all_methods() {
let methods = ["get", "post", "put", "patch", "delete", "head", "options"];
for method in methods {
let attr: proc_macro2::TokenStream = method.parse().unwrap();
let item = quote!(
pub async fn handler() -> String {
"ok".to_string()
}
);
let result = process_route_attribute(attr, item);
assert!(result.is_ok(), "Method {method} should be valid");
}
}
#[test]
fn test_route_storage_populated_by_process_route_attribute() {
let attr = quote!(
get,
path = "/{id}",
tags = ["users"],
description = "Get user by ID",
error_status = [404]
);
let item = quote!(
pub async fn get_user_test_storage() -> String {
"test".to_string()
}
);
let result = process_route_attribute(attr, item);
assert!(result.is_ok());
let storage = current_crate_routes();
let stored = storage
.iter()
.find(|s| s.fn_name == "get_user_test_storage");
assert!(
stored.is_some(),
"StoredRouteInfo should be in ROUTE_STORAGE"
);
let stored = stored.unwrap();
assert_eq!(stored.method, Some("get".to_string()));
assert_eq!(stored.custom_path, Some("/{id}".to_string()));
assert_eq!(stored.tags, Some(vec!["users".to_string()]));
assert_eq!(stored.description, Some("Get user by ID".to_string()));
assert_eq!(stored.error_status, Some(vec![404]));
assert!(stored.headers.is_empty());
assert!(stored.fn_sig_str.contains("get_user_test_storage"));
}
#[test]
fn test_route_storage_no_optional_fields() {
let attr = quote!();
let item = quote!(
pub async fn minimal_handler_test() -> String {
"test".to_string()
}
);
let result = process_route_attribute(attr, item);
assert!(result.is_ok());
let storage = current_crate_routes();
let stored = storage.iter().find(|s| s.fn_name == "minimal_handler_test");
assert!(stored.is_some());
let stored = stored.unwrap();
assert_eq!(stored.method, None);
assert_eq!(stored.custom_path, None);
assert_eq!(stored.tags, None);
assert_eq!(stored.description, None);
assert_eq!(stored.error_status, None);
assert!(stored.headers.is_empty());
}
#[test]
fn test_register_route_replaces_same_file_and_function() {
let file_path = Some("/tmp/vespera/routes/replaced.rs".to_string());
let fn_name = "__test_replace_route".to_string();
let base = StoredRouteInfo {
fn_name: fn_name.clone(),
method: Some("get".to_string()),
custom_path: Some("/before".to_string()),
success_status: None,
error_status: None,
typed_responses: None,
tags: None,
security: None,
headers: Vec::new(),
operation_id: None,
summary: None,
request_example: None,
response_example: None,
deprecated: false,
description: None,
file_path: file_path.clone(),
fn_sig_str: "pub async fn __test_replace_route ()".to_string(),
};
register_route(base);
register_route(StoredRouteInfo {
method: Some("post".to_string()),
custom_path: Some("/after".to_string()),
file_path,
fn_sig_str: "pub async fn __test_replace_route ()".to_string(),
..current_crate_routes()
.iter()
.find(|entry| entry.fn_name == fn_name)
.cloned()
.expect("first route registration should exist")
});
let matches: Vec<_> = current_crate_routes()
.iter()
.filter(|entry| entry.fn_name == fn_name)
.cloned()
.collect();
assert_eq!(matches.len(), 1, "same source route should replace");
assert_eq!(matches[0].method, Some("post".to_string()));
assert_eq!(matches[0].custom_path, Some("/after".to_string()));
}
#[test]
fn test_extract_error_status_codes_empty() {
let arr: syn::ExprArray = syn::parse_quote!([]);
assert_eq!(extract_error_status_codes(&arr), None);
}
#[test]
fn test_extract_error_status_codes_values() {
let arr: syn::ExprArray = syn::parse_quote!([400, 404, 500]);
assert_eq!(extract_error_status_codes(&arr), Some(vec![400, 404, 500]));
}
#[test]
fn test_extract_tag_strings_empty() {
let arr: syn::ExprArray = syn::parse_quote!([]);
assert_eq!(extract_tag_strings(&arr).unwrap(), None);
}
#[test]
fn test_extract_tag_strings_values() {
let arr: syn::ExprArray = syn::parse_quote!(["users", "admin", "api"]);
assert_eq!(
extract_tag_strings(&arr).unwrap(),
Some(vec![
"users".to_string(),
"admin".to_string(),
"api".to_string()
])
);
}
#[test]
fn test_extract_tag_strings_rejects_non_string() {
let arr: syn::ExprArray = syn::parse_quote!([users]);
let err = extract_tag_strings(&arr).expect_err("non-string tag must error");
assert!(err.to_string().contains("string literals"), "{err}");
}
#[test]
fn test_extract_security_strings_values() {
let arr: syn::ExprArray = syn::parse_quote!(["bearerAuth", "apiKey"]);
assert_eq!(
extract_security_strings(&arr).unwrap(),
vec!["bearerAuth".to_string(), "apiKey".to_string()]
);
}
#[test]
fn test_extract_security_strings_empty_is_ok() {
let arr: syn::ExprArray = syn::parse_quote!([]);
assert_eq!(
extract_security_strings(&arr).unwrap(),
Vec::<String>::new()
);
}
#[test]
fn test_extract_security_strings_rejects_non_string() {
let arr: syn::ExprArray = syn::parse_quote!([bearerAuth]);
let err = extract_security_strings(&arr).expect_err("non-string security must error");
assert!(err.to_string().contains("string literals"), "{err}");
}
#[test]
fn low_level_collectors_ignore_non_literal_entries() {
let statuses: syn::ExprArray = syn::parse_quote!([400, BAD_REQUEST, "404", 70_000]);
let strings: syn::ExprArray = syn::parse_quote!(["users", admin, 7]);
assert_eq!(extract_error_status_codes(&statuses), Some(vec![400]));
assert_eq!(
collect_string_literal_values(&strings),
vec!["users".to_string()]
);
}
#[rstest]
#[case::json_array("[1,2]", serde_json::json!([1, 2]))]
#[case::plain_text("not json", serde_json::Value::String("not json".to_string()))]
fn example_parser_preserves_json_or_falls_back_to_text(
#[case] source: &str,
#[case] expected: serde_json::Value,
) {
let literal = syn::LitStr::new(source, proc_macro2::Span::call_site());
assert_eq!(parse_example_string(&literal), expected);
}
#[rstest]
#[case::empty(quote!([]), None)]
#[case::not_a_tuple(quote!([NotFound]), None)]
#[case::missing_status(quote!([()]), None)]
#[case::non_integer_status(quote!([(NOT_FOUND, ErrorBody)]), None)]
#[case::out_of_range_status(quote!([(70_000, ErrorBody)]), None)]
#[case::missing_schema(quote!([(404,)]), None)]
#[case::non_path_schema(quote!([(404, "ErrorBody")]), None)]
#[case::qualified_schema(
quote!([(404, crate::errors::NotFound)]),
Some(vec![(404, "NotFound".to_string())])
)]
fn typed_response_extractor_accepts_only_status_path_tuples(
#[case] source: proc_macro2::TokenStream,
#[case] expected: Option<Vec<(u16, String)>>,
) {
let array = syn::parse2::<syn::ExprArray>(source).expect("valid response array fixture");
assert_eq!(extract_typed_responses(&array), expected);
}
}