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 openapi: Option<&'static str>,
52}
53
54impl EndpointRoute {
55 #[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 #[must_use]
72 pub const fn with_openapi(mut self, operation: &'static str) -> Self {
73 self.openapi = Some(operation);
74 self
75 }
76
77 #[must_use]
79 pub const fn route_id(self) -> &'static str {
80 self.route_id
81 }
82
83 #[must_use]
85 pub const fn method(self) -> &'static str {
86 self.method
87 }
88
89 #[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
110pub type EndpointFuture = NativeRequestFuture<EndpointHandle>;
112
113pub trait HttpEndpoint: Clone + std::fmt::Debug + 'static {
119 const ROUTES: &'static [EndpointRoute];
121
122 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#[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#[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}