Skip to main content

lenso_capability_http_endpoint_macros/
lib.rs

1//! Attribute authoring for statically routed Lenso HTTP Endpoint providers.
2
3use proc_macro::TokenStream;
4use quote::quote;
5use syn::{
6    Attribute, Error, ImplItem, ItemImpl, LitStr, Result, Token,
7    parse::{Parse, ParseStream},
8    parse_macro_input,
9    punctuated::Punctuated,
10    spanned::Spanned,
11};
12
13/// Generates one HTTP Endpoint provider from handler route attributes.
14///
15/// Supported handler attributes are `get`, `post`, `put`, `patch`, `delete`,
16/// `head`, and `options`. Each accepts a stable route ID and path.
17#[proc_macro_attribute]
18pub fn endpoint(arguments: TokenStream, input: TokenStream) -> TokenStream {
19    if !arguments.is_empty() {
20        return Error::new(
21            proc_macro2::Span::call_site(),
22            "endpoint does not accept arguments",
23        )
24        .into_compile_error()
25        .into();
26    }
27
28    let implementation = parse_macro_input!(input as ItemImpl);
29    expand_endpoint(implementation)
30        .unwrap_or_else(Error::into_compile_error)
31        .into()
32}
33
34fn expand_endpoint(mut implementation: ItemImpl) -> Result<proc_macro2::TokenStream> {
35    if implementation.trait_.is_some() {
36        return Err(Error::new_spanned(
37            implementation.impl_token,
38            "endpoint can only be applied to an inherent impl block",
39        ));
40    }
41    if !implementation.generics.params.is_empty() {
42        return Err(Error::new_spanned(
43            &implementation.generics,
44            "endpoint does not support generic impl blocks",
45        ));
46    }
47
48    let provider = implementation.self_ty.clone();
49    let mut routes = Vec::new();
50    for item in &mut implementation.items {
51        let ImplItem::Fn(method) = item else {
52            continue;
53        };
54        let route = take_route(&mut method.attrs)?;
55        if let Some(route) = route {
56            routes.push((route, method.sig.ident.clone()));
57        }
58    }
59    if routes.is_empty() {
60        return Err(Error::new_spanned(
61            &implementation.self_ty,
62            "endpoint impl must declare at least one HTTP handler attribute",
63        ));
64    }
65
66    let route_ids = routes.iter().map(|(route, _)| &route.id);
67    let methods = routes.iter().map(|(route, _)| &route.method);
68    let paths = routes.iter().map(|(route, _)| &route.path);
69    let handlers = routes.iter().map(|(_, handler)| handler);
70
71    Ok(quote! {
72        #implementation
73
74        ::lenso_capability_http_endpoint::http_endpoint! {
75            impl #provider {
76                #(
77                    #route_ids => (#methods, #paths) => #handlers,
78                )*
79            }
80        }
81    })
82}
83
84fn take_route(attributes: &mut Vec<Attribute>) -> Result<Option<Route>> {
85    let mut route = None;
86    let mut retained = Vec::with_capacity(attributes.len());
87    for attribute in attributes.drain(..) {
88        let Some(method) = http_method(&attribute) else {
89            retained.push(attribute);
90            continue;
91        };
92        if route.is_some() {
93            return Err(Error::new_spanned(
94                attribute,
95                "an endpoint handler may declare only one HTTP method",
96            ));
97        }
98        let arguments = attribute.parse_args::<RouteArguments>()?;
99        route = Some(Route {
100            method: LitStr::new(method, attribute.path().span()),
101            id: arguments.route_id,
102            path: arguments.path,
103        });
104    }
105    *attributes = retained;
106    Ok(route)
107}
108
109fn http_method(attribute: &Attribute) -> Option<&'static str> {
110    let path = attribute.path();
111    [
112        ("get", "GET"),
113        ("post", "POST"),
114        ("put", "PUT"),
115        ("patch", "PATCH"),
116        ("delete", "DELETE"),
117        ("head", "HEAD"),
118        ("options", "OPTIONS"),
119    ]
120    .into_iter()
121    .find_map(|(attribute, method)| path.is_ident(attribute).then_some(method))
122}
123
124struct RouteArguments {
125    route_id: LitStr,
126    path: LitStr,
127}
128
129impl Parse for RouteArguments {
130    fn parse(input: ParseStream<'_>) -> Result<Self> {
131        let arguments = Punctuated::<LitStr, Token![,]>::parse_terminated(input)?;
132        if arguments.len() != 2 {
133            return Err(Error::new(
134                input.span(),
135                "HTTP handler attributes require a route ID and path",
136            ));
137        }
138        let mut arguments = arguments.into_iter();
139        Ok(Self {
140            route_id: arguments.next().expect("length was checked"),
141            path: arguments.next().expect("length was checked"),
142        })
143    }
144}
145
146struct Route {
147    method: LitStr,
148    id: LitStr,
149    path: LitStr,
150}
151
152#[cfg(test)]
153mod tests {
154    use super::expand_endpoint;
155    use syn::parse_quote;
156
157    #[test]
158    fn expands_handler_attributes_into_the_static_route_table() {
159        let expanded = expand_endpoint(parse_quote! {
160            impl OrdersHttp {
161                #[get("orders.read", "/orders/{order_id}")]
162                async fn read(&self) {}
163            }
164        })
165        .unwrap()
166        .to_string();
167
168        assert!(expanded.contains("http_endpoint"));
169        assert!(expanded.contains("orders.read"));
170        assert!(expanded.contains("GET"));
171        assert!(expanded.contains("/orders/{order_id}"));
172        assert!(!expanded.contains("# [get"));
173    }
174
175    #[test]
176    fn rejects_handlers_with_multiple_http_methods() {
177        let error = expand_endpoint(parse_quote! {
178            impl OrdersHttp {
179                #[get("orders.read", "/orders/{order_id}")]
180                #[post("orders.read", "/orders/{order_id}")]
181                async fn read(&self) {}
182            }
183        })
184        .unwrap_err();
185
186        assert!(error.to_string().contains("only one HTTP method"));
187    }
188
189    #[test]
190    fn rejects_impls_without_handlers() {
191        let error = expand_endpoint(parse_quote! {
192            impl OrdersHttp {
193                fn helper(&self) {}
194            }
195        })
196        .unwrap_err();
197
198        assert!(error.to_string().contains("at least one HTTP handler"));
199    }
200}