use crate::{args::RouteArgs, http::is_http_method, metadata::HeaderParam};
pub use crate::parser::schema::extract_doc_comment;
#[derive(Debug)]
pub struct RouteInfo {
pub method: String,
pub 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>,
}
#[allow(clippy::manual_map, clippy::option_if_let_else)]
fn lit_value(lit: Option<&syn::LitStr>) -> Option<String> {
if let Some(lit) = lit {
Some(lit.value())
} else {
None
}
}
fn build_route_info_from_args(route_args: &RouteArgs) -> RouteInfo {
let method = route_args
.method
.as_ref()
.map_or_else(|| "get".to_string(), syn::Ident::to_string);
let path = lit_value(route_args.path.as_ref());
let error_status = route_args
.error_status
.as_ref()
.and_then(extract_status_codes);
let tags = route_args.tags.as_ref().and_then(extract_non_empty_strings);
let typed_responses = route_args
.responses
.as_ref()
.and_then(extract_typed_responses);
let security = route_args.security.as_ref().map(extract_strings);
let headers = route_args.headers.clone().unwrap_or_default();
let description = lit_value(route_args.description.as_ref());
let operation_id = lit_value(route_args.operation_id.as_ref());
let summary = lit_value(route_args.summary.as_ref());
let request_example = route_args
.request_example
.as_ref()
.map(parse_example_string);
let response_example = route_args
.response_example
.as_ref()
.map(parse_example_string);
RouteInfo {
method,
path,
success_status: route_args.success_status,
error_status,
typed_responses,
tags,
security,
headers,
operation_id,
summary,
request_example,
response_example,
deprecated: route_args.deprecated,
description,
}
}
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_status_codes(array: &syn::ExprArray) -> Option<Vec<u16>> {
let status_codes: Vec<u16> = array
.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();
(!status_codes.is_empty()).then_some(status_codes)
}
fn extract_strings(array: &syn::ExprArray) -> Vec<String> {
array
.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_non_empty_strings(array: &syn::ExprArray) -> Option<Vec<String>> {
let values = extract_strings(array);
(!values.is_empty()).then_some(values)
}
fn extract_typed_responses(array: &syn::ExprArray) -> Option<Vec<(u16, String)>> {
let responses: Vec<(u16, String)> = array
.elems
.iter()
.filter_map(extract_typed_response)
.collect();
(!responses.is_empty()).then_some(responses)
}
fn extract_typed_response(elem: &syn::Expr) -> Option<(u16, String)> {
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))
}
pub fn check_route_by_meta(meta: &syn::Meta) -> bool {
match meta {
syn::Meta::List(meta_list) => {
(meta_list.path.segments.len() == 2
&& meta_list.path.segments[0].ident == "vespera"
&& meta_list.path.segments[1].ident == "route")
|| (meta_list.path.segments.len() == 1
&& meta_list.path.segments[0].ident == "route")
}
syn::Meta::Path(path) => {
(path.segments.len() == 2
&& path.segments[0].ident == "vespera"
&& path.segments[1].ident == "route")
|| (path.segments.len() == 1 && path.segments[0].ident == "route")
}
syn::Meta::NameValue(meta_nv) => {
(meta_nv.path.segments.len() == 2
&& meta_nv.path.segments[0].ident == "vespera"
&& meta_nv.path.segments[1].ident == "route")
|| (meta_nv.path.segments.len() == 1 && meta_nv.path.segments[0].ident == "route")
}
}
}
pub fn extract_route_info(attrs: &[syn::Attribute]) -> Option<RouteInfo> {
for attr in attrs {
let is_route_meta = check_route_by_meta(&attr.meta);
if is_route_meta && let Some(info) = try_extract_from_meta(&attr.meta) {
return Some(info);
}
}
None
}
fn try_extract_from_meta(meta: &syn::Meta) -> Option<RouteInfo> {
match meta {
syn::Meta::List(meta_list) => {
let route_args = meta_list.parse_args::<RouteArgs>().ok()?;
Some(build_route_info_from_args(&route_args))
}
syn::Meta::NameValue(meta_nv) => {
let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(lit_str),
..
}) = &meta_nv.value
else {
return None;
};
let method_str = lit_str.value().to_lowercase();
if !is_http_method(&method_str) {
return None;
}
Some(RouteInfo {
method: method_str,
path: None,
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,
})
}
syn::Meta::Path(_) => Some(RouteInfo {
method: "get".to_string(),
path: None,
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,
}),
}
}
#[cfg(test)]
mod tests {
use rstest::rstest;
use super::*;
fn parse_meta_from_attr(attr_str: &str) -> syn::Meta {
let full_code = format!("{attr_str} fn test() {{}}");
let file: syn::File = syn::parse_str(&full_code).expect("Failed to parse with attribute");
if let Some(syn::Item::Fn(fn_item)) = file.items.first()
&& let Some(attr) = fn_item.attrs.first()
{
return attr.meta.clone();
}
panic!("Failed to extract meta from attribute: {attr_str}");
}
#[rstest]
#[case("#[route()]", true)]
#[case("#[vespera::route()]", true)]
#[case("#[route(get)]", true)]
#[case("#[vespera::route(get)]", true)]
#[case("#[route(post)]", true)]
#[case("#[vespera::route(post)]", true)]
#[case("#[route(get, path = \"/api\")]", true)]
#[case("#[vespera::route(get, path = \"/api\")]", true)]
#[case("#[route]", true)]
#[case("#[vespera::route]", true)]
#[case("#[route = \"get\"]", true)]
#[case("#[vespera::route = \"get\"]", true)]
#[case("#[other()]", false)]
#[case("#[vespera::other()]", false)]
#[case("#[other(get)]", false)]
#[case("#[vespera::other(get)]", false)]
#[case("#[derive(Schema)]", false)]
#[case("#[serde(rename_all = \"camelCase\")]", false)]
#[case("#[test]", false)]
#[case("#[vespera::route::something]", false)]
#[case("#[vespera::route::something()]", false)]
fn test_check_route_by_meta(#[case] attr_str: &str, #[case] expected: bool) {
let meta = parse_meta_from_attr(attr_str);
let result = check_route_by_meta(&meta);
assert_eq!(
result, expected,
"Failed for attribute: {attr_str}, expected: {expected}"
);
}
fn parse_attrs_from_code(code: &str) -> Vec<syn::Attribute> {
let file: syn::File = syn::parse_str(code).expect("Failed to parse code");
if let Some(syn::Item::Fn(fn_item)) = file.items.first() {
return fn_item.attrs.clone();
}
vec![]
}
#[rstest]
#[case("#[route(get)] fn test() {}", Some(("get".to_string(), None, None)))]
#[case("#[route(post)] fn test() {}", Some(("post".to_string(), None, None)))]
#[case("#[route(put)] fn test() {}", Some(("put".to_string(), None, None)))]
#[case("#[route(patch)] fn test() {}", Some(("patch".to_string(), None, None)))]
#[case("#[route(delete)] fn test() {}", Some(("delete".to_string(), None, None)))]
#[case("#[route(head)] fn test() {}", Some(("head".to_string(), None, None)))]
#[case("#[route(options)] fn test() {}", Some(("options".to_string(), None, None)))]
#[case("#[vespera::route(get)] fn test() {}", Some(("get".to_string(), None, None)))]
#[case("#[route(get, path = \"/api\")] fn test() {}", Some(("get".to_string(), Some("/api".to_string()), None)))]
#[case("#[route(post, path = \"/users\")] fn test() {}", Some(("post".to_string(), Some("/users".to_string()), None)))]
#[case("#[route(get, path = \"/api/v1\")] fn test() {}", Some(("get".to_string(), Some("/api/v1".to_string()), None)))]
#[case("#[route(get, error_status = [400])] fn test() {}", Some(("get".to_string(), None, Some(vec![400]))))]
#[case("#[route(get, error_status = [400, 404])] fn test() {}", Some(("get".to_string(), None, Some(vec![400, 404]))))]
#[case("#[route(get, error_status = [400, 404, 500])] fn test() {}", Some(("get".to_string(), None, Some(vec![400, 404, 500]))))]
#[case("#[route(get, path = \"/api\", error_status = [400])] fn test() {}", Some(("get".to_string(), Some("/api".to_string()), Some(vec![400]))))]
#[case("#[route(post, path = \"/users\", error_status = [400, 404])] fn test() {}", Some(("post".to_string(), Some("/users".to_string()), Some(vec![400, 404]))))]
#[case("#[route()] fn test() {}", Some(("get".to_string(), None, None)))]
#[case("#[route(path = \"/api\")] fn test() {}", Some(("get".to_string(), Some("/api".to_string()), None)))]
#[case("#[route] fn test() {}", Some(("get".to_string(), None, None)))]
#[case("#[vespera::route] fn test() {}", Some(("get".to_string(), None, None)))]
#[case("#[route(get, error_status = [])] fn test() {}", Some(("get".to_string(), None, None)))]
#[case("#[route = \"get\"] fn test() {}", Some(("get".to_string(), None, None)))]
#[case("#[route = \"post\"] fn test() {}", Some(("post".to_string(), None, None)))]
#[case("#[route = \"put\"] fn test() {}", Some(("put".to_string(), None, None)))]
#[case("#[route = \"patch\"] fn test() {}", Some(("patch".to_string(), None, None)))]
#[case("#[route = \"delete\"] fn test() {}", Some(("delete".to_string(), None, None)))]
#[case("#[route = \"head\"] fn test() {}", Some(("head".to_string(), None, None)))]
#[case("#[route = \"options\"] fn test() {}", Some(("options".to_string(), None, None)))]
#[case("#[vespera::route = \"get\"] fn test() {}", Some(("get".to_string(), None, None)))]
#[case("#[other(get)] fn test() {}", None)]
#[case("#[derive(Schema)] fn test() {}", None)]
#[case("#[test] fn test() {}", None)]
#[case("fn test() {}", None)]
#[case("#[route = \"invalid\"] fn test() {}", None)]
#[case("#[route = \"GET\"] fn test() {}", Some(("get".to_string(), None, None)))]
#[case("#[route = 42] fn test() {}", None)]
#[case("#[route = true] fn test() {}", None)]
#[case("#[derive(Debug)] #[route(get, path = \"/api\")] #[test] fn test() {}", Some(("get".to_string(), Some("/api".to_string()), None)))]
#[case("#[route(get, path = \"/first\")] #[route(post, path = \"/second\")] fn test() {}", Some(("get".to_string(), Some("/first".to_string()), None)))]
#[case("#[route(path = \"/test\")] fn test() {}", Some(("get".to_string(), Some("/test".to_string()), None)))] #[case("#[route()] fn test() {}", Some(("get".to_string(), None, None)))] #[case("#[route(post)] fn test() {}", Some(("post".to_string(), None, None)))] #[case("#[route(put, path = \"/test\")] fn test() {}", Some(("put".to_string(), Some("/test".to_string()), None)))] fn test_extract_route_info(
#[case] code: &str,
#[case] expected: Option<(String, Option<String>, Option<Vec<u16>>)>,
) {
let attrs = parse_attrs_from_code(code);
let result = extract_route_info(&attrs);
match expected {
Some((exp_method, exp_path, exp_error_status)) => {
assert!(
result.is_some(),
"Expected Some but got None for code: {code}"
);
let route_info = result.unwrap();
assert_eq!(
route_info.method, exp_method,
"Method mismatch for code: {code}"
);
assert_eq!(route_info.path, exp_path, "Path mismatch for code: {code}");
assert_eq!(
route_info.error_status, exp_error_status,
"Error status mismatch for code: {code}"
);
}
None => {
assert!(
result.is_none(),
"Expected None but got Some({result:?}) for code: {code}"
);
}
}
}
#[test]
fn test_extract_doc_comment_single_line() {
let code = r"
/// This is a doc comment
fn test() {}
";
let file: syn::File = syn::parse_str(code).unwrap();
if let Some(syn::Item::Fn(fn_item)) = file.items.first() {
let doc = extract_doc_comment(&fn_item.attrs);
assert_eq!(doc, Some("This is a doc comment".to_string()));
}
}
#[test]
fn test_extract_doc_comment_multi_line() {
let code = r"
/// First line
/// Second line
/// Third line
fn test() {}
";
let file: syn::File = syn::parse_str(code).unwrap();
if let Some(syn::Item::Fn(fn_item)) = file.items.first() {
let doc = extract_doc_comment(&fn_item.attrs);
assert_eq!(doc, Some("First line\nSecond line\nThird line".to_string()));
}
}
#[test]
fn test_extract_doc_comment_empty() {
let code = "fn test() {}";
let file: syn::File = syn::parse_str(code).unwrap();
if let Some(syn::Item::Fn(fn_item)) = file.items.first() {
let doc = extract_doc_comment(&fn_item.attrs);
assert_eq!(doc, None);
}
}
#[test]
fn test_extract_doc_comment_with_other_attrs() {
let code = r"
#[inline]
/// Doc comment
#[test]
fn test() {}
";
let file: syn::File = syn::parse_str(code).unwrap();
if let Some(syn::Item::Fn(fn_item)) = file.items.first() {
let doc = extract_doc_comment(&fn_item.attrs);
assert_eq!(doc, Some("Doc comment".to_string()));
}
}
#[test]
fn test_extract_doc_comment_no_leading_space() {
let code = r"
///No leading space
fn test() {}
";
let file: syn::File = syn::parse_str(code).unwrap();
if let Some(syn::Item::Fn(fn_item)) = file.items.first() {
let doc = extract_doc_comment(&fn_item.attrs);
assert_eq!(doc, Some("No leading space".to_string()));
}
}
#[test]
fn test_extract_route_info_with_tags() {
let code = r#"#[route(get, tags = ["users", "admin"])] fn test() {}"#;
let attrs = parse_attrs_from_code(code);
let result = extract_route_info(&attrs);
assert!(result.is_some());
let route_info = result.unwrap();
assert_eq!(
route_info.tags,
Some(vec!["users".to_string(), "admin".to_string()])
);
}
#[test]
fn test_extract_route_info_with_single_tag() {
let code = r#"#[route(get, tags = ["users"])] fn test() {}"#;
let attrs = parse_attrs_from_code(code);
let result = extract_route_info(&attrs);
assert!(result.is_some());
let route_info = result.unwrap();
assert_eq!(route_info.tags, Some(vec!["users".to_string()]));
}
#[test]
fn test_extract_route_info_with_empty_tags() {
let code = r"#[route(get, tags = [])] fn test() {}";
let attrs = parse_attrs_from_code(code);
let result = extract_route_info(&attrs);
assert!(result.is_some());
let route_info = result.unwrap();
assert_eq!(route_info.tags, None); }
#[test]
fn test_extract_route_info_with_description() {
let code = r#"#[route(get, description = "Get all users")] fn test() {}"#;
let attrs = parse_attrs_from_code(code);
let result = extract_route_info(&attrs);
assert!(result.is_some());
let route_info = result.unwrap();
assert_eq!(route_info.description, Some("Get all users".to_string()));
}
#[test]
fn test_extract_route_info_with_tags_and_description() {
let code = r#"#[route(get, tags = ["users"], description = "Get users")] fn test() {}"#;
let attrs = parse_attrs_from_code(code);
let result = extract_route_info(&attrs);
assert!(result.is_some());
let route_info = result.unwrap();
assert_eq!(route_info.tags, Some(vec!["users".to_string()]));
assert_eq!(route_info.description, Some("Get users".to_string()));
}
#[test]
fn test_extract_route_info_all_options() {
let code = r#"#[route(post, path = "/api/users", error_status = [400, 404], tags = ["users", "api"], description = "Create a new user")] fn test() {}"#;
let attrs = parse_attrs_from_code(code);
let result = extract_route_info(&attrs);
assert!(result.is_some());
let route_info = result.unwrap();
assert_eq!(route_info.method, "post");
assert_eq!(route_info.path, Some("/api/users".to_string()));
assert_eq!(route_info.error_status, Some(vec![400, 404]));
assert_eq!(
route_info.tags,
Some(vec!["users".to_string(), "api".to_string()])
);
assert_eq!(
route_info.description,
Some("Create a new user".to_string())
);
}
#[rstest]
#[case::json_object(r#"{"id":7}"#, serde_json::json!({"id": 7}))]
#[case::plain_text("not json", serde_json::Value::String("not json".to_string()))]
fn parse_example_string_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);
}
#[test]
fn status_and_string_extractors_ignore_wrong_expression_kinds() {
let statuses: syn::ExprArray = syn::parse_quote!([400, BAD_REQUEST, "404", 70_000]);
let strings: syn::ExprArray = syn::parse_quote!(["users", admin, 7]);
let no_strings: syn::ExprArray = syn::parse_quote!([admin, 7]);
assert_eq!(extract_status_codes(&statuses), Some(vec![400]));
assert_eq!(extract_strings(&strings), vec!["users".to_string()]);
assert_eq!(extract_non_empty_strings(&no_strings), None);
}
#[rstest]
#[case::not_a_tuple(quote::quote!(NotFound), None)]
#[case::missing_status(quote::quote!(()), None)]
#[case::non_integer_status(quote::quote!((NOT_FOUND, ErrorBody)), None)]
#[case::out_of_range_status(quote::quote!((70_000, ErrorBody)), None)]
#[case::missing_schema(quote::quote!((404,)), None)]
#[case::non_path_schema(quote::quote!((404, "ErrorBody")), None)]
#[case::qualified_schema(
quote::quote!((404, crate::errors::NotFound)),
Some((404, "NotFound".to_string()))
)]
fn typed_response_extracts_only_valid_status_and_schema_pairs(
#[case] source: proc_macro2::TokenStream,
#[case] expected: Option<(u16, String)>,
) {
let expression = syn::parse2::<syn::Expr>(source).expect("valid expression fixture");
assert_eq!(extract_typed_response(&expression), expected);
}
#[test]
fn typed_responses_drop_invalid_entries_and_report_empty_results() {
let mixed: syn::ExprArray = syn::parse_quote!([
NotATuple,
(NOT_FOUND, ErrorBody),
(404, "ErrorBody"),
(422, crate::errors::ValidationError),
]);
let invalid: syn::ExprArray = syn::parse_quote!([NotATuple, (404, "ErrorBody")]);
assert_eq!(
extract_typed_responses(&mixed),
Some(vec![(422, "ValidationError".to_string())])
);
assert_eq!(extract_typed_responses(&invalid), None);
}
}