topcoat-asset 0.11.0

A modular, batteries-included Rust web framework for server-rendered apps.
Documentation
// Without the `serve` feature the docs' links to the serving configuration
// cannot resolve; they degrade to plain text instead of failing the build.
#![cfg_attr(not(feature = "serve"), allow(rustdoc::broken_intra_doc_links))]

#[cfg(feature = "serve")]
use std::collections::HashSet;

use topcoat_router::RouterBuilder;

#[cfg(feature = "serve")]
use crate::AssetRoute;
use crate::{AssetConfig, config::Host};

/// Registers assets on a [`RouterBuilder`].
///
/// Import this trait to call [`assets`](Self::assets).
pub trait RouterBuilderAssetExt {
    /// Registers an [`AssetConfig`] on the router.
    ///
    /// Makes the configuration available in app context so views can resolve
    /// [`Asset`](crate::Asset) values to URLs.
    ///
    /// [`AssetConfig::serve`] adds a route for each distinct bundled file.
    /// [`AssetConfig::hosted_at`] adds no routes. Upload those files to the
    /// configured host separately.
    ///
    /// Passing an [`AssetBundle`](crate::AssetBundle) directly serves its files
    /// from the application.
    ///
    /// # Examples
    ///
    /// ```rust
    /// # #[topcoat::router::page] async fn about() -> topcoat::Result<impl topcoat::view::View> { Ok(topcoat::view::view! {}) }
    /// use topcoat::asset::{AssetBundle, RouterBuilderAssetExt};
    /// use topcoat::router::{Router, module_router};
    ///
    /// pub fn router() -> Router {
    ///     module_router!()
    ///         .assets(AssetBundle::load().unwrap())
    ///         .build()
    /// }
    /// ```
    ///
    /// Hosting the bundled files on a CDN instead of serving them:
    ///
    /// ```rust
    /// use topcoat::{
    ///     asset::{AssetBundle, AssetConfig, RouterBuilderAssetExt},
    ///     router::Router,
    /// };
    ///
    /// pub fn router() -> Router {
    ///     Router::builder()
    ///         .assets(AssetConfig::hosted_at(
    ///             "https://cdn.example.com/assets",
    ///             AssetBundle::load().unwrap(),
    ///         ))
    ///         .build()
    /// }
    /// ```
    #[must_use]
    fn assets(self, config: impl Into<AssetConfig>) -> Self;
}

impl RouterBuilderAssetExt for RouterBuilder {
    #[track_caller]
    fn assets(self, config: impl Into<AssetConfig>) -> Self {
        let config = config.into();

        let builder = match &config.host {
            #[cfg(feature = "serve")]
            Host::Serve { dir } => {
                let mut builder = self;
                let mut registered = HashSet::new();
                for asset in config.catalog.assets() {
                    if registered.insert(asset.name()) {
                        builder = builder.route(AssetRoute::new(dir, asset));
                    }
                }
                builder
            }
            Host::External { .. } => self,
        };

        builder.app_context(config)
    }
}

#[cfg(all(test, feature = "serve"))]
mod tests {
    use std::{env, fmt::Write, fs, path::PathBuf};

    use http::{Method, StatusCode, header::CONTENT_TYPE};
    use topcoat_router::{Body, Router};

    use super::*;
    use crate::{AssetBundle, AssetId, AssetOptions, Manifest, serve::ASSET_ROUTE_PREFIX};

    const OPTIONS: AssetOptions = AssetOptions::NONE;
    const MAIN: AssetId = AssetId::new("app", "src/main.rs", "assets/mark.svg", &OPTIONS);
    const OTHER: AssetId = AssetId::new("app", "src/other.rs", "assets/mark.svg", &OPTIONS);
    const FILE: &str = "mark-16313d41ffdefca4.svg";

    fn temp_dir(name: &str) -> PathBuf {
        let dir = env::temp_dir().join(format!("topcoat-asset-router-{name}"));
        let _ = fs::remove_dir_all(&dir);
        fs::create_dir_all(&dir).unwrap();
        dir
    }

    fn bundle(dir: PathBuf, entries: &[(AssetId, &str)]) -> AssetBundle {
        let mut toml = String::from("version = 1\n");
        for (id, content_type) in entries {
            let id = id.as_u64();
            let _ = write!(
                toml,
                "\n[[assets]]\nid = {id}\nfile = \"{FILE}\"\nhash = \"0\"\ncontent_type = \"{content_type}\"\n"
            );
        }
        AssetBundle {
            dir,
            catalog: Manifest::parse(&toml).unwrap().into(),
        }
    }

    fn get(router: &Router, path: &str) -> http::response::Parts {
        let request = http::Request::builder()
            .method(Method::GET)
            .uri(path)
            .body(Body::empty())
            .unwrap();
        let response = tokio::runtime::Builder::new_current_thread()
            .build()
            .unwrap()
            .block_on(router.handle(request));
        response.into_parts().0
    }

    #[test]
    fn one_file_declared_at_two_call_sites_serves_one_route() {
        let dir = temp_dir("shared-file");
        fs::write(dir.join(FILE), "<svg></svg>").unwrap();
        let bundle = bundle(dir, &[(MAIN, "image/svg+xml"), (OTHER, "image/svg+xml")]);

        let router = Router::builder().assets(bundle).build();

        let parts = get(&router, &format!("{ASSET_ROUTE_PREFIX}/{FILE}"));
        assert_eq!(parts.status, StatusCode::OK);
        assert_eq!(parts.headers.get(CONTENT_TYPE).unwrap(), "image/svg+xml");
    }

    #[test]
    fn a_catalog_that_disagrees_on_content_type_still_serves_one_route() {
        let dir = temp_dir("conflicting-content-types");
        fs::write(dir.join(FILE), "<svg></svg>").unwrap();
        let bundle = bundle(dir, &[(MAIN, "image/svg+xml"), (OTHER, "text/plain")]);

        let router = Router::builder().assets(bundle).build();

        let parts = get(&router, &format!("{ASSET_ROUTE_PREFIX}/{FILE}"));
        assert_eq!(parts.status, StatusCode::OK);
        let content_type = parts.headers.get(CONTENT_TYPE).unwrap();
        assert!(content_type == "image/svg+xml" || content_type == "text/plain");
    }

    #[cfg(all(feature = "bundler", not(windows)))]
    #[test]
    fn serves_a_bundled_filename_with_url_delimiters() {
        use crate::{Asset, Bundler, BundlerConfig, ENCODED_ASSET_SIZE, RawAsset};

        const NAME: &str = "logo#dark?50%25.svg";
        const ID: AssetId = AssetId::new("app", "src/lib.rs", NAME, &OPTIONS);
        static ENCODED: [u8; ENCODED_ASSET_SIZE] =
            RawAsset::encode(ID, NAME, "app", "/app", "src/lib.rs", &OPTIONS);
        let asset = Asset::new(&ENCODED);
        let dir = temp_dir("url-delimiters");
        let contents = "<svg><!-- logo --></svg>";
        fs::write(dir.join(NAME), contents).unwrap();
        let binary = RawAsset::encode(
            ID,
            NAME,
            "app",
            dir.to_str().unwrap(),
            "src/lib.rs",
            &OPTIONS,
        );
        let out = dir.join("assets");
        Bundler::new(&BundlerConfig::new().cache_dir(dir.join("cache")))
            .bundle(&binary, &out)
            .unwrap();

        let bundle = AssetBundle::load_dir(out).unwrap();
        let suffix = bundle
            .get(ID)
            .unwrap()
            .name()
            .strip_prefix("logo#dark?50%25-")
            .unwrap()
            .to_owned();
        let config = AssetConfig::serve(bundle);
        let url = config.resolve(asset);
        assert_eq!(
            url,
            format!("{ASSET_ROUTE_PREFIX}/logo%23dark%3F50%2525-{suffix}")
        );

        let router = Router::builder().assets(config).build();
        let request = http::Request::builder()
            .uri(url)
            .body(Body::empty())
            .unwrap();
        tokio::runtime::Builder::new_current_thread()
            .build()
            .unwrap()
            .block_on(async {
                let response = router.handle(request).await;
                assert_eq!(response.status(), StatusCode::OK);
                assert_eq!(response.headers()[CONTENT_TYPE], "image/svg+xml");
                let bytes = topcoat_router::to_bytes(response.into_body(), usize::MAX)
                    .await
                    .unwrap();
                assert_eq!(bytes, contents);
            });
    }
}