lenso_capability_http_endpoint_macros/
lib.rs1use 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#[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}