lenso_capability_http_endpoint/
authoring.rs1use futures::future::LocalBoxFuture;
2use lenso_kernel::InvocationContext;
3
4use crate::{
5 DescribeRequest, DescribeResponse, DescribeResponseRoutesItem, EndpointDescribeInvocationError,
6 EndpointHandleInvocationError, EndpointProvider, HandleRequest, HandleResponse,
7};
8
9#[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 #[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 #[must_use]
30 pub const fn route_id(self) -> &'static str {
31 self.route_id
32 }
33
34 #[must_use]
36 pub const fn method(self) -> &'static str {
37 self.method
38 }
39
40 #[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
55pub type EndpointFuture =
57 LocalBoxFuture<'static, Result<HandleResponse, EndpointHandleInvocationError>>;
58
59pub trait HttpEndpoint: Clone + std::fmt::Debug + 'static {
65 const ROUTES: &'static [EndpointRoute];
67
68 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#[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#[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}