#![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};
pub trait RouterBuilderAssetExt {
#[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);
});
}
}