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(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}