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}