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