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#[cfg(test)]
332mod tests {
333    use super::*;
334    use ranvier_core::Outcome;
335
336    #[test]
337    fn string_into_response_sets_200_and_text_body() {
338        let response = "hello".to_string().into_response();
339        assert_eq!(response.status(), StatusCode::OK);
340    }
341
342    #[test]
343    fn tuple_into_response_preserves_status_code() {
344        let response = (StatusCode::CREATED, "created").into_response();
345        assert_eq!(response.status(), StatusCode::CREATED);
346    }
347
348    #[test]
349    fn outcome_fault_maps_to_internal_server_error() {
350        let response = outcome_to_response::<String, &str>(Outcome::Fault("boom"));
351        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
352    }
353
354    #[test]
355    fn json_error_response_sets_json_content_type() {
356        let response = json_error_response(StatusCode::UNAUTHORIZED, "forbidden");
357        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
358        assert_eq!(
359            response
360                .headers()
361                .get(CONTENT_TYPE)
362                .and_then(|value| value.to_str().ok()),
363            Some("application/json")
364        );
365    }
366
367    #[test]
368    fn problem_detail_new_sets_defaults() {
369        let pd = ProblemDetail::new(404, "Not Found");
370        assert_eq!(pd.status, 404);
371        assert_eq!(pd.title, "Not Found");
372        assert_eq!(pd.type_uri, "about:blank");
373        assert!(pd.detail.is_none());
374        assert!(pd.instance.is_none());
375        assert!(pd.extensions.is_empty());
376    }
377
378    #[test]
379    fn problem_detail_builder_methods() {
380        let pd = ProblemDetail::new(400, "Bad Request")
381            .with_type_uri("https://ranvier.studio/errors/validation")
382            .with_detail("2 validation errors")
383            .with_instance("/api/todos")
384            .with_extension("trace_id", "abc123");
385        assert_eq!(pd.type_uri, "https://ranvier.studio/errors/validation");
386        assert_eq!(pd.detail.as_deref(), Some("2 validation errors"));
387        assert_eq!(pd.instance.as_deref(), Some("/api/todos"));
388        assert_eq!(pd.extensions.get("trace_id").unwrap(), "abc123");
389    }
390
391    #[test]
392    fn problem_detail_into_response_sets_problem_json_content_type() {
393        let pd = ProblemDetail::new(404, "Not Found");
394        let response = pd.into_response();
395        assert_eq!(response.status(), StatusCode::NOT_FOUND);
396        assert_eq!(
397            response
398                .headers()
399                .get(CONTENT_TYPE)
400                .and_then(|v| v.to_str().ok()),
401            Some("application/problem+json")
402        );
403    }
404
405    #[test]
406    fn problem_detail_serialization_roundtrip() {
407        let pd = ProblemDetail::new(500, "Internal Server Error")
408            .with_detail("Something went wrong")
409            .with_extension("transition", "GetUser");
410        let json = serde_json::to_string(&pd).unwrap();
411        let pd2: ProblemDetail = serde_json::from_str(&json).unwrap();
412        assert_eq!(pd2.status, 500);
413        assert_eq!(pd2.title, "Internal Server Error");
414        assert_eq!(pd2.detail.as_deref(), Some("Something went wrong"));
415    }
416
417    #[test]
418    fn outcome_to_problem_response_maps_fault_to_rfc7807() {
419        struct MyError;
420        impl IntoProblemDetail for MyError {
421            fn into_problem_detail(&self) -> ProblemDetail {
422                ProblemDetail::new(422, "Unprocessable Entity")
423            }
424        }
425        let outcome: Outcome<String, MyError> = Outcome::Fault(MyError);
426        let response = outcome_to_problem_response(outcome);
427        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
428    }
429}