Skip to main content

memory_serve/
lib.rs

1#![allow(clippy::needless_doctest_main)]
2#![doc = include_str!("../README.md")]
3use axum::{
4    http::{HeaderMap, StatusCode},
5    routing::get,
6};
7use std::future::ready;
8use tracing::{info, warn};
9
10mod asset;
11mod build;
12mod cache_control;
13mod load;
14mod manifest;
15mod options;
16mod util;
17
18use crate::util::route::hashed_route;
19pub use crate::{
20    asset::Asset,
21    build::{assets_to_code, load_directory, load_directory_with_embed, load_names_directories},
22    cache_control::CacheControl,
23    manifest::Manifest,
24};
25
26/// Helper struct to create and configure an axum router to serve static
27/// files from memory.
28#[derive(Debug, Default)]
29pub struct MemoryServe {
30    options: options::ServeOptions,
31    assets: &'static [Asset],
32    aliases: Vec<(&'static str, &'static str)>,
33}
34
35impl MemoryServe {
36    /// Initiate a `MemoryServe` instance, taking the output of the `load!`
37    /// macro as an argument. `load!` selects the assets prepared during the
38    /// build step.
39    pub fn new(assets: &'static [Asset]) -> Self {
40        Self {
41            assets,
42            ..Default::default()
43        }
44    }
45
46    /// Which static file to serve on the route "/" (the index)
47    /// The path (or route) should be relative to the asset directory passed
48    /// to `load_directory` (or similar) in your `build.rs`, but prepended with
49    /// a slash.
50    /// By default this is `Some("/index.html")`
51    pub fn index_file(mut self, index_file: Option<&'static str>) -> Self {
52        self.options.index_file = index_file;
53
54        self
55    }
56
57    /// Whether to serve the corresponding index.html file when a route
58    /// matches a subdirectory
59    pub fn index_on_subdirectories(mut self, enable: bool) -> Self {
60        self.options.index_on_subdirectories = enable;
61
62        self
63    }
64
65    /// Which static file to serve when no other routes are matched, also see
66    /// [fallback](https://docs.rs/axum/latest/axum/routing/struct.Router.html#method.fallback)
67    /// The path (or route) should be relative to the asset directory passed
68    /// to `load_directory` (or similar) in your `build.rs`, but prepended with
69    /// a slash.
70    /// By default this is `None`, which means axum will return an empty
71    /// response with a HTTP 404 status code when no route matches.
72    pub fn fallback(mut self, fallback: Option<&'static str>) -> Self {
73        self.options.fallback = fallback;
74
75        self
76    }
77
78    /// What HTTP status code to return when a static file is returned by the
79    /// fallback handler.
80    pub fn fallback_status(mut self, fallback_status: StatusCode) -> Self {
81        self.options.fallback_status = fallback_status;
82
83        self
84    }
85
86    /// Whether to enable gzip compression. When set to `true`, clients that
87    /// accept gzip compressed files, but not brotli compressed files,
88    /// are served gzip compressed files.
89    pub fn enable_gzip(mut self, enable_gzip: bool) -> Self {
90        self.options.enable_gzip = enable_gzip;
91
92        self
93    }
94
95    /// Whether to enable brotli compression. When set to `true`, clients that
96    /// accept brotli compressed files are served brotli compressed files.
97    pub fn enable_brotli(mut self, enable_brotli: bool) -> Self {
98        self.options.enable_brotli = enable_brotli;
99
100        self
101    }
102
103    /// Whether to enable clean URLs. When set to `true`, the routing path for
104    /// HTML files will not include the extension so that a file located at
105    /// "/about.html" maps to "/about" instead of "/about.html".
106    pub fn enable_clean_url(mut self, enable_clean_url: bool) -> Self {
107        self.options.enable_clean_url = enable_clean_url;
108
109        self
110    }
111
112    /// The Cache-Control header to set for HTML files.
113    /// See [Cache control](index.html#cache-control) for options.
114    pub fn html_cache_control(mut self, html_cache_control: CacheControl) -> Self {
115        self.options.html_cache_control = html_cache_control;
116
117        self
118    }
119
120    /// The Cache-Control header to set for non-HTML files.
121    /// See [Cache control](index.html#cache-control) for options.
122    pub fn cache_control(mut self, cache_control: CacheControl) -> Self {
123        self.options.cache_control = cache_control;
124
125        self
126    }
127
128    /// Create an alias for a route / file
129    pub fn add_alias(mut self, from: &'static str, to: &'static str) -> Self {
130        self.aliases.push((from, to));
131
132        self
133    }
134
135    /// Whether to also serve non-HTML assets on a cache-busted route containing
136    /// a content hash, e.g. `/assets/index.3f9a1c2b7d84e6a0.css`. Embedded
137    /// assets are served there with [`CacheControl::Long`].
138    /// Use [`MemoryServe::manifest`] to look up the hashed routes.
139    /// See [Cache busting](index.html#cache-busting).
140    /// By default this is `false`.
141    pub fn enable_hashed_routes(mut self, enable_hashed_routes: bool) -> Self {
142        self.options.enable_hashed_routes = enable_hashed_routes;
143
144        self
145    }
146
147    /// Create a [`Manifest`] mapping each asset route to the route it is served
148    /// on: the hashed route when [`MemoryServe::enable_hashed_routes`] is set
149    /// (except for HTML), the plain route otherwise.
150    /// Dynamically served assets are hashed by reading the file, so call this
151    /// once at startup and keep the result.
152    pub fn manifest(&self) -> Manifest {
153        let routes = self
154            .assets
155            .iter()
156            .map(|asset| {
157                let served = self
158                    .hashed_route(asset)
159                    .unwrap_or_else(|| asset.route.to_string());
160
161                (asset.route, served)
162            })
163            .collect();
164
165        Manifest::new(routes)
166    }
167
168    /// The hashed route for a non-HTML asset, if enabled and hashable.
169    fn hashed_route(&self, asset: &Asset) -> Option<String> {
170        if !self.options.enable_hashed_routes || asset.is_html() {
171            return None;
172        }
173
174        let hash = asset.content_hash()?;
175
176        Some(hashed_route(asset.route, &hash))
177    }
178
179    /// Create an axum `Router` instance that will serve the included static assets
180    /// Caution! This method leaks memory. It should only be called once (at startup).
181    pub fn into_router<S>(self) -> axum::Router<S>
182    where
183        S: Clone + Send + Sync + 'static,
184    {
185        let mut router = axum::Router::new();
186        let options: &'static options::ServeOptions = Box::leak(Box::new(self.options));
187
188        // hashed routes are immutable, so embedded assets get a long cache
189        let hashed_options: &'static options::ServeOptions =
190            Box::leak(Box::new(options::ServeOptions {
191                cache_control: CacheControl::Long,
192                ..*options
193            }));
194
195        // Warn about configuration that silently does nothing because it
196        // references a route that no asset provides.
197        let route_exists = |route: &str| self.assets.iter().any(|a| a.route == route);
198
199        if let Some(index) = options.index_file
200            && !route_exists(index)
201        {
202            warn!("index_file {index} does not match any asset route, \"/\" will not be served");
203        }
204        if let Some(fallback) = options.fallback
205            && !route_exists(fallback)
206        {
207            warn!("fallback {fallback} does not match any asset route, it will be ignored");
208        }
209        for (from, to) in self.aliases.iter() {
210            if !route_exists(to) {
211                warn!("alias {from} points to {to}, which does not match any asset route");
212            }
213        }
214
215        for asset in self.assets {
216            let (uncompressed_bytes, brotli_bytes, gzip_bytes) = asset.leak_bytes(options);
217
218            if !uncompressed_bytes.is_empty() {
219                if !brotli_bytes.is_empty() {
220                    info!(
221                        "serving {} {} -> {} bytes (compressed)",
222                        asset.route,
223                        uncompressed_bytes.len(),
224                        brotli_bytes.len()
225                    );
226                } else {
227                    info!("serving {} {} bytes", asset.route, uncompressed_bytes.len());
228                }
229            } else {
230                info!("serving {} (dynamically)", asset.route);
231            }
232
233            let handler = |headers: HeaderMap| {
234                ready(asset.handler(
235                    &headers,
236                    StatusCode::OK,
237                    uncompressed_bytes,
238                    brotli_bytes,
239                    gzip_bytes,
240                    options,
241                ))
242            };
243
244            if Some(asset.route) == options.fallback {
245                info!("serving {} as fallback", asset.route);
246
247                router = router.fallback(|headers: HeaderMap| {
248                    ready(asset.handler(
249                        &headers,
250                        options.fallback_status,
251                        uncompressed_bytes,
252                        brotli_bytes,
253                        gzip_bytes,
254                        options,
255                    ))
256                });
257            }
258
259            if let Some(index) = options.index_file {
260                if asset.route == index {
261                    info!("serving {} as index on /", asset.route);
262
263                    router = router.route("/", get(handler));
264                } else if options.index_on_subdirectories && asset.route.ends_with(index) {
265                    let path = &asset.route[..asset.route.len() - index.len()];
266                    info!("serving {} as index on {}", asset.route, path);
267
268                    router = router.route(path, get(handler));
269                }
270            }
271
272            let path = if options.enable_clean_url && asset.route.ends_with(".html") {
273                &asset.route[..asset.route.len() - 5]
274            } else {
275                asset.route
276            };
277            router = router.route(path, get(handler));
278
279            // add all aliases that point to the asset route
280            for (from, to) in self.aliases.iter() {
281                if *to == asset.route {
282                    info!("serving {} on alias {}", asset.route, from);
283
284                    router = router.route(from, get(handler));
285                }
286            }
287
288            if let Some(hashed) = self.hashed_route(asset) {
289                info!("serving {} on hashed route {}", asset.route, hashed);
290
291                // dynamically served files may change, keep the regular cache control
292                let hashed_options = if uncompressed_bytes.is_empty() {
293                    options
294                } else {
295                    hashed_options
296                };
297
298                router = router.route(
299                    &hashed,
300                    get(move |headers: HeaderMap| {
301                        ready(asset.handler(
302                            &headers,
303                            StatusCode::OK,
304                            uncompressed_bytes,
305                            brotli_bytes,
306                            gzip_bytes,
307                            hashed_options,
308                        ))
309                    }),
310                );
311            }
312        }
313
314        router
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use axum::{
321        Router,
322        body::Body,
323        http::{
324            self, HeaderMap, HeaderName, HeaderValue, Request, StatusCode,
325            header::{self, CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH},
326        },
327    };
328    use std::sync::LazyLock;
329    use tower::ServiceExt;
330
331    use crate::{self as memory_serve, Asset, CacheControl, MemoryServe};
332
333    static ASSETS: LazyLock<&'static [Asset]> =
334        LazyLock::new(|| memory_serve::build::load_test_assets("./static"));
335
336    macro_rules! test_load {
337        () => {{ MemoryServe::new(*ASSETS) }};
338    }
339
340    async fn get(
341        router: Router,
342        path: &str,
343        key: &str,
344        value: &str,
345    ) -> (StatusCode, HeaderMap<HeaderValue>) {
346        let response = router
347            .oneshot(
348                Request::builder()
349                    .method(http::Method::GET)
350                    .header(key, value)
351                    .uri(path)
352                    .body(Body::empty())
353                    .unwrap(),
354            )
355            .await
356            .unwrap();
357
358        (response.status(), response.headers().to_owned())
359    }
360
361    fn get_header<'s>(headers: &'s HeaderMap, name: &HeaderName) -> &'s str {
362        headers.get(name).unwrap().to_str().unwrap()
363    }
364
365    #[tokio::test]
366    async fn test_load_assets() {
367        let routes: Vec<&str> = ASSETS.iter().map(|a| a.route).collect();
368        let content_types: Vec<&str> = ASSETS.iter().map(|a| a.content_type).collect();
369        let etags: Vec<&str> = ASSETS.iter().map(|a| a.etag).collect();
370
371        assert_eq!(
372            routes,
373            [
374                "/about.html",
375                "/assets/icon.jpg",
376                "/assets/index.css",
377                "/assets/index.js",
378                "/assets/stars.svg",
379                "/blog/index.html",
380                "/index.html"
381            ]
382        );
383        assert_eq!(
384            content_types,
385            [
386                "text/html",
387                "image/jpeg",
388                "text/css",
389                "text/javascript",
390                "image/svg+xml",
391                "text/html",
392                "text/html"
393            ]
394        );
395        if cfg!(debug_assertions) && !cfg!(feature = "force-embed") {
396            assert_eq!(etags, ["", "", "", "", "", "", ""]);
397        } else {
398            assert_eq!(
399                etags,
400                [
401                    "56a0dcb83ec56b6c967966a1c06c7b1392e261069d0844aa4e910ca5c1e8cf58",
402                    "e64f4683bf82d854df40b7246666f6f0816666ad8cd886a8e159535896eb03d6",
403                    "ec4edeea111c854901385011f403e1259e3f1ba016dcceabb6d566316be3677b",
404                    "86a7fdfd19700843e5f7344a63d27e0b729c2554c8572903ceee71f5658d2ecf",
405                    "bd9dccc152de48cb7bedc35b9748ceeade492f6f904710f9c5d480bd6299cc7d",
406                    "89e9873a8e49f962fe83ad2bfe6ac9b21ef7c1b4040b99c34eb783dccbadebc5",
407                    "0639dc8aac157b58c74f65bbb026b2fd42bc81d9a0a64141df456fa23c214537"
408                ]
409            );
410        }
411    }
412
413    #[tokio::test]
414    async fn if_none_match_handling() {
415        let memory_router = test_load!().into_router();
416        let (code, headers) =
417            get(memory_router.clone(), "/index.html", "accept", "text/html").await;
418        let etag: &str = headers.get(header::ETAG).unwrap().to_str().unwrap();
419
420        assert_eq!(code, 200);
421        assert_eq!(
422            etag,
423            "\"0639dc8aac157b58c74f65bbb026b2fd42bc81d9a0a64141df456fa23c214537\""
424        );
425
426        let (code, headers) = get(memory_router, "/index.html", "If-None-Match", etag).await;
427        let length = get_header(&headers, &CONTENT_LENGTH);
428
429        assert_eq!(code, 304);
430        assert_eq!(length.parse::<i32>().unwrap(), 0);
431    }
432
433    #[tokio::test]
434    async fn brotli_compression() {
435        let memory_router = test_load!().enable_brotli(true).into_router();
436        let (code, headers) = get(
437            memory_router.clone(),
438            "/index.html",
439            "accept-encoding",
440            "br",
441        )
442        .await;
443        let encoding = get_header(&headers, &CONTENT_ENCODING);
444        let length = get_header(&headers, &CONTENT_LENGTH);
445
446        assert_eq!(code, 200);
447        assert_eq!(encoding, "br");
448        assert_eq!(length.parse::<i32>().unwrap(), 178);
449
450        // check disable compression
451        let memory_router = test_load!().enable_brotli(false).into_router();
452        let (code, headers) = get(
453            memory_router.clone(),
454            "/index.html",
455            "accept-encoding",
456            "br",
457        )
458        .await;
459        let length: &str = get_header(&headers, &CONTENT_LENGTH);
460
461        assert_eq!(code, 200);
462        assert_eq!(length.parse::<i32>().unwrap(), 437);
463    }
464
465    #[tokio::test]
466    async fn gzip_compression() {
467        let memory_router = test_load!().enable_gzip(true).into_router();
468        let (code, headers) = get(
469            memory_router.clone(),
470            "/index.html",
471            "accept-encoding",
472            "gzip",
473        )
474        .await;
475
476        let encoding = get_header(&headers, &CONTENT_ENCODING);
477        let length = get_header(&headers, &CONTENT_LENGTH);
478
479        assert_eq!(code, 200);
480        assert_eq!(encoding, "gzip");
481        assert_eq!(length.parse::<i32>().unwrap(), 274);
482
483        // check disable compression
484        let memory_router = test_load!().enable_gzip(false).into_router();
485        let (code, headers) = get(
486            memory_router.clone(),
487            "/index.html",
488            "accept-encoding",
489            "gzip",
490        )
491        .await;
492        let length: &str = get_header(&headers, &CONTENT_LENGTH);
493
494        assert_eq!(code, 200);
495        assert_eq!(length.parse::<i32>().unwrap(), 437);
496    }
497
498    #[tokio::test]
499    async fn encoding_preference() {
500        // Both encodings enabled; the client prefers gzip over brotli.
501        let memory_router = test_load!()
502            .enable_brotli(true)
503            .enable_gzip(true)
504            .into_router();
505        let (code, headers) = get(
506            memory_router.clone(),
507            "/index.html",
508            "accept-encoding",
509            "br;q=0.1, gzip;q=1.0",
510        )
511        .await;
512        assert_eq!(code, 200);
513        assert_eq!(get_header(&headers, &CONTENT_ENCODING), "gzip");
514
515        // The reverse preference selects brotli.
516        let (code, headers) = get(
517            memory_router.clone(),
518            "/index.html",
519            "accept-encoding",
520            "br;q=0.8, gzip;q=0.7",
521        )
522        .await;
523        assert_eq!(code, 200);
524        assert_eq!(get_header(&headers, &CONTENT_ENCODING), "br");
525    }
526
527    #[tokio::test]
528    async fn index_file() {
529        let memory_router = test_load!().index_file(None).into_router();
530
531        let (code, _) = get(memory_router.clone(), "/", "accept", "*").await;
532        assert_eq!(code, 404);
533
534        let memory_router = test_load!().index_file(Some("/index.html")).into_router();
535
536        let (code, _) = get(memory_router.clone(), "/", "accept", "*").await;
537        assert_eq!(code, 200);
538    }
539
540    #[tokio::test]
541    async fn index_file_on_subdirs() {
542        let memory_router = test_load!()
543            .index_file(Some("/index.html"))
544            .index_on_subdirectories(false)
545            .into_router();
546
547        let (code, _) = get(memory_router.clone(), "/blog", "accept", "*").await;
548        assert_eq!(code, 404);
549
550        let memory_router = test_load!()
551            .index_file(Some("/index.html"))
552            .index_on_subdirectories(true)
553            .into_router();
554
555        let (code, _) = get(memory_router.clone(), "/blog", "accept", "*").await;
556        assert_eq!(code, 200);
557    }
558
559    #[tokio::test]
560    async fn clean_url() {
561        let memory_router = test_load!().enable_clean_url(true).into_router();
562
563        let (code, _) = get(memory_router.clone(), "/about.html", "accept", "*").await;
564        assert_eq!(code, 404);
565
566        let (code, _) = get(memory_router.clone(), "/about", "accept", "*").await;
567        assert_eq!(code, 200);
568    }
569
570    #[tokio::test]
571    async fn fallback() {
572        let memory_router = test_load!().into_router();
573        let (code, _) = get(memory_router.clone(), "/foobar", "accept", "*").await;
574        assert_eq!(code, 404);
575
576        let memory_router = test_load!().fallback(Some("/index.html")).into_router();
577        let (code, headers) = get(memory_router.clone(), "/foobar", "accept", "*").await;
578        let length = get_header(&headers, &CONTENT_LENGTH);
579        assert_eq!(code, 404);
580        assert_eq!(length.parse::<i32>().unwrap(), 437);
581
582        let memory_router = test_load!()
583            .fallback(Some("/index.html"))
584            .fallback_status(StatusCode::OK)
585            .into_router();
586        let (code, headers) = get(memory_router.clone(), "/foobar", "accept", "*").await;
587        let length = get_header(&headers, &CONTENT_LENGTH);
588        assert_eq!(code, 200);
589        assert_eq!(length.parse::<i32>().unwrap(), 437);
590    }
591
592    #[tokio::test]
593    async fn cache_control() {
594        async fn check_cache_control(cache_control: CacheControl, expected: &str) {
595            let memory_router = test_load!().cache_control(cache_control).into_router();
596
597            let (code, headers) =
598                get(memory_router.clone(), "/assets/icon.jpg", "accept", "*").await;
599
600            let cache_control = get_header(&headers, &CACHE_CONTROL);
601            assert_eq!(code, 200);
602            assert_eq!(cache_control, expected);
603        }
604
605        check_cache_control(
606            CacheControl::NoCache,
607            CacheControl::NoCache.as_header().1.to_str().unwrap(),
608        )
609        .await;
610        check_cache_control(
611            CacheControl::Short,
612            CacheControl::Short.as_header().1.to_str().unwrap(),
613        )
614        .await;
615        check_cache_control(
616            CacheControl::Medium,
617            CacheControl::Medium.as_header().1.to_str().unwrap(),
618        )
619        .await;
620        check_cache_control(
621            CacheControl::Long,
622            CacheControl::Long.as_header().1.to_str().unwrap(),
623        )
624        .await;
625
626        async fn check_html_cache_control(cache_control: CacheControl, expected: &str) {
627            let memory_router = test_load!().html_cache_control(cache_control).into_router();
628
629            let (code, headers) = get(memory_router.clone(), "/index.html", "accept", "*").await;
630            let cache_control = get_header(&headers, &CACHE_CONTROL);
631            assert_eq!(code, 200);
632            assert_eq!(cache_control, expected);
633        }
634
635        check_html_cache_control(
636            CacheControl::NoCache,
637            CacheControl::NoCache.as_header().1.to_str().unwrap(),
638        )
639        .await;
640        check_html_cache_control(
641            CacheControl::Short,
642            CacheControl::Short.as_header().1.to_str().unwrap(),
643        )
644        .await;
645        check_html_cache_control(
646            CacheControl::Medium,
647            CacheControl::Medium.as_header().1.to_str().unwrap(),
648        )
649        .await;
650        check_html_cache_control(
651            CacheControl::Long,
652            CacheControl::Long.as_header().1.to_str().unwrap(),
653        )
654        .await;
655    }
656
657    #[tokio::test]
658    async fn hashed_routes() {
659        let memory_serve = test_load!().enable_hashed_routes(true);
660        let manifest = memory_serve.manifest();
661        let memory_router = memory_serve.into_router();
662
663        // non-HTML assets map to a route with a 16 character hex hash before
664        // the extension
665        let hashed = manifest.get("/assets/index.css").unwrap();
666        let hash = hashed
667            .strip_prefix("/assets/index.")
668            .and_then(|rest| rest.strip_suffix(".css"))
669            .unwrap();
670        assert_eq!(hash.len(), 16);
671        assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
672
673        if !cfg!(debug_assertions) || cfg!(feature = "force-embed") {
674            assert_eq!(hashed, "/assets/index.ec4edeea111c8549.css");
675        }
676
677        // HTML files keep their plain route, unknown routes are absent
678        assert_eq!(manifest.get("/index.html"), Some("/index.html"));
679        assert_eq!(manifest.get("/blog/index.html"), Some("/blog/index.html"));
680        assert_eq!(manifest.get("/missing.css"), None);
681        assert_eq!(manifest.len(), ASSETS.len());
682
683        // the hashed route serves the same file
684        let (code, headers) = get(memory_router.clone(), hashed, "accept", "*").await;
685        assert_eq!(code, 200);
686        assert_eq!(get_header(&headers, &header::CONTENT_TYPE), "text/css");
687        assert_eq!(get_header(&headers, &CONTENT_LENGTH), "1552");
688
689        // embedded assets are immutable on the hashed route, dynamically
690        // served assets keep the regular cache control
691        let expected_cache_control = if !cfg!(debug_assertions) || cfg!(feature = "force-embed") {
692            CacheControl::Long
693        } else {
694            CacheControl::Medium
695        };
696        assert_eq!(
697            get_header(&headers, &CACHE_CONTROL),
698            expected_cache_control.as_header().1.to_str().unwrap()
699        );
700
701        // the plain route is still served with the configured cache control
702        let (code, headers) = get(memory_router.clone(), "/assets/index.css", "accept", "*").await;
703        assert_eq!(code, 200);
704        assert_eq!(
705            get_header(&headers, &CACHE_CONTROL),
706            CacheControl::Medium.as_header().1.to_str().unwrap()
707        );
708
709        // every manifest entry resolves
710        for (_, served) in manifest.iter() {
711            let (code, _) = get(memory_router.clone(), served, "accept", "*").await;
712            assert_eq!(code, 200, "{served} should be served");
713        }
714    }
715
716    #[tokio::test]
717    async fn hashed_routes_disabled() {
718        let memory_serve = test_load!();
719        let manifest = memory_serve.manifest();
720        let memory_router = memory_serve.into_router();
721
722        // without hashed routes every asset maps to its plain route
723        assert_eq!(manifest.get("/assets/index.css"), Some("/assets/index.css"));
724        assert_eq!(manifest.get("/index.html"), Some("/index.html"));
725        assert_eq!(manifest.len(), ASSETS.len());
726
727        let (code, _) = get(
728            memory_router.clone(),
729            "/assets/index.ec4edeea111c8549.css",
730            "accept",
731            "*",
732        )
733        .await;
734        assert_eq!(code, 404);
735    }
736
737    #[tokio::test]
738    async fn aliases() {
739        let memory_router = test_load!()
740            .add_alias("/foobar", "/index.html")
741            .add_alias("/baz", "/index.html")
742            .into_router();
743        let (code, _) = get(memory_router.clone(), "/foobar", "accept", "*").await;
744        assert_eq!(code, 200);
745
746        let (code, _) = get(memory_router.clone(), "/baz", "accept", "*").await;
747        assert_eq!(code, 200);
748
749        let (code, _) = get(memory_router.clone(), "/index.html", "accept", "*").await;
750        assert_eq!(code, 200);
751
752        let (code, _) = get(memory_router.clone(), "/barfoo", "accept", "*").await;
753        assert_eq!(code, 404);
754    }
755}