Skip to content

Routing ​

EdgeZero uses matchit 0.8+ for high-performance path matching with support for parameters and catch-all segments.

Defining Routes ​

Routes are typically defined in your edgezero.toml manifest and wired automatically via the app! macro:

toml
[[triggers.http]]
id = "hello"
path = "/hello"
methods = ["GET"]
handler = "my_app_core::handlers::hello"

[[triggers.http]]
id = "echo"
path = "/echo/{name}"
methods = ["GET", "POST"]
handler = "my_app_core::handlers::echo"

You can also build routes programmatically using convenience methods:

rust
use edgezero_core::router::RouterService;

let router = RouterService::builder()
    .get("/hello", hello_handler)
    .get("/echo/{name}", echo_handler)
    .post("/echo", echo_json_handler)
    .build();

Or with explicit method specification:

rust
use edgezero_core::router::RouterService;
use edgezero_core::http::Method;

let router = RouterService::builder()
    .route("/hello", Method::GET, hello_handler)
    .route("/echo/{name}", Method::GET, echo_handler)
    .route("/echo", Method::POST, echo_json_handler)
    .build();

Path Parameters ​

Define parameters with {name} segments:

rust
use edgezero_core::action;
use edgezero_core::extractor::Path;
use edgezero_core::response::Text;

#[action]
async fn greet(Path(name): Path<String>) -> Text<String> {
    Text::new(format!("Hello, {}!", name))
}

For routes like /users/{id}/posts/{post_id}, extract multiple parameters:

rust
#[derive(serde::Deserialize)]
struct PostParams {
    id: u64,
    post_id: u64,
}

#[action]
async fn get_post(Path(params): Path<PostParams>) -> Text<String> {
    Text::new(format!("User {} Post {}", params.id, params.post_id))
}

Catch-All Segments ​

Use {*rest} for catch-all routes that match any remaining path:

rust
// Route: /files/{*path}
// Matches: /files/docs/readme.md -> path = "docs/readme.md"

#[action]
async fn serve_file(Path(path): Path<String>) -> Text<String> {
    Text::new(format!("Serving: {}", path))
}

HTTP Methods ​

Specify allowed methods in your route definition:

toml
[[triggers.http]]
path = "/resource"
methods = ["GET", "POST", "PUT", "DELETE"]
handler = "my_app_core::handlers::resource"

Or programmatically:

rust
RouterService::builder()
    .get("/resource", get_resource)
    .post("/resource", create_resource)
    .put("/resource/{id}", update_resource)
    .delete("/resource/{id}", delete_resource)
    .build()

EdgeZero automatically returns 405 Method Not Allowed for requests that match a path but use an unsupported method.

Introspection Routes ​

EdgeZero provides three bindable handlers in edgezero_core::introspection for debugging and runtime inspection:

  • manifest: Returns the full manifest JSON with secret values redacted.
  • config: Returns the effective app config from the default config store, with secret fields appearing as unresolved key-name references (secret-safe).
  • routes: Returns the registered route table as [{method, path}].

Bind them in your manifest's [[triggers.http]] like any handler. By default, generated apps and app-demo mount them under /_<app-name>/{manifest,config,routes}:

toml
[[triggers.http]]
id = "manifest"
path = "/_my-app/manifest"
methods = ["GET"]
handler = "edgezero_core::introspection::manifest"

[[triggers.http]]
id = "config"
path = "/_my-app/config"
methods = ["GET"]
handler = "edgezero_core::introspection::config"

[[triggers.http]]
id = "routes"
path = "/_my-app/routes"
methods = ["GET"]
handler = "edgezero_core::introspection::routes"

Security

These endpoints are unauthenticated wherever they are bound — restrict access at the network or middleware layer before exposing them publicly. Note that /manifest emits environment.variables[].value verbatim; only environment.secrets values are redacted. Do not store secrets in [environment.variables].

The routes handler returns JSON like:

json
[
  { "method": "GET", "path": "/_my-app/manifest" },
  { "method": "GET", "path": "/_my-app/config" },
  { "method": "GET", "path": "/_my-app/routes" }
]

Path Syntax ​

EdgeZero uses matchit's path syntax:

PatternExampleMatches
/static/staticExact match only
/{param}/users/{id}Single segment: /users/123
/{*catch}/files/{*path}Rest of path: /files/a/b/c

Legacy Syntax

Axum-style :name parameters are not supported. Use {name} instead.

Route Priority ​

Routes are matched by specificity (static segments first, then parameters, then catch-alls). Registering two routes that conflict for the same method (for example, two routes that both look like /users/{id}) panics while the routes are registered (inside build_app()) with duplicate route definition for <path>. When that happens depends on the adapter: Axum builds the router once at startup, so the conflict surfaces before any traffic, but the Fastly, Cloudflare, and Spin entry points call build_app() per request, so a conflicting route deploys successfully and then panics on the first request. Run the app under the Axum dev server before deploying to an edge target.

Intercepting before dispatch ​

Use an optional PreDispatchHook when handling a request must precede method and path lookup, including router-generated 404/405 responses. The hook receives the converted request before router state, introspection, request context, middleware or handlers. Its future can be non-Send.

rust
use async_trait::async_trait;
use edgezero_core::body::Body;
use edgezero_core::error::EdgeError;
use edgezero_core::http::{Request, Response, response_builder};
use edgezero_core::router::{PreDispatchHook, RouterService};

struct ExampleHook;

#[async_trait(?Send)]
impl PreDispatchHook for ExampleHook {
    async fn handle(&self, request: &mut Request) -> Result<Option<Response>, EdgeError> {
        if request.headers().contains_key("x-example-stop") {
            return Ok(Some(
                response_builder()
                    .status(204)
                    .body(Body::empty())
                    .map_err(EdgeError::internal)?,
            ));
        }
        Ok(None)
    }
}

let router = RouterService::builder()
    .pre_dispatch_hook(ExampleHook)
    .build();

Ok(None) continues with modified headers, extensions, remaining body and URI query. The method and URI path must remain unchanged; changing either returns an internal error before route lookup or middleware. Consuming a stream does not restore it. Ok(Some(response)) returns that response without normal router lifecycle work. Return a complete response when exact challenge or hardening headers matter. Err(error) stops dispatch; direct Service::call propagates it, while RouterService::oneshot renders it through the existing error response conversion.

Register a value with pre_dispatch_hook, or an existing shared hook with pre_dispatch_hook_arc. A second registration panics during router construction. Router clones share the same hook instance. Without a hook, existing routing is unchanged. Adapter buffering, capability bootstrap and outer response finalizers can still run before or after this router boundary; the hook does not replace those layers.

The hook value must be Send + Sync + 'static; it cannot hold Rc state, although its returned future may be non-Send. Adapter extensions such as RequestIngress, platform context and store registries are available. Router state, introspection and RequestContext have not been injected yet:

rust
let ingress = request.extensions()
    .get::<edgezero_core::ingress::RequestIngress>();

If the hook consumes the body and returns Ok(None), the handler receives only what remains unless the hook replaces the body. Generated app! applications cannot install a hook through the macro or modify the built router. A hand-written Hooks implementation does not inherit the generated metadata. Move your existing app! invocation unchanged, including all arguments (owns_logging = …, state = …), into a separate module. Delegate stores, logging, name and application configuration. For example, an application whose existing invocation uses owns_logging = true, state = 42_u32 would keep them. If the invocation has no app name, scaffolded projects use app!("../../edgezero.toml"), which defines App; import it as use manifest_app::App as ManifestApp; instead of the import below.

When combining this with the hook example above, omit its illustrative let router = …build(); statement and duplicate RouterService import:

rust
use edgezero_core::app::{App, Hooks, StoresMetadata};
use edgezero_core::manifest::{ManifestLoader, ResolvedLoggingConfig};
use edgezero_core::router::RouterService;
use std::sync::{Arc, LazyLock};

mod manifest_app {
    edgezero_core::app!(
        "edgezero.toml", ManifestApp,
        owns_logging = true, state = 42_u32
    );
}
use manifest_app::ManifestApp;

static MANIFEST_JSON: LazyLock<Arc<str>> = LazyLock::new(|| {
    let manifest = ManifestLoader::load_from_str(include_str!("../edgezero.toml"));
    serde_json::to_string(manifest.manifest()).expect("valid manifest").into()
});

pub struct HookedApp;

impl Hooks for HookedApp {
    fn build_app() -> App {
        let mut app = App::with_name(Self::routes(), Self::name());
        Self::configure(&mut app);
        app
    }

    fn routes() -> RouterService {
        RouterService::builder()
            .pre_dispatch_hook(ExampleHook)
            .with_manifest_json(Arc::clone(&MANIFEST_JSON))
            .with_state(42_u32)
            // Re-register every manifest route and middleware here, in order.
            .build()
    }

    fn stores() -> StoresMetadata { ManifestApp::stores() }
    fn logging_for(adapter: &str) -> ResolvedLoggingConfig {
        ManifestApp::logging_for(adapter)
    }
    fn owns_logging() -> bool { ManifestApp::owns_logging() }
    fn name() -> &'static str { ManifestApp::name() }
    fn configure(app: &mut App) { ManifestApp::configure(app); }
}

This outline uses ExampleHook from above, with code in src/lib.rs and edgezero.toml at the crate root. Add direct async-trait and serde_json dependencies: core does not re-export those crates. The app! path is relative to the crate root; the include_str! path is relative to the source file. Import any names used by the original arguments into the nested module (for example, use super::app_state; for state = app_state()). Point every adapter entry point (run_app::<…>, serve_app::<…>, dispatch_app::<…>, their _with_* variants, and any build_app() call inside a custom serve_custom/run_custom callback) at HookedApp. Its routes() must explicitly re-register all routes, manifest middleware and configured state; delegating metadata alone does not preserve them. with_manifest_json above preserves manifest introspection. The lazy static parses and serializes the manifest once per runtime instance. Cloudflare isolates and reused Spin P3 instances share this cached JSON. Axum's run_app and Fastly's serve_app build the application once, so routes() already runs once there. Classic Fastly Compute starts a fresh instance per request and pays the initialization cost each time. EdgeZero's spin-sdk 7 #[http_service] entry points export WASI P3 HTTP components. Spin 4.1 defaults allow up to 128 requests per instance and 16 concurrent requests, controlled by --max-instance-reuse-count and --max-instance-concurrent-reuse-count. Fresh instances still pay the initialization cost; reuse is not guaranteed. The macro embeds its JSON at compile time; exposing that literal or registering hooks directly through app! is tracked in issue #434. This manual duplication must stay synchronized with app! and the manifest, including matching .with_state(...) to the original macro's state argument. The complete executable example and regression tests are in crates/edgezero-macros/tests/hooked_app.rs. The store-free ingress fixture is not a metadata-preserving example for manifest applications.

Consult adapter ingress capabilities before classifying a request using its target or duplicate headers.

Next Steps ​

Released under the Apache License 2.0.