Skip to main content

ic_asset_router/
lib.rs

1//! Build full-stack web applications on the Internet Computer with file-based
2//! routing conventions familiar from Next.js and SvelteKit — but in Rust,
3//! compiled to a single canister. Drop a handler file into `src/routes/`,
4//! deploy, and your endpoint is live with automatic response certification,
5//! typed parameters, scoped middleware, and configurable security headers.
6//!
7//! # Features
8//!
9//! - **File-based routing** — `src/routes/` maps directly to URL paths.
10//!   Dynamic segments (`_postId/`), catch-all wildcards (`all.rs`), dotted
11//!   filenames (`og.png.rs` → `/og.png`), and nested directories all work
12//!   out of the box. See [`build::generate_routes`].
13//! - **IC response certification** — responses are automatically certified so
14//!   boundary nodes can verify them. Choose from three certification modes
15//!   (Skip, ResponseOnly, Full) per route or per asset. See
16//!   [Certification Modes](#certification-modes) below.
17//! - **Typed route context** — handlers receive a [`RouteContext`] with typed
18//!   path params, typed search params, headers, body, and the full URL.
19//! - **Scoped middleware** — place a `middleware.rs` in any directory to wrap
20//!   all handlers below it. Middleware composes from root to leaf.
21//!   See [`middleware::MiddlewareFn`].
22//! - **Catch-all wildcards** — name a file `all.rs` to capture the remaining
23//!   path. The matched tail is available as `ctx.wildcard`.
24//! - **Custom 404 handler** — place a `not_found.rs` at the routes root to
25//!   serve a styled error page instead of the default plain-text 404.
26//! - **Security headers** — choose from [`SecurityHeaders::strict`],
27//!   [`SecurityHeaders::permissive`], or [`SecurityHeaders::none`] presets,
28//!   or configure individual headers.
29//! - **Cache control & TTL** — set `Cache-Control` per asset type, configure
30//!   TTL-based expiry via [`CacheConfig`], and invalidate cached responses
31//!   with [`invalidate_path`], [`invalidate_prefix`], or
32//!   [`invalidate_all_dynamic`].
33//!
34//! # Quick Start
35//!
36//! **1. Build script** — scans `src/routes/` and generates the route tree:
37//!
38//! ```rust,ignore
39//! // build.rs
40//! fn main() {
41//!     ic_asset_router::build::generate_routes();
42//! }
43//! ```
44//!
45//! **2. Route handler** — a file in `src/routes/` with public `get`, `post`,
46//! etc. functions:
47//!
48//! ```rust,ignore
49//! // src/routes/index.rs
50//! use ic_asset_router::{HttpResponse, RouteContext, StatusCode};
51//! use std::borrow::Cow;
52//!
53//! pub fn get(_ctx: RouteContext<()>) -> HttpResponse<'static> {
54//!     HttpResponse::builder()
55//!         .with_status_code(StatusCode::OK)
56//!         .with_headers(vec![(
57//!             "content-type".to_string(),
58//!             "text/html; charset=utf-8".to_string(),
59//!         )])
60//!         .with_body(Cow::<[u8]>::Owned(b"<h1>Hello from the IC!</h1>".to_vec()))
61//!         .build()
62//! }
63//! ```
64//!
65//! **3. Canister wiring** — include the generated route tree and expose the
66//! HTTP interface:
67//!
68//! ```rust,ignore
69//! // src/lib.rs
70//! mod route_tree {
71//!     include!(concat!(env!("OUT_DIR"), "/__route_tree.rs"));
72//! }
73//!
74//! fn setup() {
75//!     route_tree::ROUTES.with(|routes| {
76//!         ic_asset_router::setup(routes).build();
77//!     });
78//! }
79//! ```
80//!
81//! See the [`examples/`](https://github.com/kristoferlund/ic-asset-router/tree/main/examples)
82//! directory for complete, deployable canister projects including a
83//! [React SPA](https://github.com/kristoferlund/ic-asset-router/tree/main/examples/react-app)
84//! with TanStack Router/Query and per-route SEO meta tags.
85//!
86//! # Route Handlers
87//!
88//! Each `.rs` file in `src/routes/` is a route handler. Export one or more
89//! public functions named after HTTP methods and the build script wires them
90//! to the matching URL path automatically.
91//!
92//! ## Supported Methods
93//!
94//! Export any combination of `get`, `post`, `put`, `patch`, `delete`, `head`,
95//! or `options` as `pub fn` from a single file. Private functions are ignored.
96//! A file with no recognized public method function causes a build error.
97//!
98//! ## Handler Signature
99//!
100//! Every handler receives a [`RouteContext`] and returns an
101//! [`HttpResponse<'static>`](HttpResponse). All types are re-exported from
102//! `ic_asset_router` — no need to depend on `ic_http_certification` directly:
103//!
104//! ```rust,ignore
105//! use ic_asset_router::{HttpResponse, RouteContext, StatusCode};
106//! use std::borrow::Cow;
107//!
108//! pub fn get(_ctx: RouteContext<()>) -> HttpResponse<'static> {
109//!     HttpResponse::builder()
110//!         .with_status_code(StatusCode::OK)
111//!         .with_headers(vec![("content-type".into(), "text/plain".into())])
112//!         .with_body(Cow::<[u8]>::Owned(b"Hello!".to_vec()))
113//!         .build()
114//! }
115//! ```
116//!
117//! The type parameter `P` in `RouteContext<P>` is the typed params struct
118//! generated by the build script for routes with dynamic segments. Use `()`
119//! for routes without dynamic segments.
120//!
121//! ## Multiple Methods in One File
122//!
123//! A single file can handle several HTTP methods. The library returns
124//! `405 Method Not Allowed` with a correct `Allow` header for methods
125//! that exist at the same path but weren't requested:
126//!
127//! ```rust,ignore
128//! // src/routes/items/_itemId/index.rs
129//! use ic_asset_router::{HttpResponse, RouteContext, StatusCode};
130//!
131//! pub fn get(ctx: RouteContext<Params>) -> HttpResponse<'static> {
132//!     // GET /items/:itemId — retrieve
133//!     # todo!()
134//! }
135//!
136//! pub fn put(ctx: RouteContext<Params>) -> HttpResponse<'static> {
137//!     // PUT /items/:itemId — update
138//!     # todo!()
139//! }
140//!
141//! pub fn delete(ctx: RouteContext<Params>) -> HttpResponse<'static> {
142//!     // DELETE /items/:itemId — delete
143//!     # todo!()
144//! }
145//!
146//! use super::Params; // generated: pub struct Params { pub item_id: String }
147//! ```
148//!
149//! ## What RouteContext Provides
150//!
151//! Handlers receive all request data through the context object:
152//!
153//! - `ctx.params` — typed path parameters (e.g. `ctx.params.post_id`)
154//! - `ctx.search` — typed search (query string) params (default `()`)
155//! - `ctx.query` — untyped query params (`HashMap<String, String>`)
156//! - `ctx.method` — HTTP method
157//! - `ctx.headers` — request headers
158//! - `ctx.body` — raw request body bytes
159//! - `ctx.url` — full request URL
160//! - `ctx.wildcard` — catch-all wildcard tail (`Option<String>`)
161//!
162//! Convenience methods: [`ctx.header()`](RouteContext::header),
163//! [`ctx.body_to_str()`](RouteContext::body_to_str),
164//! [`ctx.json::<T>()`](RouteContext::json),
165//! [`ctx.form::<T>()`](RouteContext::form),
166//! [`ctx.form_data()`](RouteContext::form_data).
167//!
168//! See the [`json-api`](https://github.com/kristoferlund/ic-asset-router/tree/main/examples/json-api)
169//! example for a complete REST API with GET, POST, PUT, and DELETE.
170//!
171//! # Middleware
172//!
173//! Place a `middleware.rs` file in any directory under `src/routes/` and it
174//! wraps every handler in that directory and all subdirectories below it.
175//! The file must export a `pub fn middleware` with the
176//! [`MiddlewareFn`](middleware::MiddlewareFn) signature:
177//!
178//! ```rust,ignore
179//! use ic_asset_router::{HttpRequest, HttpResponse, RouteParams};
180//!
181//! pub fn middleware(
182//!     req: HttpRequest,
183//!     params: &RouteParams,
184//!     next: &dyn Fn(HttpRequest, &RouteParams) -> HttpResponse<'static>,
185//! ) -> HttpResponse<'static> {
186//!     // Before: inspect or modify the request
187//!     let response = next(req, params);
188//!     // After: inspect or modify the response
189//!     response
190//! }
191//! ```
192//!
193//! Middleware can:
194//!
195//! - **Modify the request** — `req` is owned; construct or alter it before
196//!   passing to `next`.
197//! - **Modify the response** — capture the return value of `next` and
198//!   transform headers, body, or status before returning.
199//! - **Short-circuit** — return a response without calling `next` at all
200//!   (e.g. return 401 for unauthorized requests). The handler never executes.
201//!
202//! ## Composition Order
203//!
204//! Middleware at different directory levels composes automatically in
205//! root-to-leaf order. For a request to `/api/v2/data`:
206//!
207//! ```text
208//! root middleware → /api middleware → /api/v2 middleware → handler
209//! ```
210//!
211//! On the way back, responses unwind in reverse (onion model). Only one
212//! middleware per directory is allowed. Middleware also wraps the custom
213//! 404 handler — root-level middleware runs before `not_found.rs`.
214//!
215//! ## Example: CORS Middleware
216//!
217//! ```rust,ignore
218//! use ic_asset_router::{HttpRequest, HttpResponse, RouteParams, StatusCode};
219//!
220//! pub fn middleware(
221//!     req: HttpRequest,
222//!     params: &RouteParams,
223//!     next: &dyn Fn(HttpRequest, &RouteParams) -> HttpResponse<'static>,
224//! ) -> HttpResponse<'static> {
225//!     let cors_headers = vec![
226//!         ("access-control-allow-origin".into(), "*".into()),
227//!         ("access-control-allow-methods".into(), "GET, POST, PUT, DELETE, OPTIONS".into()),
228//!         ("access-control-allow-headers".into(), "content-type".into()),
229//!     ];
230//!
231//!     // Short-circuit: respond to OPTIONS preflight without running the handler
232//!     if req.method().as_str() == "OPTIONS" {
233//!         return HttpResponse::builder()
234//!             .with_status_code(StatusCode::NO_CONTENT)
235//!             .with_headers(cors_headers)
236//!             .build();
237//!     }
238//!
239//!     let response = next(req, params);
240//!
241//!     // Append CORS headers to the response
242//!     let mut headers = response.headers().to_vec();
243//!     headers.extend(cors_headers);
244//!     HttpResponse::builder()
245//!         .with_status_code(response.status_code())
246//!         .with_headers(headers)
247//!         .with_body(response.body().to_vec())
248//!         .build()
249//! }
250//! ```
251//!
252//! See the [`json-api`](https://github.com/kristoferlund/ic-asset-router/tree/main/examples/json-api)
253//! example for a working CORS middleware.
254//!
255//! # Certification Modes
256//!
257//! Every response served by an IC canister can be cryptographically certified
258//! so that boundary nodes can verify it was not tampered with. This library
259//! supports three certification modes, configurable per-route or per-asset:
260//!
261//! ## Choosing a Mode
262//!
263//! **Start with Response-Only (the default).** It is correct for 90% of
264//! routes and requires zero configuration.
265//!
266//! | Mode | When to use | Example routes |
267//! |------|-------------|----------------|
268//! | **Response-only** | Same URL always returns same content | Static pages, blog posts, docs |
269//! | **Skip** | Tampering has no security impact | Health checks, `/ping` |
270//! | **Skip + handler auth** | Fast auth-gated API (query-path perf) | `/api/customers`, `/api/me` |
271//! | **Authenticated** | Response depends on caller identity, must be tamper-proof | User profiles, dashboards |
272//! | **Custom (Full)** | Response depends on specific headers/params | Content negotiation, pagination |
273//!
274//! ## Response-Only (Default)
275//!
276//! No attribute needed — just write your handler:
277//!
278//! ```rust,ignore
279//! pub fn get(_ctx: RouteContext<()>) -> HttpResponse<'static> {
280//!     // Automatically uses ResponseOnly certification
281//!     HttpResponse::builder()
282//!         .with_status_code(StatusCode::OK)
283//!         .with_body(b"Hello!" as &[u8])
284//!         .build()
285//! }
286//! ```
287//!
288//! The response body, status code, and headers are certified. The request
289//! details are not included in the hash. This is sufficient when the
290//! response depends only on the URL path and canister state.
291//!
292//! ## Skip Certification
293//!
294//! ```rust,ignore
295//! #[route(certification = "skip")]
296//! pub fn get(_ctx: RouteContext<()>) -> HttpResponse<'static> {
297//!     // Handler runs on every query call — like a candid query
298//!     HttpResponse::builder()
299//!         .with_body(b"{\"status\":\"ok\"}" as &[u8])
300//!         .build()
301//! }
302//! ```
303//!
304//! **Handler execution:** Skip-mode routes run the handler on every query
305//! call, just like candid `query` calls. This makes them ideal for
306//! auth-gated API endpoints — combine with handler-level auth (JWT
307//! validation, `ic_cdk::caller()` checks) for fast (~200ms) authenticated
308//! queries without waiting for consensus (~2s update calls).
309//!
310//! **Security note:** Skip certification provides the same trust level as
311//! candid query calls — both trust the responding replica without
312//! cryptographic verification by the boundary node. If candid queries are
313//! acceptable for your application, skip certification is equally
314//! acceptable.
315//!
316//! ### Skip + Handler Auth Pattern
317//!
318//! ```rust,ignore
319//! #[route(certification = "skip")]
320//! pub fn get(ctx: RouteContext<()>) -> HttpResponse<'static> {
321//!     let caller = ic_cdk::caller();
322//!     if caller == Principal::anonymous() {
323//!         return HttpResponse::builder()
324//!             .with_status_code(StatusCode::UNAUTHORIZED)
325//!             .with_body(b"unauthorized" as &[u8])
326//!             .build();
327//!     }
328//!     // Return caller-specific data
329//!     HttpResponse::builder()
330//!         .with_body(format!("hello {caller}").into_bytes())
331//!         .build()
332//! }
333//! ```
334//!
335//! See the [`api-authentication`](https://github.com/kristoferlund/ic-asset-router/tree/main/examples/api-authentication)
336//! example for a complete demonstration of both patterns.
337//!
338//! ## Authenticated (Full Certification Preset)
339//!
340//! ```rust,ignore
341//! #[route(certification = "authenticated")]
342//! pub fn get(_ctx: RouteContext<()>) -> HttpResponse<'static> {
343//!     // Authorization header is included in certification
344//!     // User A cannot receive User B's cached response
345//!     HttpResponse::builder()
346//!         .with_body(b"{\"name\":\"Alice\"}" as &[u8])
347//!         .build()
348//! }
349//! ```
350//!
351//! The `authenticated` preset is a preconfigured Full mode that includes
352//! the `Authorization` request header and `Content-Type` response header.
353//!
354//! ## Custom Full Certification
355//!
356//! ```rust,ignore
357//! #[route(certification = custom(
358//!     request_headers = ["accept"],
359//!     query_params = ["page", "limit"]
360//! ))]
361//! pub fn get(_ctx: RouteContext<()>) -> HttpResponse<'static> {
362//!     // Each combination of Accept + page + limit is independently certified
363//!     HttpResponse::builder()
364//!         .with_body(b"page content" as &[u8])
365//!         .build()
366//! }
367//! ```
368//!
369//! ## Setup with Static Assets
370//!
371//! Configure and certify assets in a single builder chain during
372//! `init`/`post_upgrade`:
373//!
374//! ```rust,ignore
375//! use ic_asset_router::CertificationMode;
376//!
377//! route_tree::ROUTES.with(|routes| {
378//!     ic_asset_router::setup(routes)
379//!         .with_assets(&STATIC_DIR)                                   // response-only (default)
380//!         .with_assets_certified(&PUBLIC_DIR, CertificationMode::skip()) // skip
381//!         .build();
382//! });
383//! ```
384//!
385//! ## Performance Comparison
386//!
387//! | Mode | Relative cost | Witness size |
388//! |------|---------------|--------------|
389//! | Skip | ~0 | Minimal |
390//! | Response-only | Low | ~200 bytes |
391//! | Full (authenticated) | Medium | ~300 bytes |
392//! | Full (custom) | Medium–High | ~300–500 bytes |
393//!
394//! ## Security Model: Certification vs Candid Calls
395//!
396//! IC canisters support two HTTP interfaces and two candid call types, each
397//! with different trust assumptions:
398//!
399//! | Mechanism | Consensus | Boundary node verifies? | Trust model |
400//! |-----------|-----------|------------------------|-------------|
401//! | Candid **update** call | Yes (2s) | N/A | Consensus — response reflects agreed-upon state |
402//! | Candid **query** call | No (200ms) | No | Trust the replica |
403//! | HTTP + **ResponseOnly/Full** cert | Yes (2s) | Yes | Consensus — boundary node verifies the certificate |
404//! | HTTP + **Skip** cert | No (200ms) | No | Trust the replica |
405//!
406//! **Key insight:** Skip certification and candid query calls have the same
407//! trust model. Both execute on a single replica without consensus, and
408//! neither response is cryptographically verified. If your application
409//! already uses candid queries (as most IC apps do), skip certification
410//! is equally acceptable for equivalent operations.
411//!
412//! ## Common Mistakes
413//!
414//! - **Over-certifying:** Certifying `User-Agent` causes cache
415//!   fragmentation (every browser version gets a separate certificate).
416//!   Only certify headers that affect the response content.
417//! - **Under-certifying:** Using response-only for authenticated endpoints
418//!   means a malicious replica can serve any cached response to any user.
419//!   Use `#[route(certification = "authenticated")]` instead.
420//! - **Certifying non-deterministic data:** If the response body changes
421//!   every call (e.g., timestamps), the certificate is immediately stale.
422//!   Use `skip` or add a TTL.
423
424/// Debug logging macro gated behind the `debug-logging` feature flag.
425/// When enabled, expands to `ic_cdk::println!`; otherwise compiles to nothing.
426#[cfg(feature = "debug-logging")]
427macro_rules! debug_log {
428    ($($arg:tt)*) => { ic_cdk::println!($($arg)*) };
429}
430
431#[cfg(not(feature = "debug-logging"))]
432macro_rules! debug_log {
433    ($($arg:tt)*) => {};
434}
435
436use std::{borrow::Cow, cell::RefCell, rc::Rc};
437
438use assets::get_asset_headers;
439use ic_cdk::api::{certified_data_set, data_certificate};
440use ic_http_certification::{
441    utils::add_v2_certificate_header, DefaultCelBuilder, HttpCertification, HttpCertificationPath,
442    HttpCertificationTree, HttpCertificationTreeEntry, CERTIFICATE_EXPRESSION_HEADER_NAME,
443};
444use router::{RouteNode, RouteResult};
445
446/// Canonical path used to cache the single certified 404 response.
447///
448/// All not-found responses are certified and cached under this one path
449/// instead of per-request-path, preventing memory growth from bot scans.
450const NOT_FOUND_CANONICAL_PATH: &str = "/__not_found";
451
452/// Extract the `content-type` header value from an HTTP response.
453///
454/// Performs a case-insensitive search for the `content-type` header.
455/// Returns `"application/octet-stream"` if no content-type header is present.
456fn extract_content_type(response: &HttpResponse) -> String {
457    response
458        .headers()
459        .iter()
460        .find(|(k, _)| k.eq_ignore_ascii_case("content-type"))
461        .map(|(_, v)| v.clone())
462        .unwrap_or_else(|| "application/octet-stream".to_string())
463}
464
465/// Build a 405 Method Not Allowed response with an `Allow` header listing the
466/// permitted methods for the requested path.
467fn method_not_allowed(allowed: &[Method]) -> HttpResponse<'static> {
468    let allow = allowed
469        .iter()
470        .map(|m| m.as_str())
471        .collect::<Vec<_>>()
472        .join(", ");
473    HttpResponse::builder()
474        .with_status_code(StatusCode::METHOD_NOT_ALLOWED)
475        .with_headers(vec![
476            ("allow".to_string(), allow),
477            ("content-type".to_string(), "text/plain".to_string()),
478        ])
479        .with_body(Cow::<[u8]>::Owned(b"Method Not Allowed".to_vec()))
480        .build()
481}
482
483/// Build a plain-text error response for the given HTTP status code and message.
484///
485/// This avoids canister traps by returning a well-formed HTTP response instead
486/// of panicking on malformed input or missing internal state.
487fn error_response(status: u16, message: &str) -> HttpResponse<'static> {
488    HttpResponse::builder()
489        .with_status_code(StatusCode::from_u16(status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR))
490        .with_headers(vec![("content-type".to_string(), "text/plain".to_string())])
491        .with_body(Cow::<[u8]>::Owned(message.as_bytes().to_vec()))
492        .build()
493}
494
495/// Check whether a dynamic asset is expired, considering both the asset's own
496/// TTL and the global [`CacheConfig`] fallback.
497///
498/// Returns `false` for static assets (they never expire) or when no TTL
499/// applies.
500fn is_asset_expired(asset: &asset_router::CertifiedAsset, path: &str, now_ns: u64) -> bool {
501    if !asset.is_dynamic() {
502        return false;
503    }
504    if asset.ttl.is_some() {
505        return asset.is_expired(now_ns);
506    }
507    // Fall back to global config TTL.
508    let effective_ttl = ROUTER_CONFIG.with(|c| c.borrow().cache_config.effective_ttl(path));
509    match effective_ttl {
510        Some(ttl) => {
511            let expiry_ns = asset.certified_at.saturating_add(ttl.as_nanos() as u64);
512            now_ns >= expiry_ns
513        }
514        None => false,
515    }
516}
517
518/// Custom asset router with per-asset certification modes.
519pub mod asset_router;
520/// Static and dynamic asset certification, invalidation, and serving helpers.
521pub mod assets;
522/// Build-script utilities for file-based route generation.
523pub mod build;
524/// Certification mode configuration types.
525pub mod certification;
526/// Global configuration types: security headers, cache control, TTL settings.
527pub mod config;
528/// Request context types passed to route handlers.
529pub mod context;
530/// Middleware type definition.
531pub mod middleware;
532/// MIME type detection from file extensions.
533pub mod mime;
534/// Per-route configuration types (certification mode, TTL, headers).
535pub mod route_config;
536/// Route trie, handler types, and dispatch logic.
537pub mod router;
538
539pub use assets::{
540    delete_assets, invalidate_all_dynamic, invalidate_path, invalidate_prefix, last_certified_at,
541};
542pub use certification::{CertificationMode, FullConfig, FullConfigBuilder, ResponseOnlyConfig};
543pub use config::{AssetConfig, CacheConfig, CacheControl, SecurityHeaders};
544pub use context::{
545    deserialize_search_params, parse_form_body, parse_query, url_decode, FormBodyError,
546    JsonBodyError, QueryParams, RouteContext,
547};
548pub use ic_asset_router_macros::route;
549pub use ic_http_certification::{HttpRequest, HttpResponse, Method, StatusCode};
550pub use route_config::RouteConfig;
551pub use router::{HandlerResult, RouteParams};
552
553thread_local! {
554    static HTTP_TREE: Rc<RefCell<HttpCertificationTree>> = Default::default();
555    static ASSET_ROUTER: RefCell<asset_router::AssetRouter> = RefCell::new(asset_router::AssetRouter::with_tree(HTTP_TREE.with(|tree| tree.clone())));
556    static ROUTER_CONFIG: RefCell<AssetConfig> = RefCell::new(AssetConfig::default());
557}
558
559/// Set the global router configuration.
560fn set_asset_config(config: AssetConfig) {
561    ROUTER_CONFIG.with(|c| {
562        *c.borrow_mut() = config;
563    });
564}
565
566/// Register skip-certification tree entries for all routes configured with
567/// [`CertificationMode::Skip`].
568fn register_skip_routes(root_route_node: &router::RouteNode) {
569    let skip_paths = root_route_node.skip_certified_paths();
570    if skip_paths.is_empty() {
571        return;
572    }
573
574    // Insert skip certification tree entries directly into the shared tree
575    // WITHOUT storing a CertifiedAsset. This ensures the skip handler runs
576    // on every query call instead of serving a cached empty response.
577    HTTP_TREE.with(|tree| {
578        let mut tree = tree.borrow_mut();
579        for path in &skip_paths {
580            let tree_path = HttpCertificationPath::exact(path.to_string());
581            let certification = HttpCertification::skip();
582            let tree_entry = HttpCertificationTreeEntry::new(tree_path, certification);
583            tree.insert(&tree_entry);
584        }
585    });
586
587    // Update the root hash to include the new skip entries.
588    ASSET_ROUTER.with_borrow(|asset_router| {
589        certified_data_set(asset_router.root_hash());
590    });
591
592    debug_log!("registered {} skip certification entries", skip_paths.len());
593}
594
595// ---------------------------------------------------------------------------
596// Setup builder
597// ---------------------------------------------------------------------------
598
599/// Entry point for canister initialization. Returns a [`SetupBuilder`] that
600/// configures the asset router, certifies assets, and registers skip routes
601/// in a single fluent chain.
602///
603/// Call this during `init` and `post_upgrade`:
604///
605/// ```rust,ignore
606/// use include_dir::{include_dir, Dir};
607///
608/// mod route_tree {
609///     include!(concat!(env!("OUT_DIR"), "/__route_tree.rs"));
610/// }
611///
612/// static ASSET_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/assets");
613///
614/// fn setup() {
615///     route_tree::ROUTES.with(|routes| {
616///         ic_asset_router::setup(routes)
617///             .with_assets(&ASSET_DIR)
618///             .build();
619///     });
620/// }
621/// ```
622pub fn setup(routes: &router::RouteNode) -> SetupBuilder<'_> {
623    SetupBuilder {
624        routes,
625        config: None,
626        asset_dirs: Vec::new(),
627        delete_paths: Vec::new(),
628    }
629}
630
631/// Builder for canister initialization. Created by [`setup()`].
632///
633/// Calling [`.build()`](SetupBuilder::build) executes the following steps
634/// in order:
635///
636/// 1. Sets the global [`AssetConfig`] (or uses the default).
637/// 2. Certifies each registered asset directory.
638/// 3. Deletes any paths registered via [`delete_assets`](SetupBuilder::delete_assets).
639/// 4. Registers skip-certification tree entries for all skip-mode routes.
640/// 5. Calls `certified_data_set` with the final root hash.
641pub struct SetupBuilder<'r> {
642    routes: &'r router::RouteNode,
643    config: Option<AssetConfig>,
644    asset_dirs: Vec<(
645        &'static include_dir::Dir<'static>,
646        certification::CertificationMode,
647    )>,
648    delete_paths: Vec<&'static str>,
649}
650
651impl<'r> SetupBuilder<'r> {
652    /// Override the default [`AssetConfig`].
653    ///
654    /// If not called, [`AssetConfig::default()`] is used.
655    pub fn with_config(mut self, config: AssetConfig) -> Self {
656        self.config = Some(config);
657        self
658    }
659
660    /// Certify all files in `dir` with the default certification mode
661    /// (response-only).
662    pub fn with_assets(mut self, dir: &'static include_dir::Dir<'static>) -> Self {
663        self.asset_dirs
664            .push((dir, certification::CertificationMode::response_only()));
665        self
666    }
667
668    /// Certify all files in `dir` with a specific [`CertificationMode`].
669    pub fn with_assets_certified(
670        mut self,
671        dir: &'static include_dir::Dir<'static>,
672        mode: certification::CertificationMode,
673    ) -> Self {
674        self.asset_dirs.push((dir, mode));
675        self
676    }
677
678    /// Delete previously certified assets at the given paths.
679    ///
680    /// Useful when static assets (e.g. a SPA's `index.html`) should be
681    /// replaced by dynamically generated responses with route-specific
682    /// content (e.g. SEO meta tags).
683    pub fn delete_assets(mut self, paths: Vec<&'static str>) -> Self {
684        self.delete_paths.extend(paths);
685        self
686    }
687
688    /// Execute the setup: apply config, certify assets, register skip
689    /// routes, and commit the certification tree root hash.
690    pub fn build(self) {
691        // 1. Set config.
692        set_asset_config(self.config.unwrap_or_default());
693
694        // 2. Certify asset directories.
695        for (dir, mode) in &self.asset_dirs {
696            assets::certify_assets_with_mode(dir, mode.clone());
697        }
698
699        // 3. Delete specified asset paths.
700        if !self.delete_paths.is_empty() {
701            assets::delete_assets(self.delete_paths);
702        }
703
704        // 4. Register skip routes.
705        register_skip_routes(self.routes);
706    }
707}
708
709/// Options controlling the behavior of [`http_request`].
710pub struct HttpRequestOptions {
711    /// Whether to attempt serving a certified response from the asset router.
712    ///
713    /// When `true` (the default), the library checks the asset router for a
714    /// previously certified response and returns it directly if available.
715    /// When `false`, the handler runs on every request and the response is
716    /// served with a skip-certification proof.
717    pub certify: bool,
718}
719
720impl Default for HttpRequestOptions {
721    fn default() -> Self {
722        HttpRequestOptions { certify: true }
723    }
724}
725
726/// Attach a skip-certification proof to a response.
727///
728/// Adds the CEL skip expression header, borrows the shared HTTP
729/// certification tree, obtains the data certificate, constructs a
730/// witness, and appends the v2 certificate header. On success the
731/// response is modified in place. On failure (missing certificate or
732/// witness error) an appropriate error response is returned in the
733/// `Err` variant.
734fn attach_skip_certification(
735    path: &str,
736    response: &mut HttpResponse<'static>,
737) -> Result<(), HttpResponse<'static>> {
738    response.add_header((
739        CERTIFICATE_EXPRESSION_HEADER_NAME.to_string(),
740        DefaultCelBuilder::skip_certification().to_string(),
741    ));
742
743    HTTP_TREE.with(|tree| {
744        let tree = tree.borrow();
745
746        let cert = data_certificate().ok_or_else(|| {
747            error_response(500, "Internal Server Error: no data certificate available")
748        })?;
749
750        let tree_path = HttpCertificationPath::exact(path);
751        let certification = HttpCertification::skip();
752        let tree_entry = HttpCertificationTreeEntry::new(&tree_path, certification);
753
754        let witness = tree.witness(&tree_entry, path).map_err(|_| {
755            error_response(
756                500,
757                "Internal Server Error: failed to create certification witness",
758            )
759        })?;
760
761        add_v2_certificate_header(&cert, response, &witness, &tree_path.to_expr_path());
762
763        Ok(())
764    })
765}
766
767/// Handle the `NotFound` branch of `http_request`.
768///
769/// When certification is enabled, checks the canonical `/__not_found` cache
770/// entry: serves from cache if valid, upgrades if expired or missing. Also
771/// tries serving a static asset for the original path before upgrading.
772/// When certification is disabled, runs the not-found handler directly.
773fn handle_not_found_query(
774    req: HttpRequest,
775    path: &str,
776    root: &RouteNode,
777    certify: bool,
778) -> HttpResponse<'static> {
779    if certify {
780        // Check the unified AssetRouter for the canonical /__not_found entry.
781        let canonical_state = ASSET_ROUTER.with_borrow(|asset_router| {
782            asset_router
783                .get_asset(NOT_FOUND_CANONICAL_PATH)
784                .map(|asset| is_asset_expired(asset, NOT_FOUND_CANONICAL_PATH, ic_cdk::api::time()))
785        });
786
787        match canonical_state {
788            Some(true) => {
789                debug_log!("upgrading not-found (TTL expired for canonical path)");
790                return HttpResponse::builder().with_upgrade(true).build();
791            }
792            Some(false) => {
793                return ASSET_ROUTER.with_borrow(|asset_router| {
794                    let cert = match data_certificate() {
795                        Some(c) => c,
796                        None => {
797                            debug_log!("upgrading not-found (no data certificate)");
798                            return HttpResponse::builder().with_upgrade(true).build();
799                        }
800                    };
801                    if let Some((mut response, witness, expr_path)) = asset_router.serve_asset(&req)
802                    {
803                        add_v2_certificate_header(&cert, &mut response, &witness, &expr_path);
804                        debug_log!("serving cached not-found for {}", path);
805                        response
806                    } else {
807                        debug_log!("upgrading not-found (serve_asset failed for canonical path)");
808                        HttpResponse::builder().with_upgrade(true).build()
809                    }
810                });
811            }
812            None => {
813                // Try serving a static asset for the original path.
814                let maybe_response = ASSET_ROUTER.with_borrow(|asset_router| {
815                    let cert = data_certificate()?;
816                    let (mut response, witness, expr_path) = asset_router.serve_asset(&req)?;
817                    add_v2_certificate_header(&cert, &mut response, &witness, &expr_path);
818                    Some(response)
819                });
820                if let Some(response) = maybe_response {
821                    debug_log!("serving static asset for {}", path);
822                    return response;
823                }
824
825                debug_log!("upgrading not-found (no cached entry for {})", path);
826                return HttpResponse::builder().with_upgrade(true).build();
827            }
828        }
829    }
830
831    // Non-certified mode: execute the not-found handler directly.
832    if let Some(response) = root.execute_not_found_with_middleware(path, req) {
833        response
834    } else {
835        HttpResponse::not_found(
836            b"Not Found",
837            vec![("Content-Type".into(), "text/plain".into())],
838        )
839        .build()
840    }
841}
842
843/// Serve from the asset router cache, or upgrade to an update call.
844///
845/// Checks the asset router for a cached certified response at `path`.
846/// Returns the certified response if the asset exists and is not expired,
847/// or an upgrade response (`upgrade: true`) if the asset is missing,
848/// expired, or the data certificate is unavailable.
849fn serve_from_cache_or_upgrade(req: &HttpRequest, path: &str) -> HttpResponse<'static> {
850    enum CacheState {
851        Missing,
852        Expired,
853        Valid,
854    }
855
856    let cache_state = ASSET_ROUTER.with_borrow(|asset_router| match asset_router.get_asset(path) {
857        Some(asset) => {
858            if is_asset_expired(asset, path, ic_cdk::api::time()) {
859                CacheState::Expired
860            } else {
861                CacheState::Valid
862            }
863        }
864        None => CacheState::Missing,
865    });
866
867    match cache_state {
868        CacheState::Missing => {
869            debug_log!("upgrading (no asset: {})", path);
870            HttpResponse::builder().with_upgrade(true).build()
871        }
872        CacheState::Expired => {
873            debug_log!("upgrading (TTL expired for {})", path);
874            HttpResponse::builder().with_upgrade(true).build()
875        }
876        CacheState::Valid => ASSET_ROUTER.with_borrow(|asset_router| {
877            let cert = match data_certificate() {
878                Some(c) => c,
879                None => {
880                    debug_log!("upgrading (no data certificate)");
881                    return HttpResponse::builder().with_upgrade(true).build();
882                }
883            };
884            if let Some((mut response, witness, expr_path)) = asset_router.serve_asset(req) {
885                add_v2_certificate_header(&cert, &mut response, &witness, &expr_path);
886                debug_log!("serving directly");
887                response
888            } else {
889                debug_log!("upgrading");
890                HttpResponse::builder().with_upgrade(true).build()
891            }
892        }),
893    }
894}
895
896/// Run the handler through the middleware chain and attach a
897/// skip-certification proof to the response.
898///
899/// Used both when the caller opts out of certification globally
900/// (`opts.certify == false`) and when a route is configured with
901/// [`CertificationMode::Skip`].
902fn serve_without_certification(
903    root: &RouteNode,
904    path: &str,
905    handler: router::HandlerFn,
906    req: HttpRequest,
907    params: router::RouteParams,
908) -> HttpResponse<'static> {
909    debug_log!("serving {} without certification", path);
910    let mut response = root.execute_with_middleware(path, handler, req, params);
911    match attach_skip_certification(path, &mut response) {
912        Ok(()) => response,
913        Err(err_resp) => err_resp,
914    }
915}
916
917/// Handle an HTTP query-path request.
918///
919/// This is the IC `http_request` entry point. It resolves the incoming
920/// request against the route tree and either:
921///
922/// 1. Serves a previously certified response from the asset router, or
923/// 2. Upgrades the request to an update call (returns `upgrade: true`) so
924///    that the handler can run in `http_request_update` and certify a new
925///    response.
926///
927/// Non-GET/HEAD requests are always upgraded. GET requests for dynamic
928/// routes with expired TTLs are also upgraded.
929pub fn http_request(
930    req: HttpRequest,
931    root_route_node: &RouteNode,
932    opts: HttpRequestOptions,
933) -> HttpResponse<'static> {
934    debug_log!("http_request: {:?}", req.url());
935
936    let path = match req.get_path() {
937        Ok(p) => p,
938        Err(_) => return error_response(400, "Bad Request: malformed URL"),
939    };
940
941    let method = req.method().clone();
942
943    // Non-GET requests arriving at the query endpoint must be upgraded to an
944    // update call so that state-mutating handlers execute in the update path.
945    if method != Method::GET && method != Method::HEAD {
946        debug_log!(
947            "upgrading non-GET request ({}) to update call",
948            method.as_str()
949        );
950        return HttpResponse::builder().with_upgrade(true).build();
951    }
952
953    match root_route_node.resolve(&path, &method) {
954        RouteResult::Found(handler, params, _result_handler, pattern) => match opts.certify {
955            false => serve_without_certification(root_route_node, &path, handler, req, params),
956            true => {
957                let route_config = root_route_node.get_route_config(&pattern);
958                let cert_mode = route_config.map(|rc| &rc.certification);
959
960                // Full mode binds proof to request headers — always upgrade.
961                if matches!(cert_mode, Some(certification::CertificationMode::Full(_))) {
962                    debug_log!("upgrading (full certification mode: {})", path);
963                    return HttpResponse::builder().with_upgrade(true).build();
964                }
965
966                if matches!(cert_mode, Some(certification::CertificationMode::Skip)) {
967                    return serve_without_certification(
968                        root_route_node,
969                        &path,
970                        handler,
971                        req,
972                        params,
973                    );
974                }
975
976                serve_from_cache_or_upgrade(&req, &path)
977            }
978        },
979        RouteResult::MethodNotAllowed(allowed) => method_not_allowed(&allowed),
980        RouteResult::NotFound => handle_not_found_query(req, &path, root_route_node, opts.certify),
981    }
982}
983
984/// Certify a dynamically generated response and store it for future query-path
985/// serving.
986///
987/// The response body is stored in the `AssetRouter` via `certify_asset()`,
988/// which lets the query path use `serve_asset()`. All responses — including
989/// not-found handler output — go through this single path. The not-found
990/// Certify a dynamically generated response and store it for future query-path
991/// serving.
992///
993/// The response body is stored in the `AssetRouter` via `certify_asset()`,
994/// which lets the query path use `serve_asset()`. All responses — including
995/// not-found handler output — go through this single path. The not-found
996/// handler's response is certified at the canonical `/__not_found` path so
997/// that only one cache entry exists for all 404s.
998///
999/// When `fallback_for` is `Some`, the asset is registered as a fallback
1000/// for the given scope. This is used by the not-found handler to certify
1001/// a single `/__not_found` asset that serves as a fallback for all paths.
1002///
1003/// The `mode` parameter controls how the response is certified:
1004/// - `Skip` / `ResponseOnly` — uses `certify_asset()` (no request needed).
1005/// - `Full` — uses `certify_dynamic_asset()` with the original request.
1006///
1007/// The `request` parameter is required for `Full` mode and ignored otherwise.
1008fn certify_dynamic_response_with_ttl(
1009    response: HttpResponse<'static>,
1010    path: &str,
1011    fallback_for: Option<String>,
1012    mode: certification::CertificationMode,
1013    request: Option<&HttpRequest>,
1014    ttl_override: Option<std::time::Duration>,
1015) -> HttpResponse<'static> {
1016    let content_type = extract_content_type(&response);
1017    let effective_ttl = ttl_override
1018        .or_else(|| ROUTER_CONFIG.with(|c| c.borrow().cache_config.effective_ttl(path)));
1019
1020    let dynamic_cache_control =
1021        ROUTER_CONFIG.with(|c| c.borrow().cache_control.dynamic_assets.clone());
1022
1023    let config = asset_router::AssetCertificationConfig {
1024        mode: mode.clone(),
1025        content_type: Some(content_type),
1026        status_code: response.status_code(),
1027        headers: get_asset_headers(vec![("cache-control".to_string(), dynamic_cache_control)]),
1028        encodings: vec![],
1029        fallback_for,
1030        aliases: vec![],
1031        certified_at: ic_cdk::api::time(),
1032        ttl: effective_ttl,
1033        dynamic: true,
1034    };
1035
1036    let certified = ASSET_ROUTER.with_borrow_mut(|asset_router| {
1037        // Delete any existing asset at this path before re-certifying.
1038        asset_router.delete_asset(path);
1039
1040        match &mode {
1041            certification::CertificationMode::Full(_) => {
1042                // Full mode requires the original request for certification.
1043                let req = match request {
1044                    Some(r) => r,
1045                    None => {
1046                        debug_log!(
1047                            "certify_dynamic_response_with_ttl: Full certification mode \
1048                             requires the original request, but none was provided for path '{}'. \
1049                             Returning uncertified response.",
1050                            path
1051                        );
1052                        return false;
1053                    }
1054                };
1055                if let Err(_err) = asset_router.certify_dynamic_asset(path, req, &response, config)
1056                {
1057                    debug_log!(
1058                        "certify_dynamic_response_with_ttl: failed to certify dynamic asset \
1059                         (full) for path '{}': {}. Returning uncertified response.",
1060                        path,
1061                        _err
1062                    );
1063                    return false;
1064                }
1065            }
1066            _ => {
1067                // Skip and ResponseOnly modes use certify_asset.
1068                if let Err(_err) =
1069                    asset_router.certify_asset(path, response.body().to_vec(), config)
1070                {
1071                    debug_log!(
1072                        "certify_dynamic_response_with_ttl: failed to certify dynamic asset \
1073                         for path '{}': {}. Returning uncertified response.",
1074                        path,
1075                        _err
1076                    );
1077                    return false;
1078                }
1079            }
1080        }
1081
1082        certified_data_set(asset_router.root_hash());
1083        true
1084    });
1085
1086    if !certified {
1087        debug_log!(
1088            "certify_dynamic_response_with_ttl: certification failed for path '{}', \
1089             serving uncertified response",
1090            path
1091        );
1092    }
1093
1094    response
1095}
1096
1097/// Handle the `NotFound` branch of `http_request_update`.
1098///
1099/// Checks the canonical `/__not_found` cache entry. If a valid (non-expired)
1100/// cached 404 exists, serves it directly. Otherwise, executes the not-found
1101/// handler through the middleware chain, certifies the response at the
1102/// canonical path as a fallback, and caches it.
1103fn handle_not_found_update(
1104    req: HttpRequest,
1105    path: &str,
1106    root: &RouteNode,
1107) -> HttpResponse<'static> {
1108    let cached_valid = ASSET_ROUTER.with_borrow(|asset_router| {
1109        match asset_router.get_asset(NOT_FOUND_CANONICAL_PATH) {
1110            Some(asset) => !is_asset_expired(asset, NOT_FOUND_CANONICAL_PATH, ic_cdk::api::time()),
1111            None => false,
1112        }
1113    });
1114
1115    if cached_valid {
1116        debug_log!("not-found canonical entry still valid, serving from cache");
1117        return ASSET_ROUTER.with_borrow(|asset_router| {
1118            let canonical_req = HttpRequest::get(NOT_FOUND_CANONICAL_PATH.to_string()).build();
1119            match asset_router.serve_asset(&canonical_req) {
1120                Some((resp, _witness, _expr_path)) => resp,
1121                None => error_response(
1122                    500,
1123                    "Internal Server Error: cached not-found entry missing from asset router",
1124                ),
1125            }
1126        });
1127    }
1128
1129    // Execute the not-found handler and certify at the canonical path.
1130    let response = if let Some(response) = root.execute_not_found_with_middleware(path, req) {
1131        response
1132    } else {
1133        HttpResponse::not_found(
1134            b"Not Found",
1135            vec![("Content-Type".into(), "text/plain".into())],
1136        )
1137        .build()
1138    };
1139    certify_dynamic_response_with_ttl(
1140        response,
1141        NOT_FOUND_CANONICAL_PATH,
1142        Some("/".to_string()),
1143        certification::CertificationMode::response_only(),
1144        None,
1145        None,
1146    )
1147}
1148
1149/// Handle a `HandlerResult::NotModified` result in the update path.
1150///
1151/// Resets the TTL timer on the cached asset (if TTL-based caching is active),
1152/// then serves the existing cached response from the asset router.
1153fn handle_not_modified(req: &HttpRequest, path: &str) -> HttpResponse<'static> {
1154    debug_log!("handler returned NotModified for {}", path);
1155
1156    // Reset the certified_at timestamp so the TTL timer restarts.
1157    ASSET_ROUTER.with_borrow_mut(|asset_router| {
1158        if let Some(asset) = asset_router.get_asset_mut(path) {
1159            if asset.ttl.is_some() {
1160                asset.certified_at = ic_cdk::api::time();
1161            }
1162        }
1163    });
1164
1165    // Serve the existing cached response from the asset router.
1166    ASSET_ROUTER.with_borrow(|asset_router| match asset_router.serve_asset(req) {
1167        Some((mut resp, witness, expr_path)) => {
1168            if let Some(cert) = data_certificate() {
1169                add_v2_certificate_header(&cert, &mut resp, &witness, &expr_path);
1170            }
1171            resp
1172        }
1173        None => error_response(
1174            500,
1175            "Internal Server Error: NotModified but no cached asset found",
1176        ),
1177    })
1178}
1179
1180/// Handle an HTTP update-path request.
1181///
1182/// This is the IC `http_request_update` entry point. It runs the matched
1183/// route handler (through the middleware chain), certifies the response in
1184/// the asset router, and caches it for future query-path serving.
1185///
1186/// If a [`HandlerResultFn`](router::HandlerResultFn) is registered for the
1187/// route, it is called first to check for [`HandlerResult::NotModified`].
1188/// A `NotModified` result preserves the existing cached response and resets
1189/// the TTL timer (if TTL-based caching is active).
1190pub fn http_request_update(req: HttpRequest, root_route_node: &RouteNode) -> HttpResponse<'static> {
1191    debug_log!("http_request_update: {:?}", req.url());
1192
1193    let path = match req.get_path() {
1194        Ok(p) => p,
1195        Err(_) => return error_response(400, "Bad Request: malformed URL"),
1196    };
1197
1198    let method = req.method().clone();
1199
1200    match root_route_node.resolve(&path, &method) {
1201        RouteResult::Found(handler, params, result_handler, pattern) => {
1202            let route_config = root_route_node.get_route_config(&pattern);
1203            let cert_mode = route_config
1204                .map(|rc| rc.certification.clone())
1205                .unwrap_or_else(certification::CertificationMode::response_only);
1206            let route_ttl = route_config.and_then(|rc| rc.ttl);
1207
1208            // Skip-mode routes are handled on the query path; if one arrives
1209            // here (stale upgrade), just run the handler without re-certifying.
1210            if matches!(&cert_mode, certification::CertificationMode::Skip) {
1211                debug_log!("skip mode in update path (unexpected): {}", path);
1212                return root_route_node.execute_with_middleware(&path, handler, req, params);
1213            }
1214
1215            // Check for NotModified before running the full pipeline.
1216            if let Some(result_fn) = result_handler {
1217                match result_fn(req.clone(), params.clone()) {
1218                    router::HandlerResult::NotModified => {
1219                        return handle_not_modified(&req, &path);
1220                    }
1221                    router::HandlerResult::Response(response) => {
1222                        return certify_dynamic_response_with_ttl(
1223                            response,
1224                            &path,
1225                            None,
1226                            cert_mode,
1227                            Some(&req),
1228                            route_ttl,
1229                        );
1230                    }
1231                }
1232            }
1233
1234            // Standard path: call handler through middleware, then certify.
1235            let response =
1236                root_route_node.execute_with_middleware(&path, handler, req.clone(), params);
1237            certify_dynamic_response_with_ttl(
1238                response,
1239                &path,
1240                None,
1241                cert_mode,
1242                Some(&req),
1243                route_ttl,
1244            )
1245        }
1246        RouteResult::MethodNotAllowed(allowed) => method_not_allowed(&allowed),
1247        RouteResult::NotFound => handle_not_found_update(req, &path, root_route_node),
1248    }
1249}
1250
1251// Test coverage audit (Session 7, Spec 5.5):
1252//
1253// Covered:
1254//   - Malformed URL → 400 response (both http_request and http_request_update)
1255//   - Handler without content-type doesn't panic
1256//   - extract_content_type: JSON, HTML, missing (fallback to octet-stream), case-insensitive
1257//
1258// No significant gaps for unit-testable code. IC runtime-dependent paths
1259// (certification, asset serving, TTL upgrade, NotModified flow) require PocketIC
1260// E2E tests (spec 5.7).
1261#[cfg(test)]
1262mod tests {
1263    use super::*;
1264    use ic_http_certification::Method;
1265    use router::{NodeType, RouteNode, RouteParams};
1266    use std::time::Duration;
1267
1268    fn noop_handler(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
1269        HttpResponse::builder()
1270            .with_status_code(StatusCode::OK)
1271            .with_body(b"ok" as &[u8])
1272            .build()
1273    }
1274
1275    fn setup_router() -> RouteNode {
1276        let mut root = RouteNode::new(NodeType::Static("".into()));
1277        root.insert("/", Method::GET, noop_handler);
1278        root.insert("/*", Method::GET, noop_handler);
1279        root
1280    }
1281
1282    // ---- 1.3.5: malformed URL returns 400 (not a trap) ----
1283
1284    #[test]
1285    fn http_request_malformed_url_returns_400() {
1286        let root = setup_router();
1287        // Construct a request with a URL that will fail `get_path()` parsing.
1288        // A bare NUL byte in the URL makes the URI parser fail.
1289        let req = HttpRequest::builder()
1290            .with_method(Method::GET)
1291            .with_url("http://[::bad")
1292            .build();
1293        let opts = HttpRequestOptions { certify: false };
1294        let response = http_request(req, &root, opts);
1295        assert_eq!(response.status_code(), StatusCode::BAD_REQUEST);
1296        assert!(std::str::from_utf8(response.body())
1297            .unwrap()
1298            .contains("malformed URL"));
1299    }
1300
1301    #[test]
1302    fn http_request_update_malformed_url_returns_400() {
1303        let root = setup_router();
1304        let req = HttpRequest::builder()
1305            .with_method(Method::GET)
1306            .with_url("http://[::bad")
1307            .build();
1308        let response = http_request_update(req, &root);
1309        assert_eq!(response.status_code(), StatusCode::BAD_REQUEST);
1310        assert!(std::str::from_utf8(response.body())
1311            .unwrap()
1312            .contains("malformed URL"));
1313    }
1314
1315    // ---- 1.3.6: missing content-type in handler response doesn't trap ----
1316
1317    fn handler_no_content_type(_: HttpRequest, _: RouteParams) -> HttpResponse<'static> {
1318        // Response with no content-type header — should not cause the library to trap.
1319        HttpResponse::builder()
1320            .with_status_code(StatusCode::OK)
1321            .with_body(b"no content-type" as &[u8])
1322            .build()
1323    }
1324
1325    #[test]
1326    fn handler_without_content_type_does_not_trap() {
1327        // This test verifies that a handler returning a response without a
1328        // content-type header does not cause a panic. The http_request_update
1329        // function calls IC runtime APIs (certify_assets, certified_data_set)
1330        // that are unavailable in unit tests, so we test the handler directly
1331        // and verify the router dispatch path up to the handler call.
1332        let mut root = RouteNode::new(NodeType::Static("".into()));
1333        root.insert("/no-ct", Method::GET, handler_no_content_type);
1334
1335        // Verify the route matches and the handler runs without panic.
1336        let req = HttpRequest::builder()
1337            .with_method(Method::GET)
1338            .with_url("/no-ct")
1339            .build();
1340        match root.resolve("/no-ct", &Method::GET) {
1341            RouteResult::Found(handler, params, _, _) => {
1342                let response = handler(req, params);
1343                assert_eq!(response.status_code(), StatusCode::OK);
1344                assert_eq!(response.body(), b"no content-type");
1345                // No content-type header present — and no panic occurred.
1346                assert!(response
1347                    .headers()
1348                    .iter()
1349                    .all(|(name, _): &(String, String)| name.to_lowercase() != "content-type"));
1350            }
1351            _ => panic!("expected Found for GET /no-ct"),
1352        }
1353    }
1354
1355    // ---- 1.2: handler-controlled response metadata ----
1356
1357    #[test]
1358    fn extract_content_type_json() {
1359        let response = HttpResponse::builder()
1360            .with_status_code(StatusCode::OK)
1361            .with_headers(vec![(
1362                "content-type".to_string(),
1363                "application/json".to_string(),
1364            )])
1365            .with_body(b"{}" as &[u8])
1366            .build();
1367        assert_eq!(extract_content_type(&response), "application/json");
1368    }
1369
1370    #[test]
1371    fn extract_content_type_html() {
1372        let response = HttpResponse::builder()
1373            .with_status_code(StatusCode::OK)
1374            .with_headers(vec![("Content-Type".to_string(), "text/html".to_string())])
1375            .with_body(b"<h1>hi</h1>" as &[u8])
1376            .build();
1377        assert_eq!(extract_content_type(&response), "text/html");
1378    }
1379
1380    #[test]
1381    fn extract_content_type_missing_falls_back() {
1382        let response = HttpResponse::builder()
1383            .with_status_code(StatusCode::OK)
1384            .with_body(b"raw bytes" as &[u8])
1385            .build();
1386        assert_eq!(extract_content_type(&response), "application/octet-stream");
1387    }
1388
1389    #[test]
1390    fn extract_content_type_case_insensitive() {
1391        let response = HttpResponse::builder()
1392            .with_status_code(StatusCode::OK)
1393            .with_headers(vec![("CONTENT-TYPE".to_string(), "text/plain".to_string())])
1394            .with_body(b"hello" as &[u8])
1395            .build();
1396        assert_eq!(extract_content_type(&response), "text/plain");
1397    }
1398
1399    // ---- 8.6.5: is_asset_expired unit tests ----
1400
1401    fn make_asset_router() -> asset_router::AssetRouter {
1402        let tree = std::rc::Rc::new(std::cell::RefCell::new(
1403            ic_http_certification::HttpCertificationTree::default(),
1404        ));
1405        asset_router::AssetRouter::with_tree(tree)
1406    }
1407
1408    /// Dynamic asset with own TTL — not expired when now < certified_at + TTL.
1409    #[test]
1410    fn asset_with_own_ttl_not_expired() {
1411        let mut router = make_asset_router();
1412        let config = asset_router::AssetCertificationConfig {
1413            certified_at: 1_000_000,
1414            ttl: Some(Duration::from_secs(3600)),
1415            dynamic: true,
1416            ..Default::default()
1417        };
1418        router
1419            .certify_asset("/page", b"content".to_vec(), config)
1420            .unwrap();
1421        let asset = router.get_asset("/page").unwrap();
1422
1423        let one_hour_ns: u64 = 3_600_000_000_000;
1424        assert!(!is_asset_expired(
1425            asset,
1426            "/page",
1427            1_000_000 + one_hour_ns - 1
1428        ));
1429    }
1430
1431    /// Dynamic asset with own TTL — expired when now >= certified_at + TTL.
1432    #[test]
1433    fn asset_with_own_ttl_expired() {
1434        let mut router = make_asset_router();
1435        let config = asset_router::AssetCertificationConfig {
1436            certified_at: 1_000_000,
1437            ttl: Some(Duration::from_secs(3600)),
1438            dynamic: true,
1439            ..Default::default()
1440        };
1441        router
1442            .certify_asset("/page", b"content".to_vec(), config)
1443            .unwrap();
1444        let asset = router.get_asset("/page").unwrap();
1445
1446        let one_hour_ns: u64 = 3_600_000_000_000;
1447        // At expiry boundary.
1448        assert!(is_asset_expired(asset, "/page", 1_000_000 + one_hour_ns));
1449        // After expiry.
1450        assert!(is_asset_expired(
1451            asset,
1452            "/page",
1453            1_000_000 + one_hour_ns + 1
1454        ));
1455    }
1456
1457    /// Dynamic asset without own TTL falls back to global config.
1458    #[test]
1459    fn asset_without_ttl_uses_global_config() {
1460        let mut router = make_asset_router();
1461        let config = asset_router::AssetCertificationConfig {
1462            certified_at: 1_000_000,
1463            ttl: None,
1464            dynamic: true,
1465            ..Default::default()
1466        };
1467        router
1468            .certify_asset("/page", b"content".to_vec(), config)
1469            .unwrap();
1470        let asset = router.get_asset("/page").unwrap();
1471
1472        // Set global config with a 1-hour default TTL.
1473        ROUTER_CONFIG.with(|c| {
1474            c.borrow_mut().cache_config.default_ttl = Some(Duration::from_secs(3600));
1475        });
1476
1477        let one_hour_ns: u64 = 3_600_000_000_000;
1478        // Before expiry.
1479        assert!(!is_asset_expired(
1480            asset,
1481            "/page",
1482            1_000_000 + one_hour_ns - 1
1483        ));
1484        // At expiry.
1485        assert!(is_asset_expired(asset, "/page", 1_000_000 + one_hour_ns));
1486
1487        // Clean up thread-local.
1488        ROUTER_CONFIG.with(|c| {
1489            c.borrow_mut().cache_config.default_ttl = None;
1490        });
1491    }
1492
1493    /// Dynamic asset without own TTL and no global config → never expires.
1494    #[test]
1495    fn asset_without_ttl_no_global_config_never_expires() {
1496        let mut router = make_asset_router();
1497        let config = asset_router::AssetCertificationConfig {
1498            certified_at: 1_000_000,
1499            ttl: None,
1500            dynamic: true,
1501            ..Default::default()
1502        };
1503        router
1504            .certify_asset("/page", b"content".to_vec(), config)
1505            .unwrap();
1506        let asset = router.get_asset("/page").unwrap();
1507
1508        // Ensure global config has no TTL.
1509        ROUTER_CONFIG.with(|c| {
1510            c.borrow_mut().cache_config.default_ttl = None;
1511        });
1512
1513        // Even at u64::MAX, should not be expired.
1514        assert!(!is_asset_expired(asset, "/page", u64::MAX));
1515    }
1516
1517    /// Static asset (dynamic=false) never expires regardless of TTL settings.
1518    #[test]
1519    fn static_asset_never_expires() {
1520        let mut router = make_asset_router();
1521        // A static asset with a TTL — but is_asset_expired should still return false.
1522        let config = asset_router::AssetCertificationConfig {
1523            certified_at: 1_000_000,
1524            ttl: Some(Duration::from_secs(1)),
1525            dynamic: false,
1526            ..Default::default()
1527        };
1528        router
1529            .certify_asset("/page", b"content".to_vec(), config)
1530            .unwrap();
1531        let asset = router.get_asset("/page").unwrap();
1532
1533        // Way past the TTL, but static assets never expire.
1534        assert!(!is_asset_expired(asset, "/page", u64::MAX));
1535    }
1536}