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