Skip to main content

lenso_capability_http_endpoint/
testing.rs

1//! Socket-free testing tools for authored HTTP Endpoint Plugins.
2
3use std::{error::Error, fmt};
4
5use lenso_kernel::{CancellationToken, InvocationContext, RuntimeFailure};
6use serde::{Serialize, de::DeserializeOwned};
7
8use crate::{
9    EndpointHandleInvocationError, EndpointProvider, HandleError, HandleRequest,
10    HandleRequestHeadersItem, HandleRequestPathParametersItem, HandleResponse, HttpEndpoint,
11    response::StatusCode,
12};
13
14/// A direct test harness for one authored Endpoint Plugin.
15#[derive(Debug)]
16pub struct EndpointTest<P> {
17    provider: P,
18}
19
20impl<P> EndpointTest<P>
21where
22    P: HttpEndpoint,
23{
24    /// Creates a harness without starting Web Ingress or binding a socket.
25    #[must_use]
26    pub fn new(provider: P) -> Self {
27        Self { provider }
28    }
29
30    /// Starts a request using the method and path declared by `route_id`.
31    #[must_use]
32    pub fn request(&self, route_id: impl Into<String>) -> TestRequest<'_, P> {
33        TestRequest {
34            provider: &self.provider,
35            route_id: route_id.into(),
36            body: Vec::new(),
37            headers: Vec::new(),
38            path_parameters: Vec::new(),
39            query: None,
40        }
41    }
42}
43
44/// One direct request being prepared for an [`EndpointTest`].
45#[derive(Debug)]
46pub struct TestRequest<'a, P> {
47    provider: &'a P,
48    route_id: String,
49    body: Vec<u8>,
50    headers: Vec<HandleRequestHeadersItem>,
51    path_parameters: Vec<HandleRequestPathParametersItem>,
52    query: Option<String>,
53}
54
55impl<P> TestRequest<'_, P>
56where
57    P: HttpEndpoint,
58{
59    /// Serializes a JSON body and supplies its content type.
60    pub fn json(mut self, value: &impl Serialize) -> Result<Self, serde_json::Error> {
61        self.body = serde_json::to_vec(value)?;
62        self.headers.push(HandleRequestHeadersItem {
63            name: "content-type".to_owned(),
64            value: "application/json".to_owned(),
65        });
66        Ok(self)
67    }
68
69    /// Encodes typed URL query parameters.
70    pub fn query(mut self, value: &impl Serialize) -> Result<Self, serde_urlencoded::ser::Error> {
71        self.query = Some(serde_urlencoded::to_string(value)?);
72        Ok(self)
73    }
74
75    /// Supplies one path parameter and expands it in the declared path template.
76    #[must_use]
77    pub fn path_parameter(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
78        self.path_parameters.push(HandleRequestPathParametersItem {
79            name: name.into(),
80            value: value.into(),
81        });
82        self
83    }
84
85    /// Supplies one request header.
86    #[must_use]
87    pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
88        self.headers.push(HandleRequestHeadersItem {
89            name: name.into(),
90            value: value.into(),
91        });
92        self
93    }
94
95    /// Invokes the Endpoint directly and returns its intentional HTTP response.
96    pub async fn send(self) -> Result<TestResponse, EndpointTestError> {
97        let route = P::ROUTES
98            .iter()
99            .find(|route| route.route_id() == self.route_id)
100            .ok_or_else(|| EndpointTestError::UnknownRoute(self.route_id.clone()))?;
101        let path = self
102            .path_parameters
103            .iter()
104            .fold(route.path().to_owned(), |path, parameter| {
105                path.replace(&format!("{{{}}}", parameter.name), &parameter.value)
106            });
107        let request = HandleRequest {
108            body: self.body.into(),
109            credential: None,
110            headers: self.headers,
111            method: route.method().to_owned(),
112            path,
113            path_parameters: self.path_parameters,
114            query: self.query,
115            request_id: "endpoint-test-1".to_owned(),
116            route_id: self.route_id,
117        };
118        let context = InvocationContext::new(1, None, CancellationToken::new());
119        let response = self
120            .provider
121            .handle(context, request)
122            .await
123            .map_err(EndpointTestError::Runtime)?
124            .map_err(EndpointTestError::Domain)?;
125        Ok(TestResponse(response))
126    }
127}
128
129/// A response returned by [`EndpointTest`].
130#[derive(Clone, Debug)]
131pub struct TestResponse(HandleResponse);
132
133impl TestResponse {
134    /// Returns the validated HTTP status.
135    #[must_use]
136    pub fn status(&self) -> StatusCode {
137        u16::try_from(self.0.status)
138            .ok()
139            .and_then(|status| StatusCode::from_u16(status).ok())
140            .expect("an Endpoint response must contain a valid HTTP status")
141    }
142
143    /// Deserializes the response body as JSON.
144    pub fn json<T>(&self) -> Result<T, serde_json::Error>
145    where
146        T: DeserializeOwned,
147    {
148        serde_json::from_slice(&self.0.body)
149    }
150
151    /// Returns the first response header matching `name`, ignoring ASCII case.
152    #[must_use]
153    pub fn header(&self, name: &str) -> Option<&str> {
154        self.0
155            .headers
156            .iter()
157            .find(|header| header.name.eq_ignore_ascii_case(name))
158            .map(|header| header.value.as_str())
159    }
160
161    /// Returns the raw portable Endpoint response.
162    #[must_use]
163    pub fn into_inner(self) -> HandleResponse {
164        self.0
165    }
166}
167
168/// A failure to construct or directly invoke an Endpoint test request.
169#[derive(Debug)]
170pub enum EndpointTestError {
171    /// No authored route has the requested stable identifier.
172    UnknownRoute(String),
173    /// The Endpoint intentionally returned a Capability-domain error.
174    Domain(HandleError),
175    /// The Endpoint could not complete because its runtime failed.
176    Runtime(RuntimeFailure),
177}
178
179impl fmt::Display for EndpointTestError {
180    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
181        match self {
182            Self::UnknownRoute(route_id) => {
183                write!(formatter, "unknown Endpoint route `{route_id}`")
184            }
185            Self::Domain(error) => write!(formatter, "Endpoint domain error: {error:?}"),
186            Self::Runtime(error) => write!(formatter, "Endpoint runtime failure: {error:?}"),
187        }
188    }
189}
190
191impl Error for EndpointTestError {}
192
193impl From<EndpointHandleInvocationError> for EndpointTestError {
194    fn from(error: EndpointHandleInvocationError) -> Self {
195        match error {
196            EndpointHandleInvocationError::Domain(error) => Self::Domain(error),
197            EndpointHandleInvocationError::Runtime(error) => Self::Runtime(error),
198        }
199    }
200}