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