lenso_capability_http_endpoint/
authoring.rs1use lenso_kernel::{InvocationContext, NativeRequestFuture};
2
3use crate::{
4 DescribeRequest, DescribeResponse, DescribeResponseRoutesItem, EndpointDescribe,
5 EndpointHandle, EndpointProvider, HandleRequest, HandleResponse,
6};
7
8#[derive(Clone, Debug)]
10pub struct MiddlewareOutcome {
11 next: Option<(InvocationContext, HandleRequest)>,
12 response: Option<HandleResponse>,
13}
14
15impl MiddlewareOutcome {
16 #[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 #[must_use]
27 pub fn response(response: HandleResponse) -> Self {
28 Self {
29 next: None,
30 response: Some(response),
31 }
32 }
33
34 #[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#[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 #[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 #[must_use]
66 pub const fn route_id(self) -> &'static str {
67 self.route_id
68 }
69
70 #[must_use]
72 pub const fn method(self) -> &'static str {
73 self.method
74 }
75
76 #[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
91pub type EndpointFuture = NativeRequestFuture<EndpointHandle>;
93
94pub trait HttpEndpoint: Clone + std::fmt::Debug + 'static {
100 const ROUTES: &'static [EndpointRoute];
102
103 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#[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#[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}