lenso_capability_http_endpoint/
authoring.rs1use lenso_kernel::{InvocationContext, NativeRequestFuture};
2
3use crate::{
4 DescribeRequest, DescribeResponse, DescribeResponseRoutesItem, EndpointDescribe,
5 EndpointHandle, EndpointProvider, HandleRequest,
6};
7
8#[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 #[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 #[must_use]
29 pub const fn route_id(self) -> &'static str {
30 self.route_id
31 }
32
33 #[must_use]
35 pub const fn method(self) -> &'static str {
36 self.method
37 }
38
39 #[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
54pub type EndpointFuture = NativeRequestFuture<EndpointHandle>;
56
57pub trait HttpEndpoint: Clone + std::fmt::Debug + 'static {
63 const ROUTES: &'static [EndpointRoute];
65
66 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#[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#[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}