ironic-macros 0.1.3

Procedural macros for the Ironic application framework
Documentation
use proc_macro2::TokenStream;
use quote::quote;
use syn::{
    Attribute, Expr, FnArg, ImplItem, ImplItemFn, ItemImpl, LitInt, LitStr, Meta, Pat, ReturnType,
    Type, parse::Parse, parse::ParseStream, parse2, spanned::Spanned,
};

use crate::controller::take_components;

const HTTP_METHODS: &[(&str, &str)] = &[
    ("get", "GET"),
    ("post", "POST"),
    ("put", "PUT"),
    ("patch", "PATCH"),
    ("delete", "DELETE"),
    ("head", "HEAD"),
    ("options", "OPTIONS"),
];

pub(crate) fn expand(attribute: TokenStream, item: TokenStream) -> syn::Result<TokenStream> {
    if !attribute.is_empty() {
        return Err(syn::Error::new_spanned(
            attribute,
            "`#[routes]` does not accept arguments",
        ));
    }
    let mut item = parse2::<ItemImpl>(item)?;
    if item.trait_.is_some() {
        return Err(syn::Error::new_spanned(
            &item.self_ty,
            "`#[routes]` requires an inherent impl",
        ));
    }
    if !item.generics.params.is_empty() {
        return Err(syn::Error::new_spanned(
            &item.generics,
            "`#[routes]` does not support generic impl blocks",
        ));
    }
    let self_ty = item.self_ty.clone();
    let mut definitions = Vec::new();

    for impl_item in &mut item.items {
        let ImplItem::Fn(method) = impl_item else {
            continue;
        };
        let Some((http_method, path)) = take_http_method(&mut method.attrs)? else {
            continue;
        };
        definitions.push(expand_method(&self_ty, method, &http_method, &path)?);
    }

    Ok(quote! {
        #item

        impl #self_ty {
            #[doc(hidden)]
            pub fn route_definitions() -> ::std::vec::Vec<::ironic::RouteDefinition> {
                ::std::vec![#(#definitions),*]
            }
        }
    })
}

fn take_http_method(attrs: &mut Vec<Attribute>) -> syn::Result<Option<(syn::Ident, LitStr)>> {
    let mut route = None;
    let mut retained = Vec::new();
    for attr in attrs.drain(..) {
        let Some(name) = attr.path().get_ident().map(ToString::to_string) else {
            retained.push(attr);
            continue;
        };
        let Some((_, constant)) = HTTP_METHODS.iter().find(|(method, _)| *method == name) else {
            retained.push(attr);
            continue;
        };
        if route.is_some() {
            return Err(syn::Error::new_spanned(
                attr,
                "a handler may declare only one HTTP method attribute",
            ));
        }
        let path = match &attr.meta {
            Meta::Path(_) => LitStr::new("/", attr.span()),
            _ => attr.parse_args::<LitStr>()?,
        };
        route = Some((syn::Ident::new(constant, attr.span()), path));
    }
    *attrs = retained;
    Ok(route)
}

fn expand_method(
    self_ty: &Type,
    method: &mut ImplItemFn,
    http_method: &syn::Ident,
    path: &LitStr,
) -> syn::Result<TokenStream> {
    if method.sig.asyncness.is_none() {
        return Err(syn::Error::new_spanned(
            method.sig.fn_token,
            "route handlers must be async",
        ));
    }
    if !method.sig.generics.params.is_empty() {
        return Err(syn::Error::new_spanned(
            &method.sig.generics,
            "route handlers cannot be generic",
        ));
    }
    if matches!(method.sig.output, ReturnType::Default) {
        return Err(syn::Error::new_spanned(
            &method.sig,
            "route handlers must return `Result<_, HttpError>`",
        ));
    }

    let Some(FnArg::Receiver(receiver)) = method.sig.inputs.first() else {
        return Err(syn::Error::new_spanned(
            &method.sig,
            "route handlers require an `&self` receiver",
        ));
    };
    if receiver.reference.is_none() || receiver.mutability.is_some() {
        return Err(syn::Error::new_spanned(
            receiver,
            "route handlers require an immutable `&self` receiver",
        ));
    }

    let guards = take_components(&mut method.attrs, "use_guard")?;
    let interceptors = take_components(&mut method.attrs, "use_interceptor")?;
    let cache_ttl = take_cache_ttl(&mut method.attrs)?;
    let mut extractors = Vec::new();
    let mut bindings = Vec::new();
    let mut arguments = Vec::new();
    let mut parameter_pipes: Vec<Vec<TokenStream>> = Vec::new();

    for (index, argument) in method.sig.inputs.iter_mut().skip(1).enumerate() {
        let FnArg::Typed(argument) = argument else {
            unreachable!()
        };
        let Pat::Ident(pattern) = argument.pat.as_ref() else {
            return Err(syn::Error::new_spanned(
                &argument.pat,
                "route parameter patterns must be identifiers",
            ));
        };
        let argument_name = &pattern.ident;
        let argument_type = &argument.ty;
        let (extractor, pipes) = take_extractor(&mut argument.attrs, argument_name, argument_type)?;
        extractors.push(extractor);
        parameter_pipes.push(pipes);
        bindings.push(quote!(let #argument_name = arguments.take::<#argument_type>(#index)?;));
        arguments.push(argument_name);
    }

    let method_name = &method.sig.ident;
    let cache_call = cache_ttl.map(|ttl| {
        quote! { .cache(::ironic::CacheMetadata::new(#ttl)) }
    });
    let parameter_calls: Vec<TokenStream> = extractors
        .into_iter()
        .zip(parameter_pipes)
        .map(|(extractor, pipes)| {
            if pipes.is_empty() {
                quote! { .parameter(#extractor) }
            } else {
                quote! { .parameter_with_pipes(#extractor, [#(#pipes),*]) }
            }
        })
        .collect();

    Ok(quote! {
        ::ironic::RouteDefinition::new(
            ::ironic::HttpMethod::#http_method,
            #path,
            ::std::stringify!(#method_name),
            ::ironic::handler_fn(
                |controller: ::std::sync::Arc<#self_ty>, mut arguments| async move {
                    #(#bindings)*
                    controller.#method_name(#(#arguments),*).await
                },
            ),
        )
        .expect("the macro-validated route path is valid")
        #(#parameter_calls)*
        #(.guard(#guards))*
        #(.interceptor(#interceptors))*
        #cache_call
    })
}

fn take_extractor(
    attrs: &mut Vec<Attribute>,
    argument_name: &syn::Ident,
    argument_type: &Type,
) -> syn::Result<(TokenStream, Vec<TokenStream>)> {
    let mut extractor = None;
    let mut pipes = Vec::new();
    let mut retained = Vec::new();
    for attr in attrs.drain(..) {
        let Some(name) = attr.path().get_ident().map(ToString::to_string) else {
            retained.push(attr);
            continue;
        };
        match name.as_str() {
            "body" => {
                let value = quote!(::ironic::JsonBody::<#argument_type>::new());
                if extractor.replace(value).is_some() {
                    return Err(syn::Error::new_spanned(
                        argument_name,
                        "a route parameter must have exactly one extractor attribute",
                    ));
                }
            }
            "query" => {
                let value = quote!(::ironic::QueryParameters::<#argument_type>::new());
                if extractor.replace(value).is_some() {
                    return Err(syn::Error::new_spanned(
                        argument_name,
                        "a route parameter must have exactly one extractor attribute",
                    ));
                }
            }
            "param" => {
                let name = optional_name(&attr, argument_name)?;
                let value = quote!(::ironic::PathParameter::<#argument_type>::new(#name));
                if extractor.replace(value).is_some() {
                    return Err(syn::Error::new_spanned(
                        argument_name,
                        "a route parameter must have exactly one extractor attribute",
                    ));
                }
            }
            "header" => {
                let name = optional_name(&attr, argument_name)?;
                let value = quote!(::ironic::HeaderParameter::<#argument_type>::new(#name));
                if extractor.replace(value).is_some() {
                    return Err(syn::Error::new_spanned(
                        argument_name,
                        "a route parameter must have exactly one extractor attribute",
                    ));
                }
            }
            "custom" => {
                let extractor_type: Type = attr.parse_args()?;
                let value = quote!(#extractor_type::new());
                if extractor.replace(value).is_some() {
                    return Err(syn::Error::new_spanned(
                        argument_name,
                        "a route parameter must have exactly one extractor attribute",
                    ));
                }
            }
            "pipe" => {
                let pipe_fn: Expr = attr.parse_args()?;
                pipes.push(quote!(#pipe_fn()));
            }
            _ => {
                retained.push(attr);
            }
        }
    }
    *attrs = retained;
    let extractor = extractor.ok_or_else(|| {
        syn::Error::new_spanned(
            argument_name,
            "route parameters require one of `#[body]`, `#[query]`, `#[param]`, `#[header]`, or `#[custom(ExtractorType)]`",
        )
    })?;
    Ok((extractor, pipes))
}

fn optional_name(attr: &Attribute, argument_name: &syn::Ident) -> syn::Result<LitStr> {
    match &attr.meta {
        Meta::Path(_) => Ok(LitStr::new(
            &argument_name.to_string(),
            argument_name.span(),
        )),
        _ => attr.parse_args::<LitStr>(),
    }
}

struct CacheArgs {
    ttl_secs: u64,
}

impl Parse for CacheArgs {
    fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
        let key: syn::Ident = input.parse()?;
        if key != "ttl_secs" {
            return Err(syn::Error::new(key.span(), "expected `ttl_secs`"));
        }
        input.parse::<syn::Token![=]>()?;
        let value: LitInt = input.parse()?;
        Ok(CacheArgs {
            ttl_secs: value.base10_parse()?,
        })
    }
}

fn take_cache_ttl(attrs: &mut Vec<Attribute>) -> syn::Result<Option<u64>> {
    let mut ttl = None;
    let mut retained = Vec::new();
    for attr in attrs.drain(..) {
        if attr.path().is_ident("cache") {
            let args: CacheArgs = attr.parse_args()?;
            ttl = Some(args.ttl_secs);
        } else {
            retained.push(attr);
        }
    }
    *attrs = retained;
    Ok(ttl)
}