rama_http/service/fs/
serve_file.rs

1//! Service that serves a file.
2
3use super::ServeDir;
4use crate::dep::{mime::Mime, mime_guess};
5use crate::{HeaderValue, Request, Response};
6use rama_core::{Context, Service};
7use std::path::Path;
8
9/// Service that serves a file.
10#[derive(Clone, Debug)]
11pub struct ServeFile(ServeDir);
12
13// Note that this is just a special case of ServeDir
14impl ServeFile {
15    /// Create a new [`ServeFile`].
16    ///
17    /// The `Content-Type` will be guessed from the file extension.
18    pub fn new<P: AsRef<Path>>(path: P) -> Self {
19        let guess = mime_guess::from_path(path.as_ref());
20        let mime = guess
21            .first_raw()
22            .map(HeaderValue::from_static)
23            .unwrap_or_else(|| {
24                HeaderValue::from_str(mime::APPLICATION_OCTET_STREAM.as_ref()).unwrap()
25            });
26
27        ServeFile(ServeDir::new_single_file(path, mime))
28    }
29
30    /// Create a new [`ServeFile`] with a specific mime type.
31    ///
32    /// # Panics
33    ///
34    /// Will panic if the mime type isn't a valid [header value].
35    ///
36    /// [header value]: https://docs.rs/http/latest/http/header/struct.HeaderValue.html
37    pub fn new_with_mime<P: AsRef<Path>>(path: P, mime: &Mime) -> Self {
38        let mime = HeaderValue::from_str(mime.as_ref()).expect("mime isn't a valid header value");
39        ServeFile(ServeDir::new_single_file(path, mime))
40    }
41
42    /// Informs the service that it should also look for a precompressed gzip
43    /// version of the file.
44    ///
45    /// If the client has an `Accept-Encoding` header that allows the gzip encoding,
46    /// the file `foo.txt.gz` will be served instead of `foo.txt`.
47    /// If the precompressed file is not available, or the client doesn't support it,
48    /// the uncompressed version will be served instead.
49    /// Both the precompressed version and the uncompressed version are expected
50    /// to be present in the same directory. Different precompressed
51    /// variants can be combined.
52    pub fn precompressed_gzip(self) -> Self {
53        Self(self.0.precompressed_gzip())
54    }
55
56    /// Informs the service that it should also look for a precompressed brotli
57    /// version of the file.
58    ///
59    /// If the client has an `Accept-Encoding` header that allows the brotli encoding,
60    /// the file `foo.txt.br` will be served instead of `foo.txt`.
61    /// If the precompressed file is not available, or the client doesn't support it,
62    /// the uncompressed version will be served instead.
63    /// Both the precompressed version and the uncompressed version are expected
64    /// to be present in the same directory. Different precompressed
65    /// variants can be combined.
66    pub fn precompressed_br(self) -> Self {
67        Self(self.0.precompressed_br())
68    }
69
70    /// Informs the service that it should also look for a precompressed deflate
71    /// version of the file.
72    ///
73    /// If the client has an `Accept-Encoding` header that allows the deflate encoding,
74    /// the file `foo.txt.zz` will be served instead of `foo.txt`.
75    /// If the precompressed file is not available, or the client doesn't support it,
76    /// the uncompressed version will be served instead.
77    /// Both the precompressed version and the uncompressed version are expected
78    /// to be present in the same directory. Different precompressed
79    /// variants can be combined.
80    pub fn precompressed_deflate(self) -> Self {
81        Self(self.0.precompressed_deflate())
82    }
83
84    /// Informs the service that it should also look for a precompressed zstd
85    /// version of the file.
86    ///
87    /// If the client has an `Accept-Encoding` header that allows the zstd encoding,
88    /// the file `foo.txt.zst` will be served instead of `foo.txt`.
89    /// If the precompressed file is not available, or the client doesn't support it,
90    /// the uncompressed version will be served instead.
91    /// Both the precompressed version and the uncompressed version are expected
92    /// to be present in the same directory. Different precompressed
93    /// variants can be combined.
94    pub fn precompressed_zstd(self) -> Self {
95        Self(self.0.precompressed_zstd())
96    }
97
98    /// Set a specific read buffer chunk size.
99    ///
100    /// The default capacity is 64kb.
101    pub fn with_buf_chunk_size(self, chunk_size: usize) -> Self {
102        Self(self.0.with_buf_chunk_size(chunk_size))
103    }
104
105    /// Call the service and get a future that contains any `std::io::Error` that might have
106    /// happened.
107    ///
108    /// See [`ServeDir::try_call`] for more details.
109    #[inline]
110    pub async fn try_call<State, ReqBody>(
111        &self,
112        ctx: Context<State>,
113        req: Request<ReqBody>,
114    ) -> Result<Response, std::io::Error>
115    where
116        State: Clone + Send + Sync + 'static,
117        ReqBody: Send + 'static,
118    {
119        self.0.try_call(ctx, req).await
120    }
121}
122
123impl<State, ReqBody> Service<State, Request<ReqBody>> for ServeFile
124where
125    ReqBody: Send + 'static,
126    State: Clone + Send + Sync + 'static,
127{
128    type Error = <ServeDir as Service<State, Request<ReqBody>>>::Error;
129    type Response = <ServeDir as Service<State, Request<ReqBody>>>::Response;
130
131    #[inline]
132    async fn serve(
133        &self,
134        ctx: Context<State>,
135        req: Request<ReqBody>,
136    ) -> Result<Self::Response, Self::Error> {
137        self.0.serve(ctx, req).await
138    }
139}
140
141#[cfg(test)]
142#[cfg(feature = "compression")]
143mod compression_tests {
144    use super::*;
145    use crate::Body;
146
147    #[tokio::test]
148    #[cfg(feature = "compression")]
149    async fn precompressed_zstd() {
150        use async_compression::tokio::bufread::ZstdDecoder;
151        use rama_http_types::dep::http_body_util::BodyExt;
152        use tokio::io::AsyncReadExt;
153
154        let svc = ServeFile::new("../test-files/precompressed.txt").precompressed_zstd();
155        let request = Request::builder()
156            .header("Accept-Encoding", "zstd,br")
157            .body(Body::empty())
158            .unwrap();
159        let res = svc.serve(Context::default(), request).await.unwrap();
160
161        assert_eq!(res.headers()["content-type"], "text/plain");
162        assert_eq!(res.headers()["content-encoding"], "zstd");
163
164        let body = res.into_body().collect().await.unwrap().to_bytes();
165        let mut decoder = ZstdDecoder::new(&body[..]);
166        let mut decompressed = String::new();
167        decoder.read_to_string(&mut decompressed).await.unwrap();
168        assert!(decompressed.starts_with("\"This is a test file!\""));
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use crate::Body;
175    use crate::Method;
176    use crate::dep::http_body_util::BodyExt;
177    use crate::dep::mime::Mime;
178    use crate::header;
179    use crate::service::fs::ServeFile;
180    use crate::{Request, StatusCode};
181    use brotli::BrotliDecompress;
182    use flate2::bufread::DeflateDecoder;
183    use flate2::bufread::GzDecoder;
184    use rama_core::{Context, Service};
185    use std::io::Read;
186    use std::str::FromStr;
187
188    #[tokio::test]
189    async fn basic() {
190        let svc = ServeFile::new("../README.md");
191
192        let res = svc
193            .serve(Context::default(), Request::new(Body::empty()))
194            .await
195            .unwrap();
196
197        assert_eq!(res.headers()["content-type"], "text/markdown");
198
199        let body = res.into_body().collect().await.unwrap().to_bytes();
200        let body = String::from_utf8(body.to_vec()).unwrap();
201
202        assert!(body.starts_with("[![rama banner]"));
203    }
204
205    #[tokio::test]
206    async fn basic_with_mime() {
207        let svc = ServeFile::new_with_mime("../README.md", &Mime::from_str("image/jpg").unwrap());
208
209        let res = svc
210            .serve(Context::default(), Request::new(Body::empty()))
211            .await
212            .unwrap();
213
214        assert_eq!(res.headers()["content-type"], "image/jpg");
215
216        let body = res.into_body().collect().await.unwrap().to_bytes();
217        let body = String::from_utf8(body.to_vec()).unwrap();
218
219        assert!(body.starts_with("[![rama banner]"));
220    }
221
222    #[tokio::test]
223    async fn head_request() {
224        let svc = ServeFile::new("../test-files/precompressed.txt");
225
226        let mut request = Request::new(Body::empty());
227        *request.method_mut() = Method::HEAD;
228        let res = svc.serve(Context::default(), request).await.unwrap();
229
230        assert_eq!(res.headers()["content-type"], "text/plain");
231
232        #[cfg(target_os = "windows")]
233        assert_eq!(res.headers()["content-length"], "24");
234        #[cfg(not(target_os = "windows"))]
235        assert_eq!(res.headers()["content-length"], "23");
236
237        assert!(res.into_body().frame().await.is_none());
238    }
239
240    #[tokio::test]
241    async fn precompresed_head_request() {
242        let svc = ServeFile::new("../test-files/precompressed.txt").precompressed_gzip();
243
244        let request = Request::builder()
245            .header("Accept-Encoding", "gzip")
246            .method(Method::HEAD)
247            .body(Body::empty())
248            .unwrap();
249        let res = svc.serve(Context::default(), request).await.unwrap();
250
251        assert_eq!(res.headers()["content-type"], "text/plain");
252        assert_eq!(res.headers()["content-encoding"], "gzip");
253        assert_eq!(res.headers()["content-length"], "59");
254
255        assert!(res.into_body().frame().await.is_none());
256    }
257
258    #[tokio::test]
259    async fn precompressed_gzip() {
260        let svc = ServeFile::new("../test-files/precompressed.txt").precompressed_gzip();
261
262        let request = Request::builder()
263            .header("Accept-Encoding", "gzip")
264            .body(Body::empty())
265            .unwrap();
266        let res = svc.serve(Context::default(), request).await.unwrap();
267
268        assert_eq!(res.headers()["content-type"], "text/plain");
269        assert_eq!(res.headers()["content-encoding"], "gzip");
270
271        let body = res.into_body().collect().await.unwrap().to_bytes();
272        let mut decoder = GzDecoder::new(&body[..]);
273        let mut decompressed = String::new();
274        decoder.read_to_string(&mut decompressed).unwrap();
275        assert!(decompressed.starts_with("\"This is a test file!\""));
276    }
277
278    #[tokio::test]
279    async fn unsupported_precompression_algorithm_fallbacks_to_uncompressed() {
280        let svc = ServeFile::new("../test-files/precompressed.txt").precompressed_gzip();
281
282        let request = Request::builder()
283            .header("Accept-Encoding", "br")
284            .body(Body::empty())
285            .unwrap();
286        let res = svc.serve(Context::default(), request).await.unwrap();
287
288        assert_eq!(res.headers()["content-type"], "text/plain");
289        assert!(res.headers().get("content-encoding").is_none());
290
291        let body = res.into_body().collect().await.unwrap().to_bytes();
292        let body = String::from_utf8(body.to_vec()).unwrap();
293        assert!(body.starts_with("\"This is a test file!\""));
294    }
295
296    #[tokio::test]
297    async fn missing_precompressed_variant_fallbacks_to_uncompressed() {
298        let svc = ServeFile::new("../test-files/missing_precompressed.txt").precompressed_gzip();
299
300        let request = Request::builder()
301            .header("Accept-Encoding", "gzip")
302            .body(Body::empty())
303            .unwrap();
304        let res = svc.serve(Context::default(), request).await.unwrap();
305
306        assert_eq!(res.headers()["content-type"], "text/plain");
307        // Uncompressed file is served because compressed version is missing
308        assert!(res.headers().get("content-encoding").is_none());
309
310        let body = res.into_body().collect().await.unwrap().to_bytes();
311        let body = String::from_utf8(body.to_vec()).unwrap();
312        assert!(body.starts_with("Test file!"));
313    }
314
315    #[tokio::test]
316    async fn missing_precompressed_variant_fallbacks_to_uncompressed_head_request() {
317        let svc = ServeFile::new("../test-files/missing_precompressed.txt").precompressed_gzip();
318
319        let request = Request::builder()
320            .header("Accept-Encoding", "gzip")
321            .method(Method::HEAD)
322            .body(Body::empty())
323            .unwrap();
324        let res = svc.serve(Context::default(), request).await.unwrap();
325
326        assert_eq!(res.headers()["content-type"], "text/plain");
327        #[cfg(target_os = "windows")]
328        assert_eq!(res.headers()["content-length"], "12");
329        #[cfg(not(target_os = "windows"))]
330        assert_eq!(res.headers()["content-length"], "11");
331        // Uncompressed file is served because compressed version is missing
332        assert!(res.headers().get("content-encoding").is_none());
333
334        assert!(res.into_body().frame().await.is_none());
335    }
336
337    #[tokio::test]
338    async fn only_precompressed_variant_existing() {
339        let svc = ServeFile::new("../test-files/only_gzipped.txt").precompressed_gzip();
340
341        let request = Request::builder().body(Body::empty()).unwrap();
342        let res = svc
343            .clone()
344            .serve(Context::default(), request)
345            .await
346            .unwrap();
347
348        assert_eq!(res.status(), StatusCode::NOT_FOUND);
349
350        // Should reply with gzipped file if client supports it
351        let request = Request::builder()
352            .header("Accept-Encoding", "gzip")
353            .body(Body::empty())
354            .unwrap();
355        let res = svc.serve(Context::default(), request).await.unwrap();
356
357        assert_eq!(res.headers()["content-type"], "text/plain");
358        assert_eq!(res.headers()["content-encoding"], "gzip");
359
360        let body = res.into_body().collect().await.unwrap().to_bytes();
361        let mut decoder = GzDecoder::new(&body[..]);
362        let mut decompressed = String::new();
363        decoder.read_to_string(&mut decompressed).unwrap();
364        assert!(decompressed.starts_with("\"This is a test file\""));
365    }
366
367    #[tokio::test]
368    async fn precompressed_br() {
369        let svc = ServeFile::new("../test-files/precompressed.txt").precompressed_br();
370
371        let request = Request::builder()
372            .header("Accept-Encoding", "gzip,br")
373            .body(Body::empty())
374            .unwrap();
375        let res = svc.serve(Context::default(), request).await.unwrap();
376
377        assert_eq!(res.headers()["content-type"], "text/plain");
378        assert_eq!(res.headers()["content-encoding"], "br");
379
380        let body = res.into_body().collect().await.unwrap().to_bytes();
381        let mut decompressed = Vec::new();
382        BrotliDecompress(&mut &body[..], &mut decompressed).unwrap();
383        let decompressed = String::from_utf8(decompressed.to_vec()).unwrap();
384        assert!(decompressed.starts_with("\"This is a test file!\""));
385    }
386
387    #[tokio::test]
388    async fn precompressed_deflate() {
389        let svc = ServeFile::new("../test-files/precompressed.txt").precompressed_deflate();
390        let request = Request::builder()
391            .header("Accept-Encoding", "deflate,br")
392            .body(Body::empty())
393            .unwrap();
394        let res = svc.serve(Context::default(), request).await.unwrap();
395
396        assert_eq!(res.headers()["content-type"], "text/plain");
397        assert_eq!(res.headers()["content-encoding"], "deflate");
398
399        let body = res.into_body().collect().await.unwrap().to_bytes();
400        let mut decoder = DeflateDecoder::new(&body[..]);
401        let mut decompressed = String::new();
402        decoder.read_to_string(&mut decompressed).unwrap();
403        assert!(decompressed.starts_with("\"This is a test file!\""));
404    }
405
406    #[tokio::test]
407    async fn multi_precompressed() {
408        let svc = ServeFile::new("../test-files/precompressed.txt")
409            .precompressed_gzip()
410            .precompressed_br();
411
412        let request = Request::builder()
413            .header("Accept-Encoding", "gzip")
414            .body(Body::empty())
415            .unwrap();
416        let res = svc
417            .clone()
418            .serve(Context::default(), request)
419            .await
420            .unwrap();
421
422        assert_eq!(res.headers()["content-type"], "text/plain");
423        assert_eq!(res.headers()["content-encoding"], "gzip");
424
425        let body = res.into_body().collect().await.unwrap().to_bytes();
426        let mut decoder = GzDecoder::new(&body[..]);
427        let mut decompressed = String::new();
428        decoder.read_to_string(&mut decompressed).unwrap();
429        assert!(decompressed.starts_with("\"This is a test file!\""));
430
431        let request = Request::builder()
432            .header("Accept-Encoding", "br")
433            .body(Body::empty())
434            .unwrap();
435        let res = svc
436            .clone()
437            .serve(Context::default(), request)
438            .await
439            .unwrap();
440
441        assert_eq!(res.headers()["content-type"], "text/plain");
442        assert_eq!(res.headers()["content-encoding"], "br");
443
444        let body = res.into_body().collect().await.unwrap().to_bytes();
445        let mut decompressed = Vec::new();
446        BrotliDecompress(&mut &body[..], &mut decompressed).unwrap();
447        let decompressed = String::from_utf8(decompressed.to_vec()).unwrap();
448        assert!(decompressed.starts_with("\"This is a test file!\""));
449    }
450
451    #[tokio::test]
452    async fn with_custom_chunk_size() {
453        let svc = ServeFile::new("../README.md").with_buf_chunk_size(1024 * 32);
454
455        let res = svc
456            .serve(Context::default(), Request::new(Body::empty()))
457            .await
458            .unwrap();
459
460        assert_eq!(res.headers()["content-type"], "text/markdown");
461
462        let body = res.into_body().collect().await.unwrap().to_bytes();
463        let body = String::from_utf8(body.to_vec()).unwrap();
464
465        assert!(body.starts_with("[![rama banner]"));
466    }
467
468    #[tokio::test]
469    async fn fallbacks_to_different_precompressed_variant_if_not_found() {
470        let svc = ServeFile::new("../test-files/precompressed_br.txt")
471            .precompressed_gzip()
472            .precompressed_deflate()
473            .precompressed_br();
474
475        let request = Request::builder()
476            .header("Accept-Encoding", "gzip,deflate,br")
477            .body(Body::empty())
478            .unwrap();
479        let res = svc.serve(Context::default(), request).await.unwrap();
480
481        assert_eq!(res.headers()["content-type"], "text/plain");
482        assert_eq!(res.headers()["content-encoding"], "br");
483
484        let body = res.into_body().collect().await.unwrap().to_bytes();
485        let mut decompressed = Vec::new();
486        BrotliDecompress(&mut &body[..], &mut decompressed).unwrap();
487        let decompressed = String::from_utf8(decompressed.to_vec()).unwrap();
488        assert!(decompressed.starts_with("Test file"));
489    }
490
491    #[tokio::test]
492    async fn fallbacks_to_different_precompressed_variant_if_not_found_head_request() {
493        let svc = ServeFile::new("../test-files/precompressed_br.txt")
494            .precompressed_gzip()
495            .precompressed_deflate()
496            .precompressed_br();
497
498        let request = Request::builder()
499            .header("Accept-Encoding", "gzip,deflate,br")
500            .method(Method::HEAD)
501            .body(Body::empty())
502            .unwrap();
503        let res = svc.serve(Context::default(), request).await.unwrap();
504
505        assert_eq!(res.headers()["content-type"], "text/plain");
506        assert_eq!(res.headers()["content-length"], "15");
507        assert_eq!(res.headers()["content-encoding"], "br");
508
509        assert!(res.into_body().frame().await.is_none());
510    }
511
512    #[tokio::test]
513    async fn returns_404_if_file_doesnt_exist() {
514        let svc = ServeFile::new("../this-doesnt-exist.md");
515
516        let res = svc
517            .serve(Context::default(), Request::new(Body::empty()))
518            .await
519            .unwrap();
520
521        assert_eq!(res.status(), StatusCode::NOT_FOUND);
522        assert!(res.headers().get(header::CONTENT_TYPE).is_none());
523    }
524
525    #[tokio::test]
526    async fn returns_404_if_file_doesnt_exist_when_precompression_is_used() {
527        let svc = ServeFile::new("../this-doesnt-exist.md").precompressed_deflate();
528
529        let request = Request::builder()
530            .header("Accept-Encoding", "deflate")
531            .body(Body::empty())
532            .unwrap();
533        let res = svc.serve(Context::default(), request).await.unwrap();
534
535        assert_eq!(res.status(), StatusCode::NOT_FOUND);
536        assert!(res.headers().get(header::CONTENT_TYPE).is_none());
537    }
538
539    #[tokio::test]
540    async fn last_modified() {
541        let svc = ServeFile::new("../README.md");
542
543        let req = Request::builder().body(Body::empty()).unwrap();
544        let res = svc.serve(Context::default(), req).await.unwrap();
545
546        assert_eq!(res.status(), StatusCode::OK);
547
548        let last_modified = res
549            .headers()
550            .get(header::LAST_MODIFIED)
551            .expect("Missing last modified header!");
552
553        // -- If-Modified-Since
554
555        let svc = ServeFile::new("../README.md");
556        let req = Request::builder()
557            .header(header::IF_MODIFIED_SINCE, last_modified)
558            .body(Body::empty())
559            .unwrap();
560
561        let res = svc.serve(Context::default(), req).await.unwrap();
562        assert_eq!(res.status(), StatusCode::NOT_MODIFIED);
563        assert!(res.into_body().frame().await.is_none());
564
565        let svc = ServeFile::new("../README.md");
566        let req = Request::builder()
567            .header(header::IF_MODIFIED_SINCE, "Fri, 09 Aug 1996 14:21:40 GMT")
568            .body(Body::empty())
569            .unwrap();
570
571        let res = svc.serve(Context::default(), req).await.unwrap();
572        assert_eq!(res.status(), StatusCode::OK);
573        let readme_bytes = include_bytes!("../../../../README.md");
574        let body = res.into_body().collect().await.unwrap().to_bytes();
575        assert_eq!(body.as_ref(), readme_bytes);
576
577        // -- If-Unmodified-Since
578
579        let svc = ServeFile::new("../README.md");
580        let req = Request::builder()
581            .header(header::IF_UNMODIFIED_SINCE, last_modified)
582            .body(Body::empty())
583            .unwrap();
584
585        let res = svc.serve(Context::default(), req).await.unwrap();
586        assert_eq!(res.status(), StatusCode::OK);
587        let body = res.into_body().collect().await.unwrap().to_bytes();
588        assert_eq!(body.as_ref(), readme_bytes);
589
590        let svc = ServeFile::new("../README.md");
591        let req = Request::builder()
592            .header(header::IF_UNMODIFIED_SINCE, "Fri, 09 Aug 1996 14:21:40 GMT")
593            .body(Body::empty())
594            .unwrap();
595
596        let res = svc.serve(Context::default(), req).await.unwrap();
597        assert_eq!(res.status(), StatusCode::PRECONDITION_FAILED);
598        assert!(res.into_body().frame().await.is_none());
599    }
600}