#[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()
}
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 }
}
}
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 => {
debug_log!("Serving {} without certification", path);
let mut response =
root_route_node.execute_with_middleware(&path, handler, req, params);
response.add_header((
CERTIFICATE_EXPRESSION_HEADER_NAME.to_string(),
DefaultCelBuilder::skip_certification().to_string(),
));
HTTP_TREE.with(|tree| {
let tree = tree.borrow();
let cert = match data_certificate() {
Some(c) => c,
None => {
return 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 = match tree.witness(&tree_entry, &path) {
Ok(w) => w,
Err(_) => {
return error_response(
500,
"Internal Server Error: failed to create certification witness",
);
}
};
add_v2_certificate_header(
&cert,
&mut response,
&witness,
&tree_path.to_expr_path(),
);
response
})
}
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)) {
debug_log!("skip mode: running handler inline for {}", path);
let mut response =
root_route_node.execute_with_middleware(&path, handler, req, params);
response.add_header((
CERTIFICATE_EXPRESSION_HEADER_NAME.to_string(),
DefaultCelBuilder::skip_certification().to_string(),
));
return HTTP_TREE.with(|tree| {
let tree = tree.borrow();
let cert = match data_certificate() {
Some(c) => c,
None => {
return error_response(
500,
"Internal Server Error: no data certificate available",
);
}
};
let tree_path = HttpCertificationPath::exact(path.to_string());
let tree_certification = HttpCertification::skip();
let tree_entry =
HttpCertificationTreeEntry::new(tree_path.clone(), tree_certification);
let witness = match tree.witness(&tree_entry, &path) {
Ok(w) => w,
Err(_) => {
return error_response(
500,
"Internal Server Error: failed to create skip certification witness",
);
}
};
add_v2_certificate_header(
&cert,
&mut response,
&witness,
&tree_path.to_expr_path(),
);
response
});
}
enum CacheState {
Missing,
Expired,
Valid,
}
let cache_state = ASSET_ROUTER.with_borrow(|asset_router| {
match asset_router.get_asset(&path) {
Some(asset) => {
if asset.is_dynamic() {
let is_expired = if asset.ttl.is_some() {
asset.is_expired(ic_cdk::api::time())
} else {
let effective_ttl = ROUTER_CONFIG
.with(|c| c.borrow().cache_config.effective_ttl(&path));
if let Some(ttl) = effective_ttl {
let now_ns = ic_cdk::api::time();
let expiry_ns = asset
.certified_at
.saturating_add(ttl.as_nanos() as u64);
now_ns >= expiry_ns
} else {
false
}
};
if is_expired {
CacheState::Expired
} else {
CacheState::Valid
}
} 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()
}
}),
}
}
},
RouteResult::MethodNotAllowed(allowed) => method_not_allowed(&allowed),
RouteResult::NotFound => {
if opts.certify {
let canonical_state = ASSET_ROUTER.with_borrow(|asset_router| {
asset_router
.get_asset(NOT_FOUND_CANONICAL_PATH)
.map(|asset| {
if asset.is_dynamic() {
let is_expired = if asset.ttl.is_some() {
asset.is_expired(ic_cdk::api::time())
} else {
let effective_ttl = ROUTER_CONFIG.with(|c| {
c.borrow()
.cache_config
.effective_ttl(NOT_FOUND_CANONICAL_PATH)
});
if let Some(ttl) = effective_ttl {
let now_ns = ic_cdk::api::time();
let expiry_ns = asset
.certified_at
.saturating_add(ttl.as_nanos() as u64);
now_ns >= expiry_ns
} else {
false
}
};
is_expired
} else {
false }
})
});
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_route_node.execute_not_found_with_middleware(&path, req) {
response
} else {
HttpResponse::not_found(
b"Not Found",
vec![("Content-Type".into(), "text/plain".into())],
)
.build()
}
}
}
}
#[allow(dead_code)]
fn certify_dynamic_response(response: HttpResponse<'static>, path: &str) -> HttpResponse<'static> {
certify_dynamic_response_inner(
response,
path,
None,
certification::CertificationMode::response_only(),
None,
)
}
fn certify_dynamic_response_inner(
response: HttpResponse<'static>,
path: &str,
fallback_for: Option<String>,
mode: certification::CertificationMode,
request: Option<&HttpRequest>,
) -> HttpResponse<'static> {
certify_dynamic_response_with_ttl(response, path, fallback_for, mode, request, None)
}
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,
};
ASSET_ROUTER.with_borrow_mut(|asset_router| {
asset_router.delete_asset(path);
match &mode {
certification::CertificationMode::Full(_) => {
let req = request.unwrap_or_else(|| {
ic_cdk::trap(
"Full certification mode requires the original request, \
but none was provided",
)
});
if let Err(err) = asset_router.certify_dynamic_asset(path, req, &response, config) {
ic_cdk::trap(format!("Failed to certify dynamic asset (full): {err}"));
}
}
_ => {
if let Err(err) = asset_router.certify_asset(path, response.body().to_vec(), config)
{
ic_cdk::trap(format!("Failed to certify dynamic asset: {err}"));
}
}
}
certified_data_set(&asset_router.root_hash());
});
response
}
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 {
let result = result_fn(req.clone(), params.clone());
match result {
router::HandlerResult::NotModified => {
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();
}
}
});
return 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",
),
}
});
}
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 => {
let cached_valid = ASSET_ROUTER.with_borrow(|asset_router| {
if let Some(asset) = asset_router.get_asset(NOT_FOUND_CANONICAL_PATH) {
if asset.is_dynamic() {
let is_expired = if asset.ttl.is_some() {
asset.is_expired(ic_cdk::api::time())
} else {
let effective_ttl = ROUTER_CONFIG.with(|c| {
c.borrow()
.cache_config
.effective_ttl(NOT_FOUND_CANONICAL_PATH)
});
if let Some(ttl) = effective_ttl {
let now_ns = ic_cdk::api::time();
let expiry_ns =
asset.certified_at.saturating_add(ttl.as_nanos() as u64);
now_ns >= expiry_ns
} else {
false
}
};
!is_expired
} else {
true }
} else {
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_route_node.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_inner(
response,
NOT_FOUND_CANONICAL_PATH,
Some("/".to_string()),
certification::CertificationMode::response_only(),
None,
)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ic_http_certification::Method;
use router::{NodeType, RouteNode, RouteParams};
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");
}
}