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(mut route) = metadata.route {
60            route.openapi = metadata.openapi;
61            routes.push(Handler {
62                route,
63                middlewares: provider_middlewares
64                    .iter()
65                    .cloned()
66                    .chain(metadata.middlewares)
67                    .collect(),
68                method: method.sig.ident.clone(),
69                arguments: handler_arguments(method)?,
70            });
71        } else if !metadata.middlewares.is_empty() || metadata.openapi.is_some() {
72            return Err(Error::new_spanned(
73                &method.sig.ident,
74                "endpoint metadata can only be attached to an HTTP handler",
75            ));
76        }
77    }
78    if routes.is_empty() {
79        return Err(Error::new_spanned(
80            &implementation.self_ty,
81            "endpoint impl must declare at least one HTTP handler attribute",
82        ));
83    }
84
85    let const_routes = routes.iter().map(endpoint_route);
86    let implementation_routes = routes.iter().map(endpoint_route);
87    let dispatch_arms = routes.iter().map(dispatch_arm);
88
89    Ok(quote! {
90        #implementation
91
92        const _: () = {
93            const ROUTES: &[::lenso_capability_http_endpoint::EndpointRoute] = &[
94                #(#const_routes)*
95            ];
96            ::lenso_capability_http_endpoint::__private::validate_endpoint_routes(ROUTES);
97        };
98
99        impl ::lenso_capability_http_endpoint::HttpEndpoint for #provider {
100            const ROUTES: &'static [::lenso_capability_http_endpoint::EndpointRoute] = &[
101                #(#implementation_routes)*
102            ];
103
104            fn dispatch(
105                &self,
106                context: ::lenso_capability_http_endpoint::__private::InvocationContext,
107                request: ::lenso_capability_http_endpoint::HandleRequest,
108            ) -> ::lenso_capability_http_endpoint::EndpointFuture {
109                let provider = self.clone();
110                Box::pin(async move {
111                    let route_id = request.route_id.clone();
112                    match route_id.as_str() {
113                        #(#dispatch_arms,)*
114                        _ => Ok(Err(::lenso_capability_http_endpoint::HandleError::Rejected)),
115                    }
116                })
117            }
118        }
119    })
120}
121
122fn endpoint_route(handler: &Handler) -> proc_macro2::TokenStream {
123    let route_id = &handler.route.id;
124    let method = &handler.route.method;
125    let path = &handler.route.path;
126    let openapi = handler
127        .route
128        .openapi
129        .as_ref()
130        .map(|operation| quote!(.with_openapi(#operation)));
131    quote! {
132        ::lenso_capability_http_endpoint::EndpointRoute::new(
133            #route_id,
134            #method,
135            #path,
136        ) #openapi,
137    }
138}
139
140fn take_provider_middlewares(attributes: &mut Vec<Attribute>) -> Result<Vec<Ident>> {
141    let mut middlewares = Vec::new();
142    let mut retained = Vec::with_capacity(attributes.len());
143    for attribute in attributes.drain(..) {
144        if attribute.path().is_ident("middleware") {
145            let arguments =
146                attribute.parse_args_with(Punctuated::<Ident, Token![,]>::parse_terminated)?;
147            if arguments.is_empty() {
148                return Err(Error::new_spanned(
149                    attribute,
150                    "middleware requires at least one provider method",
151                ));
152            }
153            middlewares.extend(arguments);
154        } else {
155            retained.push(attribute);
156        }
157    }
158    *attributes = retained;
159    Ok(middlewares)
160}
161
162fn take_handler_metadata(attributes: &mut Vec<Attribute>) -> Result<HandlerMetadata> {
163    let mut route = None;
164    let mut middlewares = Vec::new();
165    let mut openapi = None;
166    let mut retained = Vec::with_capacity(attributes.len());
167    for attribute in attributes.drain(..) {
168        if attribute.path().is_ident("middleware") {
169            let arguments =
170                attribute.parse_args_with(Punctuated::<Ident, Token![,]>::parse_terminated)?;
171            if arguments.is_empty() {
172                return Err(Error::new_spanned(
173                    attribute,
174                    "middleware requires at least one provider method",
175                ));
176            }
177            middlewares.extend(arguments);
178            continue;
179        }
180        if attribute.path().is_ident("openapi") {
181            if openapi.is_some() {
182                return Err(Error::new_spanned(
183                    attribute,
184                    "an endpoint handler may declare only one OpenAPI Operation Object",
185                ));
186            }
187            let operation = attribute.parse_args::<LitStr>()?;
188            validate_openapi_operation(&operation)?;
189            openapi = Some(operation);
190            continue;
191        }
192        let Some(http_method) = http_method(&attribute) else {
193            retained.push(attribute);
194            continue;
195        };
196        if route.is_some() {
197            return Err(Error::new_spanned(
198                attribute,
199                "an endpoint handler may declare only one HTTP method",
200            ));
201        }
202        let arguments = attribute.parse_args::<RouteArguments>()?;
203        route = Some(Route {
204            method: LitStr::new(http_method, attribute.path().span()),
205            id: arguments.route_id,
206            path: arguments.path,
207            openapi: None,
208        });
209    }
210    *attributes = retained;
211    Ok(HandlerMetadata {
212        route,
213        middlewares,
214        openapi,
215    })
216}
217
218fn validate_openapi_operation(operation: &LitStr) -> Result<()> {
219    let value = serde_json::from_str::<serde_json::Value>(&operation.value()).map_err(|error| {
220        Error::new(
221            operation.span(),
222            format!("OpenAPI Operation Object is not valid JSON: {error}"),
223        )
224    })?;
225    let Some(object) = value.as_object() else {
226        return Err(Error::new(
227            operation.span(),
228            "OpenAPI operation metadata must be a JSON object",
229        ));
230    };
231    if object.contains_key("operationId") {
232        return Err(Error::new(
233            operation.span(),
234            "OpenAPI operationId is generated from the stable route ID",
235        ));
236    }
237    Ok(())
238}
239
240fn handler_arguments(method: &syn::ImplItemFn) -> Result<Vec<HandlerArgument>> {
241    let mut arguments = Vec::new();
242    let mut context_count = 0;
243    let mut request_count = 0;
244    for (index, input) in method.sig.inputs.iter().enumerate() {
245        let FnArg::Typed(argument) = input else {
246            continue;
247        };
248        let ty = (*argument.ty).clone();
249        let kind = match final_type_ident(&ty).map(Ident::to_string).as_deref() {
250            Some("InvocationContext") => {
251                context_count += 1;
252                ArgumentKind::Context
253            }
254            Some("HandleRequest") => {
255                request_count += 1;
256                ArgumentKind::Request
257            }
258            _ => ArgumentKind::Extractor(Ident::new(
259                &format!("__lenso_extracted_{index}"),
260                argument.span(),
261            )),
262        };
263        arguments.push(HandlerArgument { ty, kind });
264    }
265    if context_count > 1 || request_count > 1 {
266        return Err(Error::new_spanned(
267            &method.sig.inputs,
268            "an endpoint handler accepts at most one InvocationContext and one HandleRequest",
269        ));
270    }
271    Ok(arguments)
272}
273
274fn final_type_ident(ty: &Type) -> Option<&Ident> {
275    let Type::Path(path) = ty else {
276        return None;
277    };
278    path.path.segments.last().map(|segment| &segment.ident)
279}
280
281fn dispatch_arm(handler: &Handler) -> proc_macro2::TokenStream {
282    let route_id = &handler.route.id;
283    let method = &handler.method;
284    let middleware_steps = handler.middlewares.iter().map(|middleware| {
285        quote! {
286            let (context, request) = match provider.#middleware(context, request).await {
287                Ok(outcome) => match outcome.into_result() {
288                    Ok(next) => next,
289                    Err(response) => return Ok(Ok(response)),
290                },
291                Err(::lenso_capability_http_endpoint::EndpointHandleInvocationError::Domain(
292                    error,
293                )) => return Ok(Err(error)),
294                Err(::lenso_capability_http_endpoint::EndpointHandleInvocationError::Runtime(
295                    error,
296                )) => return Err(error),
297            };
298        }
299    });
300    let extractor_steps = handler.arguments.iter().filter_map(|argument| {
301        let ArgumentKind::Extractor(binding) = &argument.kind else {
302            return None;
303        };
304        let ty = &argument.ty;
305        Some(quote! {
306            let #binding: #ty = match <#ty as
307                ::lenso_capability_http_endpoint::__private::FromRequest<Self>
308            >::from_request(&provider, &mut context, &request).await {
309                Ok(value) => value,
310                Err(::lenso_capability_http_endpoint::__private::ExtractorRejection::Response(
311                    response,
312                )) => return Ok(Ok(response)),
313                Err(::lenso_capability_http_endpoint::__private::ExtractorRejection::Invocation(
314                    ::lenso_capability_http_endpoint::EndpointHandleInvocationError::Domain(
315                        error,
316                    ),
317                )) => return Ok(Err(error)),
318                Err(::lenso_capability_http_endpoint::__private::ExtractorRejection::Invocation(
319                    ::lenso_capability_http_endpoint::EndpointHandleInvocationError::Runtime(
320                        error,
321                    ),
322                )) => return Err(error),
323            };
324        })
325    });
326    let mutable_context = handler
327        .arguments
328        .iter()
329        .any(|argument| matches!(&argument.kind, ArgumentKind::Extractor(_)))
330        .then(|| quote!(let mut context = context;));
331    let arguments = handler
332        .arguments
333        .iter()
334        .map(|argument| match &argument.kind {
335            ArgumentKind::Context => quote!(context),
336            ArgumentKind::Request => quote!(request),
337            ArgumentKind::Extractor(binding) => quote!(#binding),
338        });
339
340    quote! {
341        #route_id => {
342            #(#middleware_steps)*
343            #mutable_context
344            #(#extractor_steps)*
345            let _ = (&context, &request);
346            match provider.#method(#(#arguments),*).await {
347                Ok(response) => Ok(Ok(response)),
348                Err(::lenso_capability_http_endpoint::EndpointHandleInvocationError::Domain(
349                    error,
350                )) => Ok(Err(error)),
351                Err(::lenso_capability_http_endpoint::EndpointHandleInvocationError::Runtime(
352                    error,
353                )) => Err(error),
354            }
355        }
356    }
357}
358
359fn http_method(attribute: &Attribute) -> Option<&'static str> {
360    let path = attribute.path();
361    [
362        ("get", "GET"),
363        ("post", "POST"),
364        ("put", "PUT"),
365        ("patch", "PATCH"),
366        ("delete", "DELETE"),
367        ("head", "HEAD"),
368        ("options", "OPTIONS"),
369    ]
370    .into_iter()
371    .find_map(|(attribute, method)| path.is_ident(attribute).then_some(method))
372}
373
374struct RouteArguments {
375    route_id: LitStr,
376    path: LitStr,
377}
378
379impl Parse for RouteArguments {
380    fn parse(input: ParseStream<'_>) -> Result<Self> {
381        let arguments = Punctuated::<LitStr, Token![,]>::parse_terminated(input)?;
382        if arguments.len() != 2 {
383            return Err(Error::new(
384                input.span(),
385                "HTTP handler attributes require a route ID and path",
386            ));
387        }
388        let mut arguments = arguments.into_iter();
389        Ok(Self {
390            route_id: arguments.next().expect("length was checked"),
391            path: arguments.next().expect("length was checked"),
392        })
393    }
394}
395
396struct Route {
397    method: LitStr,
398    id: LitStr,
399    path: LitStr,
400    openapi: Option<LitStr>,
401}
402
403struct HandlerMetadata {
404    route: Option<Route>,
405    middlewares: Vec<Ident>,
406    openapi: Option<LitStr>,
407}
408
409struct Handler {
410    route: Route,
411    middlewares: Vec<Ident>,
412    method: Ident,
413    arguments: Vec<HandlerArgument>,
414}
415
416struct HandlerArgument {
417    ty: Type,
418    kind: ArgumentKind,
419}
420
421enum ArgumentKind {
422    Context,
423    Request,
424    Extractor(Ident),
425}
426
427#[cfg(test)]
428mod tests {
429    use super::expand_endpoint;
430    use syn::parse_quote;
431
432    #[test]
433    fn expands_handler_attributes_into_the_static_route_table() {
434        let expanded = expand_endpoint(parse_quote! {
435            impl OrdersHttp {
436                #[get("orders.read", "/orders/{order_id}")]
437                #[openapi(r#"{"summary":"Read an order"}"#)]
438                async fn read(&self) {}
439            }
440        })
441        .unwrap()
442        .to_string();
443
444        assert!(expanded.contains("HttpEndpoint"));
445        assert!(expanded.contains("orders.read"));
446        assert!(expanded.contains("GET"));
447        assert!(expanded.contains("/orders/{order_id}"));
448        assert!(expanded.contains("with_openapi"));
449        assert!(!expanded.contains("# [get"));
450        assert!(!expanded.contains("# [openapi"));
451    }
452
453    #[test]
454    fn rejects_an_openapi_operation_id_that_can_drift_from_the_route_id() {
455        let error = expand_endpoint(parse_quote! {
456            impl OrdersHttp {
457                #[get("orders.read", "/orders/{order_id}")]
458                #[openapi(r#"{"operationId":"another.id"}"#)]
459                async fn read(&self) {}
460            }
461        })
462        .unwrap_err();
463
464        assert!(
465            error
466                .to_string()
467                .contains("generated from the stable route ID")
468        );
469    }
470
471    #[test]
472    fn rejects_invalid_openapi_json() {
473        let error = expand_endpoint(parse_quote! {
474            impl OrdersHttp {
475                #[get("orders.read", "/orders/{order_id}")]
476                #[openapi("not-json")]
477                async fn read(&self) {}
478            }
479        })
480        .unwrap_err();
481
482        assert!(error.to_string().contains("not valid JSON"));
483    }
484
485    #[test]
486    fn expands_middleware_and_typed_extractors_before_the_handler() {
487        let expanded = expand_endpoint(parse_quote! {
488            impl OrdersHttp {
489                #[middleware(authenticate)]
490                #[get("orders.read", "/orders/{order_id}")]
491                async fn read(
492                    &self,
493                    context: InvocationContext,
494                    Path(path): Path<OrderPath>,
495                ) {}
496            }
497        })
498        .unwrap()
499        .to_string();
500
501        assert!(expanded.contains("provider . authenticate"));
502        assert!(expanded.contains("into_result"));
503        assert!(expanded.contains("FromRequest"));
504        assert!(expanded.contains("provider . read (context , __lenso_extracted_2)"));
505        assert!(!expanded.contains("# [middleware"));
506    }
507
508    #[test]
509    fn applies_provider_middleware_before_route_middleware() {
510        let expanded = expand_endpoint(parse_quote! {
511            #[middleware(trace_all)]
512            impl OrdersHttp {
513                #[middleware(authorize_read)]
514                #[get("orders.read", "/orders/{order_id}")]
515                async fn read(&self) {}
516
517                #[get("orders.list", "/orders")]
518                async fn list(&self) {}
519            }
520        })
521        .unwrap()
522        .to_string();
523
524        assert_eq!(expanded.matches("provider . trace_all").count(), 2);
525        assert_eq!(expanded.matches("provider . authorize_read").count(), 1);
526        assert!(
527            expanded.find("provider . trace_all").unwrap()
528                < expanded.find("provider . authorize_read").unwrap()
529        );
530        assert!(!expanded.contains("# [middleware"));
531    }
532
533    #[test]
534    fn rejects_handlers_with_multiple_http_methods() {
535        let error = expand_endpoint(parse_quote! {
536            impl OrdersHttp {
537                #[get("orders.read", "/orders/{order_id}")]
538                #[post("orders.read", "/orders/{order_id}")]
539                async fn read(&self) {}
540            }
541        })
542        .unwrap_err();
543
544        assert!(error.to_string().contains("only one HTTP method"));
545    }
546
547    #[test]
548    fn rejects_impls_without_handlers() {
549        let error = expand_endpoint(parse_quote! {
550            impl OrdersHttp {
551                fn helper(&self) {}
552            }
553        })
554        .unwrap_err();
555
556        assert!(error.to_string().contains("at least one HTTP handler"));
557    }
558}