Skip to main content

ranvier_http/
response.rs

1use bytes::Bytes;
2use http::header::CONTENT_TYPE;
3use http::{Response, StatusCode};
4use http_body_util::combinators::BoxBody;
5use http_body_util::{BodyExt, Full};
6use ranvier_core::Outcome;
7use std::convert::Infallible;
8
9pub type HttpResponse = Response<BoxBody<Bytes, Infallible>>;
10
11pub trait IntoResponse {
12    fn into_response(self) -> HttpResponse;
13}
14
15pub fn json_error_response(status: StatusCode, message: impl Into<String>) -> HttpResponse {
16    let payload = serde_json::json!({ "error": message.into() });
17    Response::builder()
18        .status(status)
19        .header(CONTENT_TYPE, "application/json")
20        .body(
21            Full::new(Bytes::from(payload.to_string()))
22                .map_err(|never| match never {})
23                .boxed(),
24        )
25        .expect("response builder should be infallible")
26}
27
28/// HTML response wrapper.
29///
30/// Wraps a string body with `Content-Type: text/html; charset=utf-8`.
31///
32/// # Example
33///
34/// ```rust,ignore
35/// Outcome::next(Html("<h1>Hello</h1>".to_string()))
36/// ```
37#[derive(Debug, Clone)]
38pub struct Html(pub String);
39
40impl IntoResponse for Html {
41    fn into_response(self) -> HttpResponse {
42        Response::builder()
43            .status(StatusCode::OK)
44            .header(CONTENT_TYPE, "text/html; charset=utf-8")
45            .body(
46                Full::new(Bytes::from(self.0))
47                    .map_err(|never| match never {})
48                    .boxed(),
49            )
50            .expect("response builder should be infallible")
51    }
52}
53
54impl IntoResponse for (StatusCode, Html) {
55    fn into_response(self) -> HttpResponse {
56        Response::builder()
57            .status(self.0)
58            .header(CONTENT_TYPE, "text/html; charset=utf-8")
59            .body(
60                Full::new(Bytes::from((self.1).0))
61                    .map_err(|never| match never {})
62                    .boxed(),
63            )
64            .expect("response builder should be infallible")
65    }
66}
67
68impl IntoResponse for HttpResponse {
69    fn into_response(self) -> HttpResponse {
70        self
71    }
72}
73
74impl IntoResponse for String {
75    fn into_response(self) -> HttpResponse {
76        Response::builder()
77            .status(StatusCode::OK)
78            .header(CONTENT_TYPE, "text/plain; charset=utf-8")
79            .body(
80                Full::new(Bytes::from(self))
81                    .map_err(|never| match never {})
82                    .boxed(),
83            )
84            .expect("response builder should be infallible")
85    }
86}
87
88impl IntoResponse for &'static str {
89    fn into_response(self) -> HttpResponse {
90        Response::builder()
91            .status(StatusCode::OK)
92            .header(CONTENT_TYPE, "text/plain; charset=utf-8")
93            .body(
94                Full::new(Bytes::from(self))
95                    .map_err(|never| match never {})
96                    .boxed(),
97            )
98            .expect("response builder should be infallible")
99    }
100}
101
102impl IntoResponse for Bytes {
103    fn into_response(self) -> HttpResponse {
104        Response::builder()
105            .status(StatusCode::OK)
106            .header(CONTENT_TYPE, "application/octet-stream")
107            .body(Full::new(self).map_err(|never| match never {}).boxed())
108            .expect("response builder should be infallible")
109    }
110}
111
112impl IntoResponse for serde_json::Value {
113    fn into_response(self) -> HttpResponse {
114        Response::builder()
115            .status(StatusCode::OK)
116            .header(CONTENT_TYPE, "application/json")
117            .body(
118                Full::new(Bytes::from(self.to_string()))
119                    .map_err(|never| match never {})
120                    .boxed(),
121            )
122            .expect("response builder should be infallible")
123    }
124}
125
126impl IntoResponse for () {
127    fn into_response(self) -> HttpResponse {
128        Response::builder()
129            .status(StatusCode::NO_CONTENT)
130            .body(
131                Full::new(Bytes::new())
132                    .map_err(|never| match never {})
133                    .boxed(),
134            )
135            .expect("response builder should be infallible")
136    }
137}
138
139impl IntoResponse for (StatusCode, String) {
140    fn into_response(self) -> HttpResponse {
141        Response::builder()
142            .status(self.0)
143            .header(CONTENT_TYPE, "text/plain; charset=utf-8")
144            .body(
145                Full::new(Bytes::from(self.1))
146                    .map_err(|never| match never {})
147                    .boxed(),
148            )
149            .expect("response builder should be infallible")
150    }
151}
152
153impl IntoResponse for (StatusCode, &'static str) {
154    fn into_response(self) -> HttpResponse {
155        Response::builder()
156            .status(self.0)
157            .header(CONTENT_TYPE, "text/plain; charset=utf-8")
158            .body(
159                Full::new(Bytes::from(self.1))
160                    .map_err(|never| match never {})
161                    .boxed(),
162            )
163            .expect("response builder should be infallible")
164    }
165}
166
167impl IntoResponse for (StatusCode, Bytes) {
168    fn into_response(self) -> HttpResponse {
169        Response::builder()
170            .status(self.0)
171            .header(CONTENT_TYPE, "application/octet-stream")
172            .body(Full::new(self.1).map_err(|never| match never {}).boxed())
173            .expect("response builder should be infallible")
174    }
175}
176
177// ── RFC 7807 Problem Details ──
178
179/// RFC 7807 Problem Details for HTTP APIs.
180///
181/// Provides a standardized error response format with `Content-Type: application/problem+json`.
182///
183/// # Example
184///
185/// ```rust,ignore
186/// ProblemDetail::new(404, "Resource Not Found")
187///     .with_detail("Todo with id 42 was not found")
188///     .with_instance("/api/todos/42")
189///     .with_extension("trace_id", "abc123")
190/// ```
191#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
192pub struct ProblemDetail {
193    /// A URI reference identifying the problem type (default: "about:blank").
194    #[serde(rename = "type")]
195    pub type_uri: String,
196    /// A short, human-readable summary of the problem type.
197    pub title: String,
198    /// The HTTP status code.
199    pub status: u16,
200    /// A human-readable explanation specific to this occurrence.
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub detail: Option<String>,
203    /// A URI reference identifying the specific occurrence.
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub instance: Option<String>,
206    /// Additional properties (trace_id, transition, axon, etc.).
207    #[serde(skip_serializing_if = "std::collections::HashMap::is_empty")]
208    pub extensions: std::collections::HashMap<String, serde_json::Value>,
209}
210
211impl ProblemDetail {
212    /// Create a new ProblemDetail with status and title.
213    pub fn new(status: u16, title: impl Into<String>) -> Self {
214        Self {
215            type_uri: "about:blank".to_string(),
216            title: title.into(),
217            status,
218            detail: None,
219            instance: None,
220            extensions: std::collections::HashMap::new(),
221        }
222    }
223
224    /// Set the problem type URI.
225    pub fn with_type_uri(mut self, uri: impl Into<String>) -> Self {
226        self.type_uri = uri.into();
227        self
228    }
229
230    /// Set the detail message.
231    pub fn with_detail(mut self, detail: impl Into<String>) -> Self {
232        self.detail = Some(detail.into());
233        self
234    }
235
236    /// Set the instance URI.
237    pub fn with_instance(mut self, instance: impl Into<String>) -> Self {
238        self.instance = Some(instance.into());
239        self
240    }
241
242    /// Add an extension property.
243    pub fn with_extension(mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
244        self.extensions.insert(key.into(), value.into());
245        self
246    }
247}
248
249impl IntoResponse for ProblemDetail {
250    fn into_response(self) -> HttpResponse {
251        let status = StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR);
252        let body = serde_json::to_string(&self).unwrap_or_default();
253        Response::builder()
254            .status(status)
255            .header(CONTENT_TYPE, "application/problem+json")
256            .body(
257                Full::new(Bytes::from(body))
258                    .map_err(|never| match never {})
259                    .boxed(),
260            )
261            .expect("response builder should be infallible")
262    }
263}
264
265/// Trait for converting error types into RFC 7807 ProblemDetail.
266///
267/// Implement this trait on your error types to enable automatic
268/// `Outcome::Fault` → `ProblemDetail` conversion.
269pub trait IntoProblemDetail {
270    fn into_problem_detail(&self) -> ProblemDetail;
271}
272
273/// Convert an `Outcome` to a response, using RFC 7807 for faults.
274pub fn outcome_to_problem_response<Out, E>(outcome: Outcome<Out, E>) -> HttpResponse
275where
276    Out: IntoResponse,
277    E: IntoProblemDetail,
278{
279    match outcome {
280        Outcome::Next(output) => output.into_response(),
281        Outcome::Fault(error) => error.into_problem_detail().into_response(),
282        _ => "OK".into_response(),
283    }
284}
285
286/// Convert an `Outcome` to an HTTP response with a safe default error handler.
287///
288/// In **debug builds** (`cfg(debug_assertions)`), the error's `Debug` output is
289/// included in the response body to aid local development. In **release builds**,
290/// only a generic "Internal server error" message is returned to prevent
291/// information leakage (database details, file paths, internal types, etc.).
292///
293/// For custom error formatting, use [`outcome_to_response_with_error`] or
294/// [`outcome_to_problem_response`] with [`IntoProblemDetail`].
295pub fn outcome_to_response<Out, E>(outcome: Outcome<Out, E>) -> HttpResponse
296where
297    Out: IntoResponse,
298    E: std::fmt::Debug,
299{
300    outcome_to_response_with_error(outcome, |error| {
301        if cfg!(debug_assertions) {
302            (
303                StatusCode::INTERNAL_SERVER_ERROR,
304                format!("Error: {:?}", error),
305            )
306                .into_response()
307        } else {
308            json_error_response(
309                StatusCode::INTERNAL_SERVER_ERROR,
310                "Internal server error",
311            )
312        }
313    })
314}
315
316pub fn outcome_to_response_with_error<Out, E, F>(
317    outcome: Outcome<Out, E>,
318    on_fault: F,
319) -> HttpResponse
320where
321    Out: IntoResponse,
322    F: FnOnce(&E) -> HttpResponse,
323{
324    match outcome {
325        Outcome::Next(output) => output.into_response(),
326        Outcome::Fault(error) => on_fault(&error),
327        _ => "OK".into_response(),
328    }
329}
330
331/// A wrapper for Askama templates that implements `IntoResponse`.
332///
333/// Renders the template to HTML and returns a `200 OK` response with
334/// `text/html; charset=utf-8` content type. On render error, returns
335/// `500 Internal Server Error` with a JSON error body.
336///
337/// # Example
338///
339/// ```rust,ignore
340/// use askama::Template;
341/// use ranvier_http::response::TemplateResponse;
342///
343/// #[derive(Template)]
344/// #[template(path = "index.html")]
345/// struct IndexPage { title: String }
346///
347/// let response = TemplateResponse(IndexPage { title: "Home".into() });
348/// // response.into_response() → 200 OK, text/html
349/// ```
350#[cfg(feature = "askama")]
351pub struct TemplateResponse<T: askama::Template>(pub T);
352
353#[cfg(feature = "askama")]
354impl<T: askama::Template> IntoResponse for TemplateResponse<T> {
355    fn into_response(self) -> HttpResponse {
356        match self.0.render() {
357            Ok(html) => Response::builder()
358                .status(StatusCode::OK)
359                .header(CONTENT_TYPE, "text/html; charset=utf-8")
360                .body(
361                    Full::new(Bytes::from(html))
362                        .map_err(|never| match never {})
363                        .boxed(),
364                )
365                .expect("valid HTTP response construction"),
366            Err(e) => json_error_response(
367                StatusCode::INTERNAL_SERVER_ERROR,
368                &format!("Template render error: {}", e),
369            ),
370        }
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377    use ranvier_core::Outcome;
378
379    #[test]
380    fn string_into_response_sets_200_and_text_body() {
381        let response = "hello".to_string().into_response();
382        assert_eq!(response.status(), StatusCode::OK);
383    }
384
385    #[test]
386    fn tuple_into_response_preserves_status_code() {
387        let response = (StatusCode::CREATED, "created").into_response();
388        assert_eq!(response.status(), StatusCode::CREATED);
389    }
390
391    #[test]
392    fn outcome_fault_maps_to_internal_server_error() {
393        let response = outcome_to_response::<String, &str>(Outcome::Fault("boom"));
394        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
395    }
396
397    #[test]
398    fn json_error_response_sets_json_content_type() {
399        let response = json_error_response(StatusCode::UNAUTHORIZED, "forbidden");
400        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
401        assert_eq!(
402            response
403                .headers()
404                .get(CONTENT_TYPE)
405                .and_then(|value| value.to_str().ok()),
406            Some("application/json")
407        );
408    }
409
410    #[test]
411    fn problem_detail_new_sets_defaults() {
412        let pd = ProblemDetail::new(404, "Not Found");
413        assert_eq!(pd.status, 404);
414        assert_eq!(pd.title, "Not Found");
415        assert_eq!(pd.type_uri, "about:blank");
416        assert!(pd.detail.is_none());
417        assert!(pd.instance.is_none());
418        assert!(pd.extensions.is_empty());
419    }
420
421    #[test]
422    fn problem_detail_builder_methods() {
423        let pd = ProblemDetail::new(400, "Bad Request")
424            .with_type_uri("https://ranvier.studio/errors/validation")
425            .with_detail("2 validation errors")
426            .with_instance("/api/todos")
427            .with_extension("trace_id", "abc123");
428        assert_eq!(pd.type_uri, "https://ranvier.studio/errors/validation");
429        assert_eq!(pd.detail.as_deref(), Some("2 validation errors"));
430        assert_eq!(pd.instance.as_deref(), Some("/api/todos"));
431        assert_eq!(pd.extensions.get("trace_id").unwrap(), "abc123");
432    }
433
434    #[test]
435    fn problem_detail_into_response_sets_problem_json_content_type() {
436        let pd = ProblemDetail::new(404, "Not Found");
437        let response = pd.into_response();
438        assert_eq!(response.status(), StatusCode::NOT_FOUND);
439        assert_eq!(
440            response
441                .headers()
442                .get(CONTENT_TYPE)
443                .and_then(|v| v.to_str().ok()),
444            Some("application/problem+json")
445        );
446    }
447
448    #[test]
449    fn problem_detail_serialization_roundtrip() {
450        let pd = ProblemDetail::new(500, "Internal Server Error")
451            .with_detail("Something went wrong")
452            .with_extension("transition", "GetUser");
453        let json = serde_json::to_string(&pd).unwrap();
454        let pd2: ProblemDetail = serde_json::from_str(&json).unwrap();
455        assert_eq!(pd2.status, 500);
456        assert_eq!(pd2.title, "Internal Server Error");
457        assert_eq!(pd2.detail.as_deref(), Some("Something went wrong"));
458    }
459
460    #[test]
461    fn outcome_to_problem_response_maps_fault_to_rfc7807() {
462        struct MyError;
463        impl IntoProblemDetail for MyError {
464            fn into_problem_detail(&self) -> ProblemDetail {
465                ProblemDetail::new(422, "Unprocessable Entity")
466            }
467        }
468        let outcome: Outcome<String, MyError> = Outcome::Fault(MyError);
469        let response = outcome_to_problem_response(outcome);
470        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
471    }
472}