Skip to main content

lenso_capability_http_endpoint/
response.rs

1//! Typed response construction for authored HTTP Endpoint handlers.
2
3use std::{error::Error, fmt};
4
5pub use http::{HeaderName, HeaderValue, StatusCode, header};
6use lenso_kernel::RuntimeFailure;
7use serde::Serialize;
8
9use crate::{
10    EndpointHandleInvocationError, HandleError, HandleResponse, HandleResponseHeadersItem, Json,
11};
12
13const JSON_CONTENT_TYPE: &str = "application/json; charset=utf-8";
14const PROBLEM_CONTENT_TYPE: &str = "application/problem+json; charset=utf-8";
15const TEXT_CONTENT_TYPE: &str = "text/plain; charset=utf-8";
16
17/// An authoring-time failure while constructing an HTTP response.
18#[derive(Debug)]
19pub enum ResponseBuildError {
20    /// A typed response value could not be serialized as JSON.
21    Json(serde_json::Error),
22    /// The portable Endpoint contract cannot represent a binary header value.
23    NonTextHeaderValue(http::header::ToStrError),
24}
25
26impl fmt::Display for ResponseBuildError {
27    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
28        match self {
29            Self::Json(error) => write!(formatter, "response JSON serialization failed: {error}"),
30            Self::NonTextHeaderValue(error) => {
31                write!(formatter, "response header is not valid text: {error}")
32            }
33        }
34    }
35}
36
37impl Error for ResponseBuildError {}
38
39impl From<serde_json::Error> for ResponseBuildError {
40    fn from(error: serde_json::Error) -> Self {
41        Self::Json(error)
42    }
43}
44
45impl From<ResponseBuildError> for EndpointHandleInvocationError {
46    fn from(error: ResponseBuildError) -> Self {
47        Self::Runtime(RuntimeFailure::Internal {
48            detail: error.to_string(),
49        })
50    }
51}
52
53/// Converts one typed handler value into the portable HTTP response contract.
54pub trait IntoResponse {
55    /// Builds the response or preserves a serialization/header failure.
56    fn into_response(self) -> Result<HandleResponse, ResponseBuildError>;
57}
58
59impl IntoResponse for HandleResponse {
60    fn into_response(self) -> Result<HandleResponse, ResponseBuildError> {
61        Ok(self)
62    }
63}
64
65impl<T> IntoResponse for Json<T>
66where
67    T: Serialize,
68{
69    fn into_response(self) -> Result<HandleResponse, ResponseBuildError> {
70        json(StatusCode::OK, &self.0)
71    }
72}
73
74impl<T> IntoResponse for (StatusCode, T)
75where
76    T: IntoResponse,
77{
78    fn into_response(self) -> Result<HandleResponse, ResponseBuildError> {
79        let (status, body) = self;
80        let mut response = body.into_response()?;
81        response.status = i64::from(status.as_u16());
82        Ok(response)
83    }
84}
85
86/// One intentional RFC 9457-compatible HTTP problem returned by a handler.
87#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
88pub struct Problem {
89    #[serde(rename = "type")]
90    type_uri: &'static str,
91    title: String,
92    status: u16,
93    detail: String,
94    code: String,
95}
96
97impl Problem {
98    /// Creates a problem with a stable machine-readable code.
99    #[must_use]
100    pub fn new(status: StatusCode, code: impl Into<String>, detail: impl Into<String>) -> Self {
101        Self {
102            type_uri: "about:blank",
103            title: status.canonical_reason().unwrap_or("HTTP error").to_owned(),
104            status: status.as_u16(),
105            detail: detail.into(),
106            code: code.into(),
107        }
108    }
109}
110
111impl IntoResponse for Problem {
112    fn into_response(self) -> Result<HandleResponse, ResponseBuildError> {
113        Ok(with_content_type(
114            StatusCode::from_u16(self.status).expect("Problem stores a validated HTTP status"),
115            PROBLEM_CONTENT_TYPE,
116            serde_json::to_vec(&self).expect("serializing a string-only problem cannot fail"),
117        ))
118    }
119}
120
121/// Converts a typed handler error into an intentional response or Capability failure.
122#[doc(hidden)]
123pub trait IntoEndpointError {
124    fn into_endpoint_error(self) -> Result<HandleResponse, EndpointHandleInvocationError>;
125}
126
127impl<T> IntoEndpointError for T
128where
129    T: IntoResponse,
130{
131    fn into_endpoint_error(self) -> Result<HandleResponse, EndpointHandleInvocationError> {
132        self.into_response().map_err(Into::into)
133    }
134}
135
136impl IntoEndpointError for EndpointHandleInvocationError {
137    fn into_endpoint_error(self) -> Result<HandleResponse, EndpointHandleInvocationError> {
138        Err(self)
139    }
140}
141
142/// Lowers one typed handler result into the generated Endpoint contract.
143#[doc(hidden)]
144pub trait IntoEndpointResult {
145    fn into_endpoint_result(self) -> Result<Result<HandleResponse, HandleError>, RuntimeFailure>;
146}
147
148impl<T, E> IntoEndpointResult for Result<T, E>
149where
150    T: IntoResponse,
151    E: IntoEndpointError,
152{
153    fn into_endpoint_result(self) -> Result<Result<HandleResponse, HandleError>, RuntimeFailure> {
154        match self {
155            Ok(response) => {
156                response
157                    .into_response()
158                    .map(Ok)
159                    .map_err(|error| RuntimeFailure::Internal {
160                        detail: error.to_string(),
161                    })
162            }
163            Err(error) => match error.into_endpoint_error() {
164                Ok(response) => Ok(Ok(response)),
165                Err(EndpointHandleInvocationError::Domain(error)) => Ok(Err(error)),
166                Err(EndpointHandleInvocationError::Runtime(error)) => Err(error),
167            },
168        }
169    }
170}
171
172/// Serializes a typed value and returns a JSON response.
173pub fn json(
174    status: StatusCode,
175    body: &impl Serialize,
176) -> Result<HandleResponse, ResponseBuildError> {
177    Ok(with_content_type(
178        status,
179        JSON_CONTENT_TYPE,
180        serde_json::to_vec(body)?,
181    ))
182}
183
184/// Returns an RFC 9457-compatible problem response with a stable extension code.
185pub fn problem(
186    status: StatusCode,
187    code: impl Into<String>,
188    detail: impl Into<String>,
189) -> HandleResponse {
190    Problem::new(status, code, detail)
191        .into_response()
192        .expect("serializing a string-only problem cannot fail")
193}
194
195/// Returns a UTF-8 plain-text response.
196#[must_use]
197pub fn text(status: StatusCode, body: impl Into<String>) -> HandleResponse {
198    with_content_type(status, TEXT_CONTENT_TYPE, body.into().into_bytes())
199}
200
201/// Returns an empty response without a representation content type.
202#[must_use]
203pub fn empty(status: StatusCode) -> HandleResponse {
204    HandleResponse {
205        body: Vec::new().into(),
206        headers: Vec::new(),
207        status: i64::from(status.as_u16()),
208    }
209}
210
211impl HandleResponse {
212    /// Adds one validated HTTP header to an authored response.
213    pub fn with_header(
214        mut self,
215        name: &HeaderName,
216        value: &HeaderValue,
217    ) -> Result<Self, ResponseBuildError> {
218        self.headers.push(HandleResponseHeadersItem {
219            name: name.as_str().to_owned(),
220            value: value
221                .to_str()
222                .map_err(ResponseBuildError::NonTextHeaderValue)?
223                .to_owned(),
224        });
225        Ok(self)
226    }
227}
228
229fn with_content_type(
230    status: StatusCode,
231    content_type: &'static str,
232    body: Vec<u8>,
233) -> HandleResponse {
234    HandleResponse {
235        body: body.into(),
236        headers: vec![HandleResponseHeadersItem {
237            name: header::CONTENT_TYPE.as_str().to_owned(),
238            value: content_type.to_owned(),
239        }],
240        status: i64::from(status.as_u16()),
241    }
242}