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::{ToTokens, quote};
5use serde_json::{Map, Number, Value};
6use syn::{
7    Attribute, Error, FnArg, Ident, ImplItem, ItemImpl, Lit, LitStr, Result, Token, Type, braced,
8    bracketed,
9    ext::IdentExt,
10    parse::{Parse, ParseStream},
11    parse_macro_input,
12    punctuated::Punctuated,
13    spanned::Spanned,
14};
15
16/// Generates one HTTP Endpoint provider from handler route attributes.
17///
18/// Supported handler attributes are `get`, `post`, `put`, `patch`, `delete`,
19/// `head`, `options`, and `query`. Each accepts a stable route ID and path. One or more
20/// `middleware` attributes may name async provider methods that run before the
21/// handler and its typed request extractors. Middleware on the impl applies to
22/// every route before route-specific middleware.
23#[proc_macro_attribute]
24pub fn endpoint(arguments: TokenStream, input: TokenStream) -> TokenStream {
25    let register_plugin = if arguments.is_empty() {
26        true
27    } else {
28        let mode = parse_macro_input!(arguments as Ident);
29        if mode != "standalone" {
30            return Error::new_spanned(
31                mode,
32                "endpoint accepts only the internal `standalone` mode",
33            )
34            .into_compile_error()
35            .into();
36        }
37        false
38    };
39
40    let implementation = parse_macro_input!(input as ItemImpl);
41    expand_endpoint_with_registration(implementation, register_plugin)
42        .unwrap_or_else(Error::into_compile_error)
43        .into()
44}
45
46/// Converts a JSON-like `OpenAPI` Operation Object into a validated string literal.
47///
48/// This is primarily useful with `lenso_capability_http_endpoint::http_endpoint!`.
49/// Handler attributes accept the same object directly through `#[openapi({ ... })]`.
50#[proc_macro]
51pub fn openapi_operation(input: TokenStream) -> TokenStream {
52    let operation = parse_macro_input!(input as OpenApiObject);
53    match operation_literal(&operation) {
54        Ok(operation) => quote!(#operation).into(),
55        Err(error) => error.into_compile_error().into(),
56    }
57}
58
59#[cfg(test)]
60fn expand_endpoint(implementation: ItemImpl) -> Result<proc_macro2::TokenStream> {
61    expand_endpoint_with_registration(implementation, true)
62}
63
64fn expand_endpoint_with_registration(
65    mut implementation: ItemImpl,
66    register_plugin: bool,
67) -> Result<proc_macro2::TokenStream> {
68    if implementation.trait_.is_some() {
69        return Err(Error::new_spanned(
70            implementation.impl_token,
71            "endpoint can only be applied to an inherent impl block",
72        ));
73    }
74    if !implementation.generics.params.is_empty() {
75        return Err(Error::new_spanned(
76            &implementation.generics,
77            "endpoint does not support generic impl blocks",
78        ));
79    }
80
81    let provider = implementation.self_ty.clone();
82    let provider_middlewares = take_provider_middlewares(&mut implementation.attrs)?;
83    let mut routes = Vec::new();
84    for item in &mut implementation.items {
85        let ImplItem::Fn(method) = item else {
86            continue;
87        };
88        let metadata = take_handler_metadata(&mut method.attrs)?;
89        if let Some(mut route) = metadata.route {
90            route.openapi = metadata.openapi;
91            routes.push(Handler {
92                route,
93                middlewares: provider_middlewares
94                    .iter()
95                    .cloned()
96                    .chain(metadata.middlewares)
97                    .collect(),
98                method: method.sig.ident.clone(),
99                arguments: handler_arguments(method)?,
100            });
101        } else if !metadata.middlewares.is_empty() || metadata.openapi.is_some() {
102            return Err(Error::new_spanned(
103                &method.sig.ident,
104                "endpoint metadata can only be attached to an HTTP handler",
105            ));
106        }
107    }
108    if routes.is_empty() {
109        return Err(Error::new_spanned(
110            &implementation.self_ty,
111            "endpoint impl must declare at least one HTTP handler attribute",
112        ));
113    }
114
115    let const_routes = routes.iter().map(endpoint_route);
116    let implementation_routes = routes.iter().map(endpoint_route);
117    let dispatch_arms = routes.iter().map(dispatch_arm);
118    let plugin_registration =
119        register_plugin.then(|| quote!(#[lenso::provides(http_endpoint_contract::Endpoint)]));
120
121    Ok(quote! {
122        #implementation
123
124        const _: () = {
125            const ROUTES: &[::lenso_capability_http_endpoint::EndpointRoute] = &[
126                #(#const_routes)*
127            ];
128            ::lenso_capability_http_endpoint::__private::validate_endpoint_routes(ROUTES);
129        };
130
131        #plugin_registration
132        impl ::lenso_capability_http_endpoint::HttpEndpoint for #provider {
133            const ROUTES: &'static [::lenso_capability_http_endpoint::EndpointRoute] = &[
134                #(#implementation_routes)*
135            ];
136
137            fn dispatch(
138                &self,
139                context: ::lenso_capability_http_endpoint::__private::InvocationContext,
140                request: ::lenso_capability_http_endpoint::HandleRequest,
141            ) -> ::lenso_capability_http_endpoint::EndpointFuture {
142                let provider = self.clone();
143                Box::pin(async move {
144                    let route_id = request.route_id.clone();
145                    match route_id.as_str() {
146                        #(#dispatch_arms,)*
147                        _ => Ok(Err(::lenso_capability_http_endpoint::HandleError::Rejected)),
148                    }
149                })
150            }
151        }
152    })
153}
154
155fn endpoint_route(handler: &Handler) -> proc_macro2::TokenStream {
156    let route_id = &handler.route.id;
157    let method = &handler.route.method;
158    let path = &handler.route.path;
159    let openapi = handler
160        .route
161        .openapi
162        .as_ref()
163        .map(|operation| quote!(.with_openapi(#operation)));
164    quote! {
165        ::lenso_capability_http_endpoint::EndpointRoute::new(
166            #route_id,
167            #method,
168            #path,
169        ) #openapi,
170    }
171}
172
173fn take_provider_middlewares(attributes: &mut Vec<Attribute>) -> Result<Vec<Ident>> {
174    let mut middlewares = Vec::new();
175    let mut retained = Vec::with_capacity(attributes.len());
176    for attribute in attributes.drain(..) {
177        if attribute.path().is_ident("middleware") {
178            let arguments =
179                attribute.parse_args_with(Punctuated::<Ident, Token![,]>::parse_terminated)?;
180            if arguments.is_empty() {
181                return Err(Error::new_spanned(
182                    attribute,
183                    "middleware requires at least one provider method",
184                ));
185            }
186            middlewares.extend(arguments);
187        } else {
188            retained.push(attribute);
189        }
190    }
191    *attributes = retained;
192    Ok(middlewares)
193}
194
195fn take_handler_metadata(attributes: &mut Vec<Attribute>) -> Result<HandlerMetadata> {
196    let mut route = None;
197    let mut middlewares = Vec::new();
198    let mut openapi = None;
199    let mut retained = Vec::with_capacity(attributes.len());
200    for attribute in attributes.drain(..) {
201        if attribute.path().is_ident("middleware") {
202            let arguments =
203                attribute.parse_args_with(Punctuated::<Ident, Token![,]>::parse_terminated)?;
204            if arguments.is_empty() {
205                return Err(Error::new_spanned(
206                    attribute,
207                    "middleware requires at least one provider method",
208                ));
209            }
210            middlewares.extend(arguments);
211            continue;
212        }
213        if attribute.path().is_ident("openapi") {
214            if openapi.is_some() {
215                return Err(Error::new_spanned(
216                    attribute,
217                    "an endpoint handler may declare only one OpenAPI Operation Object",
218                ));
219            }
220            let operation = attribute.parse_args::<OpenApiOperation>()?.into_literal()?;
221            openapi = Some(operation);
222            continue;
223        }
224        let Some(http_method) = http_method(&attribute) else {
225            retained.push(attribute);
226            continue;
227        };
228        if route.is_some() {
229            return Err(Error::new_spanned(
230                attribute,
231                "an endpoint handler may declare only one HTTP method",
232            ));
233        }
234        let arguments = attribute.parse_args::<RouteArguments>()?;
235        route = Some(Route {
236            method: LitStr::new(http_method, attribute.path().span()),
237            id: arguments.route_id,
238            path: arguments.path,
239            openapi: None,
240        });
241    }
242    *attributes = retained;
243    Ok(HandlerMetadata {
244        route,
245        middlewares,
246        openapi,
247    })
248}
249
250enum OpenApiOperation {
251    Json(LitStr),
252    Object(OpenApiObject),
253}
254
255impl OpenApiOperation {
256    fn into_literal(self) -> Result<LitStr> {
257        match self {
258            Self::Json(operation) => {
259                validate_openapi_operation(&operation)?;
260                Ok(operation)
261            }
262            Self::Object(operation) => operation_literal(&operation),
263        }
264    }
265}
266
267impl Parse for OpenApiOperation {
268    fn parse(input: ParseStream<'_>) -> Result<Self> {
269        if input.peek(LitStr) {
270            return input.parse().map(Self::Json);
271        }
272        input.parse().map(Self::Object)
273    }
274}
275
276struct OpenApiObject {
277    value: Map<String, Value>,
278    span: proc_macro2::Span,
279}
280
281impl Parse for OpenApiObject {
282    fn parse(input: ParseStream<'_>) -> Result<Self> {
283        let content;
284        let brace = braced!(content in input);
285        Ok(Self {
286            value: parse_object_entries(&content)?,
287            span: brace.span.join(),
288        })
289    }
290}
291
292fn parse_object_entries(input: ParseStream<'_>) -> Result<Map<String, Value>> {
293    let mut object = Map::new();
294    while !input.is_empty() {
295        let (key, span) = parse_object_key(input)?;
296        input.parse::<Token![:]>()?;
297        let value = parse_json_value(input)?;
298        if object.insert(key.clone(), value).is_some() {
299            return Err(Error::new(
300                span,
301                format!("duplicate OpenAPI object key `{key}`"),
302            ));
303        }
304        if input.is_empty() {
305            break;
306        }
307        input.parse::<Token![,]>()?;
308    }
309    Ok(object)
310}
311
312fn parse_object_key(input: ParseStream<'_>) -> Result<(String, proc_macro2::Span)> {
313    if input.peek(LitStr) {
314        let key = input.parse::<LitStr>()?;
315        return Ok((key.value(), key.span()));
316    }
317    let key = Ident::parse_any(input)?;
318    Ok((key.unraw().to_string(), key.span()))
319}
320
321fn parse_json_value(input: ParseStream<'_>) -> Result<Value> {
322    if input.peek(syn::token::Brace) {
323        let content;
324        braced!(content in input);
325        return parse_object_entries(&content).map(Value::Object);
326    }
327    if input.peek(syn::token::Bracket) {
328        let content;
329        bracketed!(content in input);
330        let values = Punctuated::<JsonValue, Token![,]>::parse_terminated(&content)?;
331        return Ok(Value::Array(
332            values.into_iter().map(|value| value.0).collect(),
333        ));
334    }
335    if input.peek(Token![-]) {
336        input.parse::<Token![-]>()?;
337        let literal = input.parse::<Lit>()?;
338        return parse_number_literal(&literal, true);
339    }
340    if input.peek(syn::LitBool) {
341        let literal = input.parse::<Lit>()?;
342        return parse_literal(&literal);
343    }
344    if input.peek(Ident::peek_any) {
345        let ident = Ident::parse_any(input)?;
346        if ident == "null" {
347            return Ok(Value::Null);
348        }
349        return Err(Error::new(ident.span(), "expected a JSON value"));
350    }
351    let literal = input.parse::<Lit>()?;
352    parse_literal(&literal)
353}
354
355struct JsonValue(Value);
356
357impl Parse for JsonValue {
358    fn parse(input: ParseStream<'_>) -> Result<Self> {
359        parse_json_value(input).map(Self)
360    }
361}
362
363fn parse_literal(literal: &Lit) -> Result<Value> {
364    match literal {
365        Lit::Str(value) => Ok(Value::String(value.value())),
366        Lit::Bool(value) => Ok(Value::Bool(value.value)),
367        Lit::Int(_) | Lit::Float(_) => parse_number_literal(literal, false),
368        _ => Err(Error::new(
369            literal.span(),
370            "OpenAPI metadata supports JSON string, number, boolean, null, array, and object values",
371        )),
372    }
373}
374
375fn parse_number_literal(literal: &Lit, negative: bool) -> Result<Value> {
376    let mut source = literal.to_token_stream().to_string().replace(' ', "");
377    if negative {
378        source.insert(0, '-');
379    }
380    let number = source.parse::<Number>().map_err(|_| {
381        Error::new(
382            literal.span(),
383            "OpenAPI numeric values must be unsuffixed JSON numbers",
384        )
385    })?;
386    Ok(Value::Number(number))
387}
388
389fn operation_literal(operation: &OpenApiObject) -> Result<LitStr> {
390    if operation.value.contains_key("operationId") {
391        return Err(Error::new(
392            operation.span,
393            "OpenAPI operationId is generated from the stable route ID",
394        ));
395    }
396    let json = serde_json::to_string(&operation.value).map_err(|error| {
397        Error::new(
398            operation.span,
399            format!("could not encode OpenAPI Operation Object: {error}"),
400        )
401    })?;
402    Ok(LitStr::new(&json, operation.span))
403}
404
405fn validate_openapi_operation(operation: &LitStr) -> Result<()> {
406    let value = serde_json::from_str::<serde_json::Value>(&operation.value()).map_err(|error| {
407        Error::new(
408            operation.span(),
409            format!("OpenAPI Operation Object is not valid JSON: {error}"),
410        )
411    })?;
412    let Some(object) = value.as_object() else {
413        return Err(Error::new(
414            operation.span(),
415            "OpenAPI operation metadata must be a JSON object",
416        ));
417    };
418    if object.contains_key("operationId") {
419        return Err(Error::new(
420            operation.span(),
421            "OpenAPI operationId is generated from the stable route ID",
422        ));
423    }
424    Ok(())
425}
426
427fn handler_arguments(method: &syn::ImplItemFn) -> Result<Vec<HandlerArgument>> {
428    let mut arguments = Vec::new();
429    let mut context_count = 0;
430    let mut request_count = 0;
431    for (index, input) in method.sig.inputs.iter().enumerate() {
432        let FnArg::Typed(argument) = input else {
433            continue;
434        };
435        let ty = (*argument.ty).clone();
436        let kind = match final_type_ident(&ty).map(Ident::to_string).as_deref() {
437            Some("InvocationContext") => {
438                context_count += 1;
439                ArgumentKind::Context
440            }
441            Some("HandleRequest") => {
442                request_count += 1;
443                ArgumentKind::Request
444            }
445            _ => ArgumentKind::Extractor(Ident::new(
446                &format!("__lenso_extracted_{index}"),
447                argument.span(),
448            )),
449        };
450        arguments.push(HandlerArgument { ty, kind });
451    }
452    if context_count > 1 || request_count > 1 {
453        return Err(Error::new_spanned(
454            &method.sig.inputs,
455            "an endpoint handler accepts at most one InvocationContext and one HandleRequest",
456        ));
457    }
458    Ok(arguments)
459}
460
461fn final_type_ident(ty: &Type) -> Option<&Ident> {
462    let Type::Path(path) = ty else {
463        return None;
464    };
465    path.path.segments.last().map(|segment| &segment.ident)
466}
467
468fn dispatch_arm(handler: &Handler) -> proc_macro2::TokenStream {
469    let route_id = &handler.route.id;
470    let method = &handler.method;
471    let middleware_steps = handler.middlewares.iter().map(|middleware| {
472        quote! {
473            let (context, request) = match provider.#middleware(context, request).await {
474                Ok(outcome) => match outcome.into_result() {
475                    Ok(next) => next,
476                    Err(response) => return Ok(Ok(response)),
477                },
478                Err(::lenso_capability_http_endpoint::EndpointHandleInvocationError::Domain(
479                    error,
480                )) => return Ok(Err(error)),
481                Err(::lenso_capability_http_endpoint::EndpointHandleInvocationError::Runtime(
482                    error,
483                )) => return Err(error),
484            };
485        }
486    });
487    let extractor_steps = handler.arguments.iter().filter_map(|argument| {
488        let ArgumentKind::Extractor(binding) = &argument.kind else {
489            return None;
490        };
491        let ty = &argument.ty;
492        Some(quote! {
493            let #binding: #ty = match <#ty as
494                ::lenso_capability_http_endpoint::__private::FromRequest<Self>
495            >::from_request(&provider, &mut context, &request).await {
496                Ok(value) => value,
497                Err(::lenso_capability_http_endpoint::__private::ExtractorRejection::Response(
498                    response,
499                )) => return Ok(Ok(response)),
500                Err(::lenso_capability_http_endpoint::__private::ExtractorRejection::Invocation(
501                    ::lenso_capability_http_endpoint::EndpointHandleInvocationError::Domain(
502                        error,
503                    ),
504                )) => return Ok(Err(error)),
505                Err(::lenso_capability_http_endpoint::__private::ExtractorRejection::Invocation(
506                    ::lenso_capability_http_endpoint::EndpointHandleInvocationError::Runtime(
507                        error,
508                    ),
509                )) => return Err(error),
510            };
511        })
512    });
513    let mutable_context = handler
514        .arguments
515        .iter()
516        .any(|argument| matches!(&argument.kind, ArgumentKind::Extractor(_)))
517        .then(|| quote!(let mut context = context;));
518    let arguments = handler
519        .arguments
520        .iter()
521        .map(|argument| match &argument.kind {
522            ArgumentKind::Context => quote!(context),
523            ArgumentKind::Request => quote!(request),
524            ArgumentKind::Extractor(binding) => quote!(#binding),
525        });
526
527    quote! {
528        #route_id => {
529            #(#middleware_steps)*
530            #mutable_context
531            #(#extractor_steps)*
532            let _ = (&context, &request);
533            ::lenso_capability_http_endpoint::__private::IntoEndpointResult::into_endpoint_result(
534                provider.#method(#(#arguments),*).await,
535            )
536        }
537    }
538}
539
540fn http_method(attribute: &Attribute) -> Option<&'static str> {
541    let path = attribute.path();
542    [
543        ("get", "GET"),
544        ("post", "POST"),
545        ("put", "PUT"),
546        ("patch", "PATCH"),
547        ("delete", "DELETE"),
548        ("head", "HEAD"),
549        ("options", "OPTIONS"),
550        ("query", "QUERY"),
551    ]
552    .into_iter()
553    .find_map(|(attribute, method)| path.is_ident(attribute).then_some(method))
554}
555
556struct RouteArguments {
557    route_id: LitStr,
558    path: LitStr,
559}
560
561impl Parse for RouteArguments {
562    fn parse(input: ParseStream<'_>) -> Result<Self> {
563        let arguments = Punctuated::<LitStr, Token![,]>::parse_terminated(input)?;
564        if arguments.len() != 2 {
565            return Err(Error::new(
566                input.span(),
567                "HTTP handler attributes require a route ID and path",
568            ));
569        }
570        let mut arguments = arguments.into_iter();
571        Ok(Self {
572            route_id: arguments.next().expect("length was checked"),
573            path: arguments.next().expect("length was checked"),
574        })
575    }
576}
577
578struct Route {
579    method: LitStr,
580    id: LitStr,
581    path: LitStr,
582    openapi: Option<LitStr>,
583}
584
585struct HandlerMetadata {
586    route: Option<Route>,
587    middlewares: Vec<Ident>,
588    openapi: Option<LitStr>,
589}
590
591struct Handler {
592    route: Route,
593    middlewares: Vec<Ident>,
594    method: Ident,
595    arguments: Vec<HandlerArgument>,
596}
597
598struct HandlerArgument {
599    ty: Type,
600    kind: ArgumentKind,
601}
602
603enum ArgumentKind {
604    Context,
605    Request,
606    Extractor(Ident),
607}
608
609#[cfg(test)]
610mod tests {
611    use super::expand_endpoint;
612    use syn::parse_quote;
613
614    #[test]
615    fn expands_handler_attributes_into_the_static_route_table() {
616        let expanded = expand_endpoint(parse_quote! {
617            impl OrdersHttp {
618                #[get("orders.read", "/orders/{order_id}")]
619                #[openapi({
620                    summary: "Read an order",
621                    tags: ["orders"],
622                    responses: {
623                        "200": { description: "Order" }
624                    }
625                })]
626                async fn read(&self) {}
627            }
628        })
629        .unwrap()
630        .to_string();
631
632        assert!(expanded.contains("HttpEndpoint"));
633        assert!(expanded.contains("orders.read"));
634        assert!(expanded.contains("GET"));
635        assert!(expanded.contains("/orders/{order_id}"));
636        assert!(expanded.contains("with_openapi"));
637        assert!(!expanded.contains("# [get"));
638        assert!(!expanded.contains("# [openapi"));
639        assert!(expanded.contains("Read an order"));
640        assert!(expanded.contains(r#"\"200\""#));
641    }
642
643    #[test]
644    fn accepts_legacy_json_string_metadata() {
645        let expanded = expand_endpoint(parse_quote! {
646            impl OrdersHttp {
647                #[get("orders.read", "/orders/{order_id}")]
648                #[openapi(r#"{"summary":"Read an order"}"#)]
649                async fn read(&self) {}
650            }
651        })
652        .unwrap()
653        .to_string();
654
655        assert!(expanded.contains("Read an order"));
656    }
657
658    #[test]
659    fn rejects_an_operation_id_in_structured_metadata() {
660        let error = expand_endpoint(parse_quote! {
661            impl OrdersHttp {
662                #[get("orders.read", "/orders/{order_id}")]
663                #[openapi({ operationId: "another.id" })]
664                async fn read(&self) {}
665            }
666        })
667        .unwrap_err();
668
669        assert!(
670            error
671                .to_string()
672                .contains("generated from the stable route ID")
673        );
674    }
675
676    #[test]
677    fn rejects_duplicate_structured_metadata_keys() {
678        let error = expand_endpoint(parse_quote! {
679            impl OrdersHttp {
680                #[get("orders.read", "/orders/{order_id}")]
681                #[openapi({ summary: "Read", summary: "Read again" })]
682                async fn read(&self) {}
683            }
684        })
685        .unwrap_err();
686
687        assert!(error.to_string().contains("duplicate OpenAPI object key"));
688    }
689
690    #[test]
691    fn rejects_an_openapi_operation_id_that_can_drift_from_the_route_id() {
692        let error = expand_endpoint(parse_quote! {
693            impl OrdersHttp {
694                #[get("orders.read", "/orders/{order_id}")]
695                #[openapi(r#"{"operationId":"another.id"}"#)]
696                async fn read(&self) {}
697            }
698        })
699        .unwrap_err();
700
701        assert!(
702            error
703                .to_string()
704                .contains("generated from the stable route ID")
705        );
706    }
707
708    #[test]
709    fn rejects_invalid_openapi_json() {
710        let error = expand_endpoint(parse_quote! {
711            impl OrdersHttp {
712                #[get("orders.read", "/orders/{order_id}")]
713                #[openapi("not-json")]
714                async fn read(&self) {}
715            }
716        })
717        .unwrap_err();
718
719        assert!(error.to_string().contains("not valid JSON"));
720    }
721
722    #[test]
723    fn expands_middleware_and_typed_extractors_before_the_handler() {
724        let expanded = expand_endpoint(parse_quote! {
725            impl OrdersHttp {
726                #[middleware(authenticate)]
727                #[get("orders.read", "/orders/{order_id}")]
728                async fn read(
729                    &self,
730                    context: InvocationContext,
731                    Path(path): Path<OrderPath>,
732                ) {}
733            }
734        })
735        .unwrap()
736        .to_string();
737
738        assert!(expanded.contains("provider . authenticate"));
739        assert!(expanded.contains("into_result"));
740        assert!(expanded.contains("FromRequest"));
741        assert!(expanded.contains("provider . read (context , __lenso_extracted_2)"));
742        assert!(!expanded.contains("# [middleware"));
743    }
744
745    #[test]
746    fn applies_provider_middleware_before_route_middleware() {
747        let expanded = expand_endpoint(parse_quote! {
748            #[middleware(trace_all)]
749            impl OrdersHttp {
750                #[middleware(authorize_read)]
751                #[get("orders.read", "/orders/{order_id}")]
752                async fn read(&self) {}
753
754                #[get("orders.list", "/orders")]
755                async fn list(&self) {}
756            }
757        })
758        .unwrap()
759        .to_string();
760
761        assert_eq!(expanded.matches("provider . trace_all").count(), 2);
762        assert_eq!(expanded.matches("provider . authorize_read").count(), 1);
763        assert!(
764            expanded.find("provider . trace_all").unwrap()
765                < expanded.find("provider . authorize_read").unwrap()
766        );
767        assert!(!expanded.contains("# [middleware"));
768    }
769
770    #[test]
771    fn rejects_handlers_with_multiple_http_methods() {
772        let error = expand_endpoint(parse_quote! {
773            impl OrdersHttp {
774                #[get("orders.read", "/orders/{order_id}")]
775                #[post("orders.read", "/orders/{order_id}")]
776                async fn read(&self) {}
777            }
778        })
779        .unwrap_err();
780
781        assert!(error.to_string().contains("only one HTTP method"));
782    }
783
784    #[test]
785    fn rejects_impls_without_handlers() {
786        let error = expand_endpoint(parse_quote! {
787            impl OrdersHttp {
788                fn helper(&self) {}
789            }
790        })
791        .unwrap_err();
792
793        assert!(error.to_string().contains("at least one HTTP handler"));
794    }
795}