#![allow(
clippy::doc_markdown,
clippy::option_if_let_else,
clippy::single_match_else,
clippy::use_self
)]
use proc_macro2::{Span, TokenStream};
use quote::quote;
use syn::parse::{Parse, ParseStream, Parser as _};
use syn::{Attribute, Expr, ExprLit, Ident, Lit, LitBool, LitStr, Token};
#[derive(Default)]
pub struct ApiDocAttr {
pub summary: Option<LitStr>,
pub description: Option<LitStr>,
pub tags: Vec<LitStr>,
pub operation_id: Option<LitStr>,
pub status: Option<u16>,
pub hidden: bool,
}
enum KeyValue {
Summary(LitStr),
Description(LitStr),
Tag(LitStr),
Tags(Vec<LitStr>),
OperationId(LitStr),
Status(u16),
Hidden,
}
impl Parse for KeyValue {
fn parse(input: ParseStream) -> syn::Result<Self> {
let key: Ident = input.parse()?;
let key_str = key.to_string();
if key_str == "hidden" {
if input.peek(Token![=]) {
let _eq: Token![=] = input.parse()?;
let value: LitBool = input.parse()?;
return Ok(if value.value {
KeyValue::Hidden
} else {
KeyValue::Tags(Vec::new())
});
}
return Ok(KeyValue::Hidden);
}
let _eq: Token![=] = input.parse()?;
match key_str.as_str() {
"summary" => Ok(KeyValue::Summary(input.parse()?)),
"description" => Ok(KeyValue::Description(input.parse()?)),
"tag" => Ok(KeyValue::Tag(input.parse()?)),
"tags" => {
let content;
syn::bracketed!(content in input);
let items =
syn::punctuated::Punctuated::<LitStr, Token![,]>::parse_terminated(&content)?;
Ok(KeyValue::Tags(items.into_iter().collect()))
}
"operation_id" => Ok(KeyValue::OperationId(input.parse()?)),
"status" => {
let value: Expr = input.parse()?;
let n = expect_u16(&value)?;
Ok(KeyValue::Status(n))
}
other => Err(syn::Error::new(
key.span(),
format!(
"unknown key `{other}` in `#[api_doc(...)]`. \
Supported keys: summary, description, tag, tags, operation_id, status, hidden."
),
)),
}
}
}
fn expect_u16(expr: &Expr) -> syn::Result<u16> {
if let Expr::Lit(ExprLit {
lit: Lit::Int(int), ..
}) = expr
{
int.base10_parse::<u16>()
} else {
Err(syn::Error::new_spanned(
expr,
"expected an integer HTTP status code (e.g. `status = 201`)",
))
}
}
impl ApiDocAttr {
fn merge(&mut self, kv: KeyValue) {
match kv {
KeyValue::Summary(v) => self.summary = Some(v),
KeyValue::Description(v) => self.description = Some(v),
KeyValue::Tag(v) => self.tags = vec![v],
KeyValue::Tags(v) if !v.is_empty() => self.tags = v,
KeyValue::Tags(_) => {}
KeyValue::OperationId(v) => self.operation_id = Some(v),
KeyValue::Status(n) => self.status = Some(n),
KeyValue::Hidden => self.hidden = true,
}
}
}
impl Parse for ApiDocAttr {
fn parse(input: ParseStream) -> syn::Result<Self> {
let items = syn::punctuated::Punctuated::<KeyValue, Token![,]>::parse_terminated(input)?;
let mut out = ApiDocAttr::default();
for kv in items {
out.merge(kv);
}
Ok(out)
}
}
pub fn extract(attrs: &mut Vec<Attribute>) -> Result<ApiDocAttr, TokenStream> {
let mut collected = ApiDocAttr::default();
let mut error: Option<TokenStream> = None;
attrs.retain(|attr| {
if !attr.path().is_ident("api_doc") {
return true;
}
let parsed: syn::Result<ApiDocAttr> = match &attr.meta {
syn::Meta::Path(_) => Ok(ApiDocAttr::default()),
syn::Meta::List(list) => syn::parse2(list.tokens.clone()),
syn::Meta::NameValue(nv) => Err(syn::Error::new_spanned(
nv,
"expected `#[api_doc(...)]`, not `#[api_doc = ...]`",
)),
};
match parsed {
Ok(parsed) => {
collected.absorb(parsed);
}
Err(err) => {
if error.is_none() {
error = Some(err.to_compile_error());
}
}
}
false
});
if let Some(err) = error {
return Err(err);
}
Ok(collected)
}
impl ApiDocAttr {
fn absorb(&mut self, other: ApiDocAttr) {
if other.summary.is_some() {
self.summary = other.summary;
}
if other.description.is_some() {
self.description = other.description;
}
if !other.tags.is_empty() {
self.tags = other.tags;
}
if other.operation_id.is_some() {
self.operation_id = other.operation_id;
}
if other.status.is_some() {
self.status = other.status;
}
if other.hidden {
self.hidden = true;
}
}
pub fn emit_ident_fields(&self, default_operation_id: &Ident) -> TokenStream {
let summary = option_str(self.summary.as_ref());
let description = option_str(self.description.as_ref());
let tags = slice_str(&self.tags);
let op_id = if let Some(id) = &self.operation_id {
quote! { #id }
} else {
quote! { ::core::stringify!(#default_operation_id) }
};
let status = self.status.unwrap_or(200);
let hidden = self.hidden;
quote! {
operation_id: #op_id,
summary: #summary,
description: #description,
tags: #tags,
success_status: #status,
hidden: #hidden,
}
}
}
fn option_str(lit: Option<&LitStr>) -> TokenStream {
match lit {
Some(v) => quote! { ::core::option::Option::Some(#v) },
None => quote! { ::core::option::Option::None },
}
}
fn slice_str(items: &[LitStr]) -> TokenStream {
if items.is_empty() {
quote! { &[] }
} else {
let literals: Vec<_> = items.iter().map(|s| quote! { #s }).collect();
quote! { &[#(#literals),*] }
}
}
pub fn extract_path_params(path: &str) -> Vec<String> {
let mut out = Vec::new();
let mut remaining = path;
while let Some(start) = remaining.find('{') {
let after_brace = &remaining[start + 1..];
if let Some(rest) = after_brace.strip_prefix('{') {
remaining = rest;
continue;
}
let Some(end_rel) = after_brace.find('}') else {
break;
};
let inner = &after_brace[..end_rel];
let name = inner.split(':').next().unwrap_or(inner).trim();
if !name.is_empty() {
out.push(name.to_owned());
}
remaining = &after_brace[end_rel + 1..];
}
out
}
pub fn emit_path_param_slice(params: &[String]) -> TokenStream {
if params.is_empty() {
quote! { &[] }
} else {
let literals: Vec<_> = params
.iter()
.map(|p| LitStr::new(p, Span::call_site()))
.collect();
quote! { &[#(#literals),*] }
}
}
pub fn infer_request_body(input_fn: &syn::ItemFn) -> Option<TokenStream> {
for arg in &input_fn.sig.inputs {
let syn::FnArg::Typed(pat) = arg else {
continue;
};
if let Some(inner) = unwrap_json_body(&pat.ty) {
return Some(schema_entry_for_type(&inner));
}
}
None
}
fn unwrap_json_body(ty: &syn::Type) -> Option<syn::Type> {
if let Some(inner) = unwrap_single_generic(ty, "Json") {
return Some(inner);
}
if let Some(inner) = unwrap_single_generic(ty, "Valid")
&& let Some(payload) = unwrap_single_generic(&inner, "Json")
{
return Some(payload);
}
None
}
pub fn infer_response_body(input_fn: &syn::ItemFn) -> Option<TokenStream> {
let syn::ReturnType::Type(_, ty) = &input_fn.sig.output else {
return None;
};
let ty = unwrap_result_ok(ty).unwrap_or_else(|| (**ty).clone());
find_json_in_type(&ty).map(|inner| schema_entry_for_type(&inner))
}
fn find_json_in_type(ty: &syn::Type) -> Option<syn::Type> {
if let Some(inner) = unwrap_single_generic(ty, "Json") {
return Some(inner);
}
if let syn::Type::Tuple(tup) = ty {
for elem in &tup.elems {
if let Some(inner) = unwrap_single_generic(elem, "Json") {
return Some(inner);
}
}
}
None
}
fn unwrap_result_ok(ty: &syn::Type) -> Option<syn::Type> {
let path = match ty {
syn::Type::Path(p) => &p.path,
_ => return None,
};
let last = path.segments.last()?;
let name = last.ident.to_string();
let syn::PathArguments::AngleBracketed(args) = &last.arguments else {
return None;
};
match name.as_str() {
"Result" => args.args.iter().find_map(|arg| match arg {
syn::GenericArgument::Type(t) => Some(t.clone()),
_ => None,
}),
"AutumnResult" => args.args.iter().find_map(|arg| match arg {
syn::GenericArgument::Type(t) => Some(t.clone()),
_ => None,
}),
_ => None,
}
}
pub fn unwrap_single_generic(ty: &syn::Type, wrapper: &str) -> Option<syn::Type> {
let syn::Type::Path(path) = ty else {
return None;
};
let last = path.path.segments.last()?;
if last.ident != wrapper {
return None;
}
let syn::PathArguments::AngleBracketed(args) = &last.arguments else {
return None;
};
args.args.iter().find_map(|arg| match arg {
syn::GenericArgument::Type(t) => Some(t.clone()),
_ => None,
})
}
fn schema_entry_for_type(ty: &syn::Type) -> TokenStream {
if let Some(inner) = unwrap_single_generic(ty, "Vec") {
let inner_tokens = schema_entry_for_type(&inner);
return quote! {
::autumn_web::openapi::SchemaEntry {
name: "array",
kind: ::autumn_web::openapi::SchemaKind::Array(&#inner_tokens),
}
};
}
if let Some(inner) = unwrap_single_generic(ty, "Option") {
let inner_tokens = schema_entry_for_type(&inner);
return quote! {
::autumn_web::openapi::SchemaEntry {
name: "nullable",
kind: ::autumn_web::openapi::SchemaKind::Nullable(&#inner_tokens),
}
};
}
let name = last_segment_name(ty).unwrap_or_else(|| "Schema".to_owned());
let name_lit = LitStr::new(&name, Span::call_site());
if let Some(json_type) = primitive_json_type(&name) {
let json_lit = LitStr::new(json_type, Span::call_site());
quote! {
::autumn_web::openapi::SchemaEntry {
name: #name_lit,
kind: ::autumn_web::openapi::SchemaKind::Primitive(#json_lit),
}
}
} else {
quote! {
::autumn_web::openapi::SchemaEntry {
name: #name_lit,
kind: ::autumn_web::openapi::SchemaKind::Ref,
}
}
}
}
pub fn primitive_json_type(name: &str) -> Option<&'static str> {
Some(match name {
"String" | "str" => "string",
"bool" => "boolean",
"i8" | "i16" | "i32" | "i64" | "u8" | "u16" | "u32" | "u64" | "isize" | "usize" => {
"integer"
}
"f32" | "f64" => "number",
_ => return None,
})
}
pub fn last_segment_name(ty: &syn::Type) -> Option<String> {
match ty {
syn::Type::Path(p) => p.path.segments.last().map(|s| s.ident.to_string()),
syn::Type::Reference(r) => last_segment_name(&r.elem),
_ => None,
}
}
pub fn infer_query_params(input_fn: &syn::ItemFn) -> Option<TokenStream> {
for arg in &input_fn.sig.inputs {
let syn::FnArg::Typed(pat) = arg else {
continue;
};
if let Some(inner) = unwrap_single_generic(&pat.ty, "Query") {
return Some(schema_entry_for_type(&inner));
}
}
None
}
pub fn extract_secured_info(input_fn: &syn::ItemFn) -> (bool, TokenStream) {
for attr in &input_fn.attrs {
if attr.path().is_ident("secured")
|| attr
.path()
.segments
.last()
.is_some_and(|s| s.ident == "secured")
{
let roles = extract_secured_roles(attr);
let roles_tokens = emit_static_str_slice(&roles);
return (true, roles_tokens);
}
}
if let Some(roles) = extract_secured_roles_marker(input_fn) {
let roles_tokens = emit_static_str_slice(&roles);
return (true, roles_tokens);
}
let has_session = input_fn.sig.inputs.iter().any(|param| {
if let syn::FnArg::Typed(pt) = param
&& let syn::Pat::Ident(pi) = pt.pat.as_ref()
{
return pi.ident == "__autumn_session";
}
false
});
if has_session {
return (true, quote! { &[] });
}
(false, quote! { &[] })
}
fn extract_secured_roles_marker(input_fn: &syn::ItemFn) -> Option<Vec<String>> {
extract_secured_roles_marker_from_stmts(&input_fn.block.stmts)
}
fn extract_secured_roles_marker_from_stmts(stmts: &[syn::Stmt]) -> Option<Vec<String>> {
stmts
.iter()
.find_map(extract_secured_roles_marker_from_stmt)
}
fn extract_secured_roles_marker_from_stmt(stmt: &syn::Stmt) -> Option<Vec<String>> {
match stmt {
syn::Stmt::Item(syn::Item::Const(item_const))
if item_const.ident == "__AUTUMN_SECURED_ROLES" =>
{
extract_roles_from_marker_expr(&item_const.expr)
}
syn::Stmt::Expr(expr, _) => extract_secured_roles_marker_from_expr(expr),
syn::Stmt::Local(local) => local
.init
.as_ref()
.and_then(|init| extract_secured_roles_marker_from_expr(&init.expr)),
_ => None,
}
}
fn extract_secured_roles_marker_from_expr(expr: &syn::Expr) -> Option<Vec<String>> {
match expr {
syn::Expr::Block(block) => extract_secured_roles_marker_from_stmts(&block.block.stmts),
syn::Expr::Async(block) => extract_secured_roles_marker_from_stmts(&block.block.stmts),
syn::Expr::Unsafe(block) => extract_secured_roles_marker_from_stmts(&block.block.stmts),
_ => None,
}
}
fn extract_roles_from_marker_expr(expr: &syn::Expr) -> Option<Vec<String>> {
let syn::Expr::Reference(reference) = expr else {
return None;
};
let syn::Expr::Array(array) = reference.expr.as_ref() else {
return None;
};
let mut roles = Vec::with_capacity(array.elems.len());
for elem in &array.elems {
let syn::Expr::Lit(lit) = elem else {
return None;
};
let syn::Lit::Str(role) = &lit.lit else {
return None;
};
roles.push(role.value());
}
Some(roles)
}
fn extract_secured_roles(attr: &syn::Attribute) -> Vec<String> {
let syn::Meta::List(list) = &attr.meta else {
return Vec::new();
};
let roles: syn::Result<syn::punctuated::Punctuated<syn::LitStr, syn::Token![,]>> =
syn::punctuated::Punctuated::<syn::LitStr, syn::Token![,]>::parse_terminated
.parse2(list.tokens.clone());
match roles {
Ok(r) => r.iter().map(syn::LitStr::value).collect(),
Err(_) => Vec::new(),
}
}
fn emit_static_str_slice(items: &[String]) -> TokenStream {
if items.is_empty() {
quote! { &[] }
} else {
let lits: Vec<_> = items
.iter()
.map(|s| LitStr::new(s, Span::call_site()))
.collect();
quote! { &[#(#lits),*] }
}
}
pub fn schema_option(expr: Option<TokenStream>) -> TokenStream {
match expr {
Some(e) => quote! { ::core::option::Option::Some(#e) },
None => quote! { ::core::option::Option::None },
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_path_params_handles_single() {
assert_eq!(extract_path_params("/users/{id}"), vec!["id".to_owned()]);
}
#[test]
fn extract_path_params_handles_multiple() {
assert_eq!(
extract_path_params("/posts/{year}/{slug}"),
vec!["year".to_owned(), "slug".to_owned()]
);
}
#[test]
fn extract_path_params_handles_regex_prefix() {
assert_eq!(
extract_path_params("/users/{id:[0-9]+}"),
vec!["id".to_owned()]
);
}
#[test]
fn extract_path_params_returns_empty_for_static() {
assert!(extract_path_params("/hello").is_empty());
assert!(extract_path_params("/").is_empty());
}
#[test]
fn extract_path_params_ignores_unclosed_braces() {
assert!(extract_path_params("/oops/{broken").is_empty());
}
#[test]
fn extract_path_params_skips_escaped_braces() {
assert!(extract_path_params("/{{hello}}").is_empty());
assert_eq!(
extract_path_params("/{{literal}}/{id}"),
vec!["id".to_owned()]
);
}
#[test]
fn primitive_json_type_matches_common() {
assert_eq!(primitive_json_type("String"), Some("string"));
assert_eq!(primitive_json_type("i64"), Some("integer"));
assert_eq!(primitive_json_type("bool"), Some("boolean"));
assert_eq!(primitive_json_type("Foo"), None);
}
#[test]
fn secured_roles_marker_extracts_roles() {
let input_fn: syn::ItemFn = syn::parse_quote! {
async fn handler() {
const __AUTUMN_SECURED_ROLES: &[&str] = &["admin", "editor"];
}
};
assert_eq!(
extract_secured_roles_marker(&input_fn),
Some(vec!["admin".to_owned(), "editor".to_owned()])
);
}
#[test]
fn secured_roles_marker_extracts_empty_roles() {
let input_fn: syn::ItemFn = syn::parse_quote! {
async fn handler() {
const __AUTUMN_SECURED_ROLES: &[&str] = &[];
}
};
assert_eq!(extract_secured_roles_marker(&input_fn), Some(Vec::new()));
}
#[test]
fn secured_roles_marker_extracts_nested_roles() {
let input_fn: syn::ItemFn = syn::parse_quote! {
async fn handler() {
{
const __AUTUMN_SECURED_ROLES: &[&str] = &["admin"];
}
}
};
assert_eq!(
extract_secured_roles_marker(&input_fn),
Some(vec!["admin".to_owned()])
);
}
}