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