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
286pub fn outcome_to_response<Out, E>(outcome: Outcome<Out, E>) -> HttpResponse
287where
288    Out: IntoResponse,
289    E: std::fmt::Debug,
290{
291    outcome_to_response_with_error(outcome, |error| {
292        (
293            StatusCode::INTERNAL_SERVER_ERROR,
294            format!("Error: {:?}", error),
295        )
296            .into_response()
297    })
298}
299
300pub fn outcome_to_response_with_error<Out, E, F>(
301    outcome: Outcome<Out, E>,
302    on_fault: F,
303) -> HttpResponse
304where
305    Out: IntoResponse,
306    F: FnOnce(&E) -> HttpResponse,
307{
308    match outcome {
309        Outcome::Next(output) => output.into_response(),
310        Outcome::Fault(error) => on_fault(&error),
311        _ => "OK".into_response(),
312    }
313}
314
315#[cfg(test)]
316mod tests {
317    use super::*;
318    use ranvier_core::Outcome;
319
320    #[test]
321    fn string_into_response_sets_200_and_text_body() {
322        let response = "hello".to_string().into_response();
323        assert_eq!(response.status(), StatusCode::OK);
324    }
325
326    #[test]
327    fn tuple_into_response_preserves_status_code() {
328        let response = (StatusCode::CREATED, "created").into_response();
329        assert_eq!(response.status(), StatusCode::CREATED);
330    }
331
332    #[test]
333    fn outcome_fault_maps_to_internal_server_error() {
334        let response = outcome_to_response::<String, &str>(Outcome::Fault("boom"));
335        assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
336    }
337
338    #[test]
339    fn json_error_response_sets_json_content_type() {
340        let response = json_error_response(StatusCode::UNAUTHORIZED, "forbidden");
341        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
342        assert_eq!(
343            response
344                .headers()
345                .get(CONTENT_TYPE)
346                .and_then(|value| value.to_str().ok()),
347            Some("application/json")
348        );
349    }
350
351    #[test]
352    fn problem_detail_new_sets_defaults() {
353        let pd = ProblemDetail::new(404, "Not Found");
354        assert_eq!(pd.status, 404);
355        assert_eq!(pd.title, "Not Found");
356        assert_eq!(pd.type_uri, "about:blank");
357        assert!(pd.detail.is_none());
358        assert!(pd.instance.is_none());
359        assert!(pd.extensions.is_empty());
360    }
361
362    #[test]
363    fn problem_detail_builder_methods() {
364        let pd = ProblemDetail::new(400, "Bad Request")
365            .with_type_uri("https://ranvier.studio/errors/validation")
366            .with_detail("2 validation errors")
367            .with_instance("/api/todos")
368            .with_extension("trace_id", "abc123");
369        assert_eq!(pd.type_uri, "https://ranvier.studio/errors/validation");
370        assert_eq!(pd.detail.as_deref(), Some("2 validation errors"));
371        assert_eq!(pd.instance.as_deref(), Some("/api/todos"));
372        assert_eq!(pd.extensions.get("trace_id").unwrap(), "abc123");
373    }
374
375    #[test]
376    fn problem_detail_into_response_sets_problem_json_content_type() {
377        let pd = ProblemDetail::new(404, "Not Found");
378        let response = pd.into_response();
379        assert_eq!(response.status(), StatusCode::NOT_FOUND);
380        assert_eq!(
381            response
382                .headers()
383                .get(CONTENT_TYPE)
384                .and_then(|v| v.to_str().ok()),
385            Some("application/problem+json")
386        );
387    }
388
389    #[test]
390    fn problem_detail_serialization_roundtrip() {
391        let pd = ProblemDetail::new(500, "Internal Server Error")
392            .with_detail("Something went wrong")
393            .with_extension("transition", "GetUser");
394        let json = serde_json::to_string(&pd).unwrap();
395        let pd2: ProblemDetail = serde_json::from_str(&json).unwrap();
396        assert_eq!(pd2.status, 500);
397        assert_eq!(pd2.title, "Internal Server Error");
398        assert_eq!(pd2.detail.as_deref(), Some("Something went wrong"));
399    }
400
401    #[test]
402    fn outcome_to_problem_response_maps_fault_to_rfc7807() {
403        struct MyError;
404        impl IntoProblemDetail for MyError {
405            fn into_problem_detail(&self) -> ProblemDetail {
406                ProblemDetail::new(422, "Unprocessable Entity")
407            }
408        }
409        let outcome: Outcome<String, MyError> = Outcome::Fault(MyError);
410        let response = outcome_to_problem_response(outcome);
411        assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
412    }
413}