Skip to main content

lenso_capability_http_endpoint/
authoring.rs

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