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, FnArg, Ident, ImplItem, ItemImpl, LitStr, Result, Token, Type,
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. One or more
17/// `middleware` attributes may name async provider methods that run before the
18/// handler and its typed request extractors. Middleware on the impl applies to
19/// every route before route-specific middleware.
20#[proc_macro_attribute]
21pub fn endpoint(arguments: TokenStream, input: TokenStream) -> TokenStream {
22    if !arguments.is_empty() {
23        return Error::new(
24            proc_macro2::Span::call_site(),
25            "endpoint does not accept arguments",
26        )
27        .into_compile_error()
28        .into();
29    }
30
31    let implementation = parse_macro_input!(input as ItemImpl);
32    expand_endpoint(implementation)
33        .unwrap_or_else(Error::into_compile_error)
34        .into()
35}
36
37fn expand_endpoint(mut implementation: ItemImpl) -> Result<proc_macro2::TokenStream> {
38    if implementation.trait_.is_some() {
39        return Err(Error::new_spanned(
40            implementation.impl_token,
41            "endpoint can only be applied to an inherent impl block",
42        ));
43    }
44    if !implementation.generics.params.is_empty() {
45        return Err(Error::new_spanned(
46            &implementation.generics,
47            "endpoint does not support generic impl blocks",
48        ));
49    }
50
51    let provider = implementation.self_ty.clone();
52    let provider_middlewares = take_provider_middlewares(&mut implementation.attrs)?;
53    let mut routes = Vec::new();
54    for item in &mut implementation.items {
55        let ImplItem::Fn(method) = item else {
56            continue;
57        };
58        let metadata = take_handler_metadata(&mut method.attrs)?;
59        if let Some(route) = metadata.route {
60            routes.push(Handler {
61                route,
62                middlewares: provider_middlewares
63                    .iter()
64                    .cloned()
65                    .chain(metadata.middlewares)
66                    .collect(),
67                method: method.sig.ident.clone(),
68                arguments: handler_arguments(method)?,
69            });
70        } else if !metadata.middlewares.is_empty() {
71            return Err(Error::new_spanned(
72                &method.sig.ident,
73                "endpoint middleware can only be attached to an HTTP handler",
74            ));
75        }
76    }
77    if routes.is_empty() {
78        return Err(Error::new_spanned(
79            &implementation.self_ty,
80            "endpoint impl must declare at least one HTTP handler attribute",
81        ));
82    }
83
84    let const_routes = routes.iter().map(|handler| {
85        let route_id = &handler.route.id;
86        let method = &handler.route.method;
87        let path = &handler.route.path;
88        quote! {
89            ::lenso_capability_http_endpoint::EndpointRoute::new(
90                #route_id,
91                #method,
92                #path,
93            ),
94        }
95    });
96    let implementation_routes = routes.iter().map(|handler| {
97        let route_id = &handler.route.id;
98        let method = &handler.route.method;
99        let path = &handler.route.path;
100        quote! {
101            ::lenso_capability_http_endpoint::EndpointRoute::new(
102                #route_id,
103                #method,
104                #path,
105            ),
106        }
107    });
108    let dispatch_arms = routes.iter().map(dispatch_arm);
109
110    Ok(quote! {
111        #implementation
112
113        const _: () = {
114            const ROUTES: &[::lenso_capability_http_endpoint::EndpointRoute] = &[
115                #(#const_routes)*
116            ];
117            ::lenso_capability_http_endpoint::__private::validate_endpoint_routes(ROUTES);
118        };
119
120        impl ::lenso_capability_http_endpoint::HttpEndpoint for #provider {
121            const ROUTES: &'static [::lenso_capability_http_endpoint::EndpointRoute] = &[
122                #(#implementation_routes)*
123            ];
124
125            fn dispatch(
126                &self,
127                context: ::lenso_capability_http_endpoint::__private::InvocationContext,
128                request: ::lenso_capability_http_endpoint::HandleRequest,
129            ) -> ::lenso_capability_http_endpoint::EndpointFuture {
130                let provider = self.clone();
131                Box::pin(async move {
132                    let route_id = request.route_id.clone();
133                    match route_id.as_str() {
134                        #(#dispatch_arms,)*
135                        _ => Ok(Err(::lenso_capability_http_endpoint::HandleError::Rejected)),
136                    }
137                })
138            }
139        }
140    })
141}
142
143fn take_provider_middlewares(attributes: &mut Vec<Attribute>) -> Result<Vec<Ident>> {
144    let mut middlewares = Vec::new();
145    let mut retained = Vec::with_capacity(attributes.len());
146    for attribute in attributes.drain(..) {
147        if attribute.path().is_ident("middleware") {
148            let arguments =
149                attribute.parse_args_with(Punctuated::<Ident, Token![,]>::parse_terminated)?;
150            if arguments.is_empty() {
151                return Err(Error::new_spanned(
152                    attribute,
153                    "middleware requires at least one provider method",
154                ));
155            }
156            middlewares.extend(arguments);
157        } else {
158            retained.push(attribute);
159        }
160    }
161    *attributes = retained;
162    Ok(middlewares)
163}
164
165fn take_handler_metadata(attributes: &mut Vec<Attribute>) -> Result<HandlerMetadata> {
166    let mut route = None;
167    let mut middlewares = Vec::new();
168    let mut retained = Vec::with_capacity(attributes.len());
169    for attribute in attributes.drain(..) {
170        if attribute.path().is_ident("middleware") {
171            let arguments =
172                attribute.parse_args_with(Punctuated::<Ident, Token![,]>::parse_terminated)?;
173            if arguments.is_empty() {
174                return Err(Error::new_spanned(
175                    attribute,
176                    "middleware requires at least one provider method",
177                ));
178            }
179            middlewares.extend(arguments);
180            continue;
181        }
182        let Some(http_method) = http_method(&attribute) else {
183            retained.push(attribute);
184            continue;
185        };
186        if route.is_some() {
187            return Err(Error::new_spanned(
188                attribute,
189                "an endpoint handler may declare only one HTTP method",
190            ));
191        }
192        let arguments = attribute.parse_args::<RouteArguments>()?;
193        route = Some(Route {
194            method: LitStr::new(http_method, attribute.path().span()),
195            id: arguments.route_id,
196            path: arguments.path,
197        });
198    }
199    *attributes = retained;
200    Ok(HandlerMetadata { route, middlewares })
201}
202
203fn handler_arguments(method: &syn::ImplItemFn) -> Result<Vec<HandlerArgument>> {
204    let mut arguments = Vec::new();
205    let mut context_count = 0;
206    let mut request_count = 0;
207    for (index, input) in method.sig.inputs.iter().enumerate() {
208        let FnArg::Typed(argument) = input else {
209            continue;
210        };
211        let ty = (*argument.ty).clone();
212        let kind = match final_type_ident(&ty).map(Ident::to_string).as_deref() {
213            Some("InvocationContext") => {
214                context_count += 1;
215                ArgumentKind::Context
216            }
217            Some("HandleRequest") => {
218                request_count += 1;
219                ArgumentKind::Request
220            }
221            _ => ArgumentKind::Extractor(Ident::new(
222                &format!("__lenso_extracted_{index}"),
223                argument.span(),
224            )),
225        };
226        arguments.push(HandlerArgument { ty, kind });
227    }
228    if context_count > 1 || request_count > 1 {
229        return Err(Error::new_spanned(
230            &method.sig.inputs,
231            "an endpoint handler accepts at most one InvocationContext and one HandleRequest",
232        ));
233    }
234    Ok(arguments)
235}
236
237fn final_type_ident(ty: &Type) -> Option<&Ident> {
238    let Type::Path(path) = ty else {
239        return None;
240    };
241    path.path.segments.last().map(|segment| &segment.ident)
242}
243
244fn dispatch_arm(handler: &Handler) -> proc_macro2::TokenStream {
245    let route_id = &handler.route.id;
246    let method = &handler.method;
247    let middleware_steps = handler.middlewares.iter().map(|middleware| {
248        quote! {
249            let (context, request) = match provider.#middleware(context, request).await {
250                Ok(outcome) => match outcome.into_result() {
251                    Ok(next) => next,
252                    Err(response) => return Ok(Ok(response)),
253                },
254                Err(::lenso_capability_http_endpoint::EndpointHandleInvocationError::Domain(
255                    error,
256                )) => return Ok(Err(error)),
257                Err(::lenso_capability_http_endpoint::EndpointHandleInvocationError::Runtime(
258                    error,
259                )) => return Err(error),
260            };
261        }
262    });
263    let extractor_steps = handler.arguments.iter().filter_map(|argument| {
264        let ArgumentKind::Extractor(binding) = &argument.kind else {
265            return None;
266        };
267        let ty = &argument.ty;
268        Some(quote! {
269            let #binding: #ty = match <#ty as
270                ::lenso_capability_http_endpoint::__private::FromRequest<Self>
271            >::from_request(&provider, &mut context, &request).await {
272                Ok(value) => value,
273                Err(::lenso_capability_http_endpoint::__private::ExtractorRejection::Response(
274                    response,
275                )) => return Ok(Ok(response)),
276                Err(::lenso_capability_http_endpoint::__private::ExtractorRejection::Invocation(
277                    ::lenso_capability_http_endpoint::EndpointHandleInvocationError::Domain(
278                        error,
279                    ),
280                )) => return Ok(Err(error)),
281                Err(::lenso_capability_http_endpoint::__private::ExtractorRejection::Invocation(
282                    ::lenso_capability_http_endpoint::EndpointHandleInvocationError::Runtime(
283                        error,
284                    ),
285                )) => return Err(error),
286            };
287        })
288    });
289    let mutable_context = handler
290        .arguments
291        .iter()
292        .any(|argument| matches!(&argument.kind, ArgumentKind::Extractor(_)))
293        .then(|| quote!(let mut context = context;));
294    let arguments = handler
295        .arguments
296        .iter()
297        .map(|argument| match &argument.kind {
298            ArgumentKind::Context => quote!(context),
299            ArgumentKind::Request => quote!(request),
300            ArgumentKind::Extractor(binding) => quote!(#binding),
301        });
302
303    quote! {
304        #route_id => {
305            #(#middleware_steps)*
306            #mutable_context
307            #(#extractor_steps)*
308            let _ = (&context, &request);
309            match provider.#method(#(#arguments),*).await {
310                Ok(response) => Ok(Ok(response)),
311                Err(::lenso_capability_http_endpoint::EndpointHandleInvocationError::Domain(
312                    error,
313                )) => Ok(Err(error)),
314                Err(::lenso_capability_http_endpoint::EndpointHandleInvocationError::Runtime(
315                    error,
316                )) => Err(error),
317            }
318        }
319    }
320}
321
322fn http_method(attribute: &Attribute) -> Option<&'static str> {
323    let path = attribute.path();
324    [
325        ("get", "GET"),
326        ("post", "POST"),
327        ("put", "PUT"),
328        ("patch", "PATCH"),
329        ("delete", "DELETE"),
330        ("head", "HEAD"),
331        ("options", "OPTIONS"),
332    ]
333    .into_iter()
334    .find_map(|(attribute, method)| path.is_ident(attribute).then_some(method))
335}
336
337struct RouteArguments {
338    route_id: LitStr,
339    path: LitStr,
340}
341
342impl Parse for RouteArguments {
343    fn parse(input: ParseStream<'_>) -> Result<Self> {
344        let arguments = Punctuated::<LitStr, Token![,]>::parse_terminated(input)?;
345        if arguments.len() != 2 {
346            return Err(Error::new(
347                input.span(),
348                "HTTP handler attributes require a route ID and path",
349            ));
350        }
351        let mut arguments = arguments.into_iter();
352        Ok(Self {
353            route_id: arguments.next().expect("length was checked"),
354            path: arguments.next().expect("length was checked"),
355        })
356    }
357}
358
359struct Route {
360    method: LitStr,
361    id: LitStr,
362    path: LitStr,
363}
364
365struct HandlerMetadata {
366    route: Option<Route>,
367    middlewares: Vec<Ident>,
368}
369
370struct Handler {
371    route: Route,
372    middlewares: Vec<Ident>,
373    method: Ident,
374    arguments: Vec<HandlerArgument>,
375}
376
377struct HandlerArgument {
378    ty: Type,
379    kind: ArgumentKind,
380}
381
382enum ArgumentKind {
383    Context,
384    Request,
385    Extractor(Ident),
386}
387
388#[cfg(test)]
389mod tests {
390    use super::expand_endpoint;
391    use syn::parse_quote;
392
393    #[test]
394    fn expands_handler_attributes_into_the_static_route_table() {
395        let expanded = expand_endpoint(parse_quote! {
396            impl OrdersHttp {
397                #[get("orders.read", "/orders/{order_id}")]
398                async fn read(&self) {}
399            }
400        })
401        .unwrap()
402        .to_string();
403
404        assert!(expanded.contains("HttpEndpoint"));
405        assert!(expanded.contains("orders.read"));
406        assert!(expanded.contains("GET"));
407        assert!(expanded.contains("/orders/{order_id}"));
408        assert!(!expanded.contains("# [get"));
409    }
410
411    #[test]
412    fn expands_middleware_and_typed_extractors_before_the_handler() {
413        let expanded = expand_endpoint(parse_quote! {
414            impl OrdersHttp {
415                #[middleware(authenticate)]
416                #[get("orders.read", "/orders/{order_id}")]
417                async fn read(
418                    &self,
419                    context: InvocationContext,
420                    Path(path): Path<OrderPath>,
421                ) {}
422            }
423        })
424        .unwrap()
425        .to_string();
426
427        assert!(expanded.contains("provider . authenticate"));
428        assert!(expanded.contains("into_result"));
429        assert!(expanded.contains("FromRequest"));
430        assert!(expanded.contains("provider . read (context , __lenso_extracted_2)"));
431        assert!(!expanded.contains("# [middleware"));
432    }
433
434    #[test]
435    fn applies_provider_middleware_before_route_middleware() {
436        let expanded = expand_endpoint(parse_quote! {
437            #[middleware(trace_all)]
438            impl OrdersHttp {
439                #[middleware(authorize_read)]
440                #[get("orders.read", "/orders/{order_id}")]
441                async fn read(&self) {}
442
443                #[get("orders.list", "/orders")]
444                async fn list(&self) {}
445            }
446        })
447        .unwrap()
448        .to_string();
449
450        assert_eq!(expanded.matches("provider . trace_all").count(), 2);
451        assert_eq!(expanded.matches("provider . authorize_read").count(), 1);
452        assert!(
453            expanded.find("provider . trace_all").unwrap()
454                < expanded.find("provider . authorize_read").unwrap()
455        );
456        assert!(!expanded.contains("# [middleware"));
457    }
458
459    #[test]
460    fn rejects_handlers_with_multiple_http_methods() {
461        let error = expand_endpoint(parse_quote! {
462            impl OrdersHttp {
463                #[get("orders.read", "/orders/{order_id}")]
464                #[post("orders.read", "/orders/{order_id}")]
465                async fn read(&self) {}
466            }
467        })
468        .unwrap_err();
469
470        assert!(error.to_string().contains("only one HTTP method"));
471    }
472
473    #[test]
474    fn rejects_impls_without_handlers() {
475        let error = expand_endpoint(parse_quote! {
476            impl OrdersHttp {
477                fn helper(&self) {}
478            }
479        })
480        .unwrap_err();
481
482        assert!(error.to_string().contains("at least one HTTP handler"));
483    }
484}