1use 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#[derive(Debug, Clone)]
25pub struct Redirect {
26 status: StatusCode,
27 location: Result<HeaderValue, ()>,
28}
29
30impl Redirect {
31 #[must_use]
33 pub fn to(uri: &str) -> Self {
34 Self::with_status(StatusCode::SEE_OTHER, uri)
35 }
36
37 #[must_use]
39 pub fn temporary(uri: &str) -> Self {
40 Self::with_status(StatusCode::TEMPORARY_REDIRECT, uri)
41 }
42
43 #[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
74pub struct StreamingResponse {
76 body: Body,
77 content_type: Result<HeaderValue, ()>,
78 status: StatusCode,
79}
80
81impl StreamingResponse {
82 #[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 #[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 #[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
133pub struct FileResponse {
135 file: tokio::fs::File,
136 content_type: &'static str,
137 attachment: Option<String>,
138}
139
140impl FileResponse {
141 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 #[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
202pub struct WithHeaders<R> {
205 inner: R,
206 headers: Vec<(HeaderName, HeaderValue)>,
207 error: Option<String>,
208}
209
210impl<R> WithHeaders<R> {
211 #[must_use]
213 pub fn new(inner: R) -> Self {
214 Self {
215 inner,
216 headers: Vec::new(),
217 error: None,
218 }
219 }
220
221 #[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
264pub struct Cached<R> {
270 inner: R,
271 max_age: Duration,
272}
273
274impl<R> Cached<R> {
275 #[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}