Skip to main content

siderite_core/
responses.rs

1//! Redirect, streaming, file and header-decorated responses.
2
3use crate::body::Body;
4use crate::error::ApiError;
5use crate::response::{IntoResponse, Response, SUCCESS, status_only};
6use bytes::Bytes;
7use futures_util::Stream;
8use http::header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE, LOCATION};
9use http::{HeaderName, HeaderValue, StatusCode};
10use siderite_openapi::{Operation, SchemaObject, SchemaRegistry};
11use std::error::Error as StdError;
12use std::io::ErrorKind;
13use std::path::Path;
14use std::pin::Pin;
15use std::task::{Context, Poll};
16use std::time::Duration;
17use tokio::io::{AsyncRead, ReadBuf};
18
19/// HTTP redirect (`Location` header).
20///
21/// Constructors never panic: an invalid URI is rendered as a `500` problem
22/// details response. OpenAPI documents a `303` response (range tokens such
23/// as `3XX` are not valid OpenAPI 3.1 status keys).
24#[derive(Debug, Clone)]
25pub struct Redirect {
26    status: StatusCode,
27    location: Result<HeaderValue, ()>,
28}
29
30impl Redirect {
31    /// `303 See Other` redirect to `uri`.
32    #[must_use]
33    pub fn to(uri: &str) -> Self {
34        Self::with_status(StatusCode::SEE_OTHER, uri)
35    }
36
37    /// `307 Temporary Redirect` to `uri` (method and body preserved).
38    #[must_use]
39    pub fn temporary(uri: &str) -> Self {
40        Self::with_status(StatusCode::TEMPORARY_REDIRECT, uri)
41    }
42
43    /// `308 Permanent Redirect` to `uri` (method and body preserved).
44    #[must_use]
45    pub fn permanent(uri: &str) -> Self {
46        Self::with_status(StatusCode::PERMANENT_REDIRECT, uri)
47    }
48
49    fn with_status(status: StatusCode, uri: &str) -> Self {
50        Self {
51            status,
52            location: HeaderValue::from_str(uri).map_err(|_| ()),
53        }
54    }
55}
56
57impl IntoResponse for Redirect {
58    fn into_response(self) -> Response {
59        match self.location {
60            Ok(location) => {
61                let mut response = status_only(self.status);
62                response.headers_mut().insert(LOCATION, location);
63                response
64            }
65            Err(()) => ApiError::internal("invalid redirect URI").into_response(),
66        }
67    }
68
69    fn describe(op: &mut Operation, _registry: &mut SchemaRegistry) {
70        op.add_response("303", "Redirect", None);
71    }
72}
73
74/// Streaming response body built from a [`Stream`] of byte chunks.
75pub struct StreamingResponse {
76    body: Body,
77    content_type: Result<HeaderValue, ()>,
78    status: StatusCode,
79}
80
81impl StreamingResponse {
82    /// Stream `stream` as `application/octet-stream` with status `200`.
83    ///
84    /// Stream errors are forwarded to the HTTP body; they do not panic.
85    #[must_use]
86    pub fn new<S, E>(stream: S) -> Self
87    where
88        S: Stream<Item = Result<Bytes, E>> + Send + 'static,
89        E: Into<Box<dyn StdError + Send + Sync>> + Send + 'static,
90    {
91        Self {
92            body: Body::from_inner(axum::body::Body::from_stream(stream)),
93            content_type: Ok(HeaderValue::from_static("application/octet-stream")),
94            status: StatusCode::OK,
95        }
96    }
97
98    /// Set the `Content-Type`. Invalid values become a `500` problem.
99    #[must_use]
100    pub fn content_type(mut self, value: impl AsRef<str>) -> Self {
101        self.content_type = HeaderValue::from_str(value.as_ref()).map_err(|_| ());
102        self
103    }
104
105    /// Set the HTTP status code.
106    #[must_use]
107    pub fn status(mut self, status: StatusCode) -> Self {
108        self.status = status;
109        self
110    }
111}
112
113impl IntoResponse for StreamingResponse {
114    fn into_response(self) -> Response {
115        let Ok(content_type) = self.content_type else {
116            return ApiError::internal("invalid Content-Type").into_response();
117        };
118        let mut response = Response::new(self.body);
119        *response.status_mut() = self.status;
120        response.headers_mut().insert(CONTENT_TYPE, content_type);
121        response
122    }
123
124    fn describe(op: &mut Operation, _registry: &mut SchemaRegistry) {
125        op.add_response(
126            SUCCESS,
127            "Successful Response",
128            Some(("application/octet-stream", SchemaObject::of_type("string"))),
129        );
130    }
131}
132
133/// File streamed from disk.
134pub struct FileResponse {
135    file: tokio::fs::File,
136    content_type: &'static str,
137    attachment: Option<String>,
138}
139
140impl FileResponse {
141    /// Open `path` for streaming. Missing files yield a `404` problem.
142    ///
143    /// # Errors
144    /// Returns [`ApiError::not_found`] if the file does not exist, or
145    /// [`ApiError::internal`] on other I/O failures.
146    pub async fn open(path: impl AsRef<Path>) -> Result<Self, ApiError> {
147        let path = path.as_ref();
148        let file = tokio::fs::File::open(path).await.map_err(|err| {
149            if err.kind() == ErrorKind::NotFound {
150                ApiError::not_found("File not found.")
151            } else {
152                ApiError::internal(err)
153            }
154        })?;
155        Ok(Self {
156            file,
157            content_type: mime_from_path(path),
158            attachment: None,
159        })
160    }
161
162    /// Send the file as an attachment with `filename` (`Content-Disposition`).
163    #[must_use]
164    pub fn attachment(mut self, filename: impl Into<String>) -> Self {
165        self.attachment = Some(filename.into());
166        self
167    }
168}
169
170impl IntoResponse for FileResponse {
171    fn into_response(self) -> Response {
172        let disposition = match self.attachment.as_deref() {
173            Some(name) => match HeaderValue::from_str(&content_disposition_attachment(name)) {
174                Ok(value) => Some(value),
175                Err(_) => {
176                    return ApiError::internal("invalid attachment filename").into_response();
177                }
178            },
179            None => None,
180        };
181        let mut response = Response::new(Body::from_inner(axum::body::Body::from_stream(
182            FileStream { file: self.file },
183        )));
184        response
185            .headers_mut()
186            .insert(CONTENT_TYPE, HeaderValue::from_static(self.content_type));
187        if let Some(value) = disposition {
188            response.headers_mut().insert(CONTENT_DISPOSITION, value);
189        }
190        response
191    }
192
193    fn describe(op: &mut Operation, _registry: &mut SchemaRegistry) {
194        op.add_response(
195            SUCCESS,
196            "Successful Response",
197            Some(("application/octet-stream", SchemaObject::of_type("string"))),
198        );
199    }
200}
201
202/// Wraps a response and adds headers. Invalid names or values become a `500`
203/// problem instead of panicking.
204pub struct WithHeaders<R> {
205    inner: R,
206    headers: Vec<(HeaderName, HeaderValue)>,
207    error: Option<String>,
208}
209
210impl<R> WithHeaders<R> {
211    /// Wrap `inner` with no extra headers.
212    #[must_use]
213    pub fn new(inner: R) -> Self {
214        Self {
215            inner,
216            headers: Vec::new(),
217            error: None,
218        }
219    }
220
221    /// Append a header. Invalid names or values are recorded and rendered as
222    /// a `500` problem in [`IntoResponse`].
223    #[must_use]
224    pub fn header(mut self, name: impl AsRef<str>, value: impl AsRef<str>) -> Self {
225        if self.error.is_some() {
226            return self;
227        }
228        let name = match HeaderName::try_from(name.as_ref()) {
229            Ok(name) => name,
230            Err(_) => {
231                self.error = Some(format!("invalid header name `{}`", name.as_ref()));
232                return self;
233            }
234        };
235        let value = match HeaderValue::try_from(value.as_ref()) {
236            Ok(value) => value,
237            Err(_) => {
238                self.error = Some(format!("invalid header value for `{name}`"));
239                return self;
240            }
241        };
242        self.headers.push((name, value));
243        self
244    }
245}
246
247impl<R: IntoResponse> IntoResponse for WithHeaders<R> {
248    fn into_response(self) -> Response {
249        if let Some(error) = self.error {
250            return ApiError::internal(error).into_response();
251        }
252        let mut response = self.inner.into_response();
253        for (name, value) in self.headers {
254            response.headers_mut().append(name, value);
255        }
256        response
257    }
258
259    fn describe(op: &mut Operation, registry: &mut SchemaRegistry) {
260        R::describe(op, registry);
261    }
262}
263
264/// Marks a response as shareable by caches: sets
265/// `Cache-Control: public, max-age=<seconds>`, replacing any earlier value.
266///
267/// This is how a handler opts in to `siderite_cache::RouteCache`, which by
268/// default stores only `public` responses.
269pub struct Cached<R> {
270    inner: R,
271    max_age: Duration,
272}
273
274impl<R> Cached<R> {
275    /// Cache `inner` publicly for `max_age` (whole seconds, rounded down).
276    #[must_use]
277    pub fn public(max_age: Duration, inner: R) -> Self {
278        Self { inner, max_age }
279    }
280}
281
282impl<R: IntoResponse> IntoResponse for Cached<R> {
283    fn into_response(self) -> Response {
284        let mut response = self.inner.into_response();
285        let value = format!("public, max-age={}", self.max_age.as_secs());
286        if let Ok(value) = HeaderValue::try_from(value) {
287            response.headers_mut().insert(CACHE_CONTROL, value);
288        }
289        response
290    }
291
292    fn describe(op: &mut Operation, registry: &mut SchemaRegistry) {
293        R::describe(op, registry);
294    }
295}
296
297struct FileStream {
298    file: tokio::fs::File,
299}
300
301impl Stream for FileStream {
302    type Item = Result<Bytes, std::io::Error>;
303
304    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
305        let mut storage = vec![0_u8; 8192];
306        let mut buf = ReadBuf::new(&mut storage);
307        match Pin::new(&mut self.file).poll_read(cx, &mut buf) {
308            Poll::Pending => Poll::Pending,
309            Poll::Ready(Err(err)) => Poll::Ready(Some(Err(err))),
310            Poll::Ready(Ok(())) => {
311                let filled = buf.filled();
312                if filled.is_empty() {
313                    Poll::Ready(None)
314                } else {
315                    Poll::Ready(Some(Ok(Bytes::copy_from_slice(filled))))
316                }
317            }
318        }
319    }
320}
321
322fn mime_from_path(path: &Path) -> &'static str {
323    match path
324        .extension()
325        .and_then(|ext| ext.to_str())
326        .map(str::to_ascii_lowercase)
327        .as_deref()
328    {
329        Some("html") => "text/html; charset=utf-8",
330        Some("css") => "text/css",
331        Some("js") => "text/javascript",
332        Some("json") => "application/json",
333        Some("png") => "image/png",
334        Some("jpg" | "jpeg") => "image/jpeg",
335        Some("gif") => "image/gif",
336        Some("svg") => "image/svg+xml",
337        Some("txt") => "text/plain; charset=utf-8",
338        Some("pdf") => "application/pdf",
339        Some("wasm") => "application/wasm",
340        _ => "application/octet-stream",
341    }
342}
343
344fn content_disposition_attachment(filename: &str) -> String {
345    let escaped = filename.replace('\\', "\\\\").replace('"', "\\\"");
346    format!("attachment; filename=\"{escaped}\"")
347}