#![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};
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 bytes = path.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'{' {
if let Some(end_rel) = bytes[i + 1..].iter().position(|b| *b == b'}') {
let inner = &path[i + 1..i + 1 + end_rel];
let name = inner.split(':').next().unwrap_or(inner).trim();
if !name.is_empty() {
out.push(name.to_owned());
}
i += 1 + end_rel + 1;
continue;
}
}
i += 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") {
if 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,
}
}
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,
}
}
}
}
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,
})
}
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 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 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);
}
}