#[cfg(feature = "debug-logging")]
macro_rules! debug_log {
($($arg:tt)*) => { ic_cdk::println!($($arg)*) };
}
#[cfg(not(feature = "debug-logging"))]
macro_rules! debug_log {
($($arg:tt)*) => {};
}
use std::{borrow::Cow, cell::RefCell, rc::Rc};
use assets::get_asset_headers;
use ic_cdk::api::{certified_data_set, data_certificate};
use ic_http_certification::{
utils::add_v2_certificate_header, DefaultCelBuilder, HttpCertification, HttpCertificationPath,
HttpCertificationTree, HttpCertificationTreeEntry, CERTIFICATE_EXPRESSION_HEADER_NAME,
};
use router::{RouteNode, RouteResult};
const NOT_FOUND_CANONICAL_PATH: &str = "/__not_found";
fn extract_content_type(response: &HttpResponse) -> String {
response
.headers()
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
.map(|(_, v)| v.clone())
.unwrap_or_else(|| "application/octet-stream".to_string())
}
fn method_not_allowed(allowed: &[Method]) -> HttpResponse<'static> {
let allow = allowed
.iter()
.map(|m| m.as_str())
.collect::<Vec<_>>()
.join(", ");
HttpResponse::builder()
.with_status_code(StatusCode::METHOD_NOT_ALLOWED)
.with_headers(vec![
("allow".to_string(), allow),
("content-type".to_string(), "text/plain".to_string()),
])
.with_body(Cow::<[u8]>::Owned(b"Method Not Allowed".to_vec()))
.build()
}
fn error_response(status: u16, message: &str) -> HttpResponse<'static> {
HttpResponse::builder()
.with_status_code(StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
.with_headers(vec![("content-type".to_string(), "text/plain".to_string())])
.with_body(Cow::<[u8]>::Owned(message.as_bytes().to_vec()))
.build()
}
fn is_asset_expired(asset: &asset_router::CertifiedAsset, path: &str, now_ns: u64) -> bool {
if !asset.is_dynamic() {
return false;
}
if asset.ttl.is_some() {
return asset.is_expired(now_ns);
}
let effective_ttl = ROUTER_CONFIG.with(|c| c.borrow().cache_config.effective_ttl(path));
match effective_ttl {
Some(ttl) => {
let expiry_ns = asset.certified_at.saturating_add(ttl.as_nanos() as u64);
now_ns >= expiry_ns
}
None => false,
}
}
pub mod asset_router;
pub mod assets;
pub mod build;
pub mod certification;
pub mod config;
pub mod context;
pub mod middleware;
pub mod mime;
pub mod route_config;
pub mod router;
pub use assets::{
delete_assets, invalidate_all_dynamic, invalidate_path, invalidate_prefix, last_certified_at,
};
pub use certification::{CertificationMode, FullConfig, FullConfigBuilder, ResponseOnlyConfig};
pub use config::{AssetConfig, CacheConfig, CacheControl, SecurityHeaders};
pub use context::{
deserialize_search_params, parse_form_body, parse_query, url_decode, FormBodyError,
JsonBodyError, QueryParams, RouteContext,
};
pub use ic_asset_router_macros::route;
pub use ic_http_certification::{HttpRequest, HttpResponse, Method, StatusCode};
pub use route_config::RouteConfig;
pub use router::{HandlerResult, RouteParams};
thread_local! {
static HTTP_TREE: Rc<RefCell<HttpCertificationTree>> = Default::default();
static ASSET_ROUTER: RefCell<asset_router::AssetRouter> = RefCell::new(asset_router::AssetRouter::with_tree(HTTP_TREE.with(|tree| tree.clone())));
static ROUTER_CONFIG: RefCell<AssetConfig> = RefCell::new(AssetConfig::default());
}
fn set_asset_config(config: AssetConfig) {
ROUTER_CONFIG.with(|c| {
*c.borrow_mut() = config;
});
}
fn register_skip_routes(root_route_node: &router::RouteNode) {
let skip_paths = root_route_node.skip_certified_paths();
if skip_paths.is_empty() {
return;
}
HTTP_TREE.with(|tree| {
let mut tree = tree.borrow_mut();
for path in &skip_paths {
let tree_path = HttpCertificationPath::exact(path.to_string());
let certification = HttpCertification::skip();
let tree_entry = HttpCertificationTreeEntry::new(tree_path, certification);
tree.insert(&tree_entry);
}
});
ASSET_ROUTER.with_borrow(|asset_router| {
certified_data_set(asset_router.root_hash());
});
debug_log!("registered {} skip certification entries", skip_paths.len());
}
pub fn setup(routes: &router::RouteNode) -> SetupBuilder<'_> {
SetupBuilder {
routes,
config: None,
asset_dirs: Vec::new(),
delete_paths: Vec::new(),
}
}
pub struct SetupBuilder<'r> {
routes: &'r router::RouteNode,
config: Option<AssetConfig>,
asset_dirs: Vec<(
&'static include_dir::Dir<'static>,
certification::CertificationMode,
)>,
delete_paths: Vec<&'static str>,
}
impl<'r> SetupBuilder<'r> {
pub fn with_config(mut self, config: AssetConfig) -> Self {
self.config = Some(config);
self
}
pub fn with_assets(mut self, dir: &'static include_dir::Dir<'static>) -> Self {
self.asset_dirs
.push((dir, certification::CertificationMode::response_only()));
self
}
pub fn with_assets_certified(
mut self,
dir: &'static include_dir::Dir<'static>,
mode: certification::CertificationMode,
) -> Self {
self.asset_dirs.push((dir, mode));
self
}
pub fn delete_assets(mut self, paths: Vec<&'static str>) -> Self {
self.delete_paths.extend(paths);
self
}
pub fn build(self) {
set_asset_config(self.config.unwrap_or_default());
for (dir, mode) in &self.asset_dirs {
assets::certify_assets_with_mode(dir, mode.clone());
}
if !self.delete_paths.is_empty() {
assets::delete_assets(self.delete_paths);
}
register_skip_routes(self.routes);
}
}
pub struct HttpRequestOptions {
pub certify: bool,
}
impl Default for HttpRequestOptions {
fn default() -> Self {
HttpRequestOptions { certify: true }
}
}
fn attach_skip_certification(
path: &str,
response: &mut HttpResponse<'static>,
) -> Result<(), HttpResponse<'static>> {
response.add_header((
CERTIFICATE_EXPRESSION_HEADER_NAME.to_string(),
DefaultCelBuilder::skip_certification().to_string(),
));
HTTP_TREE.with(|tree| {
let tree = tree.borrow();
let cert = data_certificate().ok_or_else(|| {
error_response(500, "Internal Server Error: no data certificate available")
})?;
let tree_path = HttpCertificationPath::exact(path);
let certification = HttpCertification::skip();
let tree_entry = HttpCertificationTreeEntry::new(&tree_path, certification);
let witness = tree.witness(&tree_entry, path).map_err(|_| {
error_response(
500,
"Internal Server Error: failed to create certification witness",
)
})?;
add_v2_certificate_header(&cert, response, &witness, &tree_path.to_expr_path());
Ok(())
})
}
fn handle_not_found_query(
req: HttpRequest,
path: &str,
root: &RouteNode,
certify: bool,
) -> HttpResponse<'static> {
if certify {
let canonical_state = ASSET_ROUTER.with_borrow(|asset_router| {
asset_router
.get_asset(NOT_FOUND_CANONICAL_PATH)
.map(|asset| is_asset_expired(asset, NOT_FOUND_CANONICAL_PATH, ic_cdk::api::time()))
});
match canonical_state {
Some(true) => {
debug_log!("upgrading not-found (TTL expired for canonical path)");
return HttpResponse::builder().with_upgrade(true).build();
}
Some(false) => {
return ASSET_ROUTER.with_borrow(|asset_router| {
let cert = match data_certificate() {
Some(c) => c,
None => {
debug_log!("upgrading not-found (no data certificate)");
return HttpResponse::builder().with_upgrade(true).build();
}
};
if let Some((mut response, witness, expr_path)) = asset_router.serve_asset(&req)
{
add_v2_certificate_header(&cert, &mut response, &witness, &expr_path);
debug_log!("serving cached not-found for {}", path);
response
} else {
debug_log!("upgrading not-found (serve_asset failed for canonical path)");
HttpResponse::builder().with_upgrade(true).build()
}
});
}
None => {
let maybe_response = ASSET_ROUTER.with_borrow(|asset_router| {
let cert = data_certificate()?;
let (mut response, witness, expr_path) = asset_router.serve_asset(&req)?;
add_v2_certificate_header(&cert, &mut response, &witness, &expr_path);
Some(response)
});
if let Some(response) = maybe_response {
debug_log!("serving static asset for {}", path);
return response;
}
debug_log!("upgrading not-found (no cached entry for {})", path);
return HttpResponse::builder().with_upgrade(true).build();
}
}
}
if let Some(response) = root.execute_not_found_with_middleware(path, req) {
response
} else {
HttpResponse::not_found(
b"Not Found",
vec![("Content-Type".into(), "text/plain".into())],
)
.build()
}
}
fn serve_from_cache_or_upgrade(req: &HttpRequest, path: &str) -> HttpResponse<'static> {
enum CacheState {
Missing,
Expired,
Valid,
}
let cache_state = ASSET_ROUTER.with_borrow(|asset_router| match asset_router.get_asset(path) {
Some(asset) => {
if is_asset_expired(asset, path, ic_cdk::api::time()) {
CacheState::Expired
} else {
CacheState::Valid
}
}
None => CacheState::Missing,
});
match cache_state {
CacheState::Missing => {
debug_log!("upgrading (no asset: {})", path);
HttpResponse::builder().with_upgrade(true).build()
}
CacheState::Expired => {
debug_log!("upgrading (TTL expired for {})", path);
HttpResponse::builder().with_upgrade(true).build()
}
CacheState::Valid => ASSET_ROUTER.with_borrow(|asset_router| {
let cert = match data_certificate() {
Some(c) => c,
None => {
debug_log!("upgrading (no data certificate)");
return HttpResponse::builder().with_upgrade(true).build();
}
};
if let Some((mut response, witness, expr_path)) = asset_router.serve_asset(req) {
add_v2_certificate_header(&cert, &mut response, &witness, &expr_path);
debug_log!("serving directly");
response
} else {
debug_log!("upgrading");
HttpResponse::builder().with_upgrade(true).build()
}
}),
}
}
fn serve_without_certification(
root: &RouteNode,
path: &str,
handler: router::HandlerFn,
req: HttpRequest,
params: router::RouteParams,
) -> HttpResponse<'static> {
debug_log!("serving {} without certification", path);
let mut response = root.execute_with_middleware(path, handler, req, params);
match attach_skip_certification(path, &mut response) {
Ok(()) => response,
Err(err_resp) => err_resp,
}
}
pub fn http_request(
req: HttpRequest,
root_route_node: &RouteNode,
opts: HttpRequestOptions,
) -> HttpResponse<'static> {
debug_log!("http_request: {:?}", req.url());
let path = match req.get_path() {
Ok(p) => p,
Err(_) => return error_response(400, "Bad Request: malformed URL"),
};
let method = req.method().clone();
if method != Method::GET && method != Method::HEAD {
debug_log!(
"upgrading non-GET request ({}) to update call",
method.as_str()
);
return HttpResponse::builder().with_upgrade(true).build();
}
match root_route_node.resolve(&path, &method) {
RouteResult::Found(handler, params, _result_handler, pattern) => match opts.certify {
false => serve_without_certification(root_route_node, &path, handler, req, params),
true => {
let route_config = root_route_node.get_route_config(&pattern);
let cert_mode = route_config.map(|rc| &rc.certification);
if matches!(cert_mode, Some(certification::CertificationMode::Full(_))) {
debug_log!("upgrading (full certification mode: {})", path);
return HttpResponse::builder().with_upgrade(true).build();
}
if matches!(cert_mode, Some(certification::CertificationMode::Skip)) {
return serve_without_certification(
root_route_node,
&path,
handler,
req,
params,
);
}
serve_from_cache_or_upgrade(&req, &path)
}
},
RouteResult::MethodNotAllowed(allowed) => method_not_allowed(&allowed),
RouteResult::NotFound => handle_not_found_query(req, &path, root_route_node, opts.certify),
}
}
fn certify_dynamic_response_with_ttl(
response: HttpResponse<'static>,
path: &str,
fallback_for: Option<String>,
mode: certification::CertificationMode,
request: Option<&HttpRequest>,
ttl_override: Option<std::time::Duration>,
) -> HttpResponse<'static> {
let content_type = extract_content_type(&response);
let effective_ttl = ttl_override
.or_else(|| ROUTER_CONFIG.with(|c| c.borrow().cache_config.effective_ttl(path)));
let dynamic_cache_control =
ROUTER_CONFIG.with(|c| c.borrow().cache_control.dynamic_assets.clone());
let config = asset_router::AssetCertificationConfig {
mode: mode.clone(),
content_type: Some(content_type),
status_code: response.status_code(),
headers: get_asset_headers(vec![("cache-control".to_string(), dynamic_cache_control)]),
encodings: vec![],
fallback_for,
aliases: vec![],
certified_at: ic_cdk::api::time(),
ttl: effective_ttl,
dynamic: true,
};
let certified = ASSET_ROUTER.with_borrow_mut(|asset_router| {
asset_router.delete_asset(path);
match &mode {
certification::CertificationMode::Full(_) => {
let req = match request {
Some(r) => r,
None => {
debug_log!(
"certify_dynamic_response_with_ttl: Full certification mode \
requires the original request, but none was provided for path '{}'. \
Returning uncertified response.",
path
);
return false;
}
};
if let Err(_err) = asset_router.certify_dynamic_asset(path, req, &response, config)
{
debug_log!(
"certify_dynamic_response_with_ttl: failed to certify dynamic asset \
(full) for path '{}': {}. Returning uncertified response.",
path,
_err
);
return false;
}
}
_ => {
if let Err(_err) =
asset_router.certify_asset(path, response.body().to_vec(), config)
{
debug_log!(
"certify_dynamic_response_with_ttl: failed to certify dynamic asset \
for path '{}': {}. Returning uncertified response.",
path,
_err
);
return false;
}
}
}
certified_data_set(asset_router.root_hash());
true
});
if !certified {
debug_log!(
"certify_dynamic_response_with_ttl: certification failed for path '{}', \
serving uncertified response",
path
);
}
response
}
fn handle_not_found_update(
req: HttpRequest,
path: &str,
root: &RouteNode,
) -> HttpResponse<'static> {
let cached_valid = ASSET_ROUTER.with_borrow(|asset_router| {
match asset_router.get_asset(NOT_FOUND_CANONICAL_PATH) {
Some(asset) => !is_asset_expired(asset, NOT_FOUND_CANONICAL_PATH, ic_cdk::api::time()),
None => false,
}
});
if cached_valid {
debug_log!("not-found canonical entry still valid, serving from cache");
return ASSET_ROUTER.with_borrow(|asset_router| {
let canonical_req = HttpRequest::get(NOT_FOUND_CANONICAL_PATH.to_string()).build();
match asset_router.serve_asset(&canonical_req) {
Some((resp, _witness, _expr_path)) => resp,
None => error_response(
500,
"Internal Server Error: cached not-found entry missing from asset router",
),
}
});
}
let response = if let Some(response) = root.execute_not_found_with_middleware(path, req) {
response
} else {
HttpResponse::not_found(
b"Not Found",
vec![("Content-Type".into(), "text/plain".into())],
)
.build()
};
certify_dynamic_response_with_ttl(
response,
NOT_FOUND_CANONICAL_PATH,
Some("/".to_string()),
certification::CertificationMode::response_only(),
None,
None,
)
}
fn handle_not_modified(req: &HttpRequest, path: &str) -> HttpResponse<'static> {
debug_log!("handler returned NotModified for {}", path);
ASSET_ROUTER.with_borrow_mut(|asset_router| {
if let Some(asset) = asset_router.get_asset_mut(path) {
if asset.ttl.is_some() {
asset.certified_at = ic_cdk::api::time();
}
}
});
ASSET_ROUTER.with_borrow(|asset_router| match asset_router.serve_asset(req) {
Some((mut resp, witness, expr_path)) => {
if let Some(cert) = data_certificate() {
add_v2_certificate_header(&cert, &mut resp, &witness, &expr_path);
}
resp
}
None => error_response(
500,
"Internal Server Error: NotModified but no cached asset found",
),
})
}
pub fn http_request_update(req: HttpRequest, root_route_node: &RouteNode) -> HttpResponse<'static> {
debug_log!("http_request_update: {:?}", req.url());
let path = match req.get_path() {
Ok(p) => p,
Err(_) => return error_response(400, "Bad Request: malformed URL"),
};
let method = req.method().clone();
match root_route_node.resolve(&path, &method) {
RouteResult::Found(handler, params, result_handler, pattern) => {
let route_config = root_route_node.get_route_config(&pattern);
let cert_mode = route_config
.map(|rc| rc.certification.clone())
.unwrap_or_else(certification::CertificationMode::response_only);
let route_ttl = route_config.and_then(|rc| rc.ttl);
if matches!(&cert_mode, certification::CertificationMode::Skip) {
debug_log!("skip mode in update path (unexpected): {}", path);
return root_route_node.execute_with_middleware(&path, handler, req, params);
}
if let Some(result_fn) = result_handler {
match result_fn(req.clone(), params.clone()) {
router::HandlerResult::NotModified => {
return handle_not_modified(&req, &path);
}
router::HandlerResult::Response(response) => {
return certify_dynamic_response_with_ttl(
response,
&path,
None,
cert_mode,
Some(&req),
route_ttl,
);
}
}
}
let response =
root_route_node.execute_with_middleware(&path, handler, req.clone(), params);
certify_dynamic_response_with_ttl(
response,
&path,
None,
cert_mode,
Some(&req),
route_ttl,
)
}
RouteResult::MethodNotAllowed(allowed) => method_not_allowed(&allowed),
RouteResult::NotFound => handle_not_found_update(req, &path, root_route_node),
}
}
#[cfg(test)]
mod tests {
use super::*;
use ic_http_certification::Method;
use router::{NodeType, RouteNode, RouteParams};
use std::time::Duration;
fn noop_handler(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
HttpResponse::builder()
.with_status_code(StatusCode::OK)
.with_body(b"ok" as &[u8])
.build()
}
fn setup_router() -> RouteNode {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/", Method::GET, noop_handler);
root.insert("/*", Method::GET, noop_handler);
root
}
#[test]
fn http_request_malformed_url_returns_400() {
let root = setup_router();
let req = HttpRequest::builder()
.with_method(Method::GET)
.with_url("http://[::bad")
.build();
let opts = HttpRequestOptions { certify: false };
let response = http_request(req, &root, opts);
assert_eq!(response.status_code(), StatusCode::BAD_REQUEST);
assert!(std::str::from_utf8(response.body())
.unwrap()
.contains("malformed URL"));
}
#[test]
fn http_request_update_malformed_url_returns_400() {
let root = setup_router();
let req = HttpRequest::builder()
.with_method(Method::GET)
.with_url("http://[::bad")
.build();
let response = http_request_update(req, &root);
assert_eq!(response.status_code(), StatusCode::BAD_REQUEST);
assert!(std::str::from_utf8(response.body())
.unwrap()
.contains("malformed URL"));
}
fn handler_no_content_type(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
HttpResponse::builder()
.with_status_code(StatusCode::OK)
.with_body(b"no content-type" as &[u8])
.build()
}
#[test]
fn handler_without_content_type_does_not_trap() {
let mut root = RouteNode::new(NodeType::Static("".into()));
root.insert("/no-ct", Method::GET, handler_no_content_type);
let req = HttpRequest::builder()
.with_method(Method::GET)
.with_url("/no-ct")
.build();
match root.resolve("/no-ct", &Method::GET) {
RouteResult::Found(handler, params, _, _) => {
let response = handler(req, params);
assert_eq!(response.status_code(), StatusCode::OK);
assert_eq!(response.body(), b"no content-type");
assert!(response
.headers()
.iter()
.all(|(name, _): &(String, String)| name.to_lowercase() != "content-type"));
}
_ => panic!("expected Found for GET /no-ct"),
}
}
#[test]
fn extract_content_type_json() {
let response = HttpResponse::builder()
.with_status_code(StatusCode::OK)
.with_headers(vec![(
"content-type".to_string(),
"application/json".to_string(),
)])
.with_body(b"{}" as &[u8])
.build();
assert_eq!(extract_content_type(&response), "application/json");
}
#[test]
fn extract_content_type_html() {
let response = HttpResponse::builder()
.with_status_code(StatusCode::OK)
.with_headers(vec![("Content-Type".to_string(), "text/html".to_string())])
.with_body(b"<h1>hi</h1>" as &[u8])
.build();
assert_eq!(extract_content_type(&response), "text/html");
}
#[test]
fn extract_content_type_missing_falls_back() {
let response = HttpResponse::builder()
.with_status_code(StatusCode::OK)
.with_body(b"raw bytes" as &[u8])
.build();
assert_eq!(extract_content_type(&response), "application/octet-stream");
}
#[test]
fn extract_content_type_case_insensitive() {
let response = HttpResponse::builder()
.with_status_code(StatusCode::OK)
.with_headers(vec![("CONTENT-TYPE".to_string(), "text/plain".to_string())])
.with_body(b"hello" as &[u8])
.build();
assert_eq!(extract_content_type(&response), "text/plain");
}
fn make_asset_router() -> asset_router::AssetRouter {
let tree = std::rc::Rc::new(std::cell::RefCell::new(
ic_http_certification::HttpCertificationTree::default(),
));
asset_router::AssetRouter::with_tree(tree)
}
#[test]
fn asset_with_own_ttl_not_expired() {
let mut router = make_asset_router();
let config = asset_router::AssetCertificationConfig {
certified_at: 1_000_000,
ttl: Some(Duration::from_secs(3600)),
dynamic: true,
..Default::default()
};
router
.certify_asset("/page", b"content".to_vec(), config)
.unwrap();
let asset = router.get_asset("/page").unwrap();
let one_hour_ns: u64 = 3_600_000_000_000;
assert!(!is_asset_expired(
asset,
"/page",
1_000_000 + one_hour_ns - 1
));
}
#[test]
fn asset_with_own_ttl_expired() {
let mut router = make_asset_router();
let config = asset_router::AssetCertificationConfig {
certified_at: 1_000_000,
ttl: Some(Duration::from_secs(3600)),
dynamic: true,
..Default::default()
};
router
.certify_asset("/page", b"content".to_vec(), config)
.unwrap();
let asset = router.get_asset("/page").unwrap();
let one_hour_ns: u64 = 3_600_000_000_000;
assert!(is_asset_expired(asset, "/page", 1_000_000 + one_hour_ns));
assert!(is_asset_expired(
asset,
"/page",
1_000_000 + one_hour_ns + 1
));
}
#[test]
fn asset_without_ttl_uses_global_config() {
let mut router = make_asset_router();
let config = asset_router::AssetCertificationConfig {
certified_at: 1_000_000,
ttl: None,
dynamic: true,
..Default::default()
};
router
.certify_asset("/page", b"content".to_vec(), config)
.unwrap();
let asset = router.get_asset("/page").unwrap();
ROUTER_CONFIG.with(|c| {
c.borrow_mut().cache_config.default_ttl = Some(Duration::from_secs(3600));
});
let one_hour_ns: u64 = 3_600_000_000_000;
assert!(!is_asset_expired(
asset,
"/page",
1_000_000 + one_hour_ns - 1
));
assert!(is_asset_expired(asset, "/page", 1_000_000 + one_hour_ns));
ROUTER_CONFIG.with(|c| {
c.borrow_mut().cache_config.default_ttl = None;
});
}
#[test]
fn asset_without_ttl_no_global_config_never_expires() {
let mut router = make_asset_router();
let config = asset_router::AssetCertificationConfig {
certified_at: 1_000_000,
ttl: None,
dynamic: true,
..Default::default()
};
router
.certify_asset("/page", b"content".to_vec(), config)
.unwrap();
let asset = router.get_asset("/page").unwrap();
ROUTER_CONFIG.with(|c| {
c.borrow_mut().cache_config.default_ttl = None;
});
assert!(!is_asset_expired(asset, "/page", u64::MAX));
}
#[test]
fn static_asset_never_expires() {
let mut router = make_asset_router();
let config = asset_router::AssetCertificationConfig {
certified_at: 1_000_000,
ttl: Some(Duration::from_secs(1)),
dynamic: false,
..Default::default()
};
router
.certify_asset("/page", b"content".to_vec(), config)
.unwrap();
let asset = router.get_asset("/page").unwrap();
assert!(!is_asset_expired(asset, "/page", u64::MAX));
}
}