Skip to main content

caelix_core/
response.rs

1use std::pin::Pin;
2
3use bytes::Bytes;
4use futures_core::Stream;
5use futures_util::StreamExt;
6use http::StatusCode;
7use tokio_util::io::ReaderStream;
8
9use crate::exception::{
10    ForbiddenException, HttpException, InternalServerErrorException, NotFoundException,
11};
12use crate::result::Result;
13
14/// Async sequence of body chunks. Errors use framework [`HttpException`].
15pub type BoxBodyStream = Pin<Box<dyn Stream<Item = Result<Bytes>> + Send>>;
16
17/// Response body: fully buffered, or a live stream of chunks.
18pub enum ResponseBody {
19    /// Public Caelix API.
20    Buffered(Vec<u8>),
21    /// Public Caelix API.
22    Streaming(BoxBodyStream),
23}
24
25impl ResponseBody {
26    /// Runs the `as_buffered` public API operation.
27    pub fn as_buffered(&self) -> Option<&[u8]> {
28        match self {
29            ResponseBody::Buffered(bytes) => Some(bytes.as_slice()),
30            ResponseBody::Streaming(_) => None,
31        }
32    }
33
34    /// Runs the `as_buffered_mut` public API operation.
35    pub fn as_buffered_mut(&mut self) -> Option<&mut Vec<u8>> {
36        match self {
37            ResponseBody::Buffered(bytes) => Some(bytes),
38            ResponseBody::Streaming(_) => None,
39        }
40    }
41
42    /// Runs the `is_streaming` public API operation.
43    pub fn is_streaming(&self) -> bool {
44        matches!(self, ResponseBody::Streaming(_))
45    }
46
47    /// Runs the `is_empty` public API operation.
48    pub fn is_empty(&self) -> bool {
49        match self {
50            ResponseBody::Buffered(bytes) => bytes.is_empty(),
51            ResponseBody::Streaming(_) => false,
52        }
53    }
54}
55
56/// Public Caelix type `HttpResponse`.
57pub struct HttpResponse {
58    /// The `status` value.
59    pub status: StatusCode,
60    /// The `body` value.
61    pub body: ResponseBody,
62    /// The `content_type` value.
63    pub content_type: &'static str,
64    /// Extra response headers applied by the HTTP adapter.
65    ///
66    /// Owned name/value pairs so callers can set dynamic values
67    /// (e.g. `Content-Disposition` with a generated filename). This is a
68    /// simple list, not a full `HeaderMap` with multi-value / typed APIs.
69    pub headers: Vec<(String, String)>,
70}
71
72impl HttpResponse {
73    /// Runs the `new` public API operation.
74    pub fn new(status: StatusCode, body: Vec<u8>, content_type: &'static str) -> Self {
75        Self {
76            status,
77            body: ResponseBody::Buffered(body),
78            content_type,
79            headers: Vec::new(),
80        }
81    }
82
83    /// Serializes `body` as JSON. This is the single place JSON encoding
84    /// happens — everything else below routes through here.
85    pub fn json(status: StatusCode, body: impl serde::Serialize) -> Self {
86        match serde_json::to_vec(&body) {
87            Ok(body) => Self::new(status, body, "application/json"),
88            Err(_) => json_serialization_error_response(),
89        }
90    }
91
92    /// Runs the `text` public API operation.
93    pub fn text(status: StatusCode, body: impl Into<String>) -> Self {
94        Self::new(status, body.into().into_bytes(), "text/plain")
95    }
96
97    /// Runs the `bytes` public API operation.
98    pub fn bytes(status: StatusCode, body: impl Into<Vec<u8>>) -> Self {
99        Self::new(status, body.into(), "application/octet-stream")
100    }
101
102    /// Buffered body bytes, if this response is not streaming.
103    pub fn body_bytes(&self) -> Option<&[u8]> {
104        self.body.as_buffered()
105    }
106
107    /// Append a response header. Values may be dynamic (`String` or `&str`).
108    /// Prefer this when building a response by chaining.
109    pub fn with_header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
110        self.insert_header(name, value);
111        self
112    }
113
114    /// Append a response header in place (e.g. from an interceptor).
115    pub fn insert_header(&mut self, name: impl Into<String>, value: impl Into<String>) {
116        self.headers.push((name.into(), value.into()));
117    }
118}
119
120/// Public Caelix extension trait `IntoCaelixResponse`.
121pub trait IntoCaelixResponse {
122    /// Public Caelix API.
123    fn into_response(self) -> HttpResponse;
124}
125
126/// A body whose shape isn't known at the `Response<T>` type level —
127/// used by `Response::Raw` so a handler can return e.g. `Response<()>`
128/// while still sending an arbitrary JSON/text/bytes payload.
129pub enum Body {
130    /// Public Caelix API.
131    Json(Vec<u8>),
132    /// Public Caelix API.
133    Text(String),
134    /// Public Caelix API.
135    Bytes(Vec<u8>),
136}
137
138impl Body {
139    /// Renamed from `into_response` to avoid reading like the trait
140    /// method of the same name a few lines below — this one takes an
141    /// extra `status` arg and isn't part of `IntoCaelixResponse`.
142    fn respond_with(self, status: StatusCode) -> HttpResponse {
143        match self {
144            Body::Json(bytes) => HttpResponse::new(status, bytes, "application/json"),
145            Body::Text(text) => HttpResponse::text(status, text),
146            Body::Bytes(bytes) => HttpResponse::bytes(status, bytes),
147        }
148    }
149}
150
151/// Public Caelix enumeration `Response`.
152pub enum Response<T> {
153    /// Public Caelix API.
154    Body(T),
155    /// Public Caelix API.
156    WithStatus(StatusCode, T),
157    /// Public Caelix API.
158    Raw(StatusCode, Body),
159    /// Public Caelix API.
160    Empty,
161}
162
163impl Response<()> {
164    /// Runs the `no_content` public API operation.
165    pub fn no_content() -> Self {
166        Response::Empty
167    }
168
169    /// Runs the `text` public API operation.
170    pub fn text(status: StatusCode, value: impl Into<String>) -> Self {
171        Response::Raw(status, Body::Text(value.into()))
172    }
173
174    /// Runs the `bytes` public API operation.
175    pub fn bytes(status: StatusCode, value: impl Into<Vec<u8>>) -> Self {
176        Response::Raw(status, Body::Bytes(value.into()))
177    }
178
179    /// Runs the `json` public API operation.
180    pub fn json(status: StatusCode, value: impl serde::Serialize) -> Self {
181        let bytes = match serde_json::to_vec(&value) {
182            Ok(bytes) => bytes,
183            Err(_) => {
184                return Response::Raw(
185                    StatusCode::INTERNAL_SERVER_ERROR,
186                    Body::Json(json_serialization_error_body()),
187                );
188            }
189        };
190        Response::Raw(status, Body::Json(bytes))
191    }
192
193    /// Stream response chunks as they become ready (chunked transfer encoding
194    /// is applied by the HTTP adapter).
195    pub fn stream(
196        content_type: &'static str,
197        stream: impl Stream<Item = Result<Bytes>> + Send + 'static,
198    ) -> HttpResponse {
199        HttpResponse {
200            status: StatusCode::OK,
201            body: ResponseBody::Streaming(Box::pin(stream)),
202            content_type,
203            headers: Vec::new(),
204        }
205    }
206
207    /// Server-Sent Events: each item is serialized as JSON and framed as
208    /// `data: <json>\n\n` with content type `text/event-stream`.
209    ///
210    /// Also sets `Cache-Control: no-cache` and `X-Accel-Buffering: no` so
211    /// proxies/browsers do not buffer the stream. Does not yet implement the
212    /// full SSE protocol (`id:`, `event:`, `retry:`, Last-Event-ID).
213    pub fn sse<T>(stream: impl Stream<Item = Result<T>> + Send + 'static) -> HttpResponse
214    where
215        T: serde::Serialize + 'static,
216    {
217        let framed = stream.map(|item| {
218            item.and_then(|value| {
219                let json = serde_json::to_string(&value).map_err(|err| {
220                    InternalServerErrorException::new(anyhow::anyhow!(
221                        "failed to serialize SSE event: {err}"
222                    ))
223                })?;
224                Ok(Bytes::from(format!("data: {json}\n\n")))
225            })
226        });
227        Response::stream("text/event-stream", framed)
228            .with_header("Cache-Control", "no-cache")
229            .with_header("X-Accel-Buffering", "no")
230    }
231
232    /// Stream a file from disk in chunks (not loaded fully into memory).
233    ///
234    /// Open errors: `NotFound` → 404, `PermissionDenied` → 403, other IO → 500.
235    pub async fn file(
236        path: impl AsRef<std::path::Path>,
237        content_type: &'static str,
238    ) -> Result<HttpResponse> {
239        let file = tokio::fs::File::open(path)
240            .await
241            .map_err(map_file_open_error)?;
242
243        let stream = ReaderStream::new(file).map(|chunk| {
244            chunk
245                .map(Bytes::from)
246                .map_err(|err| InternalServerErrorException::new(err))
247        });
248
249        Ok(Response::stream(content_type, stream))
250    }
251}
252
253pub(crate) fn map_file_open_error(err: std::io::Error) -> HttpException {
254    match err.kind() {
255        std::io::ErrorKind::NotFound => NotFoundException::new("file not found"),
256        std::io::ErrorKind::PermissionDenied => ForbiddenException::new("permission denied"),
257        _ => InternalServerErrorException::new(err),
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264    use std::io::{Error, ErrorKind};
265
266    #[test]
267    fn map_file_open_error_classifies_io_kinds() {
268        let not_found = map_file_open_error(Error::new(ErrorKind::NotFound, "gone"));
269        assert_eq!(not_found.status, StatusCode::NOT_FOUND);
270
271        let denied = map_file_open_error(Error::new(ErrorKind::PermissionDenied, "nope"));
272        assert_eq!(denied.status, StatusCode::FORBIDDEN);
273
274        let other = map_file_open_error(Error::new(ErrorKind::Other, "disk failed"));
275        assert_eq!(other.status, StatusCode::INTERNAL_SERVER_ERROR);
276    }
277
278    #[test]
279    fn with_header_accepts_dynamic_values() {
280        let filename = format!("report-{}.csv", 123);
281        let response = HttpResponse::text(StatusCode::OK, "a,b\n").with_header(
282            "Content-Disposition",
283            format!("attachment; filename=\"{filename}\""),
284        );
285
286        assert_eq!(
287            response.headers,
288            vec![(
289                "Content-Disposition".to_string(),
290                "attachment; filename=\"report-123.csv\"".to_string()
291            )]
292        );
293    }
294
295    #[test]
296    fn insert_header_mutates_in_place() {
297        let mut response = HttpResponse::text(StatusCode::OK, "ok");
298        response.insert_header("X-Request-Id", "abc-123");
299        assert_eq!(
300            response.headers,
301            vec![("X-Request-Id".to_string(), "abc-123".to_string())]
302        );
303    }
304}
305
306fn json_serialization_error_response() -> HttpResponse {
307    HttpResponse::new(
308        StatusCode::INTERNAL_SERVER_ERROR,
309        json_serialization_error_body(),
310        "application/json",
311    )
312}
313
314fn json_serialization_error_body() -> Vec<u8> {
315    br#"{"status":500,"error":"Internal Server Error","message":"Internal Server Error"}"#.to_vec()
316}
317
318impl IntoCaelixResponse for HttpResponse {
319    fn into_response(self) -> HttpResponse {
320        self
321    }
322}
323
324impl IntoCaelixResponse for String {
325    fn into_response(self) -> HttpResponse {
326        HttpResponse::text(StatusCode::OK, self)
327    }
328}
329
330impl IntoCaelixResponse for &'static str {
331    fn into_response(self) -> HttpResponse {
332        HttpResponse::text(StatusCode::OK, self)
333    }
334}
335
336impl IntoCaelixResponse for HttpException {
337    fn into_response(self) -> HttpResponse {
338        #[derive(serde::Serialize)]
339        struct ErrorBody {
340            status: u16,
341            error: &'static str,
342            message: String,
343            #[serde(skip_serializing_if = "Option::is_none")]
344            errors: Option<std::collections::BTreeMap<String, Vec<String>>>,
345        }
346
347        let (message, errors) = if self.status.is_server_error() {
348            ("Internal Server Error".to_string(), None)
349        } else {
350            (self.message, self.errors)
351        };
352
353        HttpResponse::json(
354            self.status,
355            ErrorBody {
356                status: self.status.as_u16(),
357                error: self.error,
358                message,
359                errors,
360            },
361        )
362    }
363}
364
365impl<T: serde::Serialize> IntoCaelixResponse for Response<T> {
366    fn into_response(self) -> HttpResponse {
367        match self {
368            Response::Body(value) => HttpResponse::json(StatusCode::OK, value),
369            Response::WithStatus(status, value) => HttpResponse::json(status, value),
370            Response::Raw(status, body) => body.respond_with(status),
371            Response::Empty => {
372                HttpResponse::new(StatusCode::NO_CONTENT, Vec::new(), "application/json")
373            }
374        }
375    }
376}