Skip to main content

lenso_capability_http_endpoint/
authoring.rs

1use lenso_kernel::{InvocationContext, NativeRequestFuture};
2
3use crate::{
4    DescribeRequest, DescribeResponse, DescribeResponseRoutesItem, EndpointDescribe,
5    EndpointHandle, EndpointProvider, HandleRequest,
6};
7
8/// One immutable HTTP route owned by an Endpoint provider.
9#[derive(Clone, Copy, Debug, Eq, PartialEq)]
10pub struct EndpointRoute {
11    route_id: &'static str,
12    method: &'static str,
13    path: &'static str,
14}
15
16impl EndpointRoute {
17    /// Declares one stable route identifier, canonical HTTP method, and path template.
18    #[must_use]
19    pub const fn new(route_id: &'static str, method: &'static str, path: &'static str) -> Self {
20        Self {
21            route_id,
22            method,
23            path,
24        }
25    }
26
27    /// Returns the stable identifier dispatched to the owning handler.
28    #[must_use]
29    pub const fn route_id(self) -> &'static str {
30        self.route_id
31    }
32
33    /// Returns the canonical uppercase HTTP method.
34    #[must_use]
35    pub const fn method(self) -> &'static str {
36        self.method
37    }
38
39    /// Returns the absolute path template understood by Web Ingress.
40    #[must_use]
41    pub const fn path(self) -> &'static str {
42        self.path
43    }
44
45    fn into_description(self) -> DescribeResponseRoutesItem {
46        DescribeResponseRoutesItem {
47            method: self.method.to_owned(),
48            path: self.path.to_owned(),
49            route_id: self.route_id.to_owned(),
50        }
51    }
52}
53
54/// Boxed local handler result used by an authored HTTP Endpoint.
55pub type EndpointFuture = NativeRequestFuture<EndpointHandle>;
56
57/// A statically routed HTTP Endpoint whose declarations and dispatch share one source.
58///
59/// Prefer [`crate::http_endpoint!`] so route identifiers cannot drift between
60/// `describe` and `handle`. Implement this trait directly only when a provider needs
61/// custom dispatch while retaining the generated `EndpointProvider` behavior.
62pub trait HttpEndpoint: Clone + std::fmt::Debug + 'static {
63    /// The complete immutable route table for this provider.
64    const ROUTES: &'static [EndpointRoute];
65
66    /// Dispatches one request already matched to a route in [`Self::ROUTES`].
67    fn dispatch(&self, context: InvocationContext, request: HandleRequest) -> EndpointFuture;
68}
69
70impl<T> EndpointProvider for T
71where
72    T: HttpEndpoint,
73{
74    fn describe(
75        &self,
76        _context: InvocationContext,
77        _request: DescribeRequest,
78    ) -> NativeRequestFuture<EndpointDescribe> {
79        let routes = T::ROUTES
80            .iter()
81            .copied()
82            .map(EndpointRoute::into_description)
83            .collect();
84        Box::pin(async move { Ok(Ok(DescribeResponse { routes })) })
85    }
86
87    fn handle(
88        &self,
89        context: InvocationContext,
90        request: HandleRequest,
91    ) -> NativeRequestFuture<EndpointHandle> {
92        self.dispatch(context, request)
93    }
94}
95
96/// Validates a route table during const evaluation in [`crate::http_endpoint!`].
97#[doc(hidden)]
98pub const fn validate_endpoint_routes(routes: &[EndpointRoute]) {
99    assert!(
100        !routes.is_empty(),
101        "an HTTP Endpoint needs at least one route"
102    );
103    let mut index = 0;
104    while index < routes.len() {
105        let route = routes[index];
106        assert!(valid_route_id(route.route_id), "HTTP route id is invalid");
107        assert!(valid_method(route.method), "HTTP route method is invalid");
108        assert!(valid_path(route.path), "HTTP route path is invalid");
109
110        let mut previous = 0;
111        while previous < index {
112            let candidate = routes[previous];
113            assert!(
114                !string_eq(candidate.route_id, route.route_id),
115                "HTTP route ids must be unique"
116            );
117            assert!(
118                !(string_eq(candidate.method, route.method)
119                    && string_eq(candidate.path, route.path)),
120                "HTTP method and path pairs must be unique"
121            );
122            previous += 1;
123        }
124        index += 1;
125    }
126}
127
128const fn valid_route_id(value: &str) -> bool {
129    let bytes = value.as_bytes();
130    if bytes.is_empty() {
131        return false;
132    }
133    let mut index = 0;
134    while index < bytes.len() {
135        if bytes[index].is_ascii_whitespace() {
136            return false;
137        }
138        index += 1;
139    }
140    true
141}
142
143const fn valid_method(value: &str) -> bool {
144    let bytes = value.as_bytes();
145    if bytes.is_empty() {
146        return false;
147    }
148    let mut index = 0;
149    while index < bytes.len() {
150        let byte = bytes[index];
151        let valid = byte.is_ascii_uppercase()
152            || byte.is_ascii_digit()
153            || matches!(
154                byte,
155                b'!' | b'#'
156                    | b'$'
157                    | b'%'
158                    | b'&'
159                    | b'\''
160                    | b'*'
161                    | b'+'
162                    | b'-'
163                    | b'.'
164                    | b'^'
165                    | b'_'
166                    | b'`'
167                    | b'|'
168                    | b'~'
169            );
170        if !valid {
171            return false;
172        }
173        index += 1;
174    }
175    true
176}
177
178const fn valid_path(value: &str) -> bool {
179    let bytes = value.as_bytes();
180    if bytes.is_empty() || bytes[0] != b'/' {
181        return false;
182    }
183    let mut index = 0;
184    while index < bytes.len() {
185        if matches!(bytes[index], b'?' | b'#') {
186            return false;
187        }
188        index += 1;
189    }
190    true
191}
192
193const fn string_eq(left: &str, right: &str) -> bool {
194    let left = left.as_bytes();
195    let right = right.as_bytes();
196    if left.len() != right.len() {
197        return false;
198    }
199    let mut index = 0;
200    while index < left.len() {
201        if left[index] != right[index] {
202            return false;
203        }
204        index += 1;
205    }
206    true
207}
208
209/// Implements a statically routed [`HttpEndpoint`] from one route table.
210///
211/// Each handler is an async method with the following shape:
212///
213/// ```ignore
214/// async fn handler(
215///     &self,
216///     context: InvocationContext,
217///     request: HandleRequest,
218/// ) -> Result<HandleResponse, EndpointHandleInvocationError>
219/// ```
220///
221/// Route identifiers, methods, and paths appear only in this invocation. The
222/// generated implementation publishes them through `describe` and dispatches
223/// `handle` to the selected method without an application-owned string match.
224/// Duplicate declarations fail during const evaluation:
225///
226/// ```compile_fail
227/// use lenso_capability_http_endpoint::{
228///     EndpointHandleInvocationError, HandleRequest, HandleResponse, http_endpoint,
229/// };
230/// use lenso_kernel::InvocationContext;
231///
232/// #[derive(Clone, Debug)]
233/// struct DuplicateRoutes;
234///
235/// impl DuplicateRoutes {
236///     async fn handle(
237///         &self,
238///         _context: InvocationContext,
239///         _request: HandleRequest,
240///     ) -> Result<HandleResponse, EndpointHandleInvocationError> {
241///         unimplemented!()
242///     }
243/// }
244///
245/// http_endpoint! {
246///     impl DuplicateRoutes {
247///         "orders.read" => ("GET", "/orders/{order_id}") => handle,
248///         "orders.read" => ("GET", "/orders/{another_id}") => handle,
249///     }
250/// }
251/// ```
252#[macro_export]
253macro_rules! http_endpoint {
254    (
255        impl $provider:ty {
256            $(
257                $route_id:literal => ($method:literal, $path:literal) => $handler:ident
258            ),+ $(,)?
259        }
260    ) => {
261        const _: () = {
262            const ROUTES: &[$crate::EndpointRoute] = &[
263                $(
264                    $crate::EndpointRoute::new($route_id, $method, $path),
265                )+
266            ];
267            $crate::validate_endpoint_routes(ROUTES);
268        };
269
270        impl $crate::HttpEndpoint for $provider {
271            const ROUTES: &'static [$crate::EndpointRoute] = &[
272                $(
273                    $crate::EndpointRoute::new($route_id, $method, $path),
274                )+
275            ];
276
277            fn dispatch(
278                &self,
279                context: $crate::__private::InvocationContext,
280                request: $crate::HandleRequest,
281            ) -> $crate::EndpointFuture {
282                let provider = self.clone();
283                Box::pin(async move {
284                    let route_id = request.route_id.clone();
285                    match route_id.as_str() {
286                        $(
287                            $route_id => match provider.$handler(context, request).await {
288                                Ok(response) => Ok(Ok(response)),
289                                Err($crate::EndpointHandleInvocationError::Domain(error)) => {
290                                    Ok(Err(error))
291                                }
292                                Err($crate::EndpointHandleInvocationError::Runtime(error)) => {
293                                    Err(error)
294                                }
295                            },
296                        )+
297                        _ => Ok(Err($crate::HandleError::Rejected)),
298                    }
299                })
300            }
301        }
302    };
303}
304
305#[doc(hidden)]
306pub mod __private {
307    pub use lenso_kernel::InvocationContext;
308}
309
310#[cfg(test)]
311mod tests {
312    use super::{EndpointRoute, validate_endpoint_routes};
313
314    #[test]
315    fn route_validation_accepts_canonical_static_routes() {
316        validate_endpoint_routes(&[
317            EndpointRoute::new("orders.create", "POST", "/orders"),
318            EndpointRoute::new("orders.read", "GET", "/orders/{order_id}"),
319        ]);
320    }
321
322    #[test]
323    #[should_panic(expected = "HTTP route ids must be unique")]
324    fn route_validation_rejects_duplicate_ids() {
325        validate_endpoint_routes(&[
326            EndpointRoute::new("orders.read", "GET", "/orders/{order_id}"),
327            EndpointRoute::new("orders.read", "GET", "/orders/{another_id}"),
328        ]);
329    }
330
331    #[test]
332    #[should_panic(expected = "HTTP method and path pairs must be unique")]
333    fn route_validation_rejects_duplicate_method_and_path_pairs() {
334        validate_endpoint_routes(&[
335            EndpointRoute::new("orders.read", "GET", "/orders/{order_id}"),
336            EndpointRoute::new("orders.copy", "GET", "/orders/{order_id}"),
337        ]);
338    }
339
340    #[test]
341    #[should_panic(expected = "HTTP route method is invalid")]
342    fn route_validation_rejects_noncanonical_methods() {
343        validate_endpoint_routes(&[EndpointRoute::new(
344            "orders.read",
345            "get",
346            "/orders/{order_id}",
347        )]);
348    }
349}