Skip to main content

lenso_capability_http_endpoint/
extract.rs

1//! Typed extraction from the portable HTTP Endpoint request.
2
3use futures::future::LocalBoxFuture;
4use lenso_kernel::InvocationContext;
5use serde::de::DeserializeOwned;
6
7use crate::{EndpointHandleInvocationError, HandleRequest, HandleResponse, response};
8
9/// A typed extractor rejection before the authored handler runs.
10#[derive(Debug)]
11pub enum ExtractorRejection {
12    /// Returns one intentional HTTP response.
13    Response(HandleResponse),
14    /// Preserves a Domain Error or Runtime Failure from asynchronous extraction.
15    Invocation(EndpointHandleInvocationError),
16}
17
18impl From<HandleResponse> for ExtractorRejection {
19    fn from(response: HandleResponse) -> Self {
20        Self::Response(response)
21    }
22}
23
24impl From<EndpointHandleInvocationError> for ExtractorRejection {
25    fn from(error: EndpointHandleInvocationError) -> Self {
26        Self::Invocation(error)
27    }
28}
29
30impl From<response::ResponseBuildError> for ExtractorRejection {
31    fn from(error: response::ResponseBuildError) -> Self {
32        Self::Invocation(error.into())
33    }
34}
35
36/// Boxed local extraction result used by an authored Endpoint handler.
37pub type ExtractorFuture<'a, T> = LocalBoxFuture<'a, Result<T, ExtractorRejection>>;
38
39/// Extracts one typed handler argument from an inbound HTTP request.
40///
41/// Extractors may inspect the Endpoint provider, await explicitly bound
42/// Capability clients, and enrich the invocation context for later extractors
43/// and the handler. They must not perform the target Module's final business
44/// authorization decision.
45pub trait FromRequest<P: ?Sized>: Sized {
46    /// Extracts this value or rejects dispatch before the handler runs.
47    fn from_request<'a>(
48        provider: &'a P,
49        context: &'a mut InvocationContext,
50        request: &'a HandleRequest,
51    ) -> ExtractorFuture<'a, Self>;
52}
53
54/// A JSON request body decoded into `T`.
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct Json<T>(pub T);
57
58impl<P, T> FromRequest<P> for Json<T>
59where
60    P: ?Sized,
61    T: DeserializeOwned + 'static,
62{
63    fn from_request<'a>(
64        _provider: &'a P,
65        _context: &'a mut InvocationContext,
66        request: &'a HandleRequest,
67    ) -> ExtractorFuture<'a, Self> {
68        Box::pin(futures::future::ready(extract_json(request)))
69    }
70}
71
72/// Route path parameters decoded into `T` by field name.
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct Path<T>(pub T);
75
76impl<P, T> FromRequest<P> for Path<T>
77where
78    P: ?Sized,
79    T: DeserializeOwned + 'static,
80{
81    fn from_request<'a>(
82        _provider: &'a P,
83        _context: &'a mut InvocationContext,
84        request: &'a HandleRequest,
85    ) -> ExtractorFuture<'a, Self> {
86        Box::pin(futures::future::ready(extract_path(request)))
87    }
88}
89
90/// The URL query string decoded into `T`.
91#[derive(Clone, Debug, Eq, PartialEq)]
92pub struct Query<T>(pub T);
93
94impl<P, T> FromRequest<P> for Query<T>
95where
96    P: ?Sized,
97    T: DeserializeOwned + 'static,
98{
99    fn from_request<'a>(
100        _provider: &'a P,
101        _context: &'a mut InvocationContext,
102        request: &'a HandleRequest,
103    ) -> ExtractorFuture<'a, Self> {
104        Box::pin(futures::future::ready(extract_query(request)))
105    }
106}
107
108/// The trusted request identifier assigned by Web Ingress.
109#[derive(Clone, Debug, Eq, PartialEq)]
110pub struct RequestId(pub String);
111
112impl<P> FromRequest<P> for RequestId
113where
114    P: ?Sized,
115{
116    fn from_request<'a>(
117        _provider: &'a P,
118        _context: &'a mut InvocationContext,
119        request: &'a HandleRequest,
120    ) -> ExtractorFuture<'a, Self> {
121        Box::pin(futures::future::ready(Ok(Self(request.request_id.clone()))))
122    }
123}
124
125fn extract_json<T>(request: &HandleRequest) -> Result<Json<T>, ExtractorRejection>
126where
127    T: DeserializeOwned,
128{
129    if !has_json_content_type(request) {
130        return Err(response::problem(
131            response::StatusCode::UNSUPPORTED_MEDIA_TYPE,
132            "json_content_type_required",
133            "The request content type must be application/json.",
134        )
135        .into());
136    }
137    serde_json::from_slice(request.body.as_ref())
138        .map(Json)
139        .map_err(|_| {
140            response::problem(
141                response::StatusCode::BAD_REQUEST,
142                "invalid_json_body",
143                "The request body is not valid JSON for this endpoint.",
144            )
145            .into()
146        })
147}
148
149fn extract_path<T>(request: &HandleRequest) -> Result<Path<T>, ExtractorRejection>
150where
151    T: DeserializeOwned,
152{
153    let parameters = request
154        .path_parameters
155        .iter()
156        .map(|parameter| (parameter.name.as_str(), parameter.value.as_str()))
157        .collect::<Vec<_>>();
158    let encoded = serde_urlencoded::to_string(parameters).map_err(|_| invalid_path())?;
159    serde_urlencoded::from_str(&encoded)
160        .map(Path)
161        .map_err(|_| invalid_path())
162}
163
164fn extract_query<T>(request: &HandleRequest) -> Result<Query<T>, ExtractorRejection>
165where
166    T: DeserializeOwned,
167{
168    serde_urlencoded::from_str(request.query.as_deref().unwrap_or_default())
169        .map(Query)
170        .map_err(|_| {
171            response::problem(
172                response::StatusCode::BAD_REQUEST,
173                "invalid_query",
174                "The query string is not valid for this endpoint.",
175            )
176            .into()
177        })
178}
179
180fn has_json_content_type(request: &HandleRequest) -> bool {
181    request.headers.iter().any(|header| {
182        header.name.eq_ignore_ascii_case("content-type")
183            && header
184                .value
185                .split(';')
186                .next()
187                .is_some_and(|value| value.trim().eq_ignore_ascii_case("application/json"))
188    })
189}
190
191fn invalid_path() -> ExtractorRejection {
192    response::problem(
193        response::StatusCode::BAD_REQUEST,
194        "invalid_path_parameters",
195        "The route path parameters are not valid for this endpoint.",
196    )
197    .into()
198}