From b082c3c49ca67379caec46c490a8a13fc2cd4055 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 19:50:02 -0700 Subject: [PATCH 01/29] Add design spec for pluggable introspection routes --- .../2026-07-01-introspection-routes-design.md | 346 ++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-01-introspection-routes-design.md diff --git a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md new file mode 100644 index 00000000..d61ef63f --- /dev/null +++ b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md @@ -0,0 +1,346 @@ +# Design: Pluggable Introspection Routes (manifest / config / routes) + +**Date:** 2026-07-01 +**Status:** Approved — ready for implementation planning +**Scope:** `edgezero-core`, `edgezero-macros`, `examples/app-demo`, `edgezero-cli` templates + +## Summary + +Provide three reusable, framework-supplied HTTP handlers that let any EdgeZero +app expose its own metadata at runtime: + +| Handler path | Emits | +| --------------------------------------- | ------------------------------------------------- | +| `edgezero_core::introspection::manifest` | The full manifest as JSON (baked at compile time) | +| `edgezero_core::introspection::config` | The default config-store envelope `.data` (secret-safe) | +| `edgezero_core::introspection::routes` | `[{ "method", "path" }]` from the live route index | + +These are ordinary handlers. Apps wire them like any other route via +`[[triggers.http]]` in `edgezero.toml`, choosing their own paths. There is **no** +special manifest section and **no** dedicated builder API. `app-demo` and every +generated app ship with the three routes pre-wired under a per-app namespace +`/_/{manifest,config,routes}` (e.g. `/_app-demo/manifest`), but those +are plain trigger rows a developer can edit or delete. + +This design also **removes** the existing built-in route-listing machinery +(`enable_route_listing`, `enable_route_listing_at`, `DEFAULT_ROUTE_LISTING_PATH`, +`/__edgezero/routes`, `RouteListingEntry`, `build_listing_response`) in favor of +the new bindable `routes` handler. + +## Motivation + +Today there is no runtime way to inspect what an app *is*: + +- The **manifest** is compile-time only. `Manifest` derives `Deserialize` + + `Validate` but not `Serialize`, and the portable-store rewrite removed the + `run_app(include_str!("edgezero.toml"), …)` shape, so a running adapter binary + no longer carries the manifest. +- The **app config** is reachable at runtime through the config store, but only + via the typed `AppConfig` extractor, which resolves secrets and requires the + app's concrete config type. +- The only built-in introspection is an opt-in route listing at + `/__edgezero/routes`, wired through a bespoke builder method + (`enable_route_listing`) rather than the normal routing path. + +We want a single, consistent, "bind it yourself" mechanism for all three. + +## Key Decisions (resolved during design) + +1. **Manifest output** — bake the full manifest as JSON. `Manifest` gains + `Serialize`; the `app!` macro serializes the parsed manifest at expansion time + and hands the JSON string to the router. +2. **Config output** — emit the raw config-store `BlobEnvelope.data`. This is + generic (core needs no knowledge of the app's typed config `C`) and + secret-safe: secret fields appear as unresolved key-name references, never + resolved values (resolution only happens inside the typed `AppConfig` + extractor). +3. **Wiring** — plain `[[triggers.http]]` bindings referencing stable core + handler paths. No `[introspection]` manifest section; no builder methods. +4. **Paths** — per-app namespace `/_/{manifest,config,routes}` + (single underscore). These are just the default paths written into the + templates; the developer controls them. +5. **Injection, not a global** — the app-specific data (manifest JSON + route + index) is injected into the request at the shared router dispatch chokepoint + in core. No process-global state; no per-adapter changes. +6. **Remove route listing** — delete the entire `enable_route_listing` machinery + and `/__edgezero/routes`. + +## Architecture + +### Data flow + +``` +compile time runtime (per request) +------------ --------------------- +edgezero.toml + │ app!() macro parses Manifest + │ serde_json::to_string(&manifest) + ▼ +build_router() + builder.with_manifest_json("{…}") RouterService::oneshot(req) + │ └─ RouterInner::dispatch(req) + ▼ │ req.extensions_mut().insert( +RouterInner { manifest_json, │ IntrospectionData { + route_index, … } │ manifest_json, routes }) + ▼ + handler reads ctx.introspection() + manifest → returns baked JSON + routes → projects route index + config → reads default config store +``` + +- **manifest**: parsed at compile time, re-serialized to JSON by the macro, + baked as a string literal into `build_router()`, stored on `RouterInner`, + injected into each request, returned verbatim. No runtime TOML dependency. +- **routes**: derived at request time from the live route index already held by + `RouterInner` (the actually-registered routes, not a manifest projection). +- **config**: read at request time from the default config store; independent of + the manifest JSON. + +### Component 1 — `Manifest: Serialize` (`edgezero-core/src/manifest.rs`) + +Add `Serialize` to the derive list on `Manifest` and every nested struct that +must appear in the output (`ManifestApp`, `ManifestTriggers`, +`ManifestHttpTrigger`, `ManifestEnvironment`, `ManifestBinding`, +`ManifestAdapter` and its sub-structs, `ManifestLogging*`, `ManifestStores`, +`StoreDeclaration`, etc.). + +- Internal-only fields already carry `#[serde(skip)]` (`root`, + `logging_resolved`) and stay out of the output. +- Secret **values** are never stored in the manifest — only binding + declarations (name / env / description) — so serialized output is secret-safe. +- Verify round-trip is not required; this is a one-way (serialize-for-output) + addition. Existing `Deserialize`/`Validate` behavior is unchanged. + +### Component 2 — Router injection (`edgezero-core/src/router.rs`) + +New public struct carrying the per-request introspection payload: + +```rust +#[derive(Clone)] +pub struct IntrospectionData { + pub manifest_json: Option>, + pub routes: Arc<[RouteInfo]>, +} +``` + +Changes: + +- `RouterInner` gains `manifest_json: Option>`. +- `RouterBuilder` gains `manifest_json: Option>` plus a setter: + ```rust + pub fn with_manifest_json>>(mut self, json: S) -> Self { … } + ``` + `build()` threads it into `RouterService::new(...)` / `RouterInner`. +- `RouterInner::dispatch(mut req)` inserts the extension **before** middleware and + routing: + ```rust + req.extensions_mut().insert(IntrospectionData { + manifest_json: self.manifest_json.clone(), + routes: Arc::clone(&self.route_index), + }); + ``` + `route_index` is already an `Arc<[RouteInfo]>`, so the clone is cheap. + +### Component 3 — `RequestContext` accessor (`edgezero-core/src/context.rs`) + +```rust +#[inline] +pub fn introspection(&self) -> Option<&IntrospectionData> { + self.request.extensions().get::() +} +``` + +Mirrors the existing extension-backed accessors (`config_store*`, `kv_store*`). + +### Component 4 — `edgezero_core::introspection` module (new file) + +Three handlers written with `#[action]`, plus a small JSON shape for `routes`. + +```rust +/// GET — full manifest as JSON. +#[action] +pub async fn manifest(ctx: RequestContext) -> Result { + let json = ctx + .introspection() + .and_then(|d| d.manifest_json.clone()) + .ok_or_else(|| EdgeError::internal("manifest introspection data not available"))?; + // application/json, body = json verbatim +} + +/// GET — [{ "method", "path" }] for every registered route. +#[action] +pub async fn routes(ctx: RequestContext) -> Result { + let routes = ctx.introspection().map(|d| &d.routes) /* → Vec */; + // application/json +} + +/// GET — the default config-store envelope `data` (secret-safe). +#[action] +pub async fn config(ctx: RequestContext) -> Result { + let binding = ctx.config_store_default_binding() + .ok_or_else(|| EdgeError::not_found("no default config store registered"))?; + // read raw blob at binding.default_key via binding.handle + // parse BlobEnvelope, emit envelope.data as application/json +} +``` + +Notes: + +- `RouteEntryView { method: String, path: String }` replaces the removed + `RouteListingEntry`. +- `config` reads the raw blob string from the config-store handle (the same read + `extract_from_handle` performs) and parses `BlobEnvelope`; it does **not** run + secret resolution or typed deserialization. +- Error mapping: absent manifest → `500` internal (should not happen once wired); + missing config store or missing blob → `404`. +- The handlers must be reachable by the `app!` macro's `parse_handler_path`, + which already resolves arbitrary `a::b::c` paths (it resolves + `app_demo_core::handlers::root` today), so `edgezero_core::introspection::…` + resolves the same way. + +### Component 5 — `app!` macro (`edgezero-macros/src/app.rs`) + +- After parsing the manifest, serialize it: `serde_json::to_string(&manifest)`. + On serialization error, emit a `compile_error!`. +- Emit one added line in the generated `build_router()`: + ```rust + pub fn build_router() -> edgezero_core::router::RouterService { + let mut builder = edgezero_core::router::RouterService::builder(); + builder = builder.with_manifest_json(#manifest_json_lit); + #(#middleware_tokens)* + #(#route_tokens)* + builder.build() + } + ``` +- No route wiring for introspection (routes come from `[[triggers.http]]`). +- `edgezero-macros` needs `serde_json` as a (build-time) dependency; `Manifest` + must be `Serialize` (Component 1). + +### Component 6 — Removals + +Delete from `edgezero-core/src/router.rs`: + +- `pub const DEFAULT_ROUTE_LISTING_PATH` +- `RouterBuilder::enable_route_listing`, `RouterBuilder::enable_route_listing_at` +- `RouterBuilder.route_listing_path` field and the listing branch inside `build()` +- `build_listing_response` +- `RouteListingEntry` +- All associated unit tests (`route_listing_*`) + +Grep the workspace for any other references (docs, examples, adapter code) and +remove/update them so nothing depends on `/__edgezero/routes`. + +### Component 7 — Templates (default bindings) + +Add three trigger rows, wired to the core handlers, under `/_/…`. + +`examples/app-demo/edgezero.toml`: + +```toml +[[triggers.http]] +id = "manifest" +path = "/_app-demo/manifest" +methods = ["GET"] +handler = "edgezero_core::introspection::manifest" +description = "App manifest as JSON" + +[[triggers.http]] +id = "config" +path = "/_app-demo/config" +methods = ["GET"] +handler = "edgezero_core::introspection::config" +description = "Effective app config (secret-safe)" + +[[triggers.http]] +id = "routes" +path = "/_app-demo/routes" +methods = ["GET"] +handler = "edgezero_core::introspection::routes" +description = "Registered route table" +``` + +`crates/edgezero-cli/src/templates/root/edgezero.toml.hbs`: the same three rows, +using `path = "/_{{name}}/manifest"` etc. and the same `edgezero_core::introspection::*` +handlers. (`{{name}}` is the sanitized app name already used elsewhere in the +template.) + +No template handler code is generated — the handlers live in core. + +## Interfaces (summary) + +| Unit | Public surface | Depends on | +| ----------------------- | ---------------------------------------------------------- | ----------------------------------- | +| `IntrospectionData` | `{ manifest_json: Option>, routes: Arc<[RouteInfo]> }` | `RouteInfo` | +| `RouterBuilder` | `with_manifest_json(impl Into>)` | — | +| `RequestContext` | `introspection() -> Option<&IntrospectionData>` | request extensions | +| `introspection::manifest` | `#[action]` GET → JSON | `ctx.introspection()` | +| `introspection::routes` | `#[action]` GET → JSON | `ctx.introspection()` | +| `introspection::config` | `#[action]` GET → JSON | default config store, `BlobEnvelope`| + +## Error Handling + +- **manifest** absent from `IntrospectionData`: `500 internal` (indicates a + wiring bug; always present once the macro sets it). +- **config**: no default config store → `404 not found`; no blob at + `default_key` → `404`; malformed envelope → `500 internal`. +- **routes**: `IntrospectionData` absent → empty list is acceptable, or `500`; + chosen behavior: return an empty array rather than error, since routes are + always injected by dispatch. + +## Testing Strategy + +Colocated `#[cfg(test)]`, `futures::executor::block_on` (no Tokio), no network. + +- **router.rs**: dispatch test asserting an `IntrospectionData` extension is + present in the request seen by a handler, with the expected route index and + `manifest_json`. Remove old `route_listing_*` tests. +- **introspection module**: + - `manifest` returns the injected JSON with `application/json`. + - `routes` returns the projected `[{method, path}]`. + - `config` returns `BlobEnvelope.data` from a stub config store; `404` when no + store is registered; `404` when the blob is missing. +- **macro (`edgezero-macros`)**: trybuild/expansion assertion that + `with_manifest_json(...)` is emitted with valid JSON for a sample manifest. +- **app-demo**: extend router/handler tests to hit `/_app-demo/manifest`, + `/_app-demo/config`, `/_app-demo/routes` and assert shapes. + +## CI Gates (unchanged) + +1. `cargo fmt --all -- --check` +2. `cargo clippy --workspace --all-targets --all-features -- -D warnings` +3. `cargo test --workspace --all-targets` +4. `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +5. `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` + +## Constraints & Non-Goals + +- **WASM-first**: no Tokio, no runtime-specific deps added. `Arc`, `serde_json`, + and `Once*`-free injection are all WASM-safe. `serde_json` is added only to + `edgezero-macros` (a proc-macro crate that runs at build time). +- **No auth/gating in this iteration**: endpoints are exposed wherever the app + binds them. Because they are plain triggers, a developer who does not want them + simply omits the rows. Config output is already secret-safe. Access control + (e.g. dev-only, header-gated) is a possible follow-up, out of scope here. +- **Single-app assumption**: `manifest_json` is per-`RouterService`, so multiple + distinct apps in one process each carry their own — no shared/global state and + no cross-app leakage. +- **No `[introspection]` manifest section** and **no builder-based enable API** — + explicitly rejected in favor of plain `[[triggers.http]]` bindings. + +## File-Change Checklist (for planning) + +- [ ] `crates/edgezero-core/src/manifest.rs` — add `Serialize` derives. +- [ ] `crates/edgezero-core/src/router.rs` — `IntrospectionData`, + `with_manifest_json`, dispatch injection; remove route-listing machinery + + tests. +- [ ] `crates/edgezero-core/src/context.rs` — `introspection()` accessor. +- [ ] `crates/edgezero-core/src/introspection.rs` — new module, three handlers. +- [ ] `crates/edgezero-core/src/lib.rs` — export `introspection`. +- [ ] `crates/edgezero-macros/src/app.rs` — serialize manifest, emit + `with_manifest_json`; add `serde_json` dep. +- [ ] `examples/app-demo/edgezero.toml` — three trigger rows. +- [ ] `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs` — three trigger + rows using `{{name}}`. +- [ ] Workspace grep — purge remaining `/__edgezero/routes` / + `enable_route_listing` references (docs, examples, adapters). From 2e04b2617fc878c61e2a0a0af6f6d38f7047a6fd Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:05:11 -0700 Subject: [PATCH 02/29] Add implementation plan for introspection routes --- .../plans/2026-07-02-introspection-routes.md | 831 ++++++++++++++++++ 1 file changed, 831 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-02-introspection-routes.md diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md new file mode 100644 index 00000000..16322813 --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -0,0 +1,831 @@ +# Pluggable Introspection Routes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add three reusable core `#[action]` handlers — `edgezero_core::introspection::{manifest, config, routes}` — that any app binds via `[[triggers.http]]`, default-mounted at `/_/{manifest,config,routes}`. + +**Architecture:** The `app!` macro serializes the parsed manifest to JSON at expansion time and hands it to `RouterService::builder().with_manifest_json(...)`. `RouterInner::dispatch` injects an `IntrospectionData { manifest_json, routes }` extension into each request; the three core handlers read it (config reads the default config store instead). The legacy `enable_route_listing` machinery and `/__edgezero/routes` are removed. + +**Tech Stack:** Rust 1.95 (edition 2021), `serde`/`serde_json`, `matchit` routing, `#[action]`/`app!` proc-macros, `futures::executor::block_on` for tests. WASM-first: no Tokio, no runtime-specific deps in core. + +## Global Constraints + +- Rust 1.95.0, edition 2021, resolver 2. License Apache-2.0. +- WASM compatibility first: no Tokio, no `std::time::Instant`, `async-trait` without `Send` bounds. `serde_json` may be added only to the proc-macro crate `edgezero-macros` (runs at build time). +- Colocate tests with implementation (`#[cfg(test)]` in the same file). Async tests use `futures::executor::block_on`, never Tokio. No network / no platform credentials in tests. +- Route params use matchit brace syntax `{id}` / `{*rest}`; never `:id`. +- Import HTTP aliases from `edgezero_core` re-exports, never the `http` crate directly. +- Minimal changes: touch as little as possible; no unrelated refactors or docstrings on untouched code. +- No `Co-Authored-By` trailers, "Generated with" footers, or AI bylines in commits or PR bodies. +- Every PR must pass all five CI gates (see Task 8). + +## Spec Errata / Implementation Assumptions + +These correct or refine the design spec (`docs/superpowers/specs/2026-07-01-introspection-routes-design.md`) after a close read of the code. **They override the spec where they conflict.** + +1. **Secret redaction in manifest output.** `ManifestBinding` (manifest.rs:287) has a `value: Option` field, and `ManifestEnvironment` (manifest.rs:276) uses that same type for BOTH `variables` and `secrets`. Blindly deriving `Serialize` would emit secret-shaped `value`s. The `secrets` list MUST be serialized with `value` omitted. Implemented via a `#[serde(serialize_with = ...)]` redactor on `ManifestEnvironment::secrets`. +2. **`[app]` version/kind.** `ManifestApp` (manifest.rs:217) models only `entry`/`middleware`/`name`, but app-demo's `edgezero.toml` sets `version` and `kind`; they are silently dropped on deserialize today. Add optional `version`/`kind` fields so the manifest JSON reflects the real file. +3. **`#[action]` inside core needs a self-alias.** The `#[action]` macro emits absolute `::edgezero_core::…` paths (action.rs:87). Core uses `#[action]` only in doc comments today, never compiled. Add `extern crate self as edgezero_core;` to `crates/edgezero-core/src/lib.rs` so those paths resolve within the core crate. +4. **Config handler error mapping.** Mirror `extract_from_handle` (extractor.rs:766): map `ConfigStoreError` via `EdgeError::from` (preserving 503/400/500 distinctions), parse `BlobEnvelope`, and call `envelope.verify()` before returning `.data`. Do NOT collapse backend errors to 500. +5. **Injection timing.** `dispatch` inserts the extension after route match / before the handler runs. Tests must assert visibility from a handler and from middleware, not that it changes 404/405 outcomes. +6. **Docs.** The only live public reference to route listing is `docs/guide/routing.md:118`. Update it. Do NOT touch unrelated `.__edgezero_chunks` documentation. +7. **App-demo tests** exercise routes through `build_router().oneshot(request)`, not only direct handler calls. + +--- + +## File Structure + +| File | Responsibility | Task | +| --- | --- | --- | +| `crates/edgezero-core/src/manifest.rs` | Add `Serialize` (+ secret redaction, version/kind) | 1 | +| `crates/edgezero-core/src/router.rs` | `IntrospectionData`, `with_manifest_json`, dispatch injection | 2 | +| `crates/edgezero-core/src/context.rs` | `introspection()` accessor | 2 | +| `crates/edgezero-core/src/introspection.rs` (new) | Three `#[action]` handlers | 3 | +| `crates/edgezero-core/src/lib.rs` | `extern crate self`, `pub mod introspection` | 3 | +| `crates/edgezero-macros/src/app.rs` | Serialize manifest, emit `with_manifest_json` | 4 | +| `crates/edgezero-macros/Cargo.toml` | Add `serde_json` dep | 4 | +| `crates/edgezero-core/src/router.rs` | Remove route-listing machinery + tests | 5 | +| `examples/app-demo/edgezero.toml` | Three trigger rows + router-level tests | 6 | +| `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs` | Three trigger rows | 6 | +| `docs/guide/routing.md` | Replace route-listing docs | 7 | + +--- + +### Task 1: Manifest serialization with secret redaction + +**Files:** +- Modify: `crates/edgezero-core/src/manifest.rs` (structs at :86, :217, :276, :287, and nested adapter/logging/stores structs) +- Test: same file, `#[cfg(test)]` + +**Interfaces:** +- Produces: `Manifest: Serialize` and all nested types serializable; `ManifestApp` gains `version: Option`, `kind: Option`; `ManifestEnvironment::secrets` serialized with `value` omitted. + +- [ ] **Step 1: Write the failing test** + +Add to the `#[cfg(test)]` module in `manifest.rs`: + +```rust +#[test] +fn serializes_manifest_and_redacts_secret_values() { + let toml = r#" +[app] +name = "t" +version = "0.1.0" +kind = "http" + +[[triggers.http]] +id = "root" +path = "/" +methods = ["GET"] +handler = "t::handlers::root" + +[[environment.variables]] +name = "LOG_LEVEL" +value = "info" + +[[environment.secrets]] +name = "API_TOKEN" +value = "super-secret-value" +"#; + let manifest: Manifest = toml::from_str(toml).unwrap(); + let json = serde_json::to_value(&manifest).unwrap(); + + // [app] version/kind round-trip + assert_eq!(json["app"]["version"], "0.1.0"); + assert_eq!(json["app"]["kind"], "http"); + // variables keep their value + assert_eq!(json["environment"]["variables"][0]["value"], "info"); + // secrets NEVER expose value + let secret = &json["environment"]["secrets"][0]; + assert_eq!(secret["name"], "API_TOKEN"); + assert!(secret.get("value").is_none(), "secret value must be redacted"); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p edgezero-core serializes_manifest_and_redacts_secret_values` +Expected: FAIL to compile — `Manifest` does not implement `Serialize`; `ManifestApp` has no `version`/`kind`. + +- [ ] **Step 3: Add `version`/`kind` to `ManifestApp`** + +In `ManifestApp` (manifest.rs:217), add after `name`: + +```rust + #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(length(min = 1_u64))] + pub version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(length(min = 1_u64))] + pub kind: Option, +``` + +- [ ] **Step 4: Add the secret redactor** + +Add near `ManifestEnvironment` (manifest.rs:276): + +```rust +/// Serialize a `[[environment.secrets]]` list without exposing `value`. +/// Secret bindings share `ManifestBinding` with variables, whose `value` +/// is safe to emit; secret values must never appear in manifest output. +fn serialize_secrets(secrets: &[ManifestBinding], serializer: S) -> Result +where + S: serde::Serializer, +{ + use serde::ser::SerializeSeq; + + #[derive(Serialize)] + struct RedactedBinding<'a> { + #[serde(skip_serializing_if = "Vec::is_empty")] + adapters: &'a [String], + #[serde(skip_serializing_if = "Option::is_none")] + description: &'a Option, + #[serde(skip_serializing_if = "Option::is_none")] + env: &'a Option, + name: &'a str, + // `value` intentionally omitted. + } + + let mut seq = serializer.serialize_seq(Some(secrets.len()))?; + for binding in secrets { + seq.serialize_element(&RedactedBinding { + adapters: &binding.adapters, + description: &binding.description, + env: &binding.env, + name: &binding.name, + })?; + } + seq.end() +} +``` + +- [ ] **Step 5: Add `Serialize` derives + wire the redactor** + +Add `Serialize` to the `#[derive(...)]` on: `Manifest` (:86), `ManifestApp` (:217), `ManifestTriggers` (:230), `ManifestHttpTrigger` (:238), `ManifestEnvironment` (:276), `ManifestBinding` (:287), `ManifestAdapter` (:344), `ManifestAdapterDeployed` (:368 area), `ManifestAdapterBuild`, `ManifestAdapterCommands`, `ManifestAdapterDefinition`, `ManifestLogging`, `ManifestLoggingConfig`, `ManifestStores`, `StoreDeclaration`, plus the `HttpMethod` enum used in triggers. Keep existing `Deserialize`/`Validate`. + +On `ManifestEnvironment::secrets`, add: + +```rust + #[serde(default, serialize_with = "serialize_secrets")] + #[validate(nested)] + pub secrets: Vec, +``` + +Add `#[serde(skip_serializing_if = "...")]` to keep output clean where fields are optional/empty (e.g. `Option::is_none`, `Vec::is_empty`, `BTreeMap::is_empty`). The internal `root` and `logging_resolved` fields already carry `#[serde(skip)]` — leave them. + +- [ ] **Step 6: Run tests** + +Run: `cargo test -p edgezero-core serializes_manifest_and_redacts_secret_values` +Expected: PASS. +Then: `cargo test -p edgezero-core manifest` — Expected: all existing manifest tests still PASS. + +- [ ] **Step 7: Commit** + +```bash +git add crates/edgezero-core/src/manifest.rs +git commit -m "Make Manifest serializable with secret-value redaction" +``` + +--- + +### Task 2: Router injection + RequestContext accessor + +**Files:** +- Modify: `crates/edgezero-core/src/router.rs` (`RouterBuilder` :80, `build()` :121, `RouterService::new` :343, `RouterInner` :260, `dispatch`) +- Modify: `crates/edgezero-core/src/context.rs` (accessor near the other extension accessors) +- Test: both files, `#[cfg(test)]` + +**Interfaces:** +- Consumes: `RouteInfo` (router.rs:40), existing `RouterInner.route_index: Arc<[RouteInfo]>`. +- Produces: + - `pub struct IntrospectionData { pub manifest_json: Option>, pub routes: Arc<[RouteInfo]> }` (`Clone`). + - `RouterBuilder::with_manifest_json(impl Into>) -> Self`. + - `RequestContext::introspection(&self) -> Option<&IntrospectionData>`. + +- [ ] **Step 1: Write the failing test (router injection)** + +Add to `router.rs` tests: + +```rust +#[test] +fn dispatch_injects_introspection_data() { + use crate::context::RequestContext; + use std::sync::{Arc, Mutex}; + + let seen: Arc>> = Arc::new(Mutex::new(None)); + let seen_h = Arc::clone(&seen); + + let handler = move |ctx: RequestContext| { + let seen_h = Arc::clone(&seen_h); + async move { + let d = ctx.introspection().expect("introspection data present"); + *seen_h.lock().unwrap() = + Some((d.manifest_json.is_some(), d.routes.len())); + Ok::<_, EdgeError>("ok") + } + }; + + let router = RouterService::builder() + .with_manifest_json("{\"app\":{\"name\":\"t\"}}") + .get("/", handler) + .build(); + + let request = crate::http::request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .unwrap(); + let _ = block_on(router.oneshot(request)).unwrap(); + + let (had_manifest, route_count) = seen.lock().unwrap().expect("handler ran"); + assert!(had_manifest, "manifest_json should be injected"); + assert_eq!(route_count, 1); +} +``` + +(Use whatever request-builder/`block_on` imports the existing router tests use; match them.) + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cargo test -p edgezero-core dispatch_injects_introspection_data` +Expected: FAIL to compile — `with_manifest_json` and `RequestContext::introspection` do not exist. + +- [ ] **Step 3: Add `IntrospectionData` + builder field/setter** + +In `router.rs`, define near `RouteInfo`: + +```rust +/// Per-request introspection payload injected by [`RouterInner::dispatch`]. +#[derive(Clone)] +pub struct IntrospectionData { + /// The app manifest serialized to JSON at compile time by `app!`. + pub manifest_json: Option>, + /// Every registered route, in registration order. + pub routes: Arc<[RouteInfo]>, +} +``` + +Add to `RouterBuilder` (struct at :80): `manifest_json: Option>,` (its `#[derive(Default)]` covers it). Add the setter: + +```rust + #[must_use] + pub fn with_manifest_json>>(mut self, json: S) -> Self { + self.manifest_json = Some(json.into()); + self + } +``` + +- [ ] **Step 4: Thread it through `build()` → `RouterInner`** + +`RouterInner` (:260) already needs `route_index`. Add `manifest_json: Option>`. Update `RouterService::new` (:343) to accept and store it, and `build()` (:121) to pass `self.manifest_json`. In `dispatch`, before running middleware/handler, insert the extension: + +```rust + async fn dispatch(&self, mut request: Request) -> Result { + request.extensions_mut().insert(IntrospectionData { + manifest_json: self.manifest_json.clone(), + routes: Arc::clone(&self.route_index), + }); + // ... existing match/middleware/handler logic unchanged ... + } +``` + +(If `dispatch` currently takes `request` by value already, just add `mut`. Match the existing signature.) + +- [ ] **Step 5: Add the `RequestContext` accessor** + +In `context.rs`, near `config_store_default_binding`: + +```rust + /// The per-request [`IntrospectionData`] injected by the router, if any. + #[must_use] + #[inline] + pub fn introspection(&self) -> Option<&crate::router::IntrospectionData> { + self.request.extensions().get::() + } +``` + +- [ ] **Step 6: Run tests** + +Run: `cargo test -p edgezero-core dispatch_injects_introspection_data` +Expected: PASS. +Then: `cargo test -p edgezero-core router` — Expected: PASS (existing route-listing tests still pass; they are removed in Task 5). + +- [ ] **Step 7: Commit** + +```bash +git add crates/edgezero-core/src/router.rs crates/edgezero-core/src/context.rs +git commit -m "Inject IntrospectionData at router dispatch chokepoint" +``` + +--- + +### Task 3: Introspection handler module + +**Files:** +- Create: `crates/edgezero-core/src/introspection.rs` +- Modify: `crates/edgezero-core/src/lib.rs` (add `extern crate self as edgezero_core;` and `pub mod introspection;`) +- Test: `introspection.rs`, `#[cfg(test)]` + +**Interfaces:** +- Consumes: `RequestContext::introspection()`, `IntrospectionData` (Task 2); `config_store_default_binding()` (context.rs:63); `BlobEnvelope` (blob_envelope.rs:17); `EdgeError` constructors (error.rs). +- Produces: `pub async fn manifest/config/routes` (each `#[action]`), bindable as `edgezero_core::introspection::{manifest,config,routes}`. + +- [ ] **Step 1: Add the self-alias and module declaration** + +In `crates/edgezero-core/src/lib.rs`, add at the very top of the crate (before the `pub mod` list, after any inner attributes): + +```rust +extern crate self as edgezero_core; +``` + +And add to the module list (keep alphabetical): `pub mod introspection;` + +- [ ] **Step 2: Write the failing tests** + +Create `crates/edgezero-core/src/introspection.rs`: + +```rust +//! Framework-supplied introspection handlers. Bind via `[[triggers.http]]`: +//! `handler = "edgezero_core::introspection::manifest"` etc. + +use crate::blob_envelope::BlobEnvelope; +use crate::body::Body; +use crate::context::RequestContext; +use crate::error::EdgeError; +use crate::http::{response_builder, StatusCode}; +use crate::response::Response; +use edgezero_core::action; +use serde::Serialize; + +#[derive(Serialize)] +struct RouteView { + method: String, + path: String, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::http::{request_builder, Method}; + use crate::router::RouterService; + use futures::executor::block_on; + + #[test] + fn manifest_returns_injected_json() { + let router = RouterService::builder() + .with_manifest_json("{\"app\":{\"name\":\"t\"}}") + .get("/m", manifest) + .build(); + let req = request_builder().method(Method::GET).uri("/m").body(Body::empty()).unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get("content-type").unwrap(), + "application/json" + ); + } + + #[test] + fn routes_lists_registered_routes() { + let router = RouterService::builder().get("/r", routes).build(); + let req = request_builder().method(Method::GET).uri("/r").body(Body::empty()).unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + } + + #[test] + fn config_without_store_is_not_found() { + let router = RouterService::builder().get("/c", config).build(); + let req = request_builder().method(Method::GET).uri("/c").body(Body::empty()).unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } +} +``` + +- [ ] **Step 3: Run tests to verify they fail** + +Run: `cargo test -p edgezero-core introspection` +Expected: FAIL to compile — `manifest`/`config`/`routes` not defined. + +- [ ] **Step 4: Implement the three handlers** + +Add to `introspection.rs` (above the tests): + +```rust +fn json_response(status: StatusCode, body: Body) -> Result { + response_builder() + .status(status) + .header("content-type", "application/json") + .body(body) + .map_err(EdgeError::internal) +} + +/// GET — the app manifest as JSON (baked at compile time by `app!`). +#[action] +pub async fn manifest(ctx: RequestContext) -> Result { + let json = ctx + .introspection() + .and_then(|d| d.manifest_json.clone()) + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("manifest introspection data missing")))?; + json_response(StatusCode::OK, Body::text(json.to_string())) +} + +/// GET — `[{ "method", "path" }]` for every registered route. +#[action] +pub async fn routes(ctx: RequestContext) -> Result { + let views: Vec = ctx + .introspection() + .map(|d| { + d.routes + .iter() + .map(|r| RouteView { + method: r.method().as_str().to_owned(), + path: r.path().to_owned(), + }) + .collect() + }) + .unwrap_or_default(); + let body = Body::json(&views).map_err(EdgeError::internal)?; + json_response(StatusCode::OK, body) +} + +/// GET — the default config-store envelope `data` (secret-safe: secret +/// fields remain unresolved key-name references). +#[action] +pub async fn config(ctx: RequestContext) -> Result { + let binding = ctx + .config_store_default_binding() + .ok_or_else(|| EdgeError::not_found("no default config store registered"))?; + // ConfigStoreError → EdgeError preserves 503/400/500 (see extractor.rs). + let raw = binding + .handle + .get(&binding.default_key) + .await + .map_err(EdgeError::from)? + .ok_or_else(|| EdgeError::not_found("no config blob in default store"))?; + let envelope: BlobEnvelope = serde_json::from_str(&raw) + .map_err(|err| EdgeError::internal(anyhow::anyhow!("envelope parse failed: {err}")))?; + envelope + .verify() + .map_err(|err| EdgeError::internal(anyhow::anyhow!("envelope verification failed: {err}")))?; + let body = Body::json(&envelope.into_data()).map_err(EdgeError::internal)?; + json_response(StatusCode::OK, body) +} +``` + +Notes: confirm `ConfigStoreBinding` field names are `handle` and `default_key` (context.rs uses `binding.handle`/`binding.default_key`). Confirm `Body::json` exists (body.rs:114) and `RouteInfo::method()/path()` (router.rs:48/62). If `anyhow` is not already a core dep for this pattern, mirror what `extractor.rs` uses (`anyhow::anyhow!`). + +- [ ] **Step 5: Run tests** + +Run: `cargo test -p edgezero-core introspection` +Expected: PASS (all three). + +- [ ] **Step 6: Commit** + +```bash +git add crates/edgezero-core/src/introspection.rs crates/edgezero-core/src/lib.rs +git commit -m "Add edgezero_core::introspection handlers (manifest/config/routes)" +``` + +--- + +### Task 4: `app!` macro injects the manifest JSON + +**Files:** +- Modify: `crates/edgezero-macros/src/app.rs` (`build_router` emission around :170-176) +- Modify: `crates/edgezero-macros/Cargo.toml` (add `serde_json`) +- Test: `crates/edgezero-macros` unit test or `examples/app-demo` (verified end-to-end in Task 6) + +**Interfaces:** +- Consumes: parsed `Manifest` (now `Serialize`, Task 1); `RouterBuilder::with_manifest_json` (Task 2). +- Produces: generated `build_router()` calls `builder.with_manifest_json("")`. + +- [ ] **Step 1: Add `serde_json` to the macro crate** + +In `crates/edgezero-macros/Cargo.toml` under `[dependencies]`, add the workspace dep: + +```toml +serde_json = { workspace = true } +``` + +- [ ] **Step 2: Serialize the manifest and emit the setter** + +In `app.rs`, after the manifest is parsed (near `app_name` at :126), add: + +```rust + let manifest_json = match serde_json::to_string(&manifest) { + Ok(json) => json, + Err(err) => { + return syn::Error::new( + Span::call_site(), + format!("failed to serialize manifest to JSON: {err}"), + ) + .to_compile_error() + .into(); + } + }; + let manifest_json_lit = LitStr::new(&manifest_json, Span::call_site()); +``` + +Then in the emitted `build_router()` (the `quote! { ... pub fn build_router() ... }` block around :170), insert the setter as the first builder mutation: + +```rust + pub fn build_router() -> edgezero_core::router::RouterService { + let mut builder = edgezero_core::router::RouterService::builder(); + builder = builder.with_manifest_json(#manifest_json_lit); + #(#middleware_tokens)* + #(#route_tokens)* + builder.build() + } +``` + +- [ ] **Step 3: Verify the macro crate compiles** + +Run: `cargo build -p edgezero-macros` +Expected: builds cleanly. + +- [ ] **Step 4: Verify a consumer still builds** + +Run: `cargo build -p edgezero-core` then `cargo check -p app-demo-core --manifest-path examples/app-demo/Cargo.toml` (or `cd examples/app-demo && cargo check -p app-demo-core`). +Expected: builds; the generated `build_router` now sets manifest JSON. + +- [ ] **Step 5: Commit** + +```bash +git add crates/edgezero-macros/src/app.rs crates/edgezero-macros/Cargo.toml +git commit -m "app! macro: bake manifest JSON into build_router via with_manifest_json" +``` + +--- + +### Task 5: Remove legacy route-listing machinery + +**Files:** +- Modify: `crates/edgezero-core/src/router.rs` (remove `DEFAULT_ROUTE_LISTING_PATH`, `enable_route_listing`, `enable_route_listing_at`, `route_listing_path` field, listing branch in `build()`, `build_listing_response`, `RouteListingEntry`, and all `route_listing_*` tests at :621-716) +- Test: `router.rs` (removal of obsolete tests) + +**Interfaces:** +- Produces: no public route-listing API remains; `/__edgezero/routes` is gone. + +- [ ] **Step 1: Delete the machinery** + +Remove from `router.rs`: +- `pub const DEFAULT_ROUTE_LISTING_PATH` (:21) +- `RouterBuilder.route_listing_path` field (:83) +- `RouterBuilder::enable_route_listing` (:174) and `enable_route_listing_at` (:182) +- The `if let Some(path) = listing_path { ... }` block inside `build()` (the listing-handler insertion) and the `let listing_path = self.route_listing_path.clone();` line (:122) +- `build_listing_response` (:376) +- `RouteListingEntry` struct (:71 area) +- Tests: `route_listing_duplicate_path_panics`, `route_listing_outputs_all_routes`, `route_listing_rejects_empty_path`, `route_listing_rejects_missing_slash`, `route_listing_response_handles_builder_failure`, `route_listing_response_handles_json_failure` (:621-716) + +- [ ] **Step 2: Grep for stragglers** + +Run: +```bash +grep -rn "enable_route_listing\|DEFAULT_ROUTE_LISTING_PATH\|RouteListingEntry\|__edgezero/routes\|build_listing_response" crates/ examples/ +``` +Expected: no matches in non-doc source. (The `docs/guide/routing.md` reference is handled in Task 7.) + +- [ ] **Step 3: Verify compile + tests** + +Run: `cargo test -p edgezero-core router` +Expected: PASS; no references to removed items. + +- [ ] **Step 4: Commit** + +```bash +git add crates/edgezero-core/src/router.rs +git commit -m "Remove legacy route-listing machinery and /__edgezero/routes" +``` + +--- + +### Task 6: Wire default triggers in app-demo + generated template + +**Files:** +- Modify: `examples/app-demo/edgezero.toml` +- Modify: `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs` +- Test: `examples/app-demo/crates/app-demo-core/src/lib.rs` or the crate's existing router test module (through `build_router().oneshot()`) + +**Interfaces:** +- Consumes: `edgezero_core::introspection::{manifest,config,routes}` (Task 3); manifest JSON injection (Task 4). + +- [ ] **Step 1: Add three triggers to app-demo** + +Append to `examples/app-demo/edgezero.toml` in the `[[triggers.http]]` section: + +```toml +[[triggers.http]] +id = "manifest" +path = "/_app-demo/manifest" +methods = ["GET"] +handler = "edgezero_core::introspection::manifest" +adapters = ["axum", "cloudflare", "fastly", "spin"] +description = "App manifest as JSON" + +[[triggers.http]] +id = "config" +path = "/_app-demo/config" +methods = ["GET"] +handler = "edgezero_core::introspection::config" +adapters = ["axum", "cloudflare", "fastly", "spin"] +description = "Effective app config (secret-safe)" + +[[triggers.http]] +id = "routes" +path = "/_app-demo/routes" +methods = ["GET"] +handler = "edgezero_core::introspection::routes" +adapters = ["axum", "cloudflare", "fastly", "spin"] +description = "Registered route table" +``` + +- [ ] **Step 2: Write the failing router-level test** + +In app-demo-core's test module (colocated with `build_router`/`App`), add: + +```rust +#[test] +fn introspection_routes_are_wired() { + use edgezero_core::body::Body; + use edgezero_core::http::{request_builder, Method, StatusCode}; + use futures::executor::block_on; + + let router = crate::build_router(); + for path in ["/_app-demo/manifest", "/_app-demo/routes"] { + let req = request_builder().method(Method::GET).uri(path).body(Body::empty()).unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::OK, "{path} should be 200"); + assert_eq!(resp.headers().get("content-type").unwrap(), "application/json"); + } + // /_app-demo/config is 404 without a populated config store, but must be routed + // (i.e. not a routing 404 with empty body). Assert it is reachable: + let req = request_builder().method(Method::GET).uri("/_app-demo/config").body(Body::empty()).unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert!(matches!(resp.status(), StatusCode::OK | StatusCode::NOT_FOUND)); +} +``` + +(Match the app-demo crate's existing test imports/module location; `build_router` is generated by `app!`.) + +- [ ] **Step 3: Run test to verify it fails, then passes** + +Run: `cargo test -p app-demo-core introspection_routes_are_wired` +Expected: initially FAILS if triggers not yet parsed/handler path unresolved; after Step 1 + Tasks 3-4, PASS. + +- [ ] **Step 4: Add the same rows to the generated-app template** + +In `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs`, append three trigger blocks mirroring app-demo but templated: + +```hbs +[[triggers.http]] +id = "manifest" +path = "/_{{name}}/manifest" +methods = ["GET"] +handler = "edgezero_core::introspection::manifest" +adapters = [{{{adapter_list}}}] +description = "App manifest as JSON" + +[[triggers.http]] +id = "config" +path = "/_{{name}}/config" +methods = ["GET"] +handler = "edgezero_core::introspection::config" +adapters = [{{{adapter_list}}}] +description = "Effective app config (secret-safe)" + +[[triggers.http]] +id = "routes" +path = "/_{{name}}/routes" +methods = ["GET"] +handler = "edgezero_core::introspection::routes" +adapters = [{{{adapter_list}}}] +description = "Registered route table" +``` + +(Use the same `{{{adapter_list}}}` placeholder the template already uses for other triggers — verify its exact name in the `.hbs` file.) + +- [ ] **Step 5: Verify generator tests** + +Run: `cargo test -p edgezero-cli` +Expected: PASS (scaffold/generator tests still green with the added triggers). + +- [ ] **Step 6: Commit** + +```bash +git add examples/app-demo/edgezero.toml examples/app-demo/crates/app-demo-core crates/edgezero-cli/src/templates/root/edgezero.toml.hbs +git commit -m "Wire default introspection triggers into app-demo and generated apps" +``` + +--- + +### Task 7: Update docs + +**Files:** +- Modify: `docs/guide/routing.md` (around :118, the route-listing reference) + +**Interfaces:** none (documentation only). + +- [ ] **Step 1: Locate the reference** + +Run: `grep -n "route listing\|__edgezero/routes\|enable_route_listing" docs/guide/routing.md` +Expected: a reference around line 118. Do NOT touch any `.__edgezero_chunks` docs (unrelated). + +- [ ] **Step 2: Replace with introspection-route docs** + +Rewrite that section to describe the three bindable handlers instead of `enable_route_listing`. Content to convey: +- Core provides `edgezero_core::introspection::{manifest, config, routes}`. +- Bind them in `[[triggers.http]]` like any handler; app-demo and generated apps mount them under `/_/{manifest,config,routes}` by default. +- `manifest` → full manifest JSON (secret values redacted); `config` → effective app config from the default config store (secret-safe); `routes` → registered route table. +- Remove any mention of `/__edgezero/routes` / `enable_route_listing`. + +Example block to include: + +```toml +[[triggers.http]] +id = "manifest" +path = "/_my-app/manifest" +methods = ["GET"] +handler = "edgezero_core::introspection::manifest" +``` + +- [ ] **Step 3: Verify no stale references remain** + +Run: `grep -rn "enable_route_listing\|__edgezero/routes" docs/` +Expected: no matches (excluding `.__edgezero_chunks` which is a different token — verify the grep does not match it; if it does, refine to `__edgezero/routes`). + +- [ ] **Step 4: Commit** + +```bash +git add docs/guide/routing.md +git commit -m "Docs: replace route-listing with introspection routes" +``` + +--- + +### Task 8: Full verification (CI gates + app-demo smoke) + +**Files:** none (verification only). + +- [ ] **Step 1: Format** + +Run: `cargo fmt --all -- --check` +Expected: clean (no diff). + +- [ ] **Step 2: Clippy** + +Run: `cargo clippy --workspace --all-targets --all-features -- -D warnings` +Expected: no warnings. + +- [ ] **Step 3: Workspace tests** + +Run: `cargo test --workspace --all-targets` +Expected: all pass, including the new manifest/router/introspection/app-demo tests. + +- [ ] **Step 4: Feature compilation** + +Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +Expected: builds. + +- [ ] **Step 5: Spin wasm target** + +Run: `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +Expected: builds. + +- [ ] **Step 6: app-demo dev-server smoke (manual/optional)** + +Run: `cd examples/app-demo && cargo run -p app-demo-adapter-axum` then in another shell: +```bash +curl -s localhost:8787/_app-demo/manifest | head -c 200 +curl -s localhost:8787/_app-demo/routes +curl -s -o /dev/null -w "%{http_code}\n" localhost:8787/_app-demo/config +``` +Expected: manifest JSON (no secret `value`), a routes array, and a status code for `/config` (200 if a config blob is present, 404 otherwise). + +- [ ] **Step 7: Mark PR ready** + +Update PR #300 checklist and mark it ready for review: +```bash +gh pr ready 300 +``` + +--- + +## Self-Review + +**Spec coverage:** +- Manifest→JSON (baked, Serialize): Task 1 + Task 4. ✓ (errata: secret redaction, version/kind) +- Config→envelope data (secret-safe, verify): Task 3. ✓ +- Routes→live index: Task 3. ✓ +- Router-chokepoint injection (no global/no adapter changes): Task 2. ✓ +- `RequestContext::introspection()`: Task 2. ✓ +- `#[action]` self-alias: Task 3 Step 1. ✓ +- Remove `enable_route_listing`/`/__edgezero/routes`: Task 5. ✓ +- Templates + app-demo default triggers under `/_/…`: Task 6. ✓ +- Docs update: Task 7. ✓ +- CI gates: Task 8. ✓ + +**Placeholder scan:** No TBD/TODO; every code step shows real code. Two verification notes ("confirm field names", "verify `{{{adapter_list}}}` name") are guardrails against drift, not missing content. + +**Type consistency:** `IntrospectionData { manifest_json: Option>, routes: Arc<[RouteInfo]> }`, `with_manifest_json(impl Into>)`, and `introspection() -> Option<&IntrospectionData>` are used identically across Tasks 2/3/6. Handler names `manifest`/`config`/`routes` match the trigger `handler = "edgezero_core::introspection::…"` strings in Task 6. From 98d225af2876c1dd655c1eb00b17f0c71a625e20 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:16:49 -0700 Subject: [PATCH 03/29] Revise introspection plan per review: enum serialization, imports, config/app-demo test coverage, workspace commands --- .../plans/2026-07-02-introspection-routes.md | 370 ++++++++++++++++-- 1 file changed, 331 insertions(+), 39 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md index 16322813..6073f166 100644 --- a/docs/superpowers/plans/2026-07-02-introspection-routes.md +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -99,12 +99,40 @@ value = "super-secret-value" let secret = &json["environment"]["secrets"][0]; assert_eq!(secret["name"], "API_TOKEN"); assert!(secret.get("value").is_none(), "secret value must be redacted"); + // Enums serialize to their wire strings, not Rust variant names. + assert_eq!(json["triggers"]["http"][0]["methods"][0], "GET"); +} + +#[test] +fn serializes_enums_with_wire_casing() { + let toml = r#" +[app] +name = "t" + +[[triggers.http]] +id = "r" +path = "/" +methods = ["POST"] +handler = "t::h::r" +body-mode = "buffered" + +[logging.axum] +level = "info" +"#; + let manifest: Manifest = toml::from_str(toml).unwrap(); + let json = serde_json::to_value(&manifest).unwrap(); + assert_eq!(json["triggers"]["http"][0]["methods"][0], "POST"); + assert_eq!(json["triggers"]["http"][0]["body_mode"], "buffered"); + assert_eq!(json["logging"]["axum"]["level"], "info"); } ``` +(Adjust the `[logging.axum]`/`body-mode` key spellings to match the manifest's +actual serde field renames — verify against manifest.rs before running.) + - [ ] **Step 2: Run test to verify it fails** -Run: `cargo test -p edgezero-core serializes_manifest_and_redacts_secret_values` +Run: `cargo test -p edgezero-core serializes_manifest_and_redacts_secret_values serializes_enums_with_wire_casing` Expected: FAIL to compile — `Manifest` does not implement `Serialize`; `ManifestApp` has no `version`/`kind`. - [ ] **Step 3: Add `version`/`kind` to `ManifestApp`** @@ -122,7 +150,10 @@ In `ManifestApp` (manifest.rs:217), add after `name`: - [ ] **Step 4: Add the secret redactor** -Add near `ManifestEnvironment` (manifest.rs:276): +Add near `ManifestEnvironment` (manifest.rs:276). Use an **owned** redacted +struct so serde's `skip_serializing_if` fn signatures match (a `&[String]` field +would make `Vec::is_empty` fail to type-check; an `&Option<_>` field would make +`Option::is_none` fail). Cloning is cheap and only happens at serialize time: ```rust /// Serialize a `[[environment.secrets]]` list without exposing `value`. @@ -135,33 +166,88 @@ where use serde::ser::SerializeSeq; #[derive(Serialize)] - struct RedactedBinding<'a> { + struct RedactedBinding { #[serde(skip_serializing_if = "Vec::is_empty")] - adapters: &'a [String], + adapters: Vec, #[serde(skip_serializing_if = "Option::is_none")] - description: &'a Option, + description: Option, #[serde(skip_serializing_if = "Option::is_none")] - env: &'a Option, - name: &'a str, + env: Option, + name: String, // `value` intentionally omitted. } let mut seq = serializer.serialize_seq(Some(secrets.len()))?; for binding in secrets { seq.serialize_element(&RedactedBinding { - adapters: &binding.adapters, - description: &binding.description, - env: &binding.env, - name: &binding.name, + adapters: binding.adapters.clone(), + description: binding.description.clone(), + env: binding.env.clone(), + name: binding.name.clone(), })?; } seq.end() } ``` -- [ ] **Step 5: Add `Serialize` derives + wire the redactor** +- [ ] **Step 5a: Add manual `Serialize` impls for the enums** + +`HttpMethod` (:581), `BodyMode` (:639), and `LogLevel` (:669) have hand-written +`Deserialize` impls that accept wire strings (`"GET"`, `"buffered"`, `"info"`). +A derived `Serialize` would emit variant names (`Get`/`Buffered`/`Info`) — +**wrong**. Add manual impls that mirror deserialization. Do NOT add `Serialize` +to their derive lists. `Serialize` has no defaulted methods, so no +`#[expect(clippy::missing_trait_methods)]` is needed (unlike the `Deserialize` +impls). Add after each enum's existing impl block: + +```rust +impl serde::Serialize for HttpMethod { + #[inline] + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +impl serde::Serialize for BodyMode { + #[inline] + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(match self { + Self::Buffered => "buffered", + Self::Stream => "stream", + }) + } +} + +impl serde::Serialize for LogLevel { + #[inline] + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} +``` -Add `Serialize` to the `#[derive(...)]` on: `Manifest` (:86), `ManifestApp` (:217), `ManifestTriggers` (:230), `ManifestHttpTrigger` (:238), `ManifestEnvironment` (:276), `ManifestBinding` (:287), `ManifestAdapter` (:344), `ManifestAdapterDeployed` (:368 area), `ManifestAdapterBuild`, `ManifestAdapterCommands`, `ManifestAdapterDefinition`, `ManifestLogging`, `ManifestLoggingConfig`, `ManifestStores`, `StoreDeclaration`, plus the `HttpMethod` enum used in triggers. Keep existing `Deserialize`/`Validate`. +- [ ] **Step 5: Add `Serialize` derives to the structs + wire the redactor** + +Add `Serialize` to the `#[derive(...)]` on these **structs** (verify each line +against the file — they are the Deserialize-deriving manifest structs reachable +from `Manifest` output on `main`): `Manifest` (:86), `ManifestApp` (:217), +`ManifestTriggers` (:230), `ManifestHttpTrigger` (:238), `ManifestEnvironment` +(:276), `ManifestBinding` (:287), `ManifestAdapter` (:344), +`ManifestAdapterDefinition` (:368), `ManifestAdapterBuild` (:405), +`ManifestAdapterCommands` (:418), `ManifestStores` (:460), `StoreDeclaration` +(:482), `ManifestLogging` (:519), `ManifestLoggingConfig` (:527). Keep existing +`Deserialize`/`Validate`. + +Do **not** add `Serialize` to the enums (Step 5a handles those manually), and do +**not** add it to the internal resolved/non-serde structs at :330, :338, :539 +(they are reachable only via `#[serde(skip)]` fields — `root`, +`logging_resolved`). `toml::Value` fields (e.g. any `#[serde(flatten)]` legacy +map, if present) already implement `Serialize`. + +> **Note (branch drift):** the earlier design exploration ran against the +> `feature/provision-local-impl` checkout, which has an extra +> `ManifestAdapterDeployed` struct and an adapter `deployed` field. Those do +> **not** exist on `main` (this worktree's base) — do not reference them. On `ManifestEnvironment::secrets`, add: @@ -175,7 +261,7 @@ Add `#[serde(skip_serializing_if = "...")]` to keep output clean where fields ar - [ ] **Step 6: Run tests** -Run: `cargo test -p edgezero-core serializes_manifest_and_redacts_secret_values` +Run: `cargo test -p edgezero-core serializes_manifest_and_redacts_secret_values serializes_enums_with_wire_casing` Expected: PASS. Then: `cargo test -p edgezero-core manifest` — Expected: all existing manifest tests still PASS. @@ -245,9 +331,45 @@ fn dispatch_injects_introspection_data() { (Use whatever request-builder/`block_on` imports the existing router tests use; match them.) +Also add a middleware-visibility test (errata #5 requires proving both handler +and middleware see the injected data, since injection happens before the +middleware chain runs): + +```rust +#[test] +fn middleware_sees_introspection_data() { + use crate::context::RequestContext; + use crate::middleware::{Middleware, Next}; + use std::sync::{Arc, Mutex}; + + struct Probe(Arc>); + #[async_trait::async_trait(?Send)] + impl Middleware for Probe { + async fn handle(&self, ctx: RequestContext, next: Next) -> Result { + *self.0.lock().unwrap() = ctx.introspection().is_some(); + next.run(ctx).await + } + } + + let saw = Arc::new(Mutex::new(false)); + let router = RouterService::builder() + .with_manifest_json("{}") + .middleware(Probe(Arc::clone(&saw))) + .get("/", |_ctx: RequestContext| async { Ok::<_, EdgeError>("ok") }) + .build(); + let request = crate::http::request_builder() + .method(Method::GET).uri("/").body(Body::empty()).unwrap(); + let _ = block_on(router.oneshot(request)).unwrap(); + assert!(*saw.lock().unwrap(), "middleware should see introspection data"); +} +``` + +(Match the exact `Middleware`/`Next` import paths and `async_trait` usage the +existing middleware tests in this crate use.) + - [ ] **Step 2: Run test to verify it fails** -Run: `cargo test -p edgezero-core dispatch_injects_introspection_data` +Run: `cargo test -p edgezero-core dispatch_injects_introspection_data middleware_sees_introspection_data` Expected: FAIL to compile — `with_manifest_json` and `RequestContext::introspection` do not exist. - [ ] **Step 3: Add `IntrospectionData` + builder field/setter** @@ -306,7 +428,7 @@ In `context.rs`, near `config_store_default_binding`: - [ ] **Step 6: Run tests** -Run: `cargo test -p edgezero-core dispatch_injects_introspection_data` +Run: `cargo test -p edgezero-core dispatch_injects_introspection_data middleware_sees_introspection_data` Expected: PASS. Then: `cargo test -p edgezero-core router` — Expected: PASS (existing route-listing tests still pass; they are removed in Task 5). @@ -352,8 +474,9 @@ use crate::blob_envelope::BlobEnvelope; use crate::body::Body; use crate::context::RequestContext; use crate::error::EdgeError; -use crate::http::{response_builder, StatusCode}; -use crate::response::Response; +// NOTE: `Response` is an HTTP alias exported from `crate::http`, NOT +// `crate::response` (response.rs itself imports it from crate::http). +use crate::http::{response_builder, Response, StatusCode}; use edgezero_core::action; use serde::Serialize; @@ -366,9 +489,66 @@ struct RouteView { #[cfg(test)] mod tests { use super::*; + use crate::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use crate::context::RequestContext; use crate::http::{request_builder, Method}; + use crate::params::PathParams; use crate::router::RouterService; + use crate::store_registry::{ConfigRegistry, ConfigStoreBinding, StoreRegistry}; + use async_trait::async_trait; use futures::executor::block_on; + use std::collections::BTreeMap; + use std::sync::Arc; + + // A config store returning a fixed result for `get`, used to drive the + // config handler's status-code mapping. Mirrors the pattern in + // extractor.rs::config_extractor_resolves_from_registry. + struct StubStore(Result, ConfigStoreError>); + #[async_trait(?Send)] + impl ConfigStore for StubStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + match &self.0 { + Ok(v) => Ok(v.clone()), + Err(ConfigStoreError::Unavailable { .. }) => { + Err(ConfigStoreError::unavailable("down")) + } + Err(ConfigStoreError::InvalidKey { .. }) => { + Err(ConfigStoreError::invalid_key("bad")) + } + Err(_) => Err(ConfigStoreError::internal(anyhow::anyhow!("boom"))), + } + } + } + + // Build a request carrying a default ConfigRegistry backed by `store`, + // run it through the `config` handler, and return the response. + fn run_config(store: StubStore) -> crate::http::Response { + let registry: ConfigRegistry = StoreRegistry::new( + [( + "default".to_owned(), + ConfigStoreBinding { + handle: ConfigStoreHandle::new(Arc::new(store)), + default_key: "default".to_owned(), + }, + )] + .into_iter() + .collect::>(), + "default".to_owned(), + ); + let mut request = request_builder() + .method(Method::GET) + .uri("/c") + .body(Body::empty()) + .unwrap(); + request.extensions_mut().insert(registry); + let ctx = RequestContext::new(request, PathParams::default()); + block_on(super::config(ctx)).unwrap_or_else(|e| e.into_response().unwrap()) + } + + fn valid_envelope_json(data: serde_json::Value) -> String { + // Build a real envelope so sha/version are correct. + serde_json::to_string(&BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned())).unwrap() + } #[test] fn manifest_returns_injected_json() { @@ -379,10 +559,7 @@ mod tests { let req = request_builder().method(Method::GET).uri("/m").body(Body::empty()).unwrap(); let resp = block_on(router.oneshot(req)).unwrap(); assert_eq!(resp.status(), StatusCode::OK); - assert_eq!( - resp.headers().get("content-type").unwrap(), - "application/json" - ); + assert_eq!(resp.headers().get("content-type").unwrap(), "application/json"); } #[test] @@ -400,9 +577,65 @@ mod tests { let resp = block_on(router.oneshot(req)).unwrap(); assert_eq!(resp.status(), StatusCode::NOT_FOUND); } + + #[test] + fn config_happy_path_returns_envelope_data_secret_safe() { + let data = serde_json::json!({ "greeting": "hi", "api_token": "demo_api_token" }); + let resp = run_config(StubStore(Ok(Some(valid_envelope_json(data))))); + assert_eq!(resp.status(), StatusCode::OK); + // Raw envelope `data` is returned verbatim: the secret field holds the + // KEY NAME, never a resolved value. + // (Read the body via the crate's test body helper and assert the JSON + // contains "api_token":"demo_api_token".) + } + + #[test] + fn config_missing_blob_is_not_found() { + let resp = run_config(StubStore(Ok(None))); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn config_backend_unavailable_maps_503() { + let resp = run_config(StubStore(Err(ConfigStoreError::unavailable("x")))); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[test] + fn config_invalid_key_maps_400() { + let resp = run_config(StubStore(Err(ConfigStoreError::invalid_key("x")))); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn config_malformed_envelope_maps_500() { + let resp = run_config(StubStore(Ok(Some("not json".to_owned())))); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn config_sha_mismatch_maps_500() { + // Valid JSON envelope shape but wrong sha → verify() fails. + let bad = r#"{"data":{"a":1},"generated_at":"t","sha256":"deadbeef","version":1}"#; + let resp = run_config(StubStore(Ok(Some(bad.to_owned())))); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn config_unknown_version_maps_500() { + let bad = r#"{"data":{},"generated_at":"t","sha256":"x","version":99}"#; + let resp = run_config(StubStore(Ok(Some(bad.to_owned())))); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } } ``` +Notes: verify `ConfigStoreError` variant/constructor names (`unavailable`, +`invalid_key`, `internal`) against config_store.rs:177-199, and the +`crate::http::Response` / body-reading test helper the crate already uses. The +happy-path body assertion should use whatever body-collection helper the other +core tests use (e.g. a `block_on` over the body) — match existing conventions. + - [ ] **Step 3: Run tests to verify they fail** Run: `cargo test -p edgezero-core introspection` @@ -547,7 +780,9 @@ Expected: builds cleanly. - [ ] **Step 4: Verify a consumer still builds** -Run: `cargo build -p edgezero-core` then `cargo check -p app-demo-core --manifest-path examples/app-demo/Cargo.toml` (or `cd examples/app-demo && cargo check -p app-demo-core`). +`examples/app-demo` is `exclude`d from the root workspace (Cargo.toml:12), so it must be built from its own directory: + +Run: `cargo build -p edgezero-core` then `cd examples/app-demo && cargo check -p app-demo-core` Expected: builds; the generated `build_router` now sets manifest JSON. - [ ] **Step 5: Commit** @@ -643,35 +878,74 @@ description = "Registered route table" - [ ] **Step 2: Write the failing router-level test** -In app-demo-core's test module (colocated with `build_router`/`App`), add: +In app-demo-core's test module (colocated with `build_router`/`App`), add. A +routing miss ALSO returns 404 via `oneshot` (router.rs), so an `OK | NOT_FOUND` +assertion would pass even if `/config` were never wired. Instead, **seed a +`ConfigRegistry`** so a wired `/config` route returns 200, proving the trigger +exists, and assert the raw envelope `data` exposes the key-name (never a +resolved secret value): ```rust #[test] fn introspection_routes_are_wired() { use edgezero_core::body::Body; + use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; use edgezero_core::http::{request_builder, Method, StatusCode}; + use edgezero_core::store_registry::{ConfigRegistry, ConfigStoreBinding, StoreRegistry}; + use edgezero_core::blob_envelope::BlobEnvelope; + use async_trait::async_trait; use futures::executor::block_on; + use std::collections::BTreeMap; + use std::sync::Arc; let router = crate::build_router(); + + // manifest + routes need no config store. for path in ["/_app-demo/manifest", "/_app-demo/routes"] { let req = request_builder().method(Method::GET).uri(path).body(Body::empty()).unwrap(); let resp = block_on(router.oneshot(req)).unwrap(); assert_eq!(resp.status(), StatusCode::OK, "{path} should be 200"); assert_eq!(resp.headers().get("content-type").unwrap(), "application/json"); } - // /_app-demo/config is 404 without a populated config store, but must be routed - // (i.e. not a routing 404 with empty body). Assert it is reachable: - let req = request_builder().method(Method::GET).uri("/_app-demo/config").body(Body::empty()).unwrap(); + + // /config: seed a default config store with a valid envelope so a wired + // route returns 200 (a routing miss would be 404, proving nothing). + struct FixedStore(String); + #[async_trait(?Send)] + impl ConfigStore for FixedStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone())) + } + } + let data = serde_json::json!({ "greeting": "hi", "api_token": "demo_api_token" }); + let blob = serde_json::to_string(&BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned())).unwrap(); + let registry: ConfigRegistry = StoreRegistry::new( + [( + "app_config".to_owned(), + ConfigStoreBinding { + handle: ConfigStoreHandle::new(Arc::new(FixedStore(blob))), + default_key: "app_config".to_owned(), + }, + )].into_iter().collect::>(), + "app_config".to_owned(), + ); + let mut req = request_builder().method(Method::GET).uri("/_app-demo/config").body(Body::empty()).unwrap(); + req.extensions_mut().insert(registry); let resp = block_on(router.oneshot(req)).unwrap(); - assert!(matches!(resp.status(), StatusCode::OK | StatusCode::NOT_FOUND)); + assert_eq!(resp.status(), StatusCode::OK, "/config should be wired and 200 with a store"); + // (Collect the body and assert it contains "api_token":"demo_api_token" — + // the raw key-name — and NOT a resolved secret value. Use the app-demo + // test suite's existing body-collection helper.) } ``` -(Match the app-demo crate's existing test imports/module location; `build_router` is generated by `app!`.) +(Match the app-demo crate's existing test imports/module location; `build_router` is generated by `app!`. Confirm app-demo's default config store id is `app_config` per its `[stores.config]`.) - [ ] **Step 3: Run test to verify it fails, then passes** -Run: `cargo test -p app-demo-core introspection_routes_are_wired` +`examples/app-demo` is excluded from the root workspace, so run from its directory: + +Run: `cd examples/app-demo && cargo test -p app-demo-core introspection_routes_are_wired` Expected: initially FAILS if triggers not yet parsed/handler path unresolved; after Step 1 + Tasks 3-4, PASS. - [ ] **Step 4: Add the same rows to the generated-app template** @@ -762,6 +1036,15 @@ git add docs/guide/routing.md git commit -m "Docs: replace route-listing with introspection routes" ``` +> **Out-of-scope, flagged for decision (review finding #8):** CLI docs +> (`docs/guide/cli-reference.md:241`, `docs/guide/cli-walkthrough.md:153`) state +> that typed `config push` "strips secret fields", which reportedly contradicts +> the key-name envelope model (`examples/app-demo/.../config_flow.rs:206`). This +> is a **pre-existing** inaccuracy about `config push` semantics, independent of +> introspection routes, and the push behavior itself has not been re-verified +> here. It is intentionally excluded from this plan. If desired, correct it in a +> separate change after confirming the actual push behavior. + --- ### Task 8: Full verification (CI gates + app-demo smoke) @@ -781,7 +1064,14 @@ Expected: no warnings. - [ ] **Step 3: Workspace tests** Run: `cargo test --workspace --all-targets` -Expected: all pass, including the new manifest/router/introspection/app-demo tests. +Expected: all pass (manifest/router/introspection). **Note:** the root workspace +`exclude`s `examples/app-demo` (Cargo.toml:12), so this does NOT run the +app-demo tests — those are covered by Step 3b (matching CI's separate job). + +- [ ] **Step 3b: app-demo tests (separate workspace)** + +Run: `cd examples/app-demo && cargo test --workspace --all-targets` +Expected: all pass, including `introspection_routes_are_wired`. - [ ] **Step 4: Feature compilation** @@ -815,17 +1105,19 @@ gh pr ready 300 ## Self-Review **Spec coverage:** -- Manifest→JSON (baked, Serialize): Task 1 + Task 4. ✓ (errata: secret redaction, version/kind) -- Config→envelope data (secret-safe, verify): Task 3. ✓ +- Manifest→JSON (baked, Serialize): Task 1 + Task 4. ✓ (errata: secret redaction, version/kind, manual enum Serialize with wire casing) +- Config→envelope data (secret-safe, verify): Task 3, with full status-code coverage (200/404/400/503/500×3). ✓ - Routes→live index: Task 3. ✓ -- Router-chokepoint injection (no global/no adapter changes): Task 2. ✓ +- Router-chokepoint injection (no global/no adapter changes): Task 2, with handler + middleware visibility tests. ✓ - `RequestContext::introspection()`: Task 2. ✓ - `#[action]` self-alias: Task 3 Step 1. ✓ - Remove `enable_route_listing`/`/__edgezero/routes`: Task 5. ✓ -- Templates + app-demo default triggers under `/_/…`: Task 6. ✓ -- Docs update: Task 7. ✓ -- CI gates: Task 8. ✓ +- Templates + app-demo default triggers under `/_/…`: Task 6 (config test seeds a registry to prove wiring). ✓ +- Docs update: Task 7 (cli-doc drift flagged out-of-scope). ✓ +- CI gates incl. separate app-demo workspace: Task 8. ✓ + +**Review findings applied (2026-07-02):** enum serialization + casing test (blocking); owned `RedactedBinding` + removed nonexistent `ManifestAdapterDeployed` (blocking); `Response` from `crate::http` (blocking); full config status-code tests (high); app-demo config test seeds a registry and asserts 200 (high); `cd examples/app-demo` for excluded-workspace commands (high); middleware-visibility test (medium); cli-doc drift flagged, not silently absorbed (medium). -**Placeholder scan:** No TBD/TODO; every code step shows real code. Two verification notes ("confirm field names", "verify `{{{adapter_list}}}` name") are guardrails against drift, not missing content. +**Placeholder scan:** No TBD/TODO; every code step shows real code. Verification notes ("confirm `ConfigStoreError` constructor names", "verify `{{{adapter_list}}}` name", "match body-collection helper") are drift guardrails, not missing content. -**Type consistency:** `IntrospectionData { manifest_json: Option>, routes: Arc<[RouteInfo]> }`, `with_manifest_json(impl Into>)`, and `introspection() -> Option<&IntrospectionData>` are used identically across Tasks 2/3/6. Handler names `manifest`/`config`/`routes` match the trigger `handler = "edgezero_core::introspection::…"` strings in Task 6. +**Type consistency:** `IntrospectionData { manifest_json: Option>, routes: Arc<[RouteInfo]> }`, `with_manifest_json(impl Into>)`, and `introspection() -> Option<&IntrospectionData>` are used identically across Tasks 2/3/6. Handler names `manifest`/`config`/`routes` match the trigger `handler = "edgezero_core::introspection::…"` strings in Task 6. Manual enum `Serialize` (Task 1 Step 5a) matches the `Deserialize` wire forms. From 7301bcefcac19ba4b5c204b6563b2da6ab31c217 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:30:54 -0700 Subject: [PATCH 04/29] Revise plan (round 2): real body assertions, oneshot config tests, body-mode rename, Internal->500, scaffold CI checks --- .../plans/2026-07-02-introspection-routes.md | 107 +++++++++++++----- 1 file changed, 78 insertions(+), 29 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md index 6073f166..a6150fd1 100644 --- a/docs/superpowers/plans/2026-07-02-introspection-routes.md +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -122,13 +122,16 @@ level = "info" let manifest: Manifest = toml::from_str(toml).unwrap(); let json = serde_json::to_value(&manifest).unwrap(); assert_eq!(json["triggers"]["http"][0]["methods"][0], "POST"); - assert_eq!(json["triggers"]["http"][0]["body_mode"], "buffered"); + // `body_mode` is serde-renamed to `body-mode` (manifest.rs:243), so the + // serialized key is `body-mode`, NOT `body_mode`. + assert_eq!(json["triggers"]["http"][0]["body-mode"], "buffered"); assert_eq!(json["logging"]["axum"]["level"], "info"); } ``` -(Adjust the `[logging.axum]`/`body-mode` key spellings to match the manifest's -actual serde field renames — verify against manifest.rs before running.) +(The `body-mode` key matches the `#[serde(rename = "body-mode")]` on +`ManifestHttpTrigger::body_mode`. Verify the `[logging.]` shape against +manifest.rs before running.) - [ ] **Step 2: Run test to verify it fails** @@ -490,9 +493,7 @@ struct RouteView { mod tests { use super::*; use crate::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; - use crate::context::RequestContext; use crate::http::{request_builder, Method}; - use crate::params::PathParams; use crate::router::RouterService; use crate::store_registry::{ConfigRegistry, ConfigStoreBinding, StoreRegistry}; use async_trait::async_trait; @@ -520,8 +521,16 @@ mod tests { } } - // Build a request carrying a default ConfigRegistry backed by `store`, - // run it through the `config` handler, and return the response. + // Collect a buffered response body into JSON (introspection responses are + // always `Body::Once`). `Body::to_json` works on the buffered variant. + fn body_json(resp: crate::http::Response) -> serde_json::Value { + resp.into_body().to_json().expect("buffered JSON body") + } + + // Build a request carrying a default ConfigRegistry backed by `store`, and + // drive it THROUGH THE ROUTER via `oneshot` (which maps handler `EdgeError` + // to a response internally — so we neither import `IntoResponse` nor unwrap + // an error path by hand). fn run_config(store: StubStore) -> crate::http::Response { let registry: ConfigRegistry = StoreRegistry::new( [( @@ -535,14 +544,14 @@ mod tests { .collect::>(), "default".to_owned(), ); + let router = RouterService::builder().get("/c", config).build(); let mut request = request_builder() .method(Method::GET) .uri("/c") .body(Body::empty()) .unwrap(); request.extensions_mut().insert(registry); - let ctx = RequestContext::new(request, PathParams::default()); - block_on(super::config(ctx)).unwrap_or_else(|e| e.into_response().unwrap()) + block_on(router.oneshot(request)).unwrap() } fn valid_envelope_json(data: serde_json::Value) -> String { @@ -560,6 +569,8 @@ mod tests { let resp = block_on(router.oneshot(req)).unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.headers().get("content-type").unwrap(), "application/json"); + // Body is the injected manifest JSON verbatim. + assert_eq!(body_json(resp), serde_json::json!({ "app": { "name": "t" } })); } #[test] @@ -568,6 +579,10 @@ mod tests { let req = request_builder().method(Method::GET).uri("/r").body(Body::empty()).unwrap(); let resp = block_on(router.oneshot(req)).unwrap(); assert_eq!(resp.status(), StatusCode::OK); + // Shape: [{ "method", "path" }] — the /r route itself is present. + let body = body_json(resp); + let arr = body.as_array().expect("routes array"); + assert!(arr.iter().any(|e| e["method"] == "GET" && e["path"] == "/r")); } #[test] @@ -583,10 +598,11 @@ mod tests { let data = serde_json::json!({ "greeting": "hi", "api_token": "demo_api_token" }); let resp = run_config(StubStore(Ok(Some(valid_envelope_json(data))))); assert_eq!(resp.status(), StatusCode::OK); - // Raw envelope `data` is returned verbatim: the secret field holds the - // KEY NAME, never a resolved value. - // (Read the body via the crate's test body helper and assert the JSON - // contains "api_token":"demo_api_token".) + // Raw envelope `data` verbatim: the secret field holds the KEY NAME, + // never a resolved value. + let body = body_json(resp); + assert_eq!(body["greeting"], "hi"); + assert_eq!(body["api_token"], "demo_api_token"); } #[test] @@ -607,6 +623,12 @@ mod tests { assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } + #[test] + fn config_backend_internal_maps_500() { + let resp = run_config(StubStore(Err(ConfigStoreError::internal(anyhow::anyhow!("x"))))); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + #[test] fn config_malformed_envelope_maps_500() { let resp = run_config(StubStore(Ok(Some("not json".to_owned())))); @@ -630,11 +652,11 @@ mod tests { } ``` -Notes: verify `ConfigStoreError` variant/constructor names (`unavailable`, -`invalid_key`, `internal`) against config_store.rs:177-199, and the -`crate::http::Response` / body-reading test helper the crate already uses. The -happy-path body assertion should use whatever body-collection helper the other -core tests use (e.g. a `block_on` over the body) — match existing conventions. +Notes: the `StubStore::0 = Err(...)` arm is matched by variant, so the three +`ConfigStoreError` constructors (`unavailable`, `invalid_key`, `internal`) must +match config_store.rs:177-199. `body_json` relies on `Body::to_json` (body.rs) +and `http::Response::into_body`; both exist. The malformed/sha/version cases are +driven by raw strings so they don't depend on the stub's error arm. - [ ] **Step 3: Run tests to verify they fail** @@ -900,13 +922,21 @@ fn introspection_routes_are_wired() { let router = crate::build_router(); - // manifest + routes need no config store. - for path in ["/_app-demo/manifest", "/_app-demo/routes"] { - let req = request_builder().method(Method::GET).uri(path).body(Body::empty()).unwrap(); - let resp = block_on(router.oneshot(req)).unwrap(); - assert_eq!(resp.status(), StatusCode::OK, "{path} should be 200"); - assert_eq!(resp.headers().get("content-type").unwrap(), "application/json"); - } + // manifest: 200 + JSON body whose [app].name is "app-demo". + let req = request_builder().method(Method::GET).uri("/_app-demo/manifest").body(Body::empty()).unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(resp.headers().get("content-type").unwrap(), "application/json"); + let manifest_body: serde_json::Value = resp.into_body().to_json().unwrap(); + assert_eq!(manifest_body["app"]["name"], "app-demo"); + + // routes: 200 + [{method,path}] including the root route. + let req = request_builder().method(Method::GET).uri("/_app-demo/routes").body(Body::empty()).unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let routes_body: serde_json::Value = resp.into_body().to_json().unwrap(); + let arr = routes_body.as_array().expect("routes array"); + assert!(arr.iter().any(|e| e["method"] == "GET" && e["path"] == "/")); // /config: seed a default config store with a valid envelope so a wired // route returns 200 (a routing miss would be 404, proving nothing). @@ -933,9 +963,10 @@ fn introspection_routes_are_wired() { req.extensions_mut().insert(registry); let resp = block_on(router.oneshot(req)).unwrap(); assert_eq!(resp.status(), StatusCode::OK, "/config should be wired and 200 with a store"); - // (Collect the body and assert it contains "api_token":"demo_api_token" — - // the raw key-name — and NOT a resolved secret value. Use the app-demo - // test suite's existing body-collection helper.) + // Raw envelope `data`: secret field holds the KEY NAME, not a resolved value. + let config_body: serde_json::Value = resp.into_body().to_json().unwrap(); + assert_eq!(config_body["api_token"], "demo_api_token"); + assert_eq!(config_body["greeting"], "hi"); } ``` @@ -1073,6 +1104,22 @@ app-demo tests — those are covered by Step 3b (matching CI's separate job). Run: `cd examples/app-demo && cargo test --workspace --all-targets` Expected: all pass, including `introspection_routes_are_wired`. +- [ ] **Step 3c: Generated-project build (template surface)** + +Task 6 edits the generated-app template, so exercise CI's ignored end-to-end +scaffold-and-build test (test.yml): + +Run: `cargo test -p edgezero-cli --test generated_project_builds -- --ignored` +Expected: a project scaffolded from the template (now with the three +introspection triggers) compiles. + +- [ ] **Step 3d: Nested AppConfig audit (template + app-demo surface)** + +The template and app-demo are both audited by CI (test.yml): + +Run: `cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates` +Expected: passes (the introspection triggers add no nested `AppConfig`). + - [ ] **Step 4: Feature compilation** Run: `cargo check --workspace --all-targets --features "fastly cloudflare spin"` @@ -1116,7 +1163,9 @@ gh pr ready 300 - Docs update: Task 7 (cli-doc drift flagged out-of-scope). ✓ - CI gates incl. separate app-demo workspace: Task 8. ✓ -**Review findings applied (2026-07-02):** enum serialization + casing test (blocking); owned `RedactedBinding` + removed nonexistent `ManifestAdapterDeployed` (blocking); `Response` from `crate::http` (blocking); full config status-code tests (high); app-demo config test seeds a registry and asserts 200 (high); `cd examples/app-demo` for excluded-workspace commands (high); middleware-visibility test (medium); cli-doc drift flagged, not silently absorbed (medium). +**Review findings applied (round 1):** enum serialization + casing test (blocking); owned `RedactedBinding` + removed nonexistent `ManifestAdapterDeployed` (blocking); `Response` from `crate::http` (blocking); full config status-code tests (high); app-demo config test seeds a registry and asserts 200 (high); `cd examples/app-demo` for excluded-workspace commands (high); middleware-visibility test (medium); cli-doc drift flagged, not silently absorbed (medium). + +**Review findings applied (round 2):** real body assertions for manifest (equality), routes (`[{method,path}]` shape), and config (`api_token` key-name present, secret-safe) in Tasks 3 & 6 (high); `run_config` now routes through `oneshot` so no `IntoResponse` import is needed, and unused test imports dropped (high); `body-mode` serde-rename fixed in the casing test (high); `ConfigStoreError::Internal → 500` test added (medium); Task 8 gains generated-project-build (`--ignored`) and nested-AppConfig-audit steps matching CI's template-surface jobs (medium). **Placeholder scan:** No TBD/TODO; every code step shows real code. Verification notes ("confirm `ConfigStoreError` constructor names", "verify `{{{adapter_list}}}` name", "match body-collection helper") are drift guardrails, not missing content. From 4254ed41bbc989fb6cf9373a205061f4de62a3c4 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:40:56 -0700 Subject: [PATCH 05/29] Revise plan (round 3): single-filter test commands, scoped doc greps, roadmap update, macro crate test --- .../plans/2026-07-02-introspection-routes.md | 46 +++++++++++++++---- 1 file changed, 36 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md index a6150fd1..a9b8a66c 100644 --- a/docs/superpowers/plans/2026-07-02-introspection-routes.md +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -135,7 +135,7 @@ manifest.rs before running.) - [ ] **Step 2: Run test to verify it fails** -Run: `cargo test -p edgezero-core serializes_manifest_and_redacts_secret_values serializes_enums_with_wire_casing` +Run: `cargo test -p edgezero-core serializes_` (one filter matching both `serializes_manifest_and_redacts_secret_values` and `serializes_enums_with_wire_casing` — Cargo takes a single test-name filter) Expected: FAIL to compile — `Manifest` does not implement `Serialize`; `ManifestApp` has no `version`/`kind`. - [ ] **Step 3: Add `version`/`kind` to `ManifestApp`** @@ -264,7 +264,7 @@ Add `#[serde(skip_serializing_if = "...")]` to keep output clean where fields ar - [ ] **Step 6: Run tests** -Run: `cargo test -p edgezero-core serializes_manifest_and_redacts_secret_values serializes_enums_with_wire_casing` +Run: `cargo test -p edgezero-core serializes_` (one filter matching both `serializes_manifest_and_redacts_secret_values` and `serializes_enums_with_wire_casing` — Cargo takes a single test-name filter) Expected: PASS. Then: `cargo test -p edgezero-core manifest` — Expected: all existing manifest tests still PASS. @@ -372,7 +372,7 @@ existing middleware tests in this crate use.) - [ ] **Step 2: Run test to verify it fails** -Run: `cargo test -p edgezero-core dispatch_injects_introspection_data middleware_sees_introspection_data` +Run: `cargo test -p edgezero-core introspection_data` (one filter matching both `dispatch_injects_introspection_data` and `middleware_sees_introspection_data`) Expected: FAIL to compile — `with_manifest_json` and `RequestContext::introspection` do not exist. - [ ] **Step 3: Add `IntrospectionData` + builder field/setter** @@ -431,7 +431,7 @@ In `context.rs`, near `config_store_default_binding`: - [ ] **Step 6: Run tests** -Run: `cargo test -p edgezero-core dispatch_injects_introspection_data middleware_sees_introspection_data` +Run: `cargo test -p edgezero-core introspection_data` (one filter matching both `dispatch_injects_introspection_data` and `middleware_sees_introspection_data`) Expected: PASS. Then: `cargo test -p edgezero-core router` — Expected: PASS (existing route-listing tests still pass; they are removed in Task 5). @@ -795,10 +795,22 @@ Then in the emitted `build_router()` (the `quote! { ... pub fn build_router() .. } ``` -- [ ] **Step 3: Verify the macro crate compiles** +- [ ] **Step 3: Build and test the macro crate** Run: `cargo build -p edgezero-macros` Expected: builds cleanly. +Then: `cargo test -p edgezero-macros` +Expected: PASS — the existing `app_config_derive` + `tests/ui` trybuild suite +still passes (CLAUDE.md requires `cargo test` after any code change). + +> **Macro-output coverage:** the spec calls for macro expansion coverage +> (spec:303). The `app!` output for this change (`with_manifest_json()`) is +> exercised end-to-end in **Task 6**: app-demo's `introspection_routes_are_wired` +> test builds the real `app!`-generated `build_router()` and asserts +> `/_app-demo/manifest` returns the baked JSON with `[app].name == "app-demo"`. +> That is a stronger check than a string-match expansion test, so no separate +> trybuild case is added for the positive path. (trybuild remains the right tool +> only for compile-fail cases, none of which this change introduces.) - [ ] **Step 4: Verify a consumer still builds** @@ -1029,13 +1041,16 @@ git commit -m "Wire default introspection triggers into app-demo and generated a **Files:** - Modify: `docs/guide/routing.md` (around :118, the route-listing reference) +- Modify: `docs/guide/roadmap.md:16` ("route listing + body-mode behavior") **Interfaces:** none (documentation only). -- [ ] **Step 1: Locate the reference** +- [ ] **Step 1: Locate the references** -Run: `grep -n "route listing\|__edgezero/routes\|enable_route_listing" docs/guide/routing.md` -Expected: a reference around line 118. Do NOT touch any `.__edgezero_chunks` docs (unrelated). +Run: `grep -rn "route listing\|__edgezero/routes\|enable_route_listing" docs/guide` +Expected: `docs/guide/routing.md` (~:118) and `docs/guide/roadmap.md:16`. Scope the +grep to `docs/guide` (NOT `docs/`) so it does not match this plan and the spec +under `docs/superpowers`. Do NOT touch any `.__edgezero_chunks` docs (unrelated). - [ ] **Step 2: Replace with introspection-route docs** @@ -1055,10 +1070,19 @@ methods = ["GET"] handler = "edgezero_core::introspection::manifest" ``` +- [ ] **Step 2b: Update roadmap.md** + +In `docs/guide/roadmap.md:16`, the "Example coverage" bullet ends with "…logging +precedence, and route listing + body-mode behavior". Replace "route listing" +with "introspection routes" so it reads "…logging precedence, and introspection +routes + body-mode behavior". + - [ ] **Step 3: Verify no stale references remain** -Run: `grep -rn "enable_route_listing\|__edgezero/routes" docs/` -Expected: no matches (excluding `.__edgezero_chunks` which is a different token — verify the grep does not match it; if it does, refine to `__edgezero/routes`). +Run: `grep -rn "enable_route_listing\|__edgezero/routes\|route listing" docs/guide` +Expected: no matches under `docs/guide` (scope to `docs/guide`, not `docs/`, so +the plan/spec under `docs/superpowers` are not matched; `.__edgezero_chunks` is a +different token and should not appear). - [ ] **Step 4: Commit** @@ -1165,6 +1189,8 @@ gh pr ready 300 **Review findings applied (round 1):** enum serialization + casing test (blocking); owned `RedactedBinding` + removed nonexistent `ManifestAdapterDeployed` (blocking); `Response` from `crate::http` (blocking); full config status-code tests (high); app-demo config test seeds a registry and asserts 200 (high); `cd examples/app-demo` for excluded-workspace commands (high); middleware-visibility test (medium); cli-doc drift flagged, not silently absorbed (medium). +**Review findings applied (round 3):** single-filter `cargo test` commands (Cargo takes one test-name filter) in Tasks 1 & 2 (high); Task 7 stale-doc greps scoped to `docs/guide` so they don't match the plan/spec under `docs/superpowers` (high); Task 7 also updates `docs/guide/roadmap.md:16` "route listing" phrasing (medium); Task 4 now runs `cargo test -p edgezero-macros` per CLAUDE.md, with macro-output coverage delegated to Task 6's end-to-end assertion rather than a brittle expansion test (medium). + **Review findings applied (round 2):** real body assertions for manifest (equality), routes (`[{method,path}]` shape), and config (`api_token` key-name present, secret-safe) in Tasks 3 & 6 (high); `run_config` now routes through `oneshot` so no `IntoResponse` import is needed, and unused test imports dropped (high); `body-mode` serde-rename fixed in the casing test (high); `ConfigStoreError::Internal → 500` test added (medium); Task 8 gains generated-project-build (`--ignored`) and nested-AppConfig-audit steps matching CI's template-surface jobs (medium). **Placeholder scan:** No TBD/TODO; every code step shows real code. Verification notes ("confirm `ConfigStoreError` constructor names", "verify `{{{adapter_list}}}` name", "match body-collection helper") are drift guardrails, not missing content. From 9011684699d7fde0a3e8a4399d975686e90da003 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 20:45:26 -0700 Subject: [PATCH 06/29] Plan: stage roadmap.md alongside routing.md in Task 7 commit --- docs/superpowers/plans/2026-07-02-introspection-routes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md index a9b8a66c..6aada2a7 100644 --- a/docs/superpowers/plans/2026-07-02-introspection-routes.md +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -1087,7 +1087,7 @@ different token and should not appear). - [ ] **Step 4: Commit** ```bash -git add docs/guide/routing.md +git add docs/guide/routing.md docs/guide/roadmap.md git commit -m "Docs: replace route-listing with introspection routes" ``` From 5ab3546c59b2170a77e2be1d0b395d77dfb55839 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:05:20 -0700 Subject: [PATCH 07/29] Make Manifest serializable with secret-value redaction --- crates/edgezero-core/src/manifest.rs | 177 +++++++++++++++++++++++---- 1 file changed, 153 insertions(+), 24 deletions(-) diff --git a/crates/edgezero-core/src/manifest.rs b/crates/edgezero-core/src/manifest.rs index c7746530..57ef3221 100644 --- a/crates/edgezero-core/src/manifest.rs +++ b/crates/edgezero-core/src/manifest.rs @@ -1,6 +1,6 @@ use log::LevelFilter; use serde::de::Error as DeError; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -83,7 +83,7 @@ impl ManifestLoader { } } -#[derive(Debug, Deserialize, Validate)] +#[derive(Debug, Deserialize, Serialize, Validate)] #[validate(schema(function = "validate_manifest_adapter_keys_case_unique"))] #[expect( clippy::partial_pub_fields, @@ -214,20 +214,26 @@ impl Manifest { } } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestApp { #[serde(default)] #[validate(length(min = 1_u64))] pub entry: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(length(min = 1_u64))] + pub kind: Option, #[serde(default)] pub middleware: Vec, #[serde(default)] #[validate(length(min = 1_u64))] pub name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(length(min = 1_u64))] + pub version: Option, } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestTriggers { #[serde(default)] @@ -235,7 +241,7 @@ pub struct ManifestTriggers { pub http: Vec, } -#[derive(Clone, Debug, Deserialize, Validate)] +#[derive(Clone, Debug, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestHttpTrigger { #[serde(default)] @@ -273,10 +279,10 @@ impl ManifestHttpTrigger { } } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestEnvironment { - #[serde(default)] + #[serde(default, serialize_with = "serialize_secrets")] #[validate(nested)] pub secrets: Vec, #[serde(default)] @@ -284,7 +290,7 @@ pub struct ManifestEnvironment { pub variables: Vec, } -#[derive(Debug, Deserialize, Validate)] +#[derive(Debug, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestBinding { #[serde(default)] @@ -316,6 +322,14 @@ impl ManifestBinding { } } +#[derive(Clone, Debug)] +pub struct ResolvedEnvironmentBinding { + pub description: Option, + pub env: String, + pub name: String, + pub value: Option, +} + impl ResolvedEnvironmentBinding { fn from_manifest(binding: &ManifestBinding) -> Self { Self { @@ -327,21 +341,13 @@ impl ResolvedEnvironmentBinding { } } -#[derive(Clone, Debug)] -pub struct ResolvedEnvironmentBinding { - pub description: Option, - pub env: String, - pub name: String, - pub value: Option, -} - #[derive(Clone, Debug, Default)] pub struct ResolvedEnvironment { pub secrets: Vec, pub variables: Vec, } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] #[validate(schema(function = "validate_manifest_adapter"))] pub struct ManifestAdapter { @@ -365,7 +371,7 @@ pub struct ManifestAdapter { pub logging: ManifestLoggingConfig, } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] #[validate(schema(function = "validate_manifest_adapter_definition"))] pub struct ManifestAdapterDefinition { @@ -402,7 +408,7 @@ pub struct ManifestAdapterDefinition { pub port: Option, } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestAdapterBuild { #[serde(default)] @@ -415,7 +421,7 @@ pub struct ManifestAdapterBuild { pub target: Option, } -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestAdapterCommands { /// Per-project override for `edgezero auth login --adapter `. @@ -457,7 +463,7 @@ pub struct ManifestAdapterCommands { /// adapter sections, etc.) already reject legacy fields below this /// level, so adding the rejection HERE seals the only remaining /// silent-typo path. -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[serde(deny_unknown_fields)] #[non_exhaustive] pub struct ManifestStores { @@ -479,7 +485,7 @@ pub struct ManifestStores { /// tuning. Platform-specific runtime config (store names, tuning) is supplied /// out of band; in this interim model a store's name resolves to its logical /// [`StoreDeclaration::default_id`]. -#[derive(Debug, Deserialize, Validate)] +#[derive(Debug, Deserialize, Serialize, Validate)] #[non_exhaustive] #[validate(schema(function = "validate_store_declaration"))] pub struct StoreDeclaration { @@ -516,7 +522,7 @@ impl StoreDeclaration { // Logging (unchanged) // --------------------------------------------------------------------------- -#[derive(Debug, Default, Deserialize, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Validate)] #[non_exhaustive] pub struct ManifestLogging { #[serde(flatten)] @@ -524,7 +530,7 @@ pub struct ManifestLogging { pub adapters: BTreeMap, } -#[derive(Debug, Default, Deserialize, Clone, Validate)] +#[derive(Debug, Default, Deserialize, Serialize, Clone, Validate)] #[non_exhaustive] pub struct ManifestLoggingConfig { #[serde(default)] @@ -634,6 +640,13 @@ impl<'de> Deserialize<'de> for HttpMethod { } } +impl serde::Serialize for HttpMethod { + #[inline] + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + #[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum BodyMode { @@ -664,6 +677,16 @@ impl<'de> Deserialize<'de> for BodyMode { } } +impl serde::Serialize for BodyMode { + #[inline] + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(match self { + Self::Buffered => "buffered", + Self::Stream => "stream", + }) + } +} + #[derive(Clone, Copy, Debug, Eq, PartialEq, Default)] #[non_exhaustive] pub enum LogLevel { @@ -734,6 +757,46 @@ impl<'de> Deserialize<'de> for LogLevel { } } +impl serde::Serialize for LogLevel { + #[inline] + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +/// Serialize a `[[environment.secrets]]` list without exposing `value`. +/// Secret bindings share `ManifestBinding` with variables, whose `value` +/// is safe to emit; secret values must never appear in manifest output. +fn serialize_secrets(secrets: &[ManifestBinding], serializer: S) -> Result +where + S: serde::Serializer, +{ + use serde::ser::SerializeSeq as _; + + #[derive(Serialize)] + struct RedactedBinding { + #[serde(skip_serializing_if = "Vec::is_empty")] + adapters: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + env: Option, + name: String, + // `value` intentionally omitted. + } + + let mut seq = serializer.serialize_seq(Some(secrets.len()))?; + for binding in secrets { + seq.serialize_element(&RedactedBinding { + adapters: binding.adapters.clone(), + description: binding.description.clone(), + env: binding.env.clone(), + name: binding.name.clone(), + })?; + } + seq.end() +} + fn resolve_root_path(path: &Path, cwd: &Path) -> PathBuf { match path.parent() { Some(parent) if parent.as_os_str().is_empty() => cwd.to_path_buf(), @@ -996,6 +1059,72 @@ env = "APP_TOKEN" assert_eq!(manifest.app.name.as_deref(), Some("demo")); } + #[test] + fn serializes_manifest_and_redacts_secret_values() { + let toml = r#" +[app] +name = "t" +version = "0.1.0" +kind = "http" + +[[triggers.http]] +id = "root" +path = "/" +methods = ["GET"] +handler = "t::handlers::root" + +[[environment.variables]] +name = "LOG_LEVEL" +value = "info" + +[[environment.secrets]] +name = "API_TOKEN" +value = "super-secret-value" +"#; + let manifest: Manifest = toml::from_str(toml).unwrap(); + let json = serde_json::to_value(&manifest).unwrap(); + + // [app] version/kind round-trip + assert_eq!(json["app"]["version"], "0.1.0"); + assert_eq!(json["app"]["kind"], "http"); + // variables keep their value + assert_eq!(json["environment"]["variables"][0]["value"], "info"); + // secrets NEVER expose value + let secret = &json["environment"]["secrets"][0]; + assert_eq!(secret["name"], "API_TOKEN"); + assert!( + secret.get("value").is_none(), + "secret value must be redacted" + ); + // Enums serialize to their wire strings, not Rust variant names. + assert_eq!(json["triggers"]["http"][0]["methods"][0], "GET"); + } + + #[test] + fn serializes_enums_with_wire_casing() { + let toml = r#" +[app] +name = "t" + +[[triggers.http]] +id = "r" +path = "/" +methods = ["POST"] +handler = "t::h::r" +body-mode = "buffered" + +[logging.axum] +level = "info" +"#; + let manifest: Manifest = toml::from_str(toml).unwrap(); + let json = serde_json::to_value(&manifest).unwrap(); + assert_eq!(json["triggers"]["http"][0]["methods"][0], "POST"); + // `body_mode` is serde-renamed to `body-mode` (manifest.rs:243), so the + // serialized key is `body-mode`, NOT `body_mode`. + assert_eq!(json["triggers"]["http"][0]["body-mode"], "buffered"); + assert_eq!(json["logging"]["axum"]["level"], "info"); + } + #[test] fn try_load_from_str_rejects_invalid_toml() { let err = ManifestLoader::try_load_from_str("not a [valid manifest\n") From d09c068ca94a217898a0cf6af6d9d17827ad823d Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:31:04 -0700 Subject: [PATCH 08/29] Inject IntrospectionData at router dispatch chokepoint --- crates/edgezero-core/src/context.rs | 8 +++ crates/edgezero-core/src/router.rs | 101 +++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index 56fea46c..d3656e9f 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -3,6 +3,7 @@ use crate::error::EdgeError; use crate::http::Request; use crate::params::PathParams; use crate::proxy::ProxyHandle; +use crate::router::IntrospectionData; use crate::store_registry::{ BoundConfigStore, BoundKvStore, BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, @@ -90,6 +91,13 @@ impl RequestContext { self.request } + /// The per-request [`IntrospectionData`] injected by the router, if any. + #[must_use] + #[inline] + pub fn introspection(&self) -> Option<&IntrospectionData> { + self.request.extensions().get::() + } + /// # Errors /// Returns [`EdgeError::bad_request`] if the body is not valid JSON for `T`. #[inline] diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index 18e242d7..201b39d7 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -64,6 +64,15 @@ impl RouteInfo { } } +/// Per-request introspection payload injected by [`RouterInner::dispatch`]. +#[derive(Clone)] +pub struct IntrospectionData { + /// The app manifest serialized to JSON at compile time by `app!`. + pub manifest_json: Option>, + /// Every registered route, in registration order. + pub routes: Arc<[RouteInfo]>, +} + #[derive(Serialize)] struct RouteListingEntry { method: String, @@ -78,6 +87,7 @@ enum RouteMatch<'route> { #[derive(Default)] pub struct RouterBuilder { + manifest_json: Option>, middlewares: Vec, route_info: Vec, route_listing_path: Option, @@ -157,7 +167,12 @@ impl RouterBuilder { .unwrap_or_else(|err| panic!("duplicate route definition for {path}: {err}")); } - RouterService::new(self.routes, self.middlewares, route_index) + RouterService::new( + self.routes, + self.middlewares, + route_index, + self.manifest_json, + ) } #[must_use] @@ -255,16 +270,29 @@ impl RouterBuilder { self.add_route(path, method, handler); self } + + #[must_use] + #[inline] + pub fn with_manifest_json>>(mut self, json: S) -> Self { + self.manifest_json = Some(json.into()); + self + } } struct RouterInner { + manifest_json: Option>, middlewares: Vec, route_index: Arc<[RouteInfo]>, routes: HashMap>, } impl RouterInner { - async fn dispatch(&self, request: Request) -> Result { + async fn dispatch(&self, mut request: Request) -> Result { + request.extensions_mut().insert(IntrospectionData { + manifest_json: self.manifest_json.clone(), + routes: Arc::clone(&self.route_index), + }); + let method = request.method().clone(); let path = request.uri().path().to_owned(); @@ -344,9 +372,11 @@ impl RouterService { routes: HashMap>, middlewares: Vec, route_index: Arc<[RouteInfo]>, + manifest_json: Option>, ) -> Self { Self { inner: Arc::new(RouterInner { + manifest_json, middlewares, route_index, routes, @@ -764,6 +794,73 @@ mod tests { assert!(matches!(ready, Poll::Ready(Ok(())))); } + #[test] + fn dispatch_injects_introspection_data() { + let seen: Arc>> = Arc::new(Mutex::new(None)); + let seen_capture = Arc::clone(&seen); + + let handler = move |ctx: RequestContext| { + let seen_inner = Arc::clone(&seen_capture); + async move { + let data = ctx.introspection().expect("introspection data present"); + *seen_inner.lock().unwrap() = + Some((data.manifest_json.is_some(), data.routes.len())); + Ok::<_, EdgeError>("ok") + } + }; + + let router = RouterService::builder() + .with_manifest_json("{\"app\":{\"name\":\"t\"}}") + .get("/", handler) + .build(); + + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .unwrap(); + block_on(router.oneshot(request)).unwrap(); + + let (had_manifest, route_count) = seen.lock().unwrap().expect("handler ran"); + assert!(had_manifest, "manifest_json should be injected"); + assert_eq!(route_count, 1); + } + + #[test] + fn middleware_sees_introspection_data() { + struct Probe(Arc>); + #[async_trait::async_trait(?Send)] + impl Middleware for Probe { + async fn handle( + &self, + ctx: RequestContext, + next: Next<'_>, + ) -> Result { + *self.0.lock().unwrap() = ctx.introspection().is_some(); + next.run(ctx).await + } + } + + let saw = Arc::new(Mutex::new(false)); + let router = RouterService::builder() + .with_manifest_json("{}") + .middleware(Probe(Arc::clone(&saw))) + .get("/", |_ctx: RequestContext| async { + Ok::<_, EdgeError>("ok") + }) + .build(); + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .unwrap(); + block_on(router.oneshot(request)).unwrap(); + assert!( + *saw.lock().unwrap(), + "middleware should see introspection data" + ); + } + #[test] fn streams_body_through_router() { use bytes::Bytes; From beed0a40b78074189658126b904ad6250204079c Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:36:55 -0700 Subject: [PATCH 09/29] Add edgezero_core::introspection handlers (manifest/config/routes) --- crates/edgezero-core/src/introspection.rs | 264 ++++++++++++++++++++++ crates/edgezero-core/src/lib.rs | 5 + 2 files changed, 269 insertions(+) create mode 100644 crates/edgezero-core/src/introspection.rs diff --git a/crates/edgezero-core/src/introspection.rs b/crates/edgezero-core/src/introspection.rs new file mode 100644 index 00000000..dde4d715 --- /dev/null +++ b/crates/edgezero-core/src/introspection.rs @@ -0,0 +1,264 @@ +//! Framework-supplied introspection handlers. Bind via `[[triggers.http]]`: +//! `handler = "edgezero_core::introspection::manifest"` etc. + +use crate::blob_envelope::BlobEnvelope; +use crate::body::Body; +use crate::context::RequestContext; +use crate::error::EdgeError; +// NOTE: `Response` is an HTTP alias exported from `crate::http`, NOT +// `crate::response` (response.rs itself imports it from crate::http). +use crate::http::{response_builder, Response, StatusCode}; +use edgezero_core::action; +use serde::Serialize; + +#[derive(Serialize)] +struct RouteView { + method: String, + path: String, +} + +fn json_response(status: StatusCode, body: Body) -> Result { + response_builder() + .status(status) + .header("content-type", "application/json") + .body(body) + .map_err(EdgeError::internal) +} + +/// GET — the app manifest as JSON (baked at compile time by `app!`). +#[action] +pub async fn manifest(ctx: RequestContext) -> Result { + let json = ctx + .introspection() + .and_then(|data| data.manifest_json.clone()) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!("manifest introspection data missing")) + })?; + json_response(StatusCode::OK, Body::text(json.to_string())) +} + +/// GET — `[{ "method", "path" }]` for every registered route. +#[action] +pub async fn routes(ctx: RequestContext) -> Result { + let views: Vec = ctx + .introspection() + .map(|data| { + data.routes + .iter() + .map(|route| RouteView { + method: route.method().as_str().to_owned(), + path: route.path().to_owned(), + }) + .collect() + }) + .unwrap_or_default(); + let body = Body::json(&views).map_err(EdgeError::internal)?; + json_response(StatusCode::OK, body) +} + +/// GET — the default config-store envelope `data` (secret-safe: secret +/// fields remain unresolved key-name references). +#[action] +pub async fn config(ctx: RequestContext) -> Result { + let binding = ctx + .config_store_default_binding() + .ok_or_else(|| EdgeError::not_found("no default config store registered"))?; + // ConfigStoreError → EdgeError preserves 503/400/500 (see extractor.rs). + let raw = binding + .handle + .get(&binding.default_key) + .await + .map_err(EdgeError::from)? + .ok_or_else(|| EdgeError::not_found("no config blob in default store"))?; + let envelope: BlobEnvelope = serde_json::from_str(&raw) + .map_err(|err| EdgeError::internal(anyhow::anyhow!("envelope parse failed: {err}")))?; + envelope.verify().map_err(|err| { + EdgeError::internal(anyhow::anyhow!("envelope verification failed: {err}")) + })?; + let body = Body::json(&envelope.into_data()).map_err(EdgeError::internal)?; + json_response(StatusCode::OK, body) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; + use crate::http::{request_builder, Method, Response}; + use crate::router::RouterService; + use crate::store_registry::{ConfigRegistry, ConfigStoreBinding, StoreRegistry}; + use async_trait::async_trait; + use futures::executor::block_on; + use std::collections::BTreeMap; + use std::sync::Arc; + + // A config store returning a fixed result for `get`, used to drive the + // config handler's status-code mapping. Mirrors the pattern in + // extractor.rs::config_extractor_resolves_from_registry. + struct StubStore(Result, ConfigStoreError>); + #[async_trait(?Send)] + impl ConfigStore for StubStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + match &self.0 { + Ok(val) => Ok(val.clone()), + Err(ConfigStoreError::Unavailable { .. }) => { + Err(ConfigStoreError::unavailable("down")) + } + Err(ConfigStoreError::InvalidKey { .. }) => { + Err(ConfigStoreError::invalid_key("bad")) + } + Err(_) => Err(ConfigStoreError::internal(anyhow::anyhow!("boom"))), + } + } + } + + // Collect a buffered response body into JSON (introspection responses are + // always `Body::Once`). `Body::to_json` works on the buffered variant. + fn body_json(resp: Response) -> serde_json::Value { + resp.into_body().to_json().expect("buffered JSON body") + } + + // Build a request carrying a default ConfigRegistry backed by `store`, and + // drive it THROUGH THE ROUTER via `oneshot` (which maps handler `EdgeError` + // to a response internally — so we neither import `IntoResponse` nor unwrap + // an error path by hand). + fn run_config(store: StubStore) -> Response { + let registry: ConfigRegistry = StoreRegistry::new( + [( + "default".to_owned(), + ConfigStoreBinding { + handle: ConfigStoreHandle::new(Arc::new(store)), + default_key: "default".to_owned(), + }, + )] + .into_iter() + .collect::>(), + "default".to_owned(), + ); + let router = RouterService::builder().get("/c", config).build(); + let mut request = request_builder() + .method(Method::GET) + .uri("/c") + .body(Body::empty()) + .unwrap(); + request.extensions_mut().insert(registry); + block_on(router.oneshot(request)).unwrap() + } + + fn valid_envelope_json(data: serde_json::Value) -> String { + // Build a real envelope so sha/version are correct. + serde_json::to_string(&BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned())).unwrap() + } + + #[test] + fn manifest_returns_injected_json() { + let router = RouterService::builder() + .with_manifest_json("{\"app\":{\"name\":\"t\"}}") + .get("/m", manifest) + .build(); + let req = request_builder() + .method(Method::GET) + .uri("/m") + .body(Body::empty()) + .unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get("content-type").unwrap(), + "application/json" + ); + // Body is the injected manifest JSON verbatim. + assert_eq!( + body_json(resp), + serde_json::json!({ "app": { "name": "t" } }) + ); + } + + #[test] + fn routes_lists_registered_routes() { + let router = RouterService::builder().get("/r", routes).build(); + let req = request_builder() + .method(Method::GET) + .uri("/r") + .body(Body::empty()) + .unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + // Shape: [{ "method", "path" }] — the /r route itself is present. + let body = body_json(resp); + let arr = body.as_array().expect("routes array"); + assert!(arr + .iter() + .any(|entry| entry["method"] == "GET" && entry["path"] == "/r")); + } + + #[test] + fn config_without_store_is_not_found() { + let router = RouterService::builder().get("/c", config).build(); + let req = request_builder() + .method(Method::GET) + .uri("/c") + .body(Body::empty()) + .unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn config_happy_path_returns_envelope_data_secret_safe() { + let data = serde_json::json!({ "greeting": "hi", "api_token": "demo_api_token" }); + let resp = run_config(StubStore(Ok(Some(valid_envelope_json(data))))); + assert_eq!(resp.status(), StatusCode::OK); + // Raw envelope `data` verbatim: the secret field holds the KEY NAME, + // never a resolved value. + let body = body_json(resp); + assert_eq!(body["greeting"], "hi"); + assert_eq!(body["api_token"], "demo_api_token"); + } + + #[test] + fn config_missing_blob_is_not_found() { + let resp = run_config(StubStore(Ok(None))); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[test] + fn config_backend_unavailable_maps_503() { + let resp = run_config(StubStore(Err(ConfigStoreError::unavailable("x")))); + assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[test] + fn config_invalid_key_maps_400() { + let resp = run_config(StubStore(Err(ConfigStoreError::invalid_key("x")))); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn config_backend_internal_maps_500() { + let resp = run_config(StubStore(Err(ConfigStoreError::internal(anyhow::anyhow!( + "x" + ))))); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn config_malformed_envelope_maps_500() { + let resp = run_config(StubStore(Ok(Some("not json".to_owned())))); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn config_sha_mismatch_maps_500() { + // Valid JSON envelope shape but wrong sha → verify() fails. + let bad = r#"{"data":{"a":1},"generated_at":"t","sha256":"deadbeef","version":1}"#; + let resp = run_config(StubStore(Ok(Some(bad.to_owned())))); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + #[test] + fn config_unknown_version_maps_500() { + let bad = r#"{"data":{},"generated_at":"t","sha256":"x","version":99}"#; + let resp = run_config(StubStore(Ok(Some(bad.to_owned())))); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } +} diff --git a/crates/edgezero-core/src/lib.rs b/crates/edgezero-core/src/lib.rs index 0337e5e3..d88bd5d8 100644 --- a/crates/edgezero-core/src/lib.rs +++ b/crates/edgezero-core/src/lib.rs @@ -9,6 +9,10 @@ reason = "proc-macros must be re-exported through the parent crate" )] +// Required so `#[action]` handlers defined inside this crate resolve the +// absolute `::edgezero_core::…` paths the proc-macro emits. +extern crate self as edgezero_core; + pub mod addr; pub mod app; pub mod app_config; @@ -23,6 +27,7 @@ pub mod error; pub mod extractor; pub mod handler; pub mod http; +pub mod introspection; pub mod key_value_store; pub mod manifest; pub mod middleware; From 83dd8c45f85beec48c61a9ca79067a08d1029818 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:41:31 -0700 Subject: [PATCH 10/29] app! macro: bake manifest JSON into build_router via with_manifest_json --- crates/edgezero-macros/Cargo.toml | 1 + crates/edgezero-macros/src/app.rs | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/crates/edgezero-macros/Cargo.toml b/crates/edgezero-macros/Cargo.toml index c108d1ca..19fb9cfa 100644 --- a/crates/edgezero-macros/Cargo.toml +++ b/crates/edgezero-macros/Cargo.toml @@ -16,6 +16,7 @@ log = { workspace = true } proc-macro2 = "1" quote = "1" serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } syn = { version = "2", features = ["full"] } toml = { workspace = true } validator = { workspace = true, features = ["derive"] } diff --git a/crates/edgezero-macros/src/app.rs b/crates/edgezero-macros/src/app.rs index 4858d5a1..cddfbeaf 100644 --- a/crates/edgezero-macros/src/app.rs +++ b/crates/edgezero-macros/src/app.rs @@ -120,6 +120,15 @@ pub fn expand_app(input: TokenStream) -> TokenStream { } manifest.finalize(); + let manifest_json = match serde_json::to_string(&manifest) { + Ok(json) => json, + Err(err) => { + let msg = format!("failed to serialize manifest to JSON: {err}"); + return quote!(compile_error!(#msg);).into(); + } + }; + let manifest_json_lit = LitStr::new(&manifest_json, Span::call_site()); + let app_ident = args .app_ident .unwrap_or_else(|| Ident::new("App", Span::call_site())); @@ -170,6 +179,7 @@ pub fn expand_app(input: TokenStream) -> TokenStream { pub fn build_router() -> edgezero_core::router::RouterService { let mut builder = edgezero_core::router::RouterService::builder(); + builder = builder.with_manifest_json(#manifest_json_lit); #(#middleware_tokens)* #(#route_tokens)* builder.build() From 5ed4e8c7678b285c738087e70fecc89dd795cbb5 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:44:02 -0700 Subject: [PATCH 11/29] Use workspace deps for edgezero-macros proc-macro2/quote/syn Match the edgezero-cli precedent and the workspace-dependency convention. Adds quote to [workspace.dependencies]. --- Cargo.lock | 1 + Cargo.toml | 1 + crates/edgezero-macros/Cargo.toml | 6 +++--- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b49c91ae..2100fc07 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -829,6 +829,7 @@ dependencies = [ "proc-macro2", "quote", "serde", + "serde_json", "syn 2.0.117", "tempfile", "toml", diff --git a/Cargo.toml b/Cargo.toml index 1fea962c..09e1b085 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -69,6 +69,7 @@ sha2 = "0.10" similar = "2" simple_logger = "5" proc-macro2 = { version = "1", features = ["span-locations"] } +quote = "1" syn = { version = "2", features = ["full", "extra-traits", "visit"] } subtle = "2" # Pinned to the `~6.0` range (allows 6.0.x, blocks 6.1+) so a minor diff --git a/crates/edgezero-macros/Cargo.toml b/crates/edgezero-macros/Cargo.toml index 19fb9cfa..bb584cd5 100644 --- a/crates/edgezero-macros/Cargo.toml +++ b/crates/edgezero-macros/Cargo.toml @@ -13,11 +13,11 @@ proc-macro = true [dependencies] log = { workspace = true } -proc-macro2 = "1" -quote = "1" +proc-macro2 = { workspace = true } +quote = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -syn = { version = "2", features = ["full"] } +syn = { workspace = true } toml = { workspace = true } validator = { workspace = true, features = ["derive"] } From 9b3b820837090cc8d7f5364feaf3f98ace12d709 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:44:23 -0700 Subject: [PATCH 12/29] Sync app-demo Cargo.lock for edgezero-macros serde_json dep --- examples/app-demo/Cargo.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/app-demo/Cargo.lock b/examples/app-demo/Cargo.lock index 4eab9b78..0c8bb82e 100644 --- a/examples/app-demo/Cargo.lock +++ b/examples/app-demo/Cargo.lock @@ -871,6 +871,7 @@ dependencies = [ "proc-macro2", "quote", "serde", + "serde_json", "syn 2.0.118", "toml", "validator", From 2cd9b0c54edab6458c66b3ef52f1ae007a4b0fc6 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:47:35 -0700 Subject: [PATCH 13/29] Remove legacy route-listing machinery and /__edgezero/routes --- crates/edgezero-core/src/router.rs | 219 +---------------------------- 1 file changed, 4 insertions(+), 215 deletions(-) diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index 201b39d7..7baa346a 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -3,23 +3,16 @@ use std::sync::Arc; use std::task::{Context, Poll}; use matchit::Router as PathRouter; -use serde::Serialize; use tower_service::Service; -use crate::body::Body; use crate::context::RequestContext; use crate::error::EdgeError; use crate::handler::{BoxHandler, IntoHandler}; -use crate::http::{ - header::CONTENT_TYPE, response_builder, HandlerFuture, HeaderValue, Method, Request, Response, - ResponseBuilder, StatusCode, -}; +use crate::http::{HandlerFuture, Method, Request, Response}; use crate::middleware::{BoxMiddleware, Middleware, Next}; use crate::params::PathParams; use crate::response::IntoResponse as _; -pub const DEFAULT_ROUTE_LISTING_PATH: &str = "/__edgezero/routes"; - struct RouteEntry { handler: BoxHandler, } @@ -73,12 +66,6 @@ pub struct IntrospectionData { pub routes: Arc<[RouteInfo]>, } -#[derive(Serialize)] -struct RouteListingEntry { - method: String, - path: String, -} - enum RouteMatch<'route> { Found(&'route RouteEntry, PathParams), MethodNotAllowed(Vec), @@ -90,7 +77,6 @@ pub struct RouterBuilder { manifest_json: Option>, middlewares: Vec, route_info: Vec, - route_listing_path: Option, routes: HashMap>, } @@ -118,54 +104,10 @@ impl RouterBuilder { .push(RouteInfo::new(method, path.to_owned())); } - /// # Panics - /// Panics if a route is registered for both an explicit path and the route-listing path. - /// Both paths are programmer-supplied at build time; a duplicate is a routing-config bug - /// that should fail loudly before the binary ever serves traffic. - #[expect( - clippy::panic, - reason = "duplicate route is a build-time programmer error, not a runtime condition" - )] #[must_use] #[inline] - pub fn build(mut self) -> RouterService { - let listing_path = self.route_listing_path.clone(); - - let mut route_info = self.route_info.clone(); - if let Some(path) = &listing_path { - route_info.push(RouteInfo::new(Method::GET, path.clone())); - } - - let route_index: Arc<[RouteInfo]> = Arc::from(route_info); - - if let Some(path) = listing_path { - let outer_index = Arc::clone(&route_index); - let listing_handler = move |_ctx: RequestContext| { - let inner_index = Arc::clone(&outer_index); - async move { - let payload: Vec = inner_index - .iter() - .map(|route| RouteListingEntry { - method: route.method().as_str().to_owned(), - path: route.path().to_owned(), - }) - .collect(); - - build_listing_response(&payload, response_builder()) - } - }; - - self.routes - .entry(Method::GET) - .or_default() - .insert( - path.as_str(), - RouteEntry { - handler: listing_handler.into_handler(), - }, - ) - .unwrap_or_else(|err| panic!("duplicate route definition for {path}: {err}")); - } + pub fn build(self) -> RouterService { + let route_index: Arc<[RouteInfo]> = Arc::from(self.route_info); RouterService::new( self.routes, @@ -184,33 +126,6 @@ impl RouterBuilder { self.route(path, Method::DELETE, handler) } - #[must_use] - #[inline] - pub fn enable_route_listing(self) -> Self { - self.enable_route_listing_at(DEFAULT_ROUTE_LISTING_PATH) - } - - /// # Panics - /// Panics if `path` is empty or does not begin with `/`. - #[must_use] - #[inline] - pub fn enable_route_listing_at(mut self, path: S) -> Self - where - S: Into, - { - let route_listing_path = path.into(); - assert!( - !route_listing_path.is_empty(), - "route listing path cannot be empty" - ); - assert!( - route_listing_path.starts_with('/'), - "route listing path must begin with '/'" - ); - self.route_listing_path = Some(route_listing_path); - self - } - #[must_use] #[inline] pub fn get(self, path: &str, handler: H) -> Self @@ -403,19 +318,6 @@ impl RouterService { } } -fn build_listing_response( - payload: &T, - builder: ResponseBuilder, -) -> Result { - let body = Body::json(payload).map_err(EdgeError::internal)?; - let response = builder - .status(StatusCode::OK) - .header(CONTENT_TYPE, HeaderValue::from_static("application/json")) - .body(body) - .map_err(EdgeError::internal)?; - Ok(response) -} - #[cfg(test)] mod tests { use super::*; @@ -427,9 +329,7 @@ mod tests { use crate::response::response_with_body; use futures::executor::block_on; use futures::task::noop_waker_ref; - use serde::ser::Error as _; - use serde::{Deserialize, Serialize}; - use serde_json::json; + use serde::Deserialize; use std::sync::{Arc, Mutex}; use std::task::{Context, Poll}; @@ -646,117 +546,6 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); } - #[test] - #[should_panic(expected = "duplicate route definition")] - fn route_listing_duplicate_path_panics() { - let _service = RouterService::builder() - .enable_route_listing() - .get(DEFAULT_ROUTE_LISTING_PATH, ok_handler) - .build(); - } - - #[test] - fn route_listing_outputs_all_routes() { - async fn noop(_ctx: RequestContext) -> Result<(), EdgeError> { - Ok(()) - } - - let service = RouterService::builder() - .enable_route_listing() - .get("/health", noop) - .post("/items", noop) - .build(); - - let request = request_builder() - .method(Method::GET) - .uri(DEFAULT_ROUTE_LISTING_PATH) - .body(Body::empty()) - .expect("request"); - - let response = block_on(service.clone().call(request)).expect("response"); - assert_eq!(response.status(), StatusCode::OK); - - let body = response.body().as_bytes().expect("buffered"); - let payload: Vec = serde_json::from_slice(body).expect("json payload"); - - assert!(payload.contains(&json!({ - "method": "GET", - "path": DEFAULT_ROUTE_LISTING_PATH - }))); - assert!(payload.contains(&json!({ - "method": "GET", - "path": "/health" - }))); - assert!(payload.contains(&json!({ - "method": "POST", - "path": "/items" - }))); - - let routes = service.routes(); - assert!(routes - .iter() - .any(|route| route.path() == "/health" && *route.method() == Method::GET)); - - let health_request = request_builder() - .method(Method::GET) - .uri("/health") - .body(Body::empty()) - .expect("request"); - let health_response = block_on(service.clone().call(health_request)).expect("response"); - assert_eq!(health_response.status(), StatusCode::NO_CONTENT); - - let items_request = request_builder() - .method(Method::POST) - .uri("/items") - .body(Body::empty()) - .expect("request"); - let items_response = block_on(service.clone().call(items_request)).expect("response"); - assert_eq!(items_response.status(), StatusCode::NO_CONTENT); - } - - #[test] - #[should_panic(expected = "route listing path cannot be empty")] - fn route_listing_rejects_empty_path() { - let _builder = RouterService::builder().enable_route_listing_at(""); - } - - #[test] - #[should_panic(expected = "route listing path must begin with '/'")] - fn route_listing_rejects_missing_slash() { - let _builder = RouterService::builder().enable_route_listing_at("routes"); - } - - #[test] - fn route_listing_response_handles_builder_failure() { - #[derive(Serialize)] - struct Payload { - ok: bool, - } - - let builder = response_builder().header("bad\nname", "value"); - let err = - build_listing_response(&Payload { ok: true }, builder).expect_err("expected error"); - assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); - } - - #[test] - fn route_listing_response_handles_json_failure() { - struct FailingSerialize; - - impl Serialize for FailingSerialize { - fn serialize(&self, _serializer: S) -> Result - where - S: serde::Serializer, - { - Err(S::Error::custom("boom")) - } - } - - let err = build_listing_response(&FailingSerialize, response_builder()) - .expect_err("expected error"); - assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR); - } - #[test] fn route_matches_path_params() { #[derive(Deserialize)] From 68693891f527b80b2ce508cd414478505c8f5088 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:11:46 -0700 Subject: [PATCH 14/29] Wire default introspection triggers into app-demo and generated apps --- .../src/templates/root/edgezero.toml.hbs | 26 ++++++ .../crates/app-demo-core/src/handlers.rs | 83 ++++++++++++++++++- examples/app-demo/edgezero.toml | 26 ++++++ 3 files changed, 134 insertions(+), 1 deletion(-) diff --git a/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs b/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs index b4f1c78c..c8e53cfa 100644 --- a/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs +++ b/crates/edgezero-cli/src/templates/root/edgezero.toml.hbs @@ -52,6 +52,32 @@ methods = ["GET", "POST"] handler = "{{proj_core_mod}}::handlers::proxy_demo" adapters = [{{{adapter_list}}}] +# -- Introspection routes ------------------------------------------------------ + +[[triggers.http]] +id = "manifest" +path = "/_{{name}}/manifest" +methods = ["GET"] +handler = "edgezero_core::introspection::manifest" +adapters = [{{{adapter_list}}}] +description = "App manifest as JSON" + +[[triggers.http]] +id = "config" +path = "/_{{name}}/config" +methods = ["GET"] +handler = "edgezero_core::introspection::config" +adapters = [{{{adapter_list}}}] +description = "Effective app config (secret-safe)" + +[[triggers.http]] +id = "routes" +path = "/_{{name}}/routes" +methods = ["GET"] +handler = "edgezero_core::introspection::routes" +adapters = [{{{adapter_list}}}] +description = "Registered route table" + # -- Stores ---------------------------------------------------------------- # # `[stores.]` declares logical store ids only. `default` is required diff --git a/examples/app-demo/crates/app-demo-core/src/handlers.rs b/examples/app-demo/crates/app-demo-core/src/handlers.rs index a854c0c2..8358ac5c 100644 --- a/examples/app-demo/crates/app-demo-core/src/handlers.rs +++ b/examples/app-demo/crates/app-demo-core/src/handlers.rs @@ -308,6 +308,7 @@ pub async fn secrets_echo( mod tests { use super::*; use async_trait::async_trait; + use edgezero_core::blob_envelope::BlobEnvelope; use edgezero_core::body::Body; use edgezero_core::config_store::{ConfigStore, ConfigStoreError, ConfigStoreHandle}; use edgezero_core::context::RequestContext; @@ -318,7 +319,9 @@ mod tests { use edgezero_core::proxy::{ProxyClient, ProxyHandle, ProxyResponse}; use edgezero_core::response::IntoResponse as _; use edgezero_core::secret_store::{InMemorySecretStore, SecretHandle}; - use edgezero_core::store_registry::{ConfigStoreBinding, KvRegistry}; + use edgezero_core::store_registry::{ + ConfigRegistry, ConfigStoreBinding, KvRegistry, StoreRegistry, + }; use futures::executor::block_on; use std::collections::{BTreeMap, HashMap}; use std::sync::{Arc, Mutex}; @@ -419,6 +422,84 @@ mod tests { } } + struct FixedStore(String); + + #[async_trait(?Send)] + impl ConfigStore for FixedStore { + async fn get(&self, _key: &str) -> Result, ConfigStoreError> { + Ok(Some(self.0.clone())) + } + } + + #[test] + fn introspection_routes_are_wired() { + let router = crate::build_router(); + + // manifest: 200 + JSON body whose [app].name is "app-demo". + let manifest_req = request_builder() + .method(Method::GET) + .uri("/_app-demo/manifest") + .body(Body::empty()) + .unwrap(); + let manifest_resp = block_on(router.oneshot(manifest_req)).unwrap(); + assert_eq!(manifest_resp.status(), StatusCode::OK); + assert_eq!( + manifest_resp.headers().get("content-type").unwrap(), + "application/json" + ); + let manifest_body: serde_json::Value = manifest_resp.into_body().to_json().unwrap(); + assert_eq!(manifest_body["app"]["name"], "app-demo"); + + // routes: 200 + [{method,path}] including the root route. + let routes_req = request_builder() + .method(Method::GET) + .uri("/_app-demo/routes") + .body(Body::empty()) + .unwrap(); + let routes_resp = block_on(router.oneshot(routes_req)).unwrap(); + assert_eq!(routes_resp.status(), StatusCode::OK); + let routes_body: serde_json::Value = routes_resp.into_body().to_json().unwrap(); + let arr = routes_body.as_array().expect("routes array"); + assert!(arr + .iter() + .any(|entry| entry["method"] == "GET" && entry["path"] == "/")); + + // /config: seed a default config store with a valid envelope so a wired + // route returns 200 (a routing miss would be 404, proving nothing). + let data = serde_json::json!({ "greeting": "hi", "api_token": "demo_api_token" }); + let blob = + serde_json::to_string(&BlobEnvelope::new(data, "2026-01-01T00:00:00Z".to_owned())) + .unwrap(); + let registry: ConfigRegistry = StoreRegistry::new( + [( + "app_config".to_owned(), + ConfigStoreBinding { + handle: ConfigStoreHandle::new(Arc::new(FixedStore(blob))), + default_key: "app_config".to_owned(), + }, + )] + .into_iter() + .collect::>(), + "app_config".to_owned(), + ); + let mut config_req = request_builder() + .method(Method::GET) + .uri("/_app-demo/config") + .body(Body::empty()) + .unwrap(); + config_req.extensions_mut().insert(registry); + let config_resp = block_on(router.oneshot(config_req)).unwrap(); + assert_eq!( + config_resp.status(), + StatusCode::OK, + "/config should be wired and 200 with a store" + ); + // Raw envelope `data`: secret field holds the KEY NAME, not a resolved value. + let config_body: serde_json::Value = config_resp.into_body().to_json().unwrap(); + assert_eq!(config_body["api_token"], "demo_api_token"); + assert_eq!(config_body["greeting"], "hi"); + } + #[test] fn build_proxy_target_merges_segments_and_query() { let original = Uri::from_static("/proxy/status?foo=bar"); diff --git a/examples/app-demo/edgezero.toml b/examples/app-demo/edgezero.toml index bd8b93cd..f5462639 100644 --- a/examples/app-demo/edgezero.toml +++ b/examples/app-demo/edgezero.toml @@ -101,6 +101,32 @@ handler = "app_demo_core::handlers::kv_note_delete" adapters = ["axum", "cloudflare", "fastly", "spin"] description = "Delete a note by id" +# -- Introspection routes ------------------------------------------------------ + +[[triggers.http]] +id = "manifest" +path = "/_app-demo/manifest" +methods = ["GET"] +handler = "edgezero_core::introspection::manifest" +adapters = ["axum", "cloudflare", "fastly", "spin"] +description = "App manifest as JSON" + +[[triggers.http]] +id = "config" +path = "/_app-demo/config" +methods = ["GET"] +handler = "edgezero_core::introspection::config" +adapters = ["axum", "cloudflare", "fastly", "spin"] +description = "Effective app config (secret-safe)" + +[[triggers.http]] +id = "routes" +path = "/_app-demo/routes" +methods = ["GET"] +handler = "edgezero_core::introspection::routes" +adapters = ["axum", "cloudflare", "fastly", "spin"] +description = "Registered route table" + # -- Secrets demo route -------------------------------------------------------- [[triggers.http]] From adbc94de39b6eb2dfaa7fdb565a538c756a34cf2 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:15:55 -0700 Subject: [PATCH 15/29] Docs: replace route-listing with introspection routes --- docs/guide/roadmap.md | 2 +- docs/guide/routing.md | 47 +++++++++++++++++++++++++++---------------- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/docs/guide/roadmap.md b/docs/guide/roadmap.md index 6b92ac1f..492d8ba1 100644 --- a/docs/guide/roadmap.md +++ b/docs/guide/roadmap.md @@ -14,7 +14,7 @@ shift as the roadmap evolves. - Adapter behavior matrix: document which adapters buffer bodies, which preserve streaming, and where proxy headers/automatic decompression apply so expectations match runtime behavior. - Example coverage: add focused guides for `axum.toml`, manifest `description` fields, logging - precedence, and route listing + body-mode behavior to reduce ambiguity. + precedence, and introspection routes + body-mode behavior to reduce ambiguity. - Spin support: add first-class Spin adapter support and document how EdgeZero manifests mirror Spin-compatible deployments. - Provider additions: prototype a third adapter (e.g. AWS Lambda@Edge or Vercel Edge Functions) diff --git a/docs/guide/routing.md b/docs/guide/routing.md index 98649eed..a8c2e4fb 100644 --- a/docs/guide/routing.md +++ b/docs/guide/routing.md @@ -113,33 +113,46 @@ RouterService::builder() EdgeZero automatically returns `405 Method Not Allowed` for requests that match a path but use an unsupported method. -## Route Listing +## Introspection Routes -Enable route listing for debugging: +EdgeZero provides three bindable handlers in `edgezero_core::introspection` for debugging and runtime inspection: -```rust -let router = RouterService::builder() - .enable_route_listing() - .get("/hello", hello) - .build(); +- **`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 `/_/{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" ``` -This exposes a JSON endpoint at `/__edgezero/routes`: +The `routes` handler returns JSON like: ```json [ - { "method": "GET", "path": "/hello" }, - { "method": "GET", "path": "/__edgezero/routes" } + { "method": "GET", "path": "/_my-app/manifest" }, + { "method": "GET", "path": "/_my-app/config" }, + { "method": "GET", "path": "/_my-app/routes" } ] ``` -Customize the listing path: - -```rust -RouterService::builder() - .enable_route_listing_at("/debug/routes") -``` - ## Path Syntax EdgeZero uses matchit's path syntax: From 89996235d122807a1f5b18f34bb3d46c4c3cd7a3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 22:40:19 -0700 Subject: [PATCH 16/29] Track manifest as build input; strengthen introspection tests; doc caveat - emit `include_bytes!` const in app! output so edits to edgezero.toml trigger a Cargo rebuild without requiring a .rs change - strengthen middleware_sees_introspection_data to assert manifest_json presence and non-empty route list, mirroring dispatch test - add content-type assertion to routes_lists_registered_routes - add security caveat to routing.md Introspection Routes section noting endpoints are unauthenticated and environment.variables values are emitted --- crates/edgezero-core/src/introspection.rs | 4 ++++ crates/edgezero-core/src/router.rs | 16 ++++++++++------ crates/edgezero-macros/src/app.rs | 5 +++++ docs/guide/routing.md | 4 ++++ 4 files changed, 23 insertions(+), 6 deletions(-) diff --git a/crates/edgezero-core/src/introspection.rs b/crates/edgezero-core/src/introspection.rs index dde4d715..74138628 100644 --- a/crates/edgezero-core/src/introspection.rs +++ b/crates/edgezero-core/src/introspection.rs @@ -183,6 +183,10 @@ mod tests { .unwrap(); let resp = block_on(router.oneshot(req)).unwrap(); assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get("content-type").unwrap(), + "application/json" + ); // Shape: [{ "method", "path" }] — the /r route itself is present. let body = body_json(resp); let arr = body.as_array().expect("routes array"); diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index 7baa346a..d5ba3049 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -617,7 +617,7 @@ mod tests { #[test] fn middleware_sees_introspection_data() { - struct Probe(Arc>); + struct Probe(Arc>>); #[async_trait::async_trait(?Send)] impl Middleware for Probe { async fn handle( @@ -625,14 +625,16 @@ mod tests { ctx: RequestContext, next: Next<'_>, ) -> Result { - *self.0.lock().unwrap() = ctx.introspection().is_some(); + *self.0.lock().unwrap() = ctx + .introspection() + .map(|data| (data.manifest_json.is_some(), data.routes.len())); next.run(ctx).await } } - let saw = Arc::new(Mutex::new(false)); + let saw: Arc>> = Arc::new(Mutex::new(None)); let router = RouterService::builder() - .with_manifest_json("{}") + .with_manifest_json("{\"app\":{\"name\":\"t\"}}") .middleware(Probe(Arc::clone(&saw))) .get("/", |_ctx: RequestContext| async { Ok::<_, EdgeError>("ok") @@ -644,9 +646,11 @@ mod tests { .body(Body::empty()) .unwrap(); block_on(router.oneshot(request)).unwrap(); + let (had_manifest, route_count) = saw.lock().unwrap().expect("middleware ran"); + assert!(had_manifest, "middleware should see manifest_json"); assert!( - *saw.lock().unwrap(), - "middleware should see introspection data" + route_count > 0, + "middleware should see non-empty route list" ); } diff --git a/crates/edgezero-macros/src/app.rs b/crates/edgezero-macros/src/app.rs index cddfbeaf..9d52e3a8 100644 --- a/crates/edgezero-macros/src/app.rs +++ b/crates/edgezero-macros/src/app.rs @@ -149,12 +149,17 @@ pub fn expand_app(input: TokenStream) -> TokenStream { }; let stores_tokens = build_stores_tokens(&manifest); + let manifest_path_lit = LitStr::new(&manifest_path.to_string_lossy(), Span::call_site()); + // The emitted `Hooks` impl below explicitly defines `configure` and // `build_app` even though their bodies mirror the trait defaults. This is // required because `missing_trait_methods` (restriction = deny) forbids // relying on trait defaults in the impl. If `Hooks::configure` or // `Hooks::build_app` defaults change, update these emitted bodies to match. let output = quote! { + // Force a rebuild when the manifest file changes (include_bytes tracks it as a build input). + const _: &[u8] = include_bytes!(#manifest_path_lit); + pub struct #app_ident; impl edgezero_core::app::Hooks for #app_ident { diff --git a/docs/guide/routing.md b/docs/guide/routing.md index a8c2e4fb..ad5dea0b 100644 --- a/docs/guide/routing.md +++ b/docs/guide/routing.md @@ -143,6 +143,10 @@ methods = ["GET"] handler = "edgezero_core::introspection::routes" ``` +::: warning 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 From 56cca3420577dfd041da78fe38d8290ad886a7e5 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:56:51 -0700 Subject: [PATCH 17/29] Spec/plan: introspection access via independent extractors (no gating) --- .../plans/2026-07-02-introspection-routes.md | 96 +++++++++++++++++++ .../2026-07-01-introspection-routes-design.md | 24 +++++ 2 files changed, 120 insertions(+) diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md index 6aada2a7..df562c8a 100644 --- a/docs/superpowers/plans/2026-07-02-introspection-routes.md +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -1196,3 +1196,99 @@ gh pr ready 300 **Placeholder scan:** No TBD/TODO; every code step shows real code. Verification notes ("confirm `ConfigStoreError` constructor names", "verify `{{{adapter_list}}}` name", "match body-collection helper") are drift guardrails, not missing content. **Type consistency:** `IntrospectionData { manifest_json: Option>, routes: Arc<[RouteInfo]> }`, `with_manifest_json(impl Into>)`, and `introspection() -> Option<&IntrospectionData>` are used identically across Tasks 2/3/6. Handler names `manifest`/`config`/`routes` match the trigger `handler = "edgezero_core::introspection::…"` strings in Task 6. Manual enum `Serialize` (Task 1 Step 5a) matches the `Deserialize` wire forms. + +--- + +## Addendum (2026-07-02): independent typed extractors + +**Why:** Tasks 1–8 have `manifest`/`routes` read `ctx.introspection()` directly. +Per user direction, expose the injected data through independent typed +extractors instead — matching the `Json`/`Path`/`AppConfig` idiom and making each +handler's dependency explicit. **Nothing else changes:** injection stays +unconditional at `RouterInner::dispatch`, routes stay plain `[[triggers.http]]` +bindings (already mountable), and there is NO per-route gating, NO `RouteEntry` +flag, NO `app!` macro change. `config` is untouched (it uses the config store). + +Base: merge-ready branch head (after `8999623`). Single task, edgezero-core only. + +### Task 9: independent `ManifestJson` / `RouteTable` extractors + +**Files:** `crates/edgezero-core/src/introspection.rs` (add two extractors, refactor two handlers, update/extend colocated tests). + +- [ ] **Step 1 (RED): add extractor tests + refactor-target tests.** Keep the + existing `manifest_returns_injected_json` / `routes_lists_registered_routes` / + all `config_*` tests as-is (they drive through `oneshot`, so they still pass + once handlers use extractors). Add: + - `manifest_json_extractor_reads_injected` — build a router with the `manifest` + handler + `with_manifest_json("{\"app\":{}}")`, `oneshot`, assert 200 + body. + - `manifest_json_extractor_absent_is_500` — a router WITHOUT `with_manifest_json` + bound to `manifest` yields 500 (payload has `manifest_json: None`). + Run `cargo test -p edgezero-core introspection` — new tests fail to compile + (extractors absent). + +- [ ] **Step 2: add the extractors** in `introspection.rs`: +```rust +use crate::extractor::FromRequest; // match the crate's actual FromRequest path +use crate::router::RouteInfo; +use std::sync::Arc; + +/// Extractor for the baked manifest JSON (the `IntrospectionData` injected at +/// dispatch). Errors 500 if introspection data is absent. +pub struct ManifestJson(pub Arc); + +#[async_trait::async_trait(?Send)] +impl FromRequest for ManifestJson { + async fn from_request(ctx: &RequestContext) -> Result { + ctx.introspection() + .and_then(|d| d.manifest_json.clone()) + .map(ManifestJson) + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!( + "manifest introspection data not available" + ))) + } +} + +/// Extractor for the live route index. +pub struct RouteTable(pub Arc<[RouteInfo]>); + +#[async_trait::async_trait(?Send)] +impl FromRequest for RouteTable { + async fn from_request(ctx: &RequestContext) -> Result { + ctx.introspection() + .map(|d| RouteTable(Arc::clone(&d.routes))) + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!( + "route-table introspection data not available" + ))) + } +} +``` + (Confirm the `FromRequest` trait path and `async_trait` usage against + `extractor.rs`; match how existing extractors declare the impl.) + +- [ ] **Step 3: refactor the handlers** (`config` unchanged): +```rust +#[action] +pub async fn manifest(ManifestJson(json): ManifestJson) -> Result { + json_response(StatusCode::OK, Body::text(json.to_string())) +} + +#[action] +pub async fn routes(RouteTable(table): RouteTable) -> Result { + let views: Vec = table + .iter() + .map(|r| RouteView { method: r.method().as_str().to_owned(), path: r.path().to_owned() }) + .collect(); + json_response(StatusCode::OK, Body::json(&views).map_err(EdgeError::internal)?) +} +``` + +- [ ] **Step 4 (GREEN):** `cargo test -p edgezero-core introspection` (all pass, + incl. the existing body-shape assertions and the new extractor tests); then + `cargo test -p edgezero-core`, `cargo fmt`, `cargo clippy -p edgezero-core --all-targets -- -D warnings`. + +- [ ] **Step 5: app-demo unchanged but re-verify** — the triggers already bind + `edgezero_core::introspection::{manifest,config,routes}`; handler signatures + changed but the paths/behavior didn't. Run + `cd examples/app-demo && cargo test -p app-demo-core introspection_routes_are_wired`. + +- [ ] **Step 6: Commit** — `git commit -m "Expose introspection via ManifestJson/RouteTable extractors"` diff --git a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md index d61ef63f..761f2416 100644 --- a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md +++ b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md @@ -64,6 +64,30 @@ We want a single, consistent, "bind it yourself" mechanism for all three. in core. No process-global state; no per-adapter changes. 6. **Remove route listing** — delete the entire `enable_route_listing` machinery and `/__edgezero/routes`. +7. **Typed extractors for access (revised 2026-07-02)** — handlers access the + injected data through independent typed extractors, not `ctx.introspection()`. + See the Revision section. + +> **Revision — 2026-07-02: independent typed extractors.** +> The injection (Decision 5) and the dispatch chokepoint are unchanged — +> `IntrospectionData` is still injected at `RouterInner::dispatch`. The only +> change is how handlers *access* it: instead of reaching into +> `ctx.introspection()` directly (Component 4), the two handlers that need it +> declare the dependency via independent typed extractors, matching the +> `Json`/`Path`/`AppConfig` idiom: +> +> - `ManifestJson(pub Arc)` — the baked manifest JSON. Used by `manifest`. +> - `RouteTable(pub Arc<[RouteInfo]>)` — the live route index. Used by `routes`. +> +> Both implement `FromRequest`, read from the injected `IntrospectionData` via +> `ctx.introspection()`, and error (500 internal) if it is absent. `config` takes +> `RequestContext` and uses neither — it reads the default config store. +> +> No per-route gating, no `RouteEntry` flag, no `app!` macro changes, no builder +> methods: the routes remain plain `[[triggers.http]]` bindings (mountable by +> apps), and the extractor on the handler signature is the only thing that +> changes. Where this conflicts with Component 4's "handler reads +> `ctx.introspection()`", the Revision governs. ## Architecture From 0feb1947e0ce495496c4d050dcbab33326c8f760 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Wed, 1 Jul 2026 23:58:58 -0700 Subject: [PATCH 18/29] Expose introspection via ManifestJson/RouteTable extractors --- crates/edgezero-core/src/introspection.rs | 81 +++++++++++++++++------ 1 file changed, 62 insertions(+), 19 deletions(-) diff --git a/crates/edgezero-core/src/introspection.rs b/crates/edgezero-core/src/introspection.rs index 74138628..650b17b0 100644 --- a/crates/edgezero-core/src/introspection.rs +++ b/crates/edgezero-core/src/introspection.rs @@ -5,11 +5,15 @@ use crate::blob_envelope::BlobEnvelope; use crate::body::Body; use crate::context::RequestContext; use crate::error::EdgeError; +use crate::extractor::FromRequest; // NOTE: `Response` is an HTTP alias exported from `crate::http`, NOT // `crate::response` (response.rs itself imports it from crate::http). use crate::http::{response_builder, Response, StatusCode}; +use crate::router::RouteInfo; +use async_trait::async_trait; use edgezero_core::action; use serde::Serialize; +use std::sync::Arc; #[derive(Serialize)] struct RouteView { @@ -17,6 +21,42 @@ struct RouteView { path: String, } +/// Extractor for the baked manifest JSON carried in the request's +/// [`crate::router::IntrospectionData`]. Errors with 500 if the data is +/// absent (i.e. the router did not inject it). +pub struct ManifestJson(pub Arc); + +#[async_trait(?Send)] +impl FromRequest for ManifestJson { + #[inline] + async fn from_request(ctx: &RequestContext) -> Result { + ctx.introspection() + .and_then(|data| data.manifest_json.clone()) + .map(ManifestJson) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!("manifest introspection data not available")) + }) + } +} + +/// Extractor for the live route index carried in the request's +/// [`crate::router::IntrospectionData`]. Errors with 500 if the data is absent. +pub struct RouteTable(pub Arc<[RouteInfo]>); + +#[async_trait(?Send)] +impl FromRequest for RouteTable { + #[inline] + async fn from_request(ctx: &RequestContext) -> Result { + ctx.introspection() + .map(|data| RouteTable(Arc::clone(&data.routes))) + .ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "route-table introspection data not available" + )) + }) + } +} + fn json_response(status: StatusCode, body: Body) -> Result { response_builder() .status(status) @@ -27,31 +67,20 @@ fn json_response(status: StatusCode, body: Body) -> Result /// GET — the app manifest as JSON (baked at compile time by `app!`). #[action] -pub async fn manifest(ctx: RequestContext) -> Result { - let json = ctx - .introspection() - .and_then(|data| data.manifest_json.clone()) - .ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!("manifest introspection data missing")) - })?; +pub async fn manifest(ManifestJson(json): ManifestJson) -> Result { json_response(StatusCode::OK, Body::text(json.to_string())) } /// GET — `[{ "method", "path" }]` for every registered route. #[action] -pub async fn routes(ctx: RequestContext) -> Result { - let views: Vec = ctx - .introspection() - .map(|data| { - data.routes - .iter() - .map(|route| RouteView { - method: route.method().as_str().to_owned(), - path: route.path().to_owned(), - }) - .collect() +pub async fn routes(RouteTable(table): RouteTable) -> Result { + let views: Vec = table + .iter() + .map(|route| RouteView { + method: route.method().as_str().to_owned(), + path: route.path().to_owned(), }) - .unwrap_or_default(); + .collect(); let body = Body::json(&views).map_err(EdgeError::internal)?; json_response(StatusCode::OK, body) } @@ -173,6 +202,20 @@ mod tests { ); } + #[test] + fn manifest_without_baked_json_is_500() { + // No `with_manifest_json`: IntrospectionData is still injected, but + // `manifest_json` is None, so the `ManifestJson` extractor errors 500. + let router = RouterService::builder().get("/m", manifest).build(); + let req = request_builder() + .method(Method::GET) + .uri("/m") + .body(Body::empty()) + .unwrap(); + let resp = block_on(router.oneshot(req)).unwrap(); + assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + #[test] fn routes_lists_registered_routes() { let router = RouterService::builder().get("/r", routes).build(); From c9c6f341c6fb4f1df67eb02216b7a27786db8168 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:14:46 -0700 Subject: [PATCH 19/29] Spec/plan: per-route gated introspection injection (macro auto-flags, no edgezero.toml change) --- .../plans/2026-07-02-introspection-routes.md | 183 ++++++++++-------- .../2026-07-01-introspection-routes-design.md | 39 ++-- 2 files changed, 125 insertions(+), 97 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md index df562c8a..edbf0a76 100644 --- a/docs/superpowers/plans/2026-07-02-introspection-routes.md +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -1199,96 +1199,113 @@ gh pr ready 300 --- -## Addendum (2026-07-02): independent typed extractors - -**Why:** Tasks 1–8 have `manifest`/`routes` read `ctx.introspection()` directly. -Per user direction, expose the injected data through independent typed -extractors instead — matching the `Json`/`Path`/`AppConfig` idiom and making each -handler's dependency explicit. **Nothing else changes:** injection stays -unconditional at `RouterInner::dispatch`, routes stay plain `[[triggers.http]]` -bindings (already mountable), and there is NO per-route gating, NO `RouteEntry` -flag, NO `app!` macro change. `config` is untouched (it uses the config store). - -Base: merge-ready branch head (after `8999623`). Single task, edgezero-core only. - -### Task 9: independent `ManifestJson` / `RouteTable` extractors - -**Files:** `crates/edgezero-core/src/introspection.rs` (add two extractors, refactor two handlers, update/extend colocated tests). - -- [ ] **Step 1 (RED): add extractor tests + refactor-target tests.** Keep the - existing `manifest_returns_injected_json` / `routes_lists_registered_routes` / - all `config_*` tests as-is (they drive through `oneshot`, so they still pass - once handlers use extractors). Add: - - `manifest_json_extractor_reads_injected` — build a router with the `manifest` - handler + `with_manifest_json("{\"app\":{}}")`, `oneshot`, assert 200 + body. - - `manifest_json_extractor_absent_is_500` — a router WITHOUT `with_manifest_json` - bound to `manifest` yields 500 (payload has `manifest_json: None`). - Run `cargo test -p edgezero-core introspection` — new tests fail to compile - (extractors absent). - -- [ ] **Step 2: add the extractors** in `introspection.rs`: +--- + +## Addendum (2026-07-02): independent extractors + per-route gated injection + +Two increments over Tasks 1–8. **Task 9 (extractors) is already committed** +(`0feb194`): `ManifestJson`/`RouteTable` extractors added, `manifest`/`routes` +handlers refactored to use them (`config` unchanged). This addendum records that +plus the remaining gating work. + +**Task 10 (gating)** makes injection per-route instead of every-request, with NO +app-facing change: the `app!` macro auto-flags routes whose handler is in the +`edgezero_core::introspection::` namespace. No `[introspection]` section, no +`edgezero.toml` knob. + +Base: after `0feb194`. + +### Task 10a: per-route gating in the router (edgezero-core) + +**Files:** `crates/edgezero-core/src/router.rs` (+ its tests) + +- [ ] **Step 1: flag on `RouteEntry`** — add `needs_introspection: bool`; update the manual `Clone`/`clone_from` impls to copy it. +- [ ] **Step 2: `add_route` takes the flag; add `route_introspective`.** ```rust -use crate::extractor::FromRequest; // match the crate's actual FromRequest path -use crate::router::RouteInfo; -use std::sync::Arc; - -/// Extractor for the baked manifest JSON (the `IntrospectionData` injected at -/// dispatch). Errors 500 if introspection data is absent. -pub struct ManifestJson(pub Arc); - -#[async_trait::async_trait(?Send)] -impl FromRequest for ManifestJson { - async fn from_request(ctx: &RequestContext) -> Result { - ctx.introspection() - .and_then(|d| d.manifest_json.clone()) - .map(ManifestJson) - .ok_or_else(|| EdgeError::internal(anyhow::anyhow!( - "manifest introspection data not available" - ))) + fn add_route(&mut self, path: &str, method: Method, handler: H, needs_introspection: bool) + where H: IntoHandler { + let router = self.routes.entry(method.clone()).or_default(); + router + .insert(path, RouteEntry { handler: handler.into_handler(), needs_introspection }) + .unwrap_or_else(|err| panic!("duplicate route definition for {path}: {err}")); + self.route_info.push(RouteInfo::new(method, path.to_owned())); } -} -/// Extractor for the live route index. -pub struct RouteTable(pub Arc<[RouteInfo]>); - -#[async_trait::async_trait(?Send)] -impl FromRequest for RouteTable { - async fn from_request(ctx: &RequestContext) -> Result { - ctx.introspection() - .map(|d| RouteTable(Arc::clone(&d.routes))) - .ok_or_else(|| EdgeError::internal(anyhow::anyhow!( - "route-table introspection data not available" - ))) + pub fn route(mut self, path: &str, method: Method, handler: H) -> Self + where H: IntoHandler { self.add_route(path, method, handler, false); self } + + /// Register a route whose handler needs introspection data + /// (`ManifestJson`/`RouteTable`). Only such routes get `IntrospectionData` + /// injected at dispatch. The `app!` macro emits this automatically for + /// handlers in the `edgezero_core::introspection::` namespace. + #[must_use] + pub fn route_introspective(mut self, path: &str, method: Method, handler: H) -> Self + where H: IntoHandler { self.add_route(path, method, handler, true); self } +``` + (`get`/`post`/`put`/`delete` keep delegating to `route` → flag stays false.) +- [ ] **Step 3: precompute the payload once in `build()`** and store `introspection: Arc` on `RouterInner` (replacing the bare `manifest_json` field it currently threads): +```rust + pub fn build(self) -> RouterService { + let route_index: Arc<[RouteInfo]> = Arc::from(self.route_info); + let introspection = Arc::new(IntrospectionData { + manifest_json: self.manifest_json, + routes: Arc::clone(&route_index), + }); + RouterService::new(self.routes, self.middlewares, route_index, introspection) } -} ``` - (Confirm the `FromRequest` trait path and `async_trait` usage against - `extractor.rs`; match how existing extractors declare the impl.) - -- [ ] **Step 3: refactor the handlers** (`config` unchanged): + (Update `RouterService::new` to take `introspection: Arc`.) +- [ ] **Step 4: gate the insert in `dispatch`** — move it AFTER `find_route`, only for flagged routes: ```rust -#[action] -pub async fn manifest(ManifestJson(json): ManifestJson) -> Result { - json_response(StatusCode::OK, Body::text(json.to_string())) -} - -#[action] -pub async fn routes(RouteTable(table): RouteTable) -> Result { - let views: Vec = table - .iter() - .map(|r| RouteView { method: r.method().as_str().to_owned(), path: r.path().to_owned() }) - .collect(); - json_response(StatusCode::OK, Body::json(&views).map_err(EdgeError::internal)?) -} + async fn dispatch(&self, request: Request) -> Result { + let method = request.method().clone(); + let path = request.uri().path().to_owned(); + match self.find_route(&method, &path) { + RouteMatch::Found(entry, params) => { + let needs = entry.needs_introspection; + let mut request = request; + if needs { + request.extensions_mut().insert(Arc::clone(&self.introspection)); + } + let ctx = RequestContext::new(request, params); + let next = Next::new(&self.middlewares, entry.handler.as_ref()); + next.run(ctx).await + } + RouteMatch::MethodNotAllowed(mut allowed) => { + allowed.sort_by(|l, r| l.as_str().cmp(r.as_str())); + Err(EdgeError::method_not_allowed(&method, &allowed)) + } + RouteMatch::NotFound => Err(EdgeError::not_found(path)), + } + } +``` + `RequestContext::introspection()` (context.rs) now reads `Arc`: +```rust + pub fn introspection(&self) -> Option<&crate::router::IntrospectionData> { + self.request.extensions().get::>() + .map(|arc| arc.as_ref()) + } ``` +- [ ] **Step 5: tests** — the existing `dispatch_injects_introspection_data` / `middleware_sees_introspection_data` tests register their probe route with `.route_introspective("/", Method::GET, handler)`; ADD a negative test: a plain `.get("/x", handler)` route sees `ctx.introspection().is_none()`. Run `cargo test -p edgezero-core router introspection_data`, then full `cargo test -p edgezero-core`, `cargo fmt`, `cargo clippy -p edgezero-core --all-targets -- -D warnings`. +- [ ] **Step 6: introspection.rs tests** — the extractor tests that build routers with `.get("/m", manifest)` must switch to `.route_introspective("/m", Method::GET, manifest)` so the data is injected (else they now 500). Keep `manifest_without_baked_json_is_500` but make it register introspective WITHOUT `with_manifest_json` (data present, `manifest_json` None → 500). Add/keep a test that a NON-introspective binding of `manifest` yields 500 (no data injected). Run `cargo test -p edgezero-core introspection`. +- [ ] **Step 7: Commit** — `git commit -m "Gate introspection injection per route via route_introspective"` + +### Task 10b: `app!` macro auto-flags introspection routes -- [ ] **Step 4 (GREEN):** `cargo test -p edgezero-core introspection` (all pass, - incl. the existing body-shape assertions and the new extractor tests); then - `cargo test -p edgezero-core`, `cargo fmt`, `cargo clippy -p edgezero-core --all-targets -- -D warnings`. +**Files:** `crates/edgezero-macros/src/app.rs` (+ verify app-demo / generated project) -- [ ] **Step 5: app-demo unchanged but re-verify** — the triggers already bind - `edgezero_core::introspection::{manifest,config,routes}`; handler signatures - changed but the paths/behavior didn't. Run - `cd examples/app-demo && cargo test -p app-demo-core introspection_routes_are_wired`. +- [ ] **Step 1: emit `route_introspective` for introspection-namespace handlers.** In `build_route_tokens`, per trigger: +```rust +const INTROSPECTION_NS: &str = "edgezero_core::introspection::"; +let is_introspective = trigger.handler.as_deref().is_some_and(|h| h.starts_with(INTROSPECTION_NS)); +let register = if is_introspective { + quote! { builder = builder.route_introspective(#path_lit, #method_tok, #handler_path); } +} else { + quote! { builder = builder.route(#path_lit, #method_tok, #handler_path); } +}; +``` + Match the file's existing `#method_tok`/`#handler_path` construction and how it currently emits `get`/`post`/`route`. Keep non-introspection routes byte-for-byte as before. `with_manifest_json` + `include_bytes!` tracking stay as-is. +- [ ] **Step 2: verify** — `cargo build -p edgezero-macros && cargo test -p edgezero-macros`; `cargo fmt --all && cargo clippy -p edgezero-macros -p edgezero-core --all-targets -- -D warnings`; `cd examples/app-demo && cargo test -p app-demo-core introspection_routes_are_wired` (app-demo binds the three handlers → macro auto-flags manifest/routes; assertions unchanged; `/config` unflagged but works via config store); `cargo test -p edgezero-cli --test generated_project_builds -- --ignored`. +- [ ] **Step 3: Commit** — `git commit -m "app!: auto-flag introspection-namespace routes as introspective"` -- [ ] **Step 6: Commit** — `git commit -m "Expose introspection via ManifestJson/RouteTable extractors"` +### Task 11: full verification + whole-branch review before pushing to #300. diff --git a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md index 761f2416..2380cd7f 100644 --- a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md +++ b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md @@ -68,26 +68,37 @@ We want a single, consistent, "bind it yourself" mechanism for all three. injected data through independent typed extractors, not `ctx.introspection()`. See the Revision section. -> **Revision — 2026-07-02: independent typed extractors.** -> The injection (Decision 5) and the dispatch chokepoint are unchanged — -> `IntrospectionData` is still injected at `RouterInner::dispatch`. The only -> change is how handlers *access* it: instead of reaching into -> `ctx.introspection()` directly (Component 4), the two handlers that need it -> declare the dependency via independent typed extractors, matching the -> `Json`/`Path`/`AppConfig` idiom: +> **Revision — 2026-07-02: independent typed extractors + per-route gated injection.** +> Two changes to Decision 5 and Component 4: > +> **(1) Access via independent typed extractors.** The two handlers that need +> introspection data declare the dependency via extractors, matching the +> `Json`/`Path`/`AppConfig` idiom, instead of reaching into `ctx.introspection()`: > - `ManifestJson(pub Arc)` — the baked manifest JSON. Used by `manifest`. > - `RouteTable(pub Arc<[RouteInfo]>)` — the live route index. Used by `routes`. -> -> Both implement `FromRequest`, read from the injected `IntrospectionData` via +> Both implement `FromRequest`, read the injected `IntrospectionData` via > `ctx.introspection()`, and error (500 internal) if it is absent. `config` takes > `RequestContext` and uses neither — it reads the default config store. > -> No per-route gating, no `RouteEntry` flag, no `app!` macro changes, no builder -> methods: the routes remain plain `[[triggers.http]]` bindings (mountable by -> apps), and the extractor on the handler signature is the only thing that -> changes. Where this conflicts with Component 4's "handler reads -> `ctx.introspection()`", the Revision governs. +> **(2) Gated injection (supersedes Decision 5's "every request").** The router +> no longer injects `IntrospectionData` unconditionally. `RouteEntry` carries a +> `needs_introspection` flag; `RouterInner` precomputes a single +> `Arc` at `build()`; `dispatch` inserts a clone **only after +> matching a flagged route**. Non-introspection traffic (virtually all requests) +> pays nothing. No process-global state; each router owns its payload (so tests +> and multiple apps in one process stay independent). +> +> **How a route gets flagged — no app-facing change.** App authors write the +> same `[[triggers.http]]` row (`handler = "edgezero_core::introspection::…"`); +> the `app!` macro recognizes handlers in the `edgezero_core::introspection::` +> namespace and emits the flagged registration (`route_introspective`) +> automatically. There is NO `[introspection]` manifest section and no per-route +> knob in `edgezero.toml`. Manual `RouterBuilder` users opt in via +> `route_introspective(path, method, handler)`; a plain `.get(...)` binding of an +> introspection handler leaves the route unflagged and the extractor returns 500. +> +> Where this conflicts with Decision 5 ("every request") or Component 4 ("handler +> reads `ctx.introspection()`"), the Revision governs. ## Architecture From 692dd1781120c854b508fb8c033889e85d627fbb Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:43:24 -0700 Subject: [PATCH 20/29] Spec/plan: #[action(introspection)] opt-in drives gated injection (app! untouched) --- .../plans/2026-07-02-introspection-routes.md | 155 +++++++----------- .../2026-07-01-introspection-routes-design.md | 46 ++++-- 2 files changed, 88 insertions(+), 113 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md index edbf0a76..a198559a 100644 --- a/docs/superpowers/plans/2026-07-02-introspection-routes.md +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -1201,111 +1201,70 @@ gh pr ready 300 --- -## Addendum (2026-07-02): independent extractors + per-route gated injection +--- -Two increments over Tasks 1–8. **Task 9 (extractors) is already committed** -(`0feb194`): `ManifestJson`/`RouteTable` extractors added, `manifest`/`routes` -handlers refactored to use them (`config` unchanged). This addendum records that -plus the remaining gating work. +## Addendum (2026-07-02): `#[action(introspection)]` opt-in + gated injection -**Task 10 (gating)** makes injection per-route instead of every-request, with NO -app-facing change: the `app!` macro auto-flags routes whose handler is in the -`edgezero_core::introspection::` namespace. No `[introspection]` section, no -`edgezero.toml` knob. +Supersedes the earlier "unconditional inject" (Tasks 2/3) and the dropped +"app!-flagging" idea. **Task 9 (extractors) is already committed** (`0feb194`): +`ManifestJson`/`RouteTable` added; `manifest`/`routes` refactored to use them. +This addendum makes injection per-route via an explicit `#[action]` opt-in. -Base: after `0feb194`. +**Design:** `#[action]` (no args) unchanged → handler fn (reports +`needs_introspection()==false` via the `Fn` blanket `DynHandler` impl). +`#[action(introspection)]` → handler emitted as a unit struct with its own +`impl DynHandler` reporting `true`. The router reads `boxed.needs_introspection()` +at `add_route`, flags the `RouteEntry`, and `dispatch` injects `IntrospectionData` +only for flagged routes. `app!` and `edgezero.toml` are untouched. No global, no +`FromRequest` const, no unstable specialization. Blast radius confirmed zero +(only `manifest`/`routes` become structs; neither is ever called directly). -### Task 10a: per-route gating in the router (edgezero-core) +Base: after `0feb194`. -**Files:** `crates/edgezero-core/src/router.rs` (+ its tests) +### Task 10a: `DynHandler::needs_introspection` (edgezero-core/src/handler.rs) +- [ ] Add to the `DynHandler` trait: `fn needs_introspection(&self) -> bool { false }`. Object-safe (concrete `&self`→bool). The blanket `impl DynHandler for F` inherits the default. `cargo test -p edgezero-core`; fmt; clippy. +- [ ] Commit: `Add DynHandler::needs_introspection (default false)` -- [ ] **Step 1: flag on `RouteEntry`** — add `needs_introspection: bool`; update the manual `Clone`/`clone_from` impls to copy it. -- [ ] **Step 2: `add_route` takes the flag; add `route_introspective`.** +### Task 10b: `#[action(introspection)]` param + struct codegen (edgezero-macros/src/action.rs) +- [ ] Replace the `!attr.is_empty()` rejection with a parser: `Punctuated` via `Parser::parse2`. Empty → `needs_introspection=false`. `introspection` → true. Any other ident → `compile_error!` "unknown #[action] parameter `X`; supported: introspection". (`use syn::parse::Parser; use syn::punctuated::Punctuated; use syn::Token;`) +- [ ] When `needs_introspection` is false: emit exactly today's output (the handler fn) — unchanged. +- [ ] When true: emit the inner fn (unchanged) plus: ```rust - fn add_route(&mut self, path: &str, method: Method, handler: H, needs_introspection: bool) - where H: IntoHandler { - let router = self.routes.entry(method.clone()).or_default(); - router - .insert(path, RouteEntry { handler: handler.into_handler(), needs_introspection }) - .unwrap_or_else(|err| panic!("duplicate route definition for {path}: {err}")); - self.route_info.push(RouteInfo::new(method, path.to_owned())); - } +#(#attrs)* +#[allow(non_camel_case_types)] +#vis struct #ident; - pub fn route(mut self, path: &str, method: Method, handler: H) -> Self - where H: IntoHandler { self.add_route(path, method, handler, false); self } - - /// Register a route whose handler needs introspection data - /// (`ManifestJson`/`RouteTable`). Only such routes get `IntrospectionData` - /// injected at dispatch. The `app!` macro emits this automatically for - /// handlers in the `edgezero_core::introspection::` namespace. - #[must_use] - pub fn route_introspective(mut self, path: &str, method: Method, handler: H) -> Self - where H: IntoHandler { self.add_route(path, method, handler, true); self } -``` - (`get`/`post`/`put`/`delete` keep delegating to `route` → flag stays false.) -- [ ] **Step 3: precompute the payload once in `build()`** and store `introspection: Arc` on `RouterInner` (replacing the bare `manifest_json` field it currently threads): -```rust - pub fn build(self) -> RouterService { - let route_index: Arc<[RouteInfo]> = Arc::from(self.route_info); - let introspection = Arc::new(IntrospectionData { - manifest_json: self.manifest_json, - routes: Arc::clone(&route_index), - }); - RouterService::new(self.routes, self.middlewares, route_index, introspection) - } -``` - (Update `RouterService::new` to take `introspection: Arc`.) -- [ ] **Step 4: gate the insert in `dispatch`** — move it AFTER `find_route`, only for flagged routes: -```rust - async fn dispatch(&self, request: Request) -> Result { - let method = request.method().clone(); - let path = request.uri().path().to_owned(); - match self.find_route(&method, &path) { - RouteMatch::Found(entry, params) => { - let needs = entry.needs_introspection; - let mut request = request; - if needs { - request.extensions_mut().insert(Arc::clone(&self.introspection)); - } - let ctx = RequestContext::new(request, params); - let next = Next::new(&self.middlewares, entry.handler.as_ref()); - next.run(ctx).await - } - RouteMatch::MethodNotAllowed(mut allowed) => { - allowed.sort_by(|l, r| l.as_str().cmp(r.as_str())); - Err(EdgeError::method_not_allowed(&method, &allowed)) - } - RouteMatch::NotFound => Err(EdgeError::not_found(path)), - } - } -``` - `RequestContext::introspection()` (context.rs) now reads `Arc`: -```rust - pub fn introspection(&self) -> Option<&crate::router::IntrospectionData> { - self.request.extensions().get::>() - .map(|arc| arc.as_ref()) +impl ::edgezero_core::handler::DynHandler for #ident { + #[inline] + fn call(&self, __ctx: ::edgezero_core::context::RequestContext) + -> ::edgezero_core::http::HandlerFuture { + ::std::boxed::Box::pin(async move { + #(#extract_stmts)* + let result = #inner_ident(#(#arg_idents),*).await; + ::edgezero_core::responder::Responder::respond(result) + }) } + #[inline] + fn needs_introspection(&self) -> bool { true } +} ``` -- [ ] **Step 5: tests** — the existing `dispatch_injects_introspection_data` / `middleware_sees_introspection_data` tests register their probe route with `.route_introspective("/", Method::GET, handler)`; ADD a negative test: a plain `.get("/x", handler)` route sees `ctx.introspection().is_none()`. Run `cargo test -p edgezero-core router introspection_data`, then full `cargo test -p edgezero-core`, `cargo fmt`, `cargo clippy -p edgezero-core --all-targets -- -D warnings`. -- [ ] **Step 6: introspection.rs tests** — the extractor tests that build routers with `.get("/m", manifest)` must switch to `.route_introspective("/m", Method::GET, manifest)` so the data is injected (else they now 500). Keep `manifest_without_baked_json_is_500` but make it register introspective WITHOUT `with_manifest_json` (data present, `manifest_json` None → 500). Add/keep a test that a NON-introspective binding of `manifest` yields 500 (no data injected). Run `cargo test -p edgezero-core introspection`. -- [ ] **Step 7: Commit** — `git commit -m "Gate introspection injection per route via route_introspective"` - -### Task 10b: `app!` macro auto-flags introspection routes - -**Files:** `crates/edgezero-macros/src/app.rs` (+ verify app-demo / generated project) - -- [ ] **Step 1: emit `route_introspective` for introspection-namespace handlers.** In `build_route_tokens`, per trigger: -```rust -const INTROSPECTION_NS: &str = "edgezero_core::introspection::"; -let is_introspective = trigger.handler.as_deref().is_some_and(|h| h.starts_with(INTROSPECTION_NS)); -let register = if is_introspective { - quote! { builder = builder.route_introspective(#path_lit, #method_tok, #handler_path); } -} else { - quote! { builder = builder.route(#path_lit, #method_tok, #handler_path); } -}; -``` - Match the file's existing `#method_tok`/`#handler_path` construction and how it currently emits `get`/`post`/`route`. Keep non-introspection routes byte-for-byte as before. `with_manifest_json` + `include_bytes!` tracking stay as-is. -- [ ] **Step 2: verify** — `cargo build -p edgezero-macros && cargo test -p edgezero-macros`; `cargo fmt --all && cargo clippy -p edgezero-macros -p edgezero-core --all-targets -- -D warnings`; `cd examples/app-demo && cargo test -p app-demo-core introspection_routes_are_wired` (app-demo binds the three handlers → macro auto-flags manifest/routes; assertions unchanged; `/config` unflagged but works via config store); `cargo test -p edgezero-cli --test generated_project_builds -- --ignored`. -- [ ] **Step 3: Commit** — `git commit -m "app!: auto-flag introspection-namespace routes as introspective"` - -### Task 11: full verification + whole-branch review before pushing to #300. + (`__ctx` is underscore-prefixed → no unused-var warning when a handler uses no extractors.) +- [ ] Update the macro's own unit tests: `rejects_attribute_arguments` → now assert `#[action(bogus)]` yields "unknown #[action] parameter"; add a test that `#[action(introspection)]` output contains `struct` + `DynHandler` + `needs_introspection`; keep `wraps_async_function` (plain `#[action]` still a fn). `cargo test -p edgezero-macros`; fmt; clippy. +- [ ] Commit: `#[action]: accept (introspection) param; emit capability-carrying handler struct` + +### Task 10c: router gating + context accessor (edgezero-core/src/router.rs, context.rs) +- [ ] `RouteEntry { handler, needs_introspection: bool }` (+ manual Clone copies the bool). +- [ ] `add_route`: `let boxed = handler.into_handler(); let needs = boxed.needs_introspection(); RouteEntry { handler: boxed, needs_introspection: needs }`. (No `route_introspective` needed — the flag is read from the handler.) +- [ ] `build()`: precompute `let introspection = Arc::new(IntrospectionData { manifest_json: self.manifest_json, routes: Arc::clone(&route_index) });` and pass to `RouterService::new` (replace the `manifest_json` param). `RouterInner` stores `introspection: Arc`. +- [ ] `dispatch`: remove the unconditional insert; after `RouteMatch::Found(entry, params)`, `if entry.needs_introspection { request.extensions_mut().insert(Arc::clone(&self.introspection)); }` then build `RequestContext`. +- [ ] `context.rs::introspection()`: read `Arc` (`get::>().map(|a| a.as_ref())`). +- [ ] Tests: rewrite `dispatch_injects_introspection_data`/`middleware_sees_introspection_data` to register a local probe `struct` whose `DynHandler::needs_introspection()` returns true (closures report false), asserting `ctx.introspection().is_some()`; ADD a negative test that a plain `.get("/x", closure)` route sees `ctx.introspection().is_none()`. `cargo test -p edgezero-core`; fmt; clippy. +- [ ] Commit: `Gate IntrospectionData injection on RouteEntry.needs_introspection` + +### Task 10d: opt the handlers in (edgezero-core/src/introspection.rs) +- [ ] Change `manifest` and `routes` from `#[action]` to `#[action(introspection)]`. `config` stays `#[action]`. The existing extractor tests still pass (`.get("/m", manifest)` registers the struct; auto-flagged → injected → 200; `manifest_without_baked_json_is_500` still 500 because `manifest_json` is None). `cargo test -p edgezero-core introspection`. +- [ ] Commit: `Opt manifest/routes into introspection injection via #[action(introspection)]` + +### Task 11: verification + review +- [ ] Full gates: fmt; clippy `-D warnings`; `cargo test --workspace --all-targets`; `cd examples/app-demo && cargo test --workspace --all-targets` (all existing handlers unchanged → app-demo unaffected; `introspection_routes_are_wired` still passes); `cargo test -p edgezero-cli --test generated_project_builds -- --ignored`; nested-AppConfig audit; feature checks; wasm32-wasip2. +- [ ] Whole-branch review of the addendum commits, then push to #300. diff --git a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md index 2380cd7f..3b56e95b 100644 --- a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md +++ b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md @@ -80,22 +80,38 @@ We want a single, consistent, "bind it yourself" mechanism for all three. > `ctx.introspection()`, and error (500 internal) if it is absent. `config` takes > `RequestContext` and uses neither — it reads the default config store. > -> **(2) Gated injection (supersedes Decision 5's "every request").** The router -> no longer injects `IntrospectionData` unconditionally. `RouteEntry` carries a -> `needs_introspection` flag; `RouterInner` precomputes a single -> `Arc` at `build()`; `dispatch` inserts a clone **only after -> matching a flagged route**. Non-introspection traffic (virtually all requests) -> pays nothing. No process-global state; each router owns its payload (so tests -> and multiple apps in one process stay independent). +> **(2) Gated injection, driven by an explicit `#[action]` opt-in +> (supersedes Decision 5's "every request").** The router no longer injects +> `IntrospectionData` unconditionally. The capability is declared on the handler +> and rides it to registration — no `app!` change, no `edgezero.toml` change, no +> process-global, no unstable specialization: > -> **How a route gets flagged — no app-facing change.** App authors write the -> same `[[triggers.http]]` row (`handler = "edgezero_core::introspection::…"`); -> the `app!` macro recognizes handlers in the `edgezero_core::introspection::` -> namespace and emits the flagged registration (`route_introspective`) -> automatically. There is NO `[introspection]` manifest section and no per-route -> knob in `edgezero.toml`. Manual `RouterBuilder` users opt in via -> `route_introspective(path, method, handler)`; a plain `.get(...)` binding of an -> introspection handler leaves the route unflagged and the extractor returns 500. +> - **`#[action]` gains an optional parameter list.** `#[action]` (no args) is +> **unchanged** — it still expands to a handler fn, and via the `Fn` blanket +> `impl DynHandler` reports `needs_introspection() == false`. `#[action(introspection)]` +> expands the handler to a **unit struct** with its own `impl DynHandler` whose +> `needs_introspection()` returns `true`. The parameter list is future-proofed +> (`#[action(introspection, …)]`); unknown params are a compile error. A fn +> can't carry the flag past type-erasure, which is why opt-in handlers become +> structs — but that only affects handlers that opt in (here: `manifest`, +> `routes`); every other handler stays a fn, untouched. +> - **`DynHandler` gains `fn needs_introspection(&self) -> bool { false }`.** +> - **The router reads the flag generically at registration.** `add_route` calls +> `boxed.needs_introspection()` and stores it on `RouteEntry`. `RouterInner` +> precomputes a single `Arc` at `build()`; `dispatch` inserts +> a clone **only after matching a flagged route**. Non-introspection traffic +> (virtually all requests) pays nothing. Each router owns its payload, so tests +> and multiple apps in one process stay independent. +> - **The `ManifestJson`/`RouteTable` extractors are unchanged** — they remain the +> *access* mechanism (read `ctx.introspection()`); `#[action(introspection)]` is +> the *opt-in* that causes the data to be injected. `config` stays `#[action]` +> and uses neither. +> +> **No app-facing change.** App authors write the same `[[triggers.http]]` row; +> `manifest`/`routes` carry `#[action(introspection)]` in core, so the flag is +> intrinsic to those handlers. There is NO `[introspection]` manifest section, no +> per-route knob in `edgezero.toml`, and `app!` emits ordinary `builder.get(...)` +> — it never inspects handler paths. > > Where this conflicts with Decision 5 ("every request") or Component 4 ("handler > reads `ctx.introspection()`"), the Revision governs. From 9cb3b79953f06556894874190854dade1454502b Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:54:27 -0700 Subject: [PATCH 21/29] Docs cleanup: single gated architecture in spec; plan status banner + atomic #[action(manifest|routes)] TDD tasks --- .../plans/2026-07-02-introspection-routes.md | 240 +++++-- .../2026-07-01-introspection-routes-design.md | 594 +++++++++--------- 2 files changed, 473 insertions(+), 361 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md index a198559a..beaa7ad3 100644 --- a/docs/superpowers/plans/2026-07-02-introspection-routes.md +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -2,9 +2,25 @@ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. -**Goal:** Add three reusable core `#[action]` handlers — `edgezero_core::introspection::{manifest, config, routes}` — that any app binds via `[[triggers.http]]`, default-mounted at `/_/{manifest,config,routes}`. - -**Architecture:** The `app!` macro serializes the parsed manifest to JSON at expansion time and hands it to `RouterService::builder().with_manifest_json(...)`. `RouterInner::dispatch` injects an `IntrospectionData { manifest_json, routes }` extension into each request; the three core handlers read it (config reads the default config store instead). The legacy `enable_route_listing` machinery and `/__edgezero/routes` are removed. +> ## ⚠️ EXECUTION STATUS — READ FIRST +> +> **Tasks 1–9 below are COMPLETE and committed.** Do NOT re-implement them. +> +> **Tasks 2 and 3 describe the ORIGINAL architecture — UNCONDITIONAL per-request +> injection with handlers reading `ctx.introspection()` directly. That approach +> was SUPERSEDED.** They are retained only as the historical record of what was +> committed and then evolved. Do NOT implement Tasks 2/3 "as written"; the +> committed code has since been changed by the Addendum. +> +> **The ACTIVE, authoritative implementation path is the "Addendum (2026-07-02)" +> at the end of this document** — opt-in, per-route **gated** injection driven by +> `#[action(manifest|routes)]`, with typed extractors for access. It matches the +> rewritten spec. When the Addendum conflicts with the main body, the Addendum +> governs. See the spec's "Design evolution" section for the full path. + +**Goal:** Add three reusable core introspection handlers — `edgezero_core::introspection::{manifest, config, routes}` — that any app binds via `[[triggers.http]]`, default-mounted at `/_/{manifest,config,routes}`. + +**Architecture (final — see Addendum):** `Manifest` gains `Serialize`; the `app!` macro bakes it to JSON via `RouterService::builder().with_manifest_json(...)`. Handlers opt into introspection data with an atomic `#[action(manifest)]` / `#[action(routes)]` parameter, which expands them to capability-carrying handler structs; `add_route` reads `DynHandler::needs_introspection()` and flags the `RouteEntry`; `RouterInner::dispatch` injects a shared `Arc` **only for flagged routes**. `ManifestJson`/`RouteTable` extractors read it; `config` uses the default config store (no injection). `app!` and `edgezero.toml` never learn about introspection. The legacy `enable_route_listing` machinery and `/__edgezero/routes` are removed. **Tech Stack:** Rust 1.95 (edition 2021), `serde`/`serde_json`, `matchit` routing, `#[action]`/`app!` proc-macros, `futures::executor::block_on` for tests. WASM-first: no Tokio, no runtime-specific deps in core. @@ -1199,72 +1215,188 @@ gh pr ready 300 --- ---- - ---- - -## Addendum (2026-07-02): `#[action(introspection)]` opt-in + gated injection +## Addendum (2026-07-02): ACTIVE PATH — `#[action(manifest|routes)]` opt-in + gated injection -Supersedes the earlier "unconditional inject" (Tasks 2/3) and the dropped -"app!-flagging" idea. **Task 9 (extractors) is already committed** (`0feb194`): -`ManifestJson`/`RouteTable` added; `manifest`/`routes` refactored to use them. -This addendum makes injection per-route via an explicit `#[action]` opt-in. +**This supersedes Tasks 2 & 3's unconditional injection.** Task 9 (extractors) is +already committed (`0feb194`): `ManifestJson`/`RouteTable` extractors exist and +`manifest`/`routes` use them. The tasks below make injection **per-route gated** +via an atomic `#[action(...)]` opt-in, with `app!`/`edgezero.toml` untouched. -**Design:** `#[action]` (no args) unchanged → handler fn (reports -`needs_introspection()==false` via the `Fn` blanket `DynHandler` impl). -`#[action(introspection)]` → handler emitted as a unit struct with its own -`impl DynHandler` reporting `true`. The router reads `boxed.needs_introspection()` -at `add_route`, flags the `RouteEntry`, and `dispatch` injects `IntrospectionData` -only for flagged routes. `app!` and `edgezero.toml` are untouched. No global, no -`FromRequest` const, no unstable specialization. Blast radius confirmed zero -(only `manifest`/`routes` become structs; neither is ever called directly). +**Verified facts (from investigation):** +- `DynHandler` (`handler.rs`): `pub trait DynHandler: Send + Sync { fn call(&self, ctx: RequestContext) -> HandlerFuture; }`, blanket-impl'd for `F: Fn(RequestContext) -> Fut`. Object-safe with an added defaulted `needs_introspection`. `HandlerFuture = Pin> + 'static>>` (no `Send`). +- Handlers are invoked only via `DynHandler::call` (middleware `Next::run`), never as fns; only `manifest`/`routes` will become structs and neither is called directly → **zero blast radius**. +- `Responder::respond(self) -> Result`. Base: after `0feb194`. ### Task 10a: `DynHandler::needs_introspection` (edgezero-core/src/handler.rs) -- [ ] Add to the `DynHandler` trait: `fn needs_introspection(&self) -> bool { false }`. Object-safe (concrete `&self`→bool). The blanket `impl DynHandler for F` inherits the default. `cargo test -p edgezero-core`; fmt; clippy. -- [ ] Commit: `Add DynHandler::needs_introspection (default false)` -### Task 10b: `#[action(introspection)]` param + struct codegen (edgezero-macros/src/action.rs) -- [ ] Replace the `!attr.is_empty()` rejection with a parser: `Punctuated` via `Parser::parse2`. Empty → `needs_introspection=false`. `introspection` → true. Any other ident → `compile_error!` "unknown #[action] parameter `X`; supported: introspection". (`use syn::parse::Parser; use syn::punctuated::Punctuated; use syn::Token;`) -- [ ] When `needs_introspection` is false: emit exactly today's output (the handler fn) — unchanged. -- [ ] When true: emit the inner fn (unchanged) plus: +- [ ] **Step 1 — add the defaulted method.** In `handler.rs`, add to the trait: ```rust -#(#attrs)* -#[allow(non_camel_case_types)] -#vis struct #ident; +pub trait DynHandler: Send + Sync { + fn call(&self, ctx: RequestContext) -> HandlerFuture; -impl ::edgezero_core::handler::DynHandler for #ident { - #[inline] - fn call(&self, __ctx: ::edgezero_core::context::RequestContext) - -> ::edgezero_core::http::HandlerFuture { - ::std::boxed::Box::pin(async move { + /// Whether a route bound to this handler needs `IntrospectionData` injected + /// at dispatch. Default false; `#[action(manifest|routes)]` handlers override. + fn needs_introspection(&self) -> bool { + false + } +} +``` + The blanket `impl DynHandler for F` needs NO change (inherits the default). +- [ ] **Step 2 — verify.** `cargo build -p edgezero-core` (compiles; object safety intact). `cargo fmt -p edgezero-core`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. +- [ ] **Step 3 — commit:** `Add DynHandler::needs_introspection (default false)` + +### Task 10b: `#[action(manifest|routes)]` param parser + struct codegen (edgezero-macros/src/action.rs) + +- [ ] **Step 1 (RED) — macro unit tests.** Update/add tests in `action.rs`'s `#[cfg(test)]`: + - Replace `rejects_attribute_arguments` with `rejects_unknown_param`: input `#[action(bogus)]` on an async fn → rendered output contains `unknown #[action] parameter`. + - Add `introspection_param_emits_struct`: input `#[action(manifest)]` on `async fn manifest(...)` → rendered output contains `struct manifest` AND `DynHandler` AND `needs_introspection`. + - Keep `wraps_async_function` (plain `#[action]` still contains `fn demo` + `__demo_inner`). + Run `cargo test -p edgezero-macros` → the two new/changed tests FAIL (parser/codegen not present). +- [ ] **Step 2 — parse the param list.** Replace the current `if !attr.is_empty() { return Error…"does not accept arguments" }` guard with: +```rust +use syn::parse::Parser as _; +use syn::punctuated::Punctuated; + +let params: Punctuated = if attr.is_empty() { + Punctuated::new() +} else { + match Punctuated::::parse_terminated.parse2(attr.clone()) { + Ok(p) => p, + Err(err) => return err.to_compile_error(), + } +}; +let mut needs_introspection = false; +for param in ¶ms { + // Known atomic introspection capabilities. Extensible; all currently + // collapse to the single injected `IntrospectionData` bundle. + if param == "manifest" || param == "routes" { + needs_introspection = true; + } else { + return syn::Error::new( + param.span(), + format!("unknown #[action] parameter `{param}`; supported: manifest, routes"), + ) + .to_compile_error(); + } +} +``` +- [ ] **Step 3 — branch codegen.** Keep the existing `extract_stmts`/`arg_idents`/`inner_fn` computation. Then: +```rust +let output = if needs_introspection { + quote! { + #inner_fn + + #(#attrs)* + #[allow(non_camel_case_types)] + #vis struct #ident; + + impl ::edgezero_core::handler::DynHandler for #ident { + #[inline] + fn call(&self, __ctx: ::edgezero_core::context::RequestContext) + -> ::edgezero_core::http::HandlerFuture { + ::std::boxed::Box::pin(async move { + #(#extract_stmts)* + let result = #inner_ident(#(#arg_idents),*).await; + ::edgezero_core::responder::Responder::respond(result) + }) + } + #[inline] + fn needs_introspection(&self) -> bool { true } + } + } +} else { + // UNCHANGED from today — the fn form. + quote! { + #inner_fn + + #(#attrs)* + #vis async fn #ident( + __ctx: ::edgezero_core::context::RequestContext, + ) -> ::std::result::Result<::edgezero_core::http::Response, ::edgezero_core::error::EdgeError> { #(#extract_stmts)* let result = #inner_ident(#(#arg_idents),*).await; ::edgezero_core::responder::Responder::respond(result) + } + } +}; +output +``` +- [ ] **Step 4 (GREEN):** `cargo test -p edgezero-macros` (all pass). `cargo fmt --all`; `cargo clippy -p edgezero-macros --all-targets -- -D warnings`. +- [ ] **Step 5 — commit:** `#[action]: accept atomic (manifest|routes) params; emit capability-carrying handler struct` + +### Task 10c: router gating + accessor (edgezero-core/src/router.rs, context.rs) + +- [ ] **Step 1 (RED) — router tests.** In `router.rs` tests: + - Rewrite `dispatch_injects_introspection_data` and `middleware_sees_introspection_data` to register a LOCAL probe struct whose `DynHandler::needs_introspection()` returns `true` (a closure can't opt in), asserting `ctx.introspection().is_some()` from the handler and from a middleware respectively. + - Add `plain_route_gets_no_introspection`: a `.get("/x", |_ctx| async { Ok::<_,EdgeError>("ok") })` route asserts `ctx.introspection().is_none()`. + Example probe: +```rust +struct IntrospectiveProbe(std::sync::Arc>>); +impl crate::handler::DynHandler for IntrospectiveProbe { + fn call(&self, ctx: RequestContext) -> crate::http::HandlerFuture { + let cell = std::sync::Arc::clone(&self.0); + Box::pin(async move { + *cell.lock().unwrap() = Some(ctx.introspection().is_some()); + Ok("ok".into_response()?) // match the crate's response idiom }) } - #[inline] fn needs_introspection(&self) -> bool { true } } ``` - (`__ctx` is underscore-prefixed → no unused-var warning when a handler uses no extractors.) -- [ ] Update the macro's own unit tests: `rejects_attribute_arguments` → now assert `#[action(bogus)]` yields "unknown #[action] parameter"; add a test that `#[action(introspection)]` output contains `struct` + `DynHandler` + `needs_introspection`; keep `wraps_async_function` (plain `#[action]` still a fn). `cargo test -p edgezero-macros`; fmt; clippy. -- [ ] Commit: `#[action]: accept (introspection) param; emit capability-carrying handler struct` - -### Task 10c: router gating + context accessor (edgezero-core/src/router.rs, context.rs) -- [ ] `RouteEntry { handler, needs_introspection: bool }` (+ manual Clone copies the bool). -- [ ] `add_route`: `let boxed = handler.into_handler(); let needs = boxed.needs_introspection(); RouteEntry { handler: boxed, needs_introspection: needs }`. (No `route_introspective` needed — the flag is read from the handler.) -- [ ] `build()`: precompute `let introspection = Arc::new(IntrospectionData { manifest_json: self.manifest_json, routes: Arc::clone(&route_index) });` and pass to `RouterService::new` (replace the `manifest_json` param). `RouterInner` stores `introspection: Arc`. -- [ ] `dispatch`: remove the unconditional insert; after `RouteMatch::Found(entry, params)`, `if entry.needs_introspection { request.extensions_mut().insert(Arc::clone(&self.introspection)); }` then build `RequestContext`. -- [ ] `context.rs::introspection()`: read `Arc` (`get::>().map(|a| a.as_ref())`). -- [ ] Tests: rewrite `dispatch_injects_introspection_data`/`middleware_sees_introspection_data` to register a local probe `struct` whose `DynHandler::needs_introspection()` returns true (closures report false), asserting `ctx.introspection().is_some()`; ADD a negative test that a plain `.get("/x", closure)` route sees `ctx.introspection().is_none()`. `cargo test -p edgezero-core`; fmt; clippy. -- [ ] Commit: `Gate IntrospectionData injection on RouteEntry.needs_introspection` - -### Task 10d: opt the handlers in (edgezero-core/src/introspection.rs) -- [ ] Change `manifest` and `routes` from `#[action]` to `#[action(introspection)]`. `config` stays `#[action]`. The existing extractor tests still pass (`.get("/m", manifest)` registers the struct; auto-flagged → injected → 200; `manifest_without_baked_json_is_500` still 500 because `manifest_json` is None). `cargo test -p edgezero-core introspection`. -- [ ] Commit: `Opt manifest/routes into introspection injection via #[action(introspection)]` - -### Task 11: verification + review -- [ ] Full gates: fmt; clippy `-D warnings`; `cargo test --workspace --all-targets`; `cd examples/app-demo && cargo test --workspace --all-targets` (all existing handlers unchanged → app-demo unaffected; `introspection_routes_are_wired` still passes); `cargo test -p edgezero-cli --test generated_project_builds -- --ignored`; nested-AppConfig audit; feature checks; wasm32-wasip2. + Run `cargo test -p edgezero-core router introspection_data` → fails (flag/gating not present; `introspection()` type mismatch). +- [ ] **Step 2 — `RouteEntry` flag.** Add `needs_introspection: bool` and copy it in the manual `Clone`/`clone_from`. +- [ ] **Step 3 — read the flag in `add_route`:** +```rust +let boxed = handler.into_handler(); +let needs_introspection = boxed.needs_introspection(); +router.insert(path, RouteEntry { handler: boxed, needs_introspection }) + .unwrap_or_else(|err| panic!("duplicate route definition for {path}: {err}")); +``` +- [ ] **Step 4 — precompute the payload.** In `build()`: +```rust +let route_index: Arc<[RouteInfo]> = Arc::from(self.route_info); +let introspection = Arc::new(IntrospectionData { + manifest_json: self.manifest_json, + routes: Arc::clone(&route_index), +}); +RouterService::new(self.routes, self.middlewares, route_index, introspection) +``` + Change `RouterInner`'s `manifest_json: Option>` field to `introspection: Arc`, and `RouterService::new`'s last param likewise. +- [ ] **Step 5 — gate `dispatch`.** Remove the unconditional insert at the top; inside `RouteMatch::Found(entry, params)`: +```rust +let mut request = request; +if entry.needs_introspection { + request.extensions_mut().insert(::std::sync::Arc::clone(&self.introspection)); +} +let ctx = RequestContext::new(request, params); +``` + (`dispatch` reads `method`/`path` from `request` before the match, as today; make the bound `request` mutable in the arm.) +- [ ] **Step 6 — accessor.** `context.rs`: +```rust +pub fn introspection(&self) -> Option<&crate::router::IntrospectionData> { + self.request + .extensions() + .get::>() + .map(|arc| arc.as_ref()) +} +``` +- [ ] **Step 7 (GREEN):** `cargo test -p edgezero-core` (all pass). `cargo fmt`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. +- [ ] **Step 8 — commit:** `Gate IntrospectionData injection on RouteEntry.needs_introspection` + +### Task 10d: opt the handlers in + compile-level proof (edgezero-core/src/introspection.rs) + +- [ ] **Step 1 — opt in.** Change `manifest` from `#[action]` to `#[action(manifest)]`, and `routes` to `#[action(routes)]`. `config` stays `#[action]`. +- [ ] **Step 2 — compile/behavioral proof (this is the key test the reviewer asked for).** The existing `manifest_returns_injected_json` test already does `RouterService::builder().with_manifest_json("…").get("/m", manifest).build()` then `oneshot` → asserts 200 + body. Because `manifest` is now a UNIT STRUCT value, this test compiling and passing proves `.get("/m", manifest)` accepts the struct-as-handler and that `add_route` auto-flags it (else `oneshot` would 500 on the `ManifestJson` extractor). Keep it. Confirm `manifest_without_baked_json_is_500` still holds (route flagged → `IntrospectionData` injected, but `manifest_json` is `None` → extractor 500). Add `routes_returns_registered_routes` similarly if not already covered. +- [ ] **Step 3 — verify.** `cargo test -p edgezero-core introspection` (all pass, incl. body-shape assertions). +- [ ] **Step 4 — commit:** `Opt manifest/routes into gated injection via #[action(manifest|routes)]` + +### Task 11: full verification + whole-branch review + +- [ ] `cargo fmt --all -- --check`; `cargo clippy --workspace --all-targets --all-features -- -D warnings`; `cargo test --workspace --all-targets`. +- [ ] `cd examples/app-demo && cargo test --workspace --all-targets` (all other handlers unchanged → app-demo unaffected; `introspection_routes_are_wired` still passes: `manifest`/`routes` flagged, `config` via store). +- [ ] `cargo test -p edgezero-cli --test generated_project_builds -- --ignored` (template handlers are plain `#[action]` → still fns → generated project unaffected). +- [ ] `cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates`. +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"`; `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin`. - [ ] Whole-branch review of the addendum commits, then push to #300. diff --git a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md index 3b56e95b..490ddd52 100644 --- a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md +++ b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md @@ -1,31 +1,39 @@ # Design: Pluggable Introspection Routes (manifest / config / routes) -**Date:** 2026-07-01 -**Status:** Approved — ready for implementation planning +**Date:** 2026-07-01 (architecture finalized 2026-07-02) +**Status:** Approved — implementation in progress **Scope:** `edgezero-core`, `edgezero-macros`, `examples/app-demo`, `edgezero-cli` templates +> **Note on history.** This spec was rewritten on 2026-07-02 to describe the +> **final** architecture only: **opt-in, per-route gated injection driven by +> `#[action(manifest|routes)]`, with typed extractors for access.** Earlier drafts +> described unconditional per-request injection with handlers reading +> `ctx.introspection()` directly; that approach was superseded (it taxed all +> traffic for endpoints hit rarely). The "Design evolution" section at the end +> records the path taken. Where any older wording survives elsewhere, this +> document governs. + ## Summary -Provide three reusable, framework-supplied HTTP handlers that let any EdgeZero -app expose its own metadata at runtime: +Three reusable, framework-supplied HTTP handlers let any EdgeZero app expose its +own metadata at runtime: -| Handler path | Emits | -| --------------------------------------- | ------------------------------------------------- | -| `edgezero_core::introspection::manifest` | The full manifest as JSON (baked at compile time) | +| Handler path | Emits | +| ---------------------------------------- | ------------------------------------------------------- | +| `edgezero_core::introspection::manifest` | The full manifest as JSON (baked at compile time) | | `edgezero_core::introspection::config` | The default config-store envelope `.data` (secret-safe) | -| `edgezero_core::introspection::routes` | `[{ "method", "path" }]` from the live route index | +| `edgezero_core::introspection::routes` | `[{ "method", "path" }]` from the live route index | -These are ordinary handlers. Apps wire them like any other route via -`[[triggers.http]]` in `edgezero.toml`, choosing their own paths. There is **no** -special manifest section and **no** dedicated builder API. `app-demo` and every -generated app ship with the three routes pre-wired under a per-app namespace -`/_/{manifest,config,routes}` (e.g. `/_app-demo/manifest`), but those -are plain trigger rows a developer can edit or delete. +Apps bind them like any other route via `[[triggers.http]]`, choosing their own +paths. There is **no** `[introspection]` manifest section and **no** dedicated +builder API. `app-demo` and every generated app ship the three routes pre-wired +under `/_/{manifest,config,routes}`, as plain trigger rows a developer +can edit or delete. -This design also **removes** the existing built-in route-listing machinery +This design also **removes** the legacy built-in route-listing machinery (`enable_route_listing`, `enable_route_listing_at`, `DEFAULT_ROUTE_LISTING_PATH`, `/__edgezero/routes`, `RouteListingEntry`, `build_listing_response`) in favor of -the new bindable `routes` handler. +the bindable `routes` handler. ## Motivation @@ -36,362 +44,334 @@ Today there is no runtime way to inspect what an app *is*: `run_app(include_str!("edgezero.toml"), …)` shape, so a running adapter binary no longer carries the manifest. - The **app config** is reachable at runtime through the config store, but only - via the typed `AppConfig` extractor, which resolves secrets and requires the + via the typed `AppConfig` extractor, which resolves secrets and needs the app's concrete config type. -- The only built-in introspection is an opt-in route listing at - `/__edgezero/routes`, wired through a bespoke builder method - (`enable_route_listing`) rather than the normal routing path. +- The only built-in introspection is a route listing at `/__edgezero/routes`, + wired through a bespoke builder method rather than the normal routing path. -We want a single, consistent, "bind it yourself" mechanism for all three. +We want a single, consistent, "bind it yourself" mechanism for all three — that +costs nothing for the ~100% of requests that are not introspection calls. -## Key Decisions (resolved during design) +## Key Decisions 1. **Manifest output** — bake the full manifest as JSON. `Manifest` gains `Serialize`; the `app!` macro serializes the parsed manifest at expansion time - and hands the JSON string to the router. -2. **Config output** — emit the raw config-store `BlobEnvelope.data`. This is - generic (core needs no knowledge of the app's typed config `C`) and - secret-safe: secret fields appear as unresolved key-name references, never - resolved values (resolution only happens inside the typed `AppConfig` - extractor). -3. **Wiring** — plain `[[triggers.http]]` bindings referencing stable core - handler paths. No `[introspection]` manifest section; no builder methods. -4. **Paths** — per-app namespace `/_/{manifest,config,routes}` - (single underscore). These are just the default paths written into the - templates; the developer controls them. -5. **Injection, not a global** — the app-specific data (manifest JSON + route - index) is injected into the request at the shared router dispatch chokepoint - in core. No process-global state; no per-adapter changes. -6. **Remove route listing** — delete the entire `enable_route_listing` machinery - and `/__edgezero/routes`. -7. **Typed extractors for access (revised 2026-07-02)** — handlers access the - injected data through independent typed extractors, not `ctx.introspection()`. - See the Revision section. - -> **Revision — 2026-07-02: independent typed extractors + per-route gated injection.** -> Two changes to Decision 5 and Component 4: -> -> **(1) Access via independent typed extractors.** The two handlers that need -> introspection data declare the dependency via extractors, matching the -> `Json`/`Path`/`AppConfig` idiom, instead of reaching into `ctx.introspection()`: -> - `ManifestJson(pub Arc)` — the baked manifest JSON. Used by `manifest`. -> - `RouteTable(pub Arc<[RouteInfo]>)` — the live route index. Used by `routes`. -> Both implement `FromRequest`, read the injected `IntrospectionData` via -> `ctx.introspection()`, and error (500 internal) if it is absent. `config` takes -> `RequestContext` and uses neither — it reads the default config store. -> -> **(2) Gated injection, driven by an explicit `#[action]` opt-in -> (supersedes Decision 5's "every request").** The router no longer injects -> `IntrospectionData` unconditionally. The capability is declared on the handler -> and rides it to registration — no `app!` change, no `edgezero.toml` change, no -> process-global, no unstable specialization: -> -> - **`#[action]` gains an optional parameter list.** `#[action]` (no args) is -> **unchanged** — it still expands to a handler fn, and via the `Fn` blanket -> `impl DynHandler` reports `needs_introspection() == false`. `#[action(introspection)]` -> expands the handler to a **unit struct** with its own `impl DynHandler` whose -> `needs_introspection()` returns `true`. The parameter list is future-proofed -> (`#[action(introspection, …)]`); unknown params are a compile error. A fn -> can't carry the flag past type-erasure, which is why opt-in handlers become -> structs — but that only affects handlers that opt in (here: `manifest`, -> `routes`); every other handler stays a fn, untouched. -> - **`DynHandler` gains `fn needs_introspection(&self) -> bool { false }`.** -> - **The router reads the flag generically at registration.** `add_route` calls -> `boxed.needs_introspection()` and stores it on `RouteEntry`. `RouterInner` -> precomputes a single `Arc` at `build()`; `dispatch` inserts -> a clone **only after matching a flagged route**. Non-introspection traffic -> (virtually all requests) pays nothing. Each router owns its payload, so tests -> and multiple apps in one process stay independent. -> - **The `ManifestJson`/`RouteTable` extractors are unchanged** — they remain the -> *access* mechanism (read `ctx.introspection()`); `#[action(introspection)]` is -> the *opt-in* that causes the data to be injected. `config` stays `#[action]` -> and uses neither. -> -> **No app-facing change.** App authors write the same `[[triggers.http]]` row; -> `manifest`/`routes` carry `#[action(introspection)]` in core, so the flag is -> intrinsic to those handlers. There is NO `[introspection]` manifest section, no -> per-route knob in `edgezero.toml`, and `app!` emits ordinary `builder.get(...)` -> — it never inspects handler paths. -> -> Where this conflicts with Decision 5 ("every request") or Component 4 ("handler -> reads `ctx.introspection()`"), the Revision governs. + and hands the JSON string to the router (`with_manifest_json`). +2. **Config output** — emit the raw config-store `BlobEnvelope.data`. Generic + (core needs no knowledge of the app's typed config `C`) and secret-safe: + secret fields stay as unresolved key-name references; resolution happens only + inside the typed `AppConfig` extractor. +3. **Wiring** — plain `[[triggers.http]]` bindings to stable core handler paths. + No `[introspection]` section, no builder enable-API, and the `app!` macro does + **not** inspect handler paths. +4. **Paths** — per-app namespace `/_/{manifest,config,routes}` (single + underscore); just the default paths written into the templates. +5. **Access via typed extractors** — handlers that need injected data declare it + in their signature, matching the `Json`/`Path`/`AppConfig` idiom: + - `ManifestJson(pub Arc)` — the baked manifest JSON (used by `manifest`). + - `RouteTable(pub Arc<[RouteInfo]>)` — the live route index (used by `routes`). + Both implement `FromRequest`, read the injected `IntrospectionData` via + `ctx.introspection()`, and return `500` if it is absent. `config` takes + `RequestContext` and uses neither. +6. **Opt-in, per-route gated injection driven by `#[action(...)]`** — the router + injects `IntrospectionData` **only for routes whose handler opted in**, never + for general traffic. The opt-in is an atomic `#[action]` parameter and the + capability rides the handler to registration (details in Component 2/3). No + process-global state, no unstable specialization, no `app!`/`edgezero.toml` + change. +7. **Remove route listing** — delete the `enable_route_listing` machinery and + `/__edgezero/routes`. ## Architecture ### Data flow ``` -compile time runtime (per request) ------------- --------------------- +compile time runtime +------------ ------- edgezero.toml - │ app!() macro parses Manifest + │ app!() parses Manifest │ serde_json::to_string(&manifest) ▼ build_router() - builder.with_manifest_json("{…}") RouterService::oneshot(req) - │ └─ RouterInner::dispatch(req) - ▼ │ req.extensions_mut().insert( -RouterInner { manifest_json, │ IntrospectionData { - route_index, … } │ manifest_json, routes }) - ▼ - handler reads ctx.introspection() - manifest → returns baked JSON - routes → projects route index - config → reads default config store + builder.with_manifest_json("{…}") RouterService::oneshot(req) + builder.get(path, introspection::routes) └─ RouterInner::dispatch(req) + │ │ find_route(req) → RouteEntry + ▼ │ if entry.needs_introspection: +RouterInner { │ req.extensions.insert( + introspection: Arc, ──────┼────► Arc::clone(introspection)) +} (built once) ▼ + handler runs; extractor reads + ctx.introspection(): + manifest → ManifestJson(json) + routes → RouteTable(index) + config → default config store (no injection) ``` +- **The opt-in is on the handler.** `#[action(manifest)]` / `#[action(routes)]` + expand the handler to a capability-carrying struct (below). `add_route` reads + that capability and flags the `RouteEntry`. `dispatch` injects the shared + `Arc` only for flagged routes. - **manifest**: parsed at compile time, re-serialized to JSON by the macro, - baked as a string literal into `build_router()`, stored on `RouterInner`, - injected into each request, returned verbatim. No runtime TOML dependency. -- **routes**: derived at request time from the live route index already held by - `RouterInner` (the actually-registered routes, not a manifest projection). -- **config**: read at request time from the default config store; independent of - the manifest JSON. - -### Component 1 — `Manifest: Serialize` (`edgezero-core/src/manifest.rs`) - -Add `Serialize` to the derive list on `Manifest` and every nested struct that -must appear in the output (`ManifestApp`, `ManifestTriggers`, -`ManifestHttpTrigger`, `ManifestEnvironment`, `ManifestBinding`, -`ManifestAdapter` and its sub-structs, `ManifestLogging*`, `ManifestStores`, -`StoreDeclaration`, etc.). - -- Internal-only fields already carry `#[serde(skip)]` (`root`, - `logging_resolved`) and stay out of the output. -- Secret **values** are never stored in the manifest — only binding - declarations (name / env / description) — so serialized output is secret-safe. -- Verify round-trip is not required; this is a one-way (serialize-for-output) - addition. Existing `Deserialize`/`Validate` behavior is unchanged. - -### Component 2 — Router injection (`edgezero-core/src/router.rs`) - -New public struct carrying the per-request introspection payload: + baked into `build_router()`, held on the router's `IntrospectionData`, injected + for `manifest`-flagged routes, returned verbatim. No runtime TOML dependency. +- **routes**: projected at request time from the live route index in + `IntrospectionData`. +- **config**: read from the default config store; needs no injection. + +### Component 1 — `Manifest: Serialize` (`edgezero-core/src/manifest.rs`) *(done)* + +`Serialize` added to `Manifest` and every nested struct that appears in output. +The enums `HttpMethod`/`BodyMode`/`LogLevel` get **manual** `Serialize` impls +mirroring their manual `Deserialize` (wire strings `"GET"`/`"buffered"`/`"info"`, +and `body_mode` serializes as the renamed key `body-mode`). Secret **values** are +never serialized: `environment.secrets` entries omit `value` via a +`serialize_with` redactor; `environment.variables` keep it. Internal fields +(`root`, `logging_resolved`) stay `#[serde(skip)]`. + +### Component 2 — `#[action]` opt-in + capability-carrying handlers (`edgezero-macros/src/action.rs`) + +`#[action]` gains an **optional atomic parameter list** naming the introspection +data the handler needs: + +- **`#[action]`** (no params) — unchanged. Expands to a handler **fn**, which via + the existing `Fn` blanket `impl DynHandler` reports `needs_introspection() == false`. +- **`#[action(manifest)]`, `#[action(routes)]`, `#[action(manifest, routes)]`** — + expand the handler to a **unit struct** with its own `impl DynHandler` whose + `needs_introspection()` returns `true`. (A fn can't carry a per-item flag past + type-erasure into `Arc`; a struct can. Only opt-in handlers + become structs; every other handler stays a fn.) + +The macro validates each param against the known set `{ manifest, routes }` and +emits `compile_error!` on an unknown ident. The set is extensible (future atomic +capabilities are new idents). The atomic names are the declarative surface; since +`IntrospectionData` is one cheap `Arc` bundle, all recognized capabilities +currently collapse to the single `needs_introspection()` gate (room to split +payloads later without an attribute change). + +Generated struct (paths absolute, matching the existing macro): ```rust -#[derive(Clone)] -pub struct IntrospectionData { - pub manifest_json: Option>, - pub routes: Arc<[RouteInfo]>, +#inner_fn // the user's body, unchanged + +#(#attrs)* +#[allow(non_camel_case_types)] +#vis struct #ident; + +impl ::edgezero_core::handler::DynHandler for #ident { + #[inline] + fn call(&self, __ctx: ::edgezero_core::context::RequestContext) + -> ::edgezero_core::http::HandlerFuture { + ::std::boxed::Box::pin(async move { + #(#extract_stmts)* // ::from_request(&__ctx).await? + let result = #inner_ident(#(#arg_idents),*).await; + ::edgezero_core::responder::Responder::respond(result) + }) + } + #[inline] + fn needs_introspection(&self) -> bool { true } } ``` -Changes: +### Component 3 — Router gating + accessor (`edgezero-core/src/{handler,router,context}.rs`) -- `RouterInner` gains `manifest_json: Option>`. -- `RouterBuilder` gains `manifest_json: Option>` plus a setter: +- **`DynHandler`** gains `fn needs_introspection(&self) -> bool { false }` + (object-safe; the `Fn` blanket impl inherits the default). +- **`RouteEntry`** gains `needs_introspection: bool`. `add_route` reads it from + the boxed handler at registration: ```rust - pub fn with_manifest_json>>(mut self, json: S) -> Self { … } + let boxed = handler.into_handler(); + let needs_introspection = boxed.needs_introspection(); + router.insert(path, RouteEntry { handler: boxed, needs_introspection }); ``` - `build()` threads it into `RouterService::new(...)` / `RouterInner`. -- `RouterInner::dispatch(mut req)` inserts the extension **before** middleware and - routing: +- **`RouterInner`** holds a precomputed `introspection: Arc`, + built once in `build()` from `self.manifest_json` + the route index. + `RouterBuilder::with_manifest_json(impl Into>)` (set by the `app!` + macro) supplies the JSON. +- **`RouterInner::dispatch`** injects only for flagged routes, after matching: ```rust - req.extensions_mut().insert(IntrospectionData { - manifest_json: self.manifest_json.clone(), - routes: Arc::clone(&self.route_index), - }); + match self.find_route(&method, &path) { + RouteMatch::Found(entry, params) => { + let mut request = request; + if entry.needs_introspection { + request.extensions_mut().insert(Arc::clone(&self.introspection)); + } + let ctx = RequestContext::new(request, params); + let next = Next::new(&self.middlewares, entry.handler.as_ref()); + next.run(ctx).await + } + // MethodNotAllowed / NotFound unchanged + } + ``` +- **`RequestContext::introspection()`** reads the `Arc`: + ```rust + pub fn introspection(&self) -> Option<&crate::router::IntrospectionData> { + self.request.extensions().get::>() + .map(|arc| arc.as_ref()) + } ``` - `route_index` is already an `Arc<[RouteInfo]>`, so the clone is cheap. - -### Component 3 — `RequestContext` accessor (`edgezero-core/src/context.rs`) +`IntrospectionData` is the injected payload: ```rust -#[inline] -pub fn introspection(&self) -> Option<&IntrospectionData> { - self.request.extensions().get::() +#[derive(Clone)] +pub struct IntrospectionData { + pub manifest_json: Option>, + pub routes: Arc<[RouteInfo]>, } ``` -Mirrors the existing extension-backed accessors (`config_store*`, `kv_store*`). - -### Component 4 — `edgezero_core::introspection` module (new file) +### Component 4 — `edgezero_core::introspection` module (`edgezero-core/src/introspection.rs`) -Three handlers written with `#[action]`, plus a small JSON shape for `routes`. +The extractors and three handlers: ```rust -/// GET — full manifest as JSON. -#[action] -pub async fn manifest(ctx: RequestContext) -> Result { - let json = ctx - .introspection() - .and_then(|d| d.manifest_json.clone()) - .ok_or_else(|| EdgeError::internal("manifest introspection data not available"))?; - // application/json, body = json verbatim +pub struct ManifestJson(pub Arc); +#[async_trait(?Send)] +impl FromRequest for ManifestJson { + async fn from_request(ctx: &RequestContext) -> Result { + ctx.introspection().and_then(|d| d.manifest_json.clone()).map(ManifestJson) + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("manifest introspection data not available"))) + } } -/// GET — [{ "method", "path" }] for every registered route. -#[action] -pub async fn routes(ctx: RequestContext) -> Result { - let routes = ctx.introspection().map(|d| &d.routes) /* → Vec */; - // application/json +pub struct RouteTable(pub Arc<[RouteInfo]>); +#[async_trait(?Send)] +impl FromRequest for RouteTable { + async fn from_request(ctx: &RequestContext) -> Result { + ctx.introspection().map(|d| RouteTable(Arc::clone(&d.routes))) + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("route-table introspection data not available"))) + } +} + +#[action(manifest)] +pub async fn manifest(ManifestJson(json): ManifestJson) -> Result { + json_response(StatusCode::OK, Body::text(json.to_string())) +} + +#[action(routes)] +pub async fn routes(RouteTable(table): RouteTable) -> Result { + let views: Vec = table.iter() + .map(|r| RouteView { method: r.method().as_str().to_owned(), path: r.path().to_owned() }) + .collect(); + json_response(StatusCode::OK, Body::json(&views).map_err(EdgeError::internal)?) } -/// GET — the default config-store envelope `data` (secret-safe). #[action] pub async fn config(ctx: RequestContext) -> Result { let binding = ctx.config_store_default_binding() .ok_or_else(|| EdgeError::not_found("no default config store registered"))?; - // read raw blob at binding.default_key via binding.handle - // parse BlobEnvelope, emit envelope.data as application/json + let raw = binding.handle.get(&binding.default_key).await + .map_err(EdgeError::from)? // preserves 503/400/500 + .ok_or_else(|| EdgeError::not_found("no config blob in default store"))?; + let envelope: BlobEnvelope = serde_json::from_str(&raw) + .map_err(|e| EdgeError::internal(anyhow::anyhow!("envelope parse failed: {e}")))?; + envelope.verify().map_err(|e| EdgeError::internal(anyhow::anyhow!("envelope verification failed: {e}")))?; + json_response(StatusCode::OK, Body::json(&envelope.into_data()).map_err(EdgeError::internal)?) } ``` -Notes: - -- `RouteEntryView { method: String, path: String }` replaces the removed - `RouteListingEntry`. -- `config` reads the raw blob string from the config-store handle (the same read - `extract_from_handle` performs) and parses `BlobEnvelope`; it does **not** run - secret resolution or typed deserialization. -- Error mapping: absent manifest → `500` internal (should not happen once wired); - missing config store or missing blob → `404`. -- The handlers must be reachable by the `app!` macro's `parse_handler_path`, - which already resolves arbitrary `a::b::c` paths (it resolves - `app_demo_core::handlers::root` today), so `edgezero_core::introspection::…` - resolves the same way. - -### Component 5 — `app!` macro (`edgezero-macros/src/app.rs`) - -- After parsing the manifest, serialize it: `serde_json::to_string(&manifest)`. - On serialization error, emit a `compile_error!`. -- Emit one added line in the generated `build_router()`: - ```rust - pub fn build_router() -> edgezero_core::router::RouterService { - let mut builder = edgezero_core::router::RouterService::builder(); - builder = builder.with_manifest_json(#manifest_json_lit); - #(#middleware_tokens)* - #(#route_tokens)* - builder.build() - } - ``` -- No route wiring for introspection (routes come from `[[triggers.http]]`). -- `edgezero-macros` needs `serde_json` as a (build-time) dependency; `Manifest` - must be `Serialize` (Component 1). - -### Component 6 — Removals - -Delete from `edgezero-core/src/router.rs`: - -- `pub const DEFAULT_ROUTE_LISTING_PATH` -- `RouterBuilder::enable_route_listing`, `RouterBuilder::enable_route_listing_at` -- `RouterBuilder.route_listing_path` field and the listing branch inside `build()` -- `build_listing_response` -- `RouteListingEntry` -- All associated unit tests (`route_listing_*`) - -Grep the workspace for any other references (docs, examples, adapter code) and -remove/update them so nothing depends on `/__edgezero/routes`. - -### Component 7 — Templates (default bindings) - -Add three trigger rows, wired to the core handlers, under `/_/…`. - -`examples/app-demo/edgezero.toml`: - -```toml -[[triggers.http]] -id = "manifest" -path = "/_app-demo/manifest" -methods = ["GET"] -handler = "edgezero_core::introspection::manifest" -description = "App manifest as JSON" - -[[triggers.http]] -id = "config" -path = "/_app-demo/config" -methods = ["GET"] -handler = "edgezero_core::introspection::config" -description = "Effective app config (secret-safe)" - -[[triggers.http]] -id = "routes" -path = "/_app-demo/routes" -methods = ["GET"] -handler = "edgezero_core::introspection::routes" -description = "Registered route table" -``` +`RouteView { method: String, path: String }` (derives `Serialize`) is the JSON +shape for `routes`. `Response` is imported from `crate::http`. + +### Component 5 — `app!` macro (`edgezero-macros/src/app.rs`) *(done, unchanged by the gating work)* + +- Serialize the parsed manifest (`serde_json::to_string`, `compile_error!` on + failure) and emit `builder = builder.with_manifest_json()` as the first + builder mutation in `build_router()`. +- Emit `const _: &[u8] = include_bytes!();` so Cargo treats + `edgezero.toml` as a build input (rebuild on manifest change). +- Route registration is ordinary `builder.get(path, handler)` / `route(...)`; the + macro does **not** inspect handler paths. Gating comes entirely from the + handler's `needs_introspection()`. + +### Component 6 — Removals *(done)* + +Deleted from `router.rs`: `DEFAULT_ROUTE_LISTING_PATH`, `enable_route_listing`, +`enable_route_listing_at`, the `route_listing_path` field + listing branch in +`build()`, `build_listing_response`, `RouteListingEntry`, and all `route_listing_*` +tests. No workspace references to `/__edgezero/routes` remain. -`crates/edgezero-cli/src/templates/root/edgezero.toml.hbs`: the same three rows, -using `path = "/_{{name}}/manifest"` etc. and the same `edgezero_core::introspection::*` -handlers. (`{{name}}` is the sanitized app name already used elsewhere in the -template.) +### Component 7 — Templates + app-demo (default bindings) *(done)* +Three `[[triggers.http]]` rows in `examples/app-demo/edgezero.toml` and in +`crates/edgezero-cli/src/templates/root/edgezero.toml.hbs` (using `/_{{name}}/…` +and `{{{adapter_list}}}`), bound to `edgezero_core::introspection::{manifest,config,routes}`. No template handler code is generated — the handlers live in core. ## Interfaces (summary) -| Unit | Public surface | Depends on | -| ----------------------- | ---------------------------------------------------------- | ----------------------------------- | -| `IntrospectionData` | `{ manifest_json: Option>, routes: Arc<[RouteInfo]> }` | `RouteInfo` | -| `RouterBuilder` | `with_manifest_json(impl Into>)` | — | -| `RequestContext` | `introspection() -> Option<&IntrospectionData>` | request extensions | -| `introspection::manifest` | `#[action]` GET → JSON | `ctx.introspection()` | -| `introspection::routes` | `#[action]` GET → JSON | `ctx.introspection()` | -| `introspection::config` | `#[action]` GET → JSON | default config store, `BlobEnvelope`| +| Unit | Public surface | +| -------------------------- | --------------------------------------------------------------- | +| `IntrospectionData` | `{ manifest_json: Option>, routes: Arc<[RouteInfo]> }` | +| `DynHandler` | `fn needs_introspection(&self) -> bool { false }` | +| `RouterBuilder` | `with_manifest_json(impl Into>)` | +| `RequestContext` | `introspection() -> Option<&IntrospectionData>` | +| `introspection::ManifestJson` | `FromRequest`; `pub Arc` | +| `introspection::RouteTable` | `FromRequest`; `pub Arc<[RouteInfo]>` | +| `introspection::{manifest,routes}` | `#[action(manifest)]` / `#[action(routes)]` GET → JSON | +| `introspection::config` | `#[action]` GET → JSON (default config store) | ## Error Handling -- **manifest** absent from `IntrospectionData`: `500 internal` (indicates a - wiring bug; always present once the macro sets it). -- **config**: no default config store → `404 not found`; no blob at - `default_key` → `404`; malformed envelope → `500 internal`. -- **routes**: `IntrospectionData` absent → empty list is acceptable, or `500`; - chosen behavior: return an empty array rather than error, since routes are - always injected by dispatch. +- **manifest** / **routes**: extractor returns `500 internal` if + `IntrospectionData` is absent (route not opted in) or, for `manifest`, if + `manifest_json` is `None` (no `with_manifest_json`). +- **config**: no default config store → `404`; no blob → `404`; `ConfigStoreError` + mapped via `EdgeError::from` (503 unavailable / 400 invalid-key / 500 internal); + malformed or unverifiable envelope → `500`. ## Testing Strategy Colocated `#[cfg(test)]`, `futures::executor::block_on` (no Tokio), no network. -- **router.rs**: dispatch test asserting an `IntrospectionData` extension is - present in the request seen by a handler, with the expected route index and - `manifest_json`. Remove old `route_listing_*` tests. -- **introspection module**: - - `manifest` returns the injected JSON with `application/json`. - - `routes` returns the projected `[{method, path}]`. - - `config` returns `BlobEnvelope.data` from a stub config store; `404` when no - store is registered; `404` when the blob is missing. -- **macro (`edgezero-macros`)**: trybuild/expansion assertion that - `with_manifest_json(...)` is emitted with valid JSON for a sample manifest. -- **app-demo**: extend router/handler tests to hit `/_app-demo/manifest`, - `/_app-demo/config`, `/_app-demo/routes` and assert shapes. - -## CI Gates (unchanged) +- **macros**: `#[action]` (no params) still emits a fn; `#[action(manifest)]` + emits a struct impl'ing `DynHandler` with `needs_introspection() == true`; + `#[action(bogus)]` is a compile error. Plus a **compile/behavioral** test in + core that a struct handler registers and runs: `.get("/m", manifest)` → + `oneshot` → 200 (proves the unit-struct-as-handler-value path works end to end). +- **handler.rs / router.rs**: `needs_introspection()` default is false for fn + handlers; a flagged route injects `IntrospectionData` (handler + middleware see + it); a non-flagged route does **not** (`ctx.introspection().is_none()`). +- **introspection module**: `manifest` returns injected JSON; `routes` returns + `[{method,path}]`; `config` returns envelope `data` and the full status matrix + (200/404/400/503/500×3); `manifest` with no baked JSON → 500. +- **app-demo**: `/_app-demo/{manifest,routes}` → 200 + body shape; `/config` with + a seeded `ConfigRegistry` → 200 + secret-safe key-name. + +## CI Gates 1. `cargo fmt --all -- --check` 2. `cargo clippy --workspace --all-targets --all-features -- -D warnings` -3. `cargo test --workspace --all-targets` -4. `cargo check --workspace --all-targets --features "fastly cloudflare spin"` -5. `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` +3. `cargo test --workspace --all-targets` (+ `cd examples/app-demo && cargo test --workspace --all-targets`) +4. `cargo test -p edgezero-cli --test generated_project_builds -- --ignored` +5. `cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates` +6. `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +7. `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` ## Constraints & Non-Goals -- **WASM-first**: no Tokio, no runtime-specific deps added. `Arc`, `serde_json`, - and `Once*`-free injection are all WASM-safe. `serde_json` is added only to - `edgezero-macros` (a proc-macro crate that runs at build time). -- **No auth/gating in this iteration**: endpoints are exposed wherever the app - binds them. Because they are plain triggers, a developer who does not want them - simply omits the rows. Config output is already secret-safe. Access control - (e.g. dev-only, header-gated) is a possible follow-up, out of scope here. -- **Single-app assumption**: `manifest_json` is per-`RouterService`, so multiple - distinct apps in one process each carry their own — no shared/global state and - no cross-app leakage. -- **No `[introspection]` manifest section** and **no builder-based enable API** — - explicitly rejected in favor of plain `[[triggers.http]]` bindings. - -## File-Change Checklist (for planning) - -- [ ] `crates/edgezero-core/src/manifest.rs` — add `Serialize` derives. -- [ ] `crates/edgezero-core/src/router.rs` — `IntrospectionData`, - `with_manifest_json`, dispatch injection; remove route-listing machinery + - tests. -- [ ] `crates/edgezero-core/src/context.rs` — `introspection()` accessor. -- [ ] `crates/edgezero-core/src/introspection.rs` — new module, three handlers. -- [ ] `crates/edgezero-core/src/lib.rs` — export `introspection`. -- [ ] `crates/edgezero-macros/src/app.rs` — serialize manifest, emit - `with_manifest_json`; add `serde_json` dep. -- [ ] `examples/app-demo/edgezero.toml` — three trigger rows. -- [ ] `crates/edgezero-cli/src/templates/root/edgezero.toml.hbs` — three trigger - rows using `{{name}}`. -- [ ] Workspace grep — purge remaining `/__edgezero/routes` / - `enable_route_listing` references (docs, examples, adapters). +- **WASM-first**: no Tokio, no runtime-specific deps. `serde_json` is added only + to `edgezero-macros` (build-time proc-macro crate). +- **No auth/gating of exposure in this iteration**: endpoints are reachable + wherever bound; config output is secret-safe, and `/manifest` emits + `environment.variables[].value` (not secrets) — documented so operators don't + store secrets in `[environment.variables]`. Access control is a follow-up. +- **No process-global state**: `IntrospectionData` is per-`RouterService`, so + tests and multiple apps in one process stay independent. +- **No `[introspection]` manifest section, no builder enable-API, no `app!` + handler-path inspection** — the opt-in lives on `#[action(...)]`. + +## Design evolution (for reviewers) + +1. First cut: inject `IntrospectionData` on **every** request; handlers read + `ctx.introspection()`. Rejected — taxes all traffic for rarely-hit endpoints. +2. Considered a process-global (`OnceLock`) source — rejected: one-manifest- + per-process breaks unit tests and adds shared mutable state. +3. Considered `app!` recognizing the `edgezero_core::introspection::` handler + namespace to flag routes — rejected as a fragile string-match hack. +4. **Final:** the opt-in is an atomic `#[action(manifest|routes)]` parameter; + the capability rides the handler to registration via + `DynHandler::needs_introspection()`; the router gates injection per route. + Typed extractors (`ManifestJson`/`RouteTable`) are the access mechanism. + No global, no `app!` hack, no unstable specialization, and `#[action]` (no + params) is 100% unchanged so only `manifest`/`routes` become structs. From a9ad7bc89b73b6ab0ebf69cf3c3ab1f5bdd2a179 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:04:36 -0700 Subject: [PATCH 22/29] Spec/plan: fully-atomic introspection (IntrospectionNeeds, per-capability inject); fix plan single-filter cmd + probe response --- .../plans/2026-07-02-introspection-routes.md | 250 ++++++++-------- .../2026-07-01-introspection-routes-design.md | 281 +++++++++++------- 2 files changed, 301 insertions(+), 230 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md index beaa7ad3..683f1a06 100644 --- a/docs/superpowers/plans/2026-07-02-introspection-routes.md +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -1215,46 +1215,56 @@ gh pr ready 300 --- -## Addendum (2026-07-02): ACTIVE PATH — `#[action(manifest|routes)]` opt-in + gated injection +## Addendum (2026-07-02): ACTIVE PATH — fully-atomic `#[action(manifest|routes)]` opt-in -**This supersedes Tasks 2 & 3's unconditional injection.** Task 9 (extractors) is -already committed (`0feb194`): `ManifestJson`/`RouteTable` extractors exist and -`manifest`/`routes` use them. The tasks below make injection **per-route gated** -via an atomic `#[action(...)]` opt-in, with `app!`/`edgezero.toml` untouched. +**This supersedes Tasks 2 & 3.** Task 9 (extractors + handler refactor) is +committed (`0feb194`). The tasks below add **fully-atomic, per-capability gated +injection** matching the rewritten spec: the handler declares exactly which data +it needs; the router injects each payload independently, only for routes that +asked. No `IntrospectionData` bundle, no `ctx.introspection()`, no +`needs_introspection` bool, no `app!`/`edgezero.toml` change. -**Verified facts (from investigation):** -- `DynHandler` (`handler.rs`): `pub trait DynHandler: Send + Sync { fn call(&self, ctx: RequestContext) -> HandlerFuture; }`, blanket-impl'd for `F: Fn(RequestContext) -> Fut`. Object-safe with an added defaulted `needs_introspection`. `HandlerFuture = Pin> + 'static>>` (no `Send`). -- Handlers are invoked only via `DynHandler::call` (middleware `Next::run`), never as fns; only `manifest`/`routes` will become structs and neither is called directly → **zero blast radius**. -- `Responder::respond(self) -> Result`. +**Verified contract (from investigation):** +- `DynHandler: Send + Sync { fn call(&self, RequestContext) -> HandlerFuture; }`, blanket-impl'd for `F: Fn(RequestContext) -> Fut`. Object-safe with an added defaulted method. `HandlerFuture = Pin> + 'static>>` (no `Send`). +- `http::Extensions` requires inserted types be `Clone + Send + Sync + 'static`. +- Handlers run only via `DynHandler::call`; only `manifest`/`routes` become structs; neither is called directly → zero blast radius. -Base: after `0feb194`. +Order is chosen so **every commit compiles green** (the gating swap lands last, +after the handlers are opted in while unconditional injection is still active). -### Task 10a: `DynHandler::needs_introspection` (edgezero-core/src/handler.rs) +### Task 10a: `IntrospectionNeeds` + `DynHandler::introspection_needs` (edgezero-core/src/handler.rs) -- [ ] **Step 1 — add the defaulted method.** In `handler.rs`, add to the trait: +- [ ] **Step 1 — add the value type + defaulted method** (additive; compiles green): ```rust +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct IntrospectionNeeds { + pub manifest: bool, + pub routes: bool, +} + +impl IntrospectionNeeds { + #[must_use] + pub fn any(self) -> bool { self.manifest || self.routes } +} + pub trait DynHandler: Send + Sync { fn call(&self, ctx: RequestContext) -> HandlerFuture; - /// Whether a route bound to this handler needs `IntrospectionData` injected - /// at dispatch. Default false; `#[action(manifest|routes)]` handlers override. - fn needs_introspection(&self) -> bool { - false + /// Introspection payloads a route bound to this handler needs injected. + /// Default none; `#[action(manifest|routes)]` handlers override. + fn introspection_needs(&self) -> IntrospectionNeeds { + IntrospectionNeeds::default() } } ``` - The blanket `impl DynHandler for F` needs NO change (inherits the default). -- [ ] **Step 2 — verify.** `cargo build -p edgezero-core` (compiles; object safety intact). `cargo fmt -p edgezero-core`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. -- [ ] **Step 3 — commit:** `Add DynHandler::needs_introspection (default false)` + The `Fn` blanket impl needs no change. +- [ ] **Step 2 — verify:** `cargo build -p edgezero-core`; `cargo fmt -p edgezero-core`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. +- [ ] **Step 3 — commit:** `Add IntrospectionNeeds + DynHandler::introspection_needs` ### Task 10b: `#[action(manifest|routes)]` param parser + struct codegen (edgezero-macros/src/action.rs) -- [ ] **Step 1 (RED) — macro unit tests.** Update/add tests in `action.rs`'s `#[cfg(test)]`: - - Replace `rejects_attribute_arguments` with `rejects_unknown_param`: input `#[action(bogus)]` on an async fn → rendered output contains `unknown #[action] parameter`. - - Add `introspection_param_emits_struct`: input `#[action(manifest)]` on `async fn manifest(...)` → rendered output contains `struct manifest` AND `DynHandler` AND `needs_introspection`. - - Keep `wraps_async_function` (plain `#[action]` still contains `fn demo` + `__demo_inner`). - Run `cargo test -p edgezero-macros` → the two new/changed tests FAIL (parser/codegen not present). -- [ ] **Step 2 — parse the param list.** Replace the current `if !attr.is_empty() { return Error…"does not accept arguments" }` guard with: +- [ ] **Step 1 (RED) — macro unit tests.** Replace `rejects_attribute_arguments` with `rejects_unknown_param` (`#[action(bogus)]` → output contains `unknown #[action] parameter`); add `manifest_param_emits_struct` (`#[action(manifest)]` → output contains `struct` + `DynHandler` + `introspection_needs`); keep `wraps_async_function`. Run `cargo test -p edgezero-macros` → the two new/changed tests FAIL. +- [ ] **Step 2 — parse params** (replace the `!attr.is_empty()` rejection): ```rust use syn::parse::Parser as _; use syn::punctuated::Punctuated; @@ -1267,136 +1277,132 @@ let params: Punctuated = if attr.is_empty() { Err(err) => return err.to_compile_error(), } }; -let mut needs_introspection = false; +let mut manifest_cap = false; +let mut routes_cap = false; for param in ¶ms { - // Known atomic introspection capabilities. Extensible; all currently - // collapse to the single injected `IntrospectionData` bundle. - if param == "manifest" || param == "routes" { - needs_introspection = true; - } else { - return syn::Error::new( - param.span(), - format!("unknown #[action] parameter `{param}`; supported: manifest, routes"), - ) - .to_compile_error(); + if param == "manifest" { manifest_cap = true; } + else if param == "routes" { routes_cap = true; } + else { + return syn::Error::new(param.span(), + format!("unknown #[action] parameter `{param}`; supported: manifest, routes")) + .to_compile_error(); } } +let is_struct = manifest_cap || routes_cap; ``` -- [ ] **Step 3 — branch codegen.** Keep the existing `extract_stmts`/`arg_idents`/`inner_fn` computation. Then: +- [ ] **Step 3 — branch codegen.** When `!is_struct`: emit exactly today's fn form (unchanged). When `is_struct`: ```rust -let output = if needs_introspection { - quote! { - #inner_fn - - #(#attrs)* - #[allow(non_camel_case_types)] - #vis struct #ident; - - impl ::edgezero_core::handler::DynHandler for #ident { - #[inline] - fn call(&self, __ctx: ::edgezero_core::context::RequestContext) - -> ::edgezero_core::http::HandlerFuture { - ::std::boxed::Box::pin(async move { - #(#extract_stmts)* - let result = #inner_ident(#(#arg_idents),*).await; - ::edgezero_core::responder::Responder::respond(result) - }) - } - #[inline] - fn needs_introspection(&self) -> bool { true } +quote! { + #inner_fn + + #(#attrs)* + #[allow(non_camel_case_types)] + #vis struct #ident; + + impl ::edgezero_core::handler::DynHandler for #ident { + #[inline] + fn call(&self, __ctx: ::edgezero_core::context::RequestContext) + -> ::edgezero_core::http::HandlerFuture { + ::std::boxed::Box::pin(async move { + #(#extract_stmts)* + let result = #inner_ident(#(#arg_idents),*).await; + ::edgezero_core::responder::Responder::respond(result) + }) } - } -} else { - // UNCHANGED from today — the fn form. - quote! { - #inner_fn - - #(#attrs)* - #vis async fn #ident( - __ctx: ::edgezero_core::context::RequestContext, - ) -> ::std::result::Result<::edgezero_core::http::Response, ::edgezero_core::error::EdgeError> { - #(#extract_stmts)* - let result = #inner_ident(#(#arg_idents),*).await; - ::edgezero_core::responder::Responder::respond(result) + #[inline] + fn introspection_needs(&self) -> ::edgezero_core::handler::IntrospectionNeeds { + ::edgezero_core::handler::IntrospectionNeeds { manifest: #manifest_cap, routes: #routes_cap } } } -}; -output +} ``` -- [ ] **Step 4 (GREEN):** `cargo test -p edgezero-macros` (all pass). `cargo fmt --all`; `cargo clippy -p edgezero-macros --all-targets -- -D warnings`. + (`#manifest_cap`/`#routes_cap` interpolate as `true`/`false` bool literals.) +- [ ] **Step 4 (GREEN):** `cargo test -p edgezero-macros`; `cargo fmt --all`; `cargo clippy -p edgezero-macros --all-targets -- -D warnings`. - [ ] **Step 5 — commit:** `#[action]: accept atomic (manifest|routes) params; emit capability-carrying handler struct` -### Task 10c: router gating + accessor (edgezero-core/src/router.rs, context.rs) +### Task 10c: opt the handlers in (edgezero-core/src/introspection.rs) — stays green under the still-unconditional path + +- [ ] **Step 1 — opt in.** `manifest` → `#[action(manifest)]`; `routes` → `#[action(routes)]`; `config` stays `#[action]`. (The extractors still read `ctx.introspection()` and injection is still unconditional at this point, so behavior is unchanged — the handlers merely become structs reporting `introspection_needs`, which the router does not yet consult.) +- [ ] **Step 2 — verify:** `cargo test -p edgezero-core introspection` (all pass unchanged); `cargo fmt`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. +- [ ] **Step 3 — commit:** `Opt manifest/routes into introspection via #[action(manifest|routes)]` + +### Task 10d: the atomic gating swap (edgezero-core/src/{router,context,introspection}.rs) — all in one commit so it compiles -- [ ] **Step 1 (RED) — router tests.** In `router.rs` tests: - - Rewrite `dispatch_injects_introspection_data` and `middleware_sees_introspection_data` to register a LOCAL probe struct whose `DynHandler::needs_introspection()` returns `true` (a closure can't opt in), asserting `ctx.introspection().is_some()` from the handler and from a middleware respectively. - - Add `plain_route_gets_no_introspection`: a `.get("/x", |_ctx| async { Ok::<_,EdgeError>("ok") })` route asserts `ctx.introspection().is_none()`. - Example probe: +- [ ] **Step 1 (RED) — router tests.** Rewrite `dispatch_injects_introspection_data` and `middleware_sees_introspection_data` to register a LOCAL probe struct (a closure can not report `introspection_needs`), and add a negative test. Probe builds its response with `response_builder` (no `IntoResponse` import needed): ```rust -struct IntrospectiveProbe(std::sync::Arc>>); -impl crate::handler::DynHandler for IntrospectiveProbe { +struct ManifestProbe(std::sync::Arc>>); +impl crate::handler::DynHandler for ManifestProbe { fn call(&self, ctx: RequestContext) -> crate::http::HandlerFuture { let cell = std::sync::Arc::clone(&self.0); Box::pin(async move { - *cell.lock().unwrap() = Some(ctx.introspection().is_some()); - Ok("ok".into_response()?) // match the crate's response idiom + *cell.lock().unwrap() = + Some(ctx.extension::().is_some()); + crate::http::response_builder() + .status(crate::http::StatusCode::OK) + .body(crate::body::Body::empty()) + .map_err(EdgeError::internal) }) } - fn needs_introspection(&self) -> bool { true } + fn introspection_needs(&self) -> crate::handler::IntrospectionNeeds { + crate::handler::IntrospectionNeeds { manifest: true, routes: false } + } } ``` - Run `cargo test -p edgezero-core router introspection_data` → fails (flag/gating not present; `introspection()` type mismatch). -- [ ] **Step 2 — `RouteEntry` flag.** Add `needs_introspection: bool` and copy it in the manual `Clone`/`clone_from`. -- [ ] **Step 3 — read the flag in `add_route`:** + - flagged route: `.with_manifest_json("{}").get("/", ManifestProbe(cell))` → cell records `true`. + - middleware test: same flagged route + a probe middleware asserting `ctx.extension::().is_some()` (injection happens before middleware). + - negative test `plain_route_gets_no_manifest`: `.get("/x", |_ctx: RequestContext| async { … })` (a plain closure) → the handler records `ctx.extension::().is_none()`. + Run (single filter per command — Cargo takes ONE positional filter): + `cargo test -p edgezero-core router` (fails: `introspection_needs`/`extension`/gating absent). +- [ ] **Step 2 — `RouteEntry` flag.** Add `introspection_needs: IntrospectionNeeds` (`Copy`); copy it in the manual `Clone`/`clone_from`. +- [ ] **Step 3 — read it in `add_route`:** ```rust let boxed = handler.into_handler(); -let needs_introspection = boxed.needs_introspection(); -router.insert(path, RouteEntry { handler: boxed, needs_introspection }) +let introspection_needs = boxed.introspection_needs(); +router.insert(path, RouteEntry { handler: boxed, introspection_needs }) .unwrap_or_else(|err| panic!("duplicate route definition for {path}: {err}")); ``` -- [ ] **Step 4 — precompute the payload.** In `build()`: -```rust -let route_index: Arc<[RouteInfo]> = Arc::from(self.route_info); -let introspection = Arc::new(IntrospectionData { - manifest_json: self.manifest_json, - routes: Arc::clone(&route_index), -}); -RouterService::new(self.routes, self.middlewares, route_index, introspection) -``` - Change `RouterInner`'s `manifest_json: Option>` field to `introspection: Arc`, and `RouterService::new`'s last param likewise. -- [ ] **Step 5 — gate `dispatch`.** Remove the unconditional insert at the top; inside `RouteMatch::Found(entry, params)`: +- [ ] **Step 4 — remove the bundle; per-capability inject in `dispatch`.** Delete the `IntrospectionData` struct and the unconditional insert at the top of `dispatch`. `RouterInner` keeps `manifest_json: Option>` + `route_index`. Inside `RouteMatch::Found(entry, params)`: ```rust +let needs = entry.introspection_needs; let mut request = request; -if entry.needs_introspection { - request.extensions_mut().insert(::std::sync::Arc::clone(&self.introspection)); +if needs.manifest { + if let Some(json) = &self.manifest_json { + request.extensions_mut().insert(crate::introspection::ManifestJson(Arc::clone(json))); + } +} +if needs.routes { + request.extensions_mut().insert(crate::introspection::RouteTable(Arc::clone(&self.route_index))); } let ctx = RequestContext::new(request, params); ``` - (`dispatch` reads `method`/`path` from `request` before the match, as today; make the bound `request` mutable in the arm.) -- [ ] **Step 6 — accessor.** `context.rs`: +- [ ] **Step 5 — context accessor.** In `context.rs`, remove `introspection()`; add: +```rust +pub(crate) fn extension(&self) -> Option +where + T: Clone + Send + Sync + 'static, +{ + self.request.extensions().get::().cloned() +} +``` +- [ ] **Step 6 — extractors as payloads.** In `introspection.rs`: add `#[derive(Clone)]` to `ManifestJson` and `RouteTable`; rewrite their `from_request` to clone their own type out of the request: ```rust -pub fn introspection(&self) -> Option<&crate::router::IntrospectionData> { - self.request - .extensions() - .get::>() - .map(|arc| arc.as_ref()) +async fn from_request(ctx: &RequestContext) -> Result { + ctx.extension::() // (RouteTable in the other impl) + .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("manifest introspection data not available"))) } ``` -- [ ] **Step 7 (GREEN):** `cargo test -p edgezero-core` (all pass). `cargo fmt`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. -- [ ] **Step 8 — commit:** `Gate IntrospectionData injection on RouteEntry.needs_introspection` - -### Task 10d: opt the handlers in + compile-level proof (edgezero-core/src/introspection.rs) - -- [ ] **Step 1 — opt in.** Change `manifest` from `#[action]` to `#[action(manifest)]`, and `routes` to `#[action(routes)]`. `config` stays `#[action]`. -- [ ] **Step 2 — compile/behavioral proof (this is the key test the reviewer asked for).** The existing `manifest_returns_injected_json` test already does `RouterService::builder().with_manifest_json("…").get("/m", manifest).build()` then `oneshot` → asserts 200 + body. Because `manifest` is now a UNIT STRUCT value, this test compiling and passing proves `.get("/m", manifest)` accepts the struct-as-handler and that `add_route` auto-flags it (else `oneshot` would 500 on the `ManifestJson` extractor). Keep it. Confirm `manifest_without_baked_json_is_500` still holds (route flagged → `IntrospectionData` injected, but `manifest_json` is `None` → extractor 500). Add `routes_returns_registered_routes` similarly if not already covered. -- [ ] **Step 3 — verify.** `cargo test -p edgezero-core introspection` (all pass, incl. body-shape assertions). -- [ ] **Step 4 — commit:** `Opt manifest/routes into gated injection via #[action(manifest|routes)]` +- [ ] **Step 7 (GREEN):** `cargo test -p edgezero-core router`; `cargo test -p edgezero-core introspection`; `cargo test -p edgezero-core`; `cargo fmt`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. Confirm `manifest_without_baked_json_is_500` still holds (route opted into `manifest`, but `manifest_json` is `None` → nothing injected → extractor 500). +- [ ] **Step 8 — commit:** `Gate introspection injection per capability via IntrospectionNeeds` ### Task 11: full verification + whole-branch review -- [ ] `cargo fmt --all -- --check`; `cargo clippy --workspace --all-targets --all-features -- -D warnings`; `cargo test --workspace --all-targets`. -- [ ] `cd examples/app-demo && cargo test --workspace --all-targets` (all other handlers unchanged → app-demo unaffected; `introspection_routes_are_wired` still passes: `manifest`/`routes` flagged, `config` via store). -- [ ] `cargo test -p edgezero-cli --test generated_project_builds -- --ignored` (template handlers are plain `#[action]` → still fns → generated project unaffected). -- [ ] `cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates`. -- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"`; `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin`. +Run each on its own line (single positional filter per `cargo test`): +- [ ] `cargo fmt --all -- --check` +- [ ] `cargo clippy --workspace --all-targets --all-features -- -D warnings` +- [ ] `cargo test --workspace --all-targets` +- [ ] `cd examples/app-demo && cargo test --workspace --all-targets` (other handlers unchanged; `introspection_routes_are_wired` still passes: `manifest`/`routes` opted in, `config` via store) +- [ ] `cargo test -p edgezero-cli --test generated_project_builds -- --ignored` (template handlers are plain `#[action]` → still fns) +- [ ] `cargo run -q --bin check_no_nested_app_config --features nested-app-config-check -- examples/app-demo crates/edgezero-cli/src/templates` +- [ ] `cargo check --workspace --all-targets --features "fastly cloudflare spin"` +- [ ] `cargo check -p edgezero-adapter-spin --target wasm32-wasip2 --features spin` - [ ] Whole-branch review of the addendum commits, then push to #300. diff --git a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md index 490ddd52..92e9d9f4 100644 --- a/docs/superpowers/specs/2026-07-01-introspection-routes-design.md +++ b/docs/superpowers/specs/2026-07-01-introspection-routes-design.md @@ -4,14 +4,12 @@ **Status:** Approved — implementation in progress **Scope:** `edgezero-core`, `edgezero-macros`, `examples/app-demo`, `edgezero-cli` templates -> **Note on history.** This spec was rewritten on 2026-07-02 to describe the -> **final** architecture only: **opt-in, per-route gated injection driven by +> **Note on history.** This spec describes the **final** architecture only: +> **opt-in, per-route, per-capability gated injection driven by > `#[action(manifest|routes)]`, with typed extractors for access.** Earlier drafts -> described unconditional per-request injection with handlers reading -> `ctx.introspection()` directly; that approach was superseded (it taxed all -> traffic for endpoints hit rarely). The "Design evolution" section at the end -> records the path taken. Where any older wording survives elsewhere, this -> document governs. +> injected data on every request (or via a single blanket gate / bundle); those +> were superseded. The "Design evolution" section at the end records the path. +> This document governs. ## Summary @@ -50,7 +48,8 @@ Today there is no runtime way to inspect what an app *is*: wired through a bespoke builder method rather than the normal routing path. We want a single, consistent, "bind it yourself" mechanism for all three — that -costs nothing for the ~100% of requests that are not introspection calls. +costs nothing for the ~100% of requests that are not introspection calls, and +that only provisions the *specific* data each handler asks for. ## Key Decisions @@ -66,19 +65,29 @@ costs nothing for the ~100% of requests that are not introspection calls. **not** inspect handler paths. 4. **Paths** — per-app namespace `/_/{manifest,config,routes}` (single underscore); just the default paths written into the templates. -5. **Access via typed extractors** — handlers that need injected data declare it - in their signature, matching the `Json`/`Path`/`AppConfig` idiom: +5. **Access via typed extractors that are also the injected payloads.** Handlers + that need data declare it in their signature, matching the + `Json`/`Path`/`AppConfig` idiom: - `ManifestJson(pub Arc)` — the baked manifest JSON (used by `manifest`). - `RouteTable(pub Arc<[RouteInfo]>)` — the live route index (used by `routes`). - Both implement `FromRequest`, read the injected `IntrospectionData` via - `ctx.introspection()`, and return `500` if it is absent. `config` takes - `RequestContext` and uses neither. -6. **Opt-in, per-route gated injection driven by `#[action(...)]`** — the router - injects `IntrospectionData` **only for routes whose handler opted in**, never - for general traffic. The opt-in is an atomic `#[action]` parameter and the - capability rides the handler to registration (details in Component 2/3). No - process-global state, no unstable specialization, no `app!`/`edgezero.toml` - change. + Each derives `Clone`, is what the router injects into the request, and its + `FromRequest::from_request` clones its own type back out (`500` if absent). + `config` takes `RequestContext` and uses neither. +6. **Opt-in, per-capability gated injection driven by an atomic `#[action(...)]` + parameter.** The router injects each capability's payload **only** for routes + whose handler opted into that specific capability — never for general traffic, + and never more than the handler asked for. The opt-in is atomic all the way + down: + - `#[action(manifest)]` / `#[action(routes)]` / `#[action(manifest, routes)]` + declare exactly which data the handler consumes. `#[action]` (no params) is + unchanged. + - Each param maps 1:1 to a field of `IntrospectionNeeds { manifest, routes }`, + reported by the handler via `DynHandler::introspection_needs()`. + - `dispatch` injects `ManifestJson` iff `needs.manifest`, and `RouteTable` iff + `needs.routes`. A `manifest`-only route never carries the route table, and + vice versa. + No process-global state, no unstable specialization, no bundle struct, and no + `app!`/`edgezero.toml` change. 7. **Remove route listing** — delete the `enable_route_listing` machinery and `/__edgezero/routes`. @@ -97,26 +106,27 @@ build_router() builder.with_manifest_json("{…}") RouterService::oneshot(req) builder.get(path, introspection::routes) └─ RouterInner::dispatch(req) │ │ find_route(req) → RouteEntry - ▼ │ if entry.needs_introspection: -RouterInner { │ req.extensions.insert( - introspection: Arc, ──────┼────► Arc::clone(introspection)) -} (built once) ▼ - handler runs; extractor reads - ctx.introspection(): + ▼ │ needs = entry.introspection_needs +RouterInner { │ if needs.manifest && manifest_json: + manifest_json: Option>, ──────┼────► insert ManifestJson(clone) + route_index: Arc<[RouteInfo]>, ──────┼────► if needs.routes: insert RouteTable(clone) +} ▼ + handler runs; extractor clones its + own type out of the request: manifest → ManifestJson(json) routes → RouteTable(index) config → default config store (no injection) ``` - **The opt-in is on the handler.** `#[action(manifest)]` / `#[action(routes)]` - expand the handler to a capability-carrying struct (below). `add_route` reads - that capability and flags the `RouteEntry`. `dispatch` injects the shared - `Arc` only for flagged routes. -- **manifest**: parsed at compile time, re-serialized to JSON by the macro, - baked into `build_router()`, held on the router's `IntrospectionData`, injected - for `manifest`-flagged routes, returned verbatim. No runtime TOML dependency. -- **routes**: projected at request time from the live route index in - `IntrospectionData`. + expand the handler to a capability-carrying struct whose + `introspection_needs()` sets the matching field(s). `add_route` reads that and + stores `IntrospectionNeeds` on the `RouteEntry`. +- **manifest**: parsed at compile time, re-serialized to JSON by the macro, held + as `Option>` on the router, injected (as `ManifestJson`) only for + routes that asked for `manifest`, returned verbatim. No runtime TOML dependency. +- **routes**: injected (as `RouteTable`) only for routes that asked for `routes`; + projected at request time to `[{method, path}]`. - **config**: read from the default config store; needs no injection. ### Component 1 — `Manifest: Serialize` (`edgezero-core/src/manifest.rs`) *(done)* @@ -129,25 +139,25 @@ never serialized: `environment.secrets` entries omit `value` via a `serialize_with` redactor; `environment.variables` keep it. Internal fields (`root`, `logging_resolved`) stay `#[serde(skip)]`. -### Component 2 — `#[action]` opt-in + capability-carrying handlers (`edgezero-macros/src/action.rs`) +### Component 2 — `#[action]` atomic opt-in + capability-carrying handlers (`edgezero-macros/src/action.rs`) `#[action]` gains an **optional atomic parameter list** naming the introspection -data the handler needs: +data the handler consumes: -- **`#[action]`** (no params) — unchanged. Expands to a handler **fn**, which via - the existing `Fn` blanket `impl DynHandler` reports `needs_introspection() == false`. +- **`#[action]`** (no params) — unchanged. Expands to a handler **fn**; via the + existing `Fn` blanket `impl DynHandler` its `introspection_needs()` returns the + default (all-false). - **`#[action(manifest)]`, `#[action(routes)]`, `#[action(manifest, routes)]`** — - expand the handler to a **unit struct** with its own `impl DynHandler` whose - `needs_introspection()` returns `true`. (A fn can't carry a per-item flag past - type-erasure into `Arc`; a struct can. Only opt-in handlers - become structs; every other handler stays a fn.) - -The macro validates each param against the known set `{ manifest, routes }` and -emits `compile_error!` on an unknown ident. The set is extensible (future atomic -capabilities are new idents). The atomic names are the declarative surface; since -`IntrospectionData` is one cheap `Arc` bundle, all recognized capabilities -currently collapse to the single `needs_introspection()` gate (room to split -payloads later without an attribute change). + expand the handler to a **unit struct** with its own `impl DynHandler`, whose + `introspection_needs()` returns an `IntrospectionNeeds` with exactly the named + fields set. (A fn can't carry per-item data past type-erasure into + `Arc`; a struct can. Only opt-in handlers become structs; every + other handler stays a fn, untouched.) + +The macro parses the params as a comma-separated ident list, validates each +against the known set `{ manifest, routes }`, and emits `compile_error!` on an +unknown ident. The set is extensible (future atomic capabilities are new idents ++ new `IntrospectionNeeds` fields). Generated struct (paths absolute, matching the existing macro): @@ -169,32 +179,76 @@ impl ::edgezero_core::handler::DynHandler for #ident { }) } #[inline] - fn needs_introspection(&self) -> bool { true } + fn introspection_needs(&self) -> ::edgezero_core::handler::IntrospectionNeeds { + ::edgezero_core::handler::IntrospectionNeeds { manifest: #manifest_lit, routes: #routes_lit } + } } ``` -### Component 3 — Router gating + accessor (`edgezero-core/src/{handler,router,context}.rs`) +where `#manifest_lit` / `#routes_lit` are the `bool` literals derived from the +parsed params. + +### Component 3 — Router gating (`edgezero-core/src/{handler,router,context}.rs`) + +**`handler.rs`** — the capability value type + the reporting method: + +```rust +/// Which introspection payloads a route's handler needs injected at dispatch. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct IntrospectionNeeds { + pub manifest: bool, + pub routes: bool, +} + +impl IntrospectionNeeds { + #[must_use] + pub fn any(self) -> bool { + self.manifest || self.routes + } +} + +pub trait DynHandler: Send + Sync { + fn call(&self, ctx: RequestContext) -> HandlerFuture; -- **`DynHandler`** gains `fn needs_introspection(&self) -> bool { false }` - (object-safe; the `Fn` blanket impl inherits the default). -- **`RouteEntry`** gains `needs_introspection: bool`. `add_route` reads it from - the boxed handler at registration: + /// Introspection payloads a route bound to this handler needs. Default is + /// none; `#[action(manifest|routes)]` handlers override it. + fn introspection_needs(&self) -> IntrospectionNeeds { + IntrospectionNeeds::default() + } +} +``` + +The `Fn` blanket `impl DynHandler` needs no change (inherits the default). + +**`router.rs`:** + +- `RouteEntry` gains `introspection_needs: IntrospectionNeeds` (`Copy`; copied in + the manual `Clone`/`clone_from`). +- `add_route` reads it from the boxed handler at registration: ```rust let boxed = handler.into_handler(); - let needs_introspection = boxed.needs_introspection(); - router.insert(path, RouteEntry { handler: boxed, needs_introspection }); + let introspection_needs = boxed.introspection_needs(); + router.insert(path, RouteEntry { handler: boxed, introspection_needs }); ``` -- **`RouterInner`** holds a precomputed `introspection: Arc`, - built once in `build()` from `self.manifest_json` + the route index. +- `RouterInner` keeps `manifest_json: Option>` and + `route_index: Arc<[RouteInfo]>` (no bundle struct). `RouterBuilder::with_manifest_json(impl Into>)` (set by the `app!` macro) supplies the JSON. -- **`RouterInner::dispatch`** injects only for flagged routes, after matching: +- `dispatch` injects per capability, after matching: ```rust match self.find_route(&method, &path) { RouteMatch::Found(entry, params) => { + let needs = entry.introspection_needs; let mut request = request; - if entry.needs_introspection { - request.extensions_mut().insert(Arc::clone(&self.introspection)); + if needs.manifest { + if let Some(json) = &self.manifest_json { + request.extensions_mut() + .insert(crate::introspection::ManifestJson(Arc::clone(json))); + } + } + if needs.routes { + request.extensions_mut() + .insert(crate::introspection::RouteTable(Arc::clone(&self.route_index))); } let ctx = RequestContext::new(request, params); let next = Next::new(&self.middlewares, entry.handler.as_ref()); @@ -203,42 +257,42 @@ impl ::edgezero_core::handler::DynHandler for #ident { // MethodNotAllowed / NotFound unchanged } ``` -- **`RequestContext::introspection()`** reads the `Arc`: - ```rust - pub fn introspection(&self) -> Option<&crate::router::IntrospectionData> { - self.request.extensions().get::>() - .map(|arc| arc.as_ref()) - } - ``` -`IntrospectionData` is the injected payload: +**`context.rs`** — a single `pub(crate)` accessor the extractors share (there is +no public `introspection()` accessor and no `IntrospectionData` type): + ```rust -#[derive(Clone)] -pub struct IntrospectionData { - pub manifest_json: Option>, - pub routes: Arc<[RouteInfo]>, +pub(crate) fn extension(&self) -> Option +where + T: Clone + Send + Sync + 'static, +{ + self.request.extensions().get::().cloned() } ``` ### Component 4 — `edgezero_core::introspection` module (`edgezero-core/src/introspection.rs`) -The extractors and three handlers: +The extractors (which are also the injected payloads) and three handlers: ```rust +#[derive(Clone)] pub struct ManifestJson(pub Arc); + #[async_trait(?Send)] impl FromRequest for ManifestJson { async fn from_request(ctx: &RequestContext) -> Result { - ctx.introspection().and_then(|d| d.manifest_json.clone()).map(ManifestJson) + ctx.extension::() .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("manifest introspection data not available"))) } } +#[derive(Clone)] pub struct RouteTable(pub Arc<[RouteInfo]>); + #[async_trait(?Send)] impl FromRequest for RouteTable { async fn from_request(ctx: &RequestContext) -> Result { - ctx.introspection().map(|d| RouteTable(Arc::clone(&d.routes))) + ctx.extension::() .ok_or_else(|| EdgeError::internal(anyhow::anyhow!("route-table introspection data not available"))) } } @@ -271,7 +325,9 @@ pub async fn config(ctx: RequestContext) -> Result { ``` `RouteView { method: String, path: String }` (derives `Serialize`) is the JSON -shape for `routes`. `Response` is imported from `crate::http`. +shape for `routes`. `Response` is imported from `crate::http`. Because `dispatch` +constructs `ManifestJson`/`RouteTable`, `router.rs` imports them from +`crate::introspection` (a same-crate module reference — no crate-level cycle). ### Component 5 — `app!` macro (`edgezero-macros/src/app.rs`) *(done, unchanged by the gating work)* @@ -279,10 +335,10 @@ shape for `routes`. `Response` is imported from `crate::http`. failure) and emit `builder = builder.with_manifest_json()` as the first builder mutation in `build_router()`. - Emit `const _: &[u8] = include_bytes!();` so Cargo treats - `edgezero.toml` as a build input (rebuild on manifest change). + `edgezero.toml` as a build input. - Route registration is ordinary `builder.get(path, handler)` / `route(...)`; the macro does **not** inspect handler paths. Gating comes entirely from the - handler's `needs_introspection()`. + handler's `introspection_needs()`. ### Component 6 — Removals *(done)* @@ -300,22 +356,24 @@ No template handler code is generated — the handlers live in core. ## Interfaces (summary) -| Unit | Public surface | -| -------------------------- | --------------------------------------------------------------- | -| `IntrospectionData` | `{ manifest_json: Option>, routes: Arc<[RouteInfo]> }` | -| `DynHandler` | `fn needs_introspection(&self) -> bool { false }` | -| `RouterBuilder` | `with_manifest_json(impl Into>)` | -| `RequestContext` | `introspection() -> Option<&IntrospectionData>` | -| `introspection::ManifestJson` | `FromRequest`; `pub Arc` | -| `introspection::RouteTable` | `FromRequest`; `pub Arc<[RouteInfo]>` | -| `introspection::{manifest,routes}` | `#[action(manifest)]` / `#[action(routes)]` GET → JSON | -| `introspection::config` | `#[action]` GET → JSON (default config store) | +| Unit | Public surface | +| ----------------------------- | ----------------------------------------------------------------- | +| `IntrospectionNeeds` | `{ manifest: bool, routes: bool }`, `Copy`, `Default`, `any()` | +| `DynHandler` | `fn introspection_needs(&self) -> IntrospectionNeeds { default }` | +| `RouterBuilder` | `with_manifest_json(impl Into>)` | +| `RequestContext` | `pub(crate) extension::() -> Option` (no public accessor) | +| `introspection::ManifestJson` | `pub Arc`; `Clone`; `FromRequest` | +| `introspection::RouteTable` | `pub Arc<[RouteInfo]>`; `Clone`; `FromRequest` | +| `introspection::{manifest,routes}` | `#[action(manifest)]` / `#[action(routes)]` GET → JSON | +| `introspection::config` | `#[action]` GET → JSON (default config store) | ## Error Handling -- **manifest** / **routes**: extractor returns `500 internal` if - `IntrospectionData` is absent (route not opted in) or, for `manifest`, if - `manifest_json` is `None` (no `with_manifest_json`). +- **manifest** / **routes**: the extractor returns `500 internal` if its payload + is absent from the request — i.e. the route did not opt into that capability + (`#[action(manifest)]` / `#[action(routes)]` missing), or, for `manifest`, the + app never called `with_manifest_json` (`manifest_json` is `None`, so `dispatch` + injects nothing). - **config**: no default config store → `404`; no blob → `404`; `ConfigStoreError` mapped via `EdgeError::from` (503 unavailable / 400 invalid-key / 500 internal); malformed or unverifiable envelope → `500`. @@ -325,13 +383,15 @@ No template handler code is generated — the handlers live in core. Colocated `#[cfg(test)]`, `futures::executor::block_on` (no Tokio), no network. - **macros**: `#[action]` (no params) still emits a fn; `#[action(manifest)]` - emits a struct impl'ing `DynHandler` with `needs_introspection() == true`; - `#[action(bogus)]` is a compile error. Plus a **compile/behavioral** test in - core that a struct handler registers and runs: `.get("/m", manifest)` → - `oneshot` → 200 (proves the unit-struct-as-handler-value path works end to end). -- **handler.rs / router.rs**: `needs_introspection()` default is false for fn - handlers; a flagged route injects `IntrospectionData` (handler + middleware see - it); a non-flagged route does **not** (`ctx.introspection().is_none()`). + emits a struct impl'ing `DynHandler` with `introspection_needs()` setting + `manifest: true`; `#[action(bogus)]` is a compile error. Plus a + **compile/behavioral** test in core proving `.get("/m", manifest)` accepts the + unit-struct handler value and runs end to end (`oneshot` → 200). +- **handler.rs / router.rs**: default `introspection_needs()` is all-false for fn + handlers; a `manifest`-flagged route injects `ManifestJson` (handler + + middleware can read it) and does **not** inject `RouteTable`; a `routes`-flagged + route is the mirror; a plain route injects neither + (`ctx.extension::().is_none()`). - **introspection module**: `manifest` returns injected JSON; `routes` returns `[{method,path}]`; `config` returns envelope `data` and the full status matrix (200/404/400/503/500×3); `manifest` with no baked JSON → 500. @@ -356,22 +416,27 @@ Colocated `#[cfg(test)]`, `futures::executor::block_on` (no Tokio), no network. wherever bound; config output is secret-safe, and `/manifest` emits `environment.variables[].value` (not secrets) — documented so operators don't store secrets in `[environment.variables]`. Access control is a follow-up. -- **No process-global state**: `IntrospectionData` is per-`RouterService`, so - tests and multiple apps in one process stay independent. +- **No process-global state**: `manifest_json` / `route_index` are + per-`RouterService`, so tests and multiple apps in one process stay independent. - **No `[introspection]` manifest section, no builder enable-API, no `app!` handler-path inspection** — the opt-in lives on `#[action(...)]`. ## Design evolution (for reviewers) -1. First cut: inject `IntrospectionData` on **every** request; handlers read +1. First cut: inject a bundle on **every** request; handlers read `ctx.introspection()`. Rejected — taxes all traffic for rarely-hit endpoints. 2. Considered a process-global (`OnceLock`) source — rejected: one-manifest- per-process breaks unit tests and adds shared mutable state. 3. Considered `app!` recognizing the `edgezero_core::introspection::` handler namespace to flag routes — rejected as a fragile string-match hack. -4. **Final:** the opt-in is an atomic `#[action(manifest|routes)]` parameter; - the capability rides the handler to registration via - `DynHandler::needs_introspection()`; the router gates injection per route. - Typed extractors (`ManifestJson`/`RouteTable`) are the access mechanism. - No global, no `app!` hack, no unstable specialization, and `#[action]` (no - params) is 100% unchanged so only `manifest`/`routes` become structs. +4. Considered a single `DynHandler::needs_introspection() -> bool` + one + `IntrospectionData` bundle — rejected: inconsistent with the atomic + `#[action(manifest|routes)]` params, and it over-provisions (a `manifest`-only + route would carry the route table). +5. **Final:** fully atomic. `#[action(manifest|routes)]` → `IntrospectionNeeds` + (per-capability bools) reported by `DynHandler::introspection_needs()`; the + router injects each capability's payload independently, only for routes that + asked for it. The extractors `ManifestJson`/`RouteTable` are themselves the + injected payloads. No global, no `app!` hack, no bundle, no unstable + specialization; `#[action]` (no params) is 100% unchanged so only + `manifest`/`routes` become structs. From e7f9d5714b2c26acd977a1c5380613bfbaa2ab76 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:07:04 -0700 Subject: [PATCH 23/29] Plan: prove per-capability atomicity (3-case probe); atomic header line; cargo test in Task 10a --- .../plans/2026-07-02-introspection-routes.md | 60 ++++++++++++++----- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/docs/superpowers/plans/2026-07-02-introspection-routes.md b/docs/superpowers/plans/2026-07-02-introspection-routes.md index 683f1a06..50913430 100644 --- a/docs/superpowers/plans/2026-07-02-introspection-routes.md +++ b/docs/superpowers/plans/2026-07-02-introspection-routes.md @@ -20,7 +20,7 @@ **Goal:** Add three reusable core introspection handlers — `edgezero_core::introspection::{manifest, config, routes}` — that any app binds via `[[triggers.http]]`, default-mounted at `/_/{manifest,config,routes}`. -**Architecture (final — see Addendum):** `Manifest` gains `Serialize`; the `app!` macro bakes it to JSON via `RouterService::builder().with_manifest_json(...)`. Handlers opt into introspection data with an atomic `#[action(manifest)]` / `#[action(routes)]` parameter, which expands them to capability-carrying handler structs; `add_route` reads `DynHandler::needs_introspection()` and flags the `RouteEntry`; `RouterInner::dispatch` injects a shared `Arc` **only for flagged routes**. `ManifestJson`/`RouteTable` extractors read it; `config` uses the default config store (no injection). `app!` and `edgezero.toml` never learn about introspection. The legacy `enable_route_listing` machinery and `/__edgezero/routes` are removed. +**Architecture (final — see Addendum):** `Manifest` gains `Serialize`; the `app!` macro bakes it to JSON via `RouterService::builder().with_manifest_json(...)`. Handlers opt into introspection data with an atomic `#[action(manifest)]` / `#[action(routes)]` parameter, which expands them to capability-carrying handler structs whose `DynHandler::introspection_needs()` returns an `IntrospectionNeeds { manifest, routes }`; `add_route` reads it onto the `RouteEntry`; `RouterInner::dispatch` injects each payload **independently and only for routes that asked** — `ManifestJson` iff `needs.manifest`, `RouteTable` iff `needs.routes` (no shared bundle). The `ManifestJson`/`RouteTable` extractors are themselves the injected payloads and clone their own type back out via a `pub(crate) extension::()` accessor; `config` uses the default config store (no injection). `app!` and `edgezero.toml` never learn about introspection. The legacy `enable_route_listing` machinery and `/__edgezero/routes` are removed. **Tech Stack:** Rust 1.95 (edition 2021), `serde`/`serde_json`, `matchit` routing, `#[action]`/`app!` proc-macros, `futures::executor::block_on` for tests. WASM-first: no Tokio, no runtime-specific deps in core. @@ -1258,8 +1258,24 @@ pub trait DynHandler: Send + Sync { } ``` The `Fn` blanket impl needs no change. -- [ ] **Step 2 — verify:** `cargo build -p edgezero-core`; `cargo fmt -p edgezero-core`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. -- [ ] **Step 3 — commit:** `Add IntrospectionNeeds + DynHandler::introspection_needs` +- [ ] **Step 2 — test that a plain fn/closure handler reports the default.** Add to `handler.rs`'s `#[cfg(test)]` (the blanket `impl DynHandler for F: Fn(...)` must yield all-false): +```rust +#[test] +fn fn_handler_reports_default_introspection_needs() { + // A plain closure handler uses the blanket DynHandler impl. + let handler = |_ctx: crate::context::RequestContext| async { + Ok::<&'static str, crate::error::EdgeError>("ok") + }; + assert_eq!( + crate::handler::DynHandler::introspection_needs(&handler), + IntrospectionNeeds::default() + ); + assert!(!IntrospectionNeeds::default().any()); +} +``` + (Match the crate's test imports; `&str: IntoResponse` satisfies the blanket bound.) +- [ ] **Step 3 — verify (repo rule: `cargo test` after code changes):** `cargo test -p edgezero-core` (all pass, incl. the new test); `cargo fmt -p edgezero-core`; `cargo clippy -p edgezero-core --all-targets -- -D warnings`. +- [ ] **Step 4 — commit:** `Add IntrospectionNeeds + DynHandler::introspection_needs` ### Task 10b: `#[action(manifest|routes)]` param parser + struct codegen (edgezero-macros/src/action.rs) @@ -1328,15 +1344,28 @@ quote! { ### Task 10d: the atomic gating swap (edgezero-core/src/{router,context,introspection}.rs) — all in one commit so it compiles -- [ ] **Step 1 (RED) — router tests.** Rewrite `dispatch_injects_introspection_data` and `middleware_sees_introspection_data` to register a LOCAL probe struct (a closure can not report `introspection_needs`), and add a negative test. Probe builds its response with `response_builder` (no `IntoResponse` import needed): +- [ ] **Step 1 (RED) — router tests that PROVE per-capability atomicity.** The + key guarantee (spec §Key Decision 6): a route injects **exactly** the payloads + its handler asked for — `manifest`-only → `ManifestJson` present AND `RouteTable` + absent; `routes`-only → the mirror; plain → neither. A single-payload probe + would let an over-injecting implementation pass, so the probe records BOTH + payloads' presence and each test asserts the exact `(manifest_present, + routes_present)` pair. The probe carries its own `IntrospectionNeeds` so one + type covers all cases, and builds its response with `response_builder` (no + `IntoResponse` import): ```rust -struct ManifestProbe(std::sync::Arc>>); -impl crate::handler::DynHandler for ManifestProbe { +struct CapProbe { + seen: std::sync::Arc>>, + needs: crate::handler::IntrospectionNeeds, +} +impl crate::handler::DynHandler for CapProbe { fn call(&self, ctx: RequestContext) -> crate::http::HandlerFuture { - let cell = std::sync::Arc::clone(&self.0); + let seen = std::sync::Arc::clone(&self.seen); Box::pin(async move { - *cell.lock().unwrap() = - Some(ctx.extension::().is_some()); + *seen.lock().unwrap() = Some(( + ctx.extension::().is_some(), + ctx.extension::().is_some(), + )); crate::http::response_builder() .status(crate::http::StatusCode::OK) .body(crate::body::Body::empty()) @@ -1344,14 +1373,17 @@ impl crate::handler::DynHandler for ManifestProbe { }) } fn introspection_needs(&self) -> crate::handler::IntrospectionNeeds { - crate::handler::IntrospectionNeeds { manifest: true, routes: false } + self.needs } } ``` - - flagged route: `.with_manifest_json("{}").get("/", ManifestProbe(cell))` → cell records `true`. - - middleware test: same flagged route + a probe middleware asserting `ctx.extension::().is_some()` (injection happens before middleware). - - negative test `plain_route_gets_no_manifest`: `.get("/x", |_ctx: RequestContext| async { … })` (a plain closure) → the handler records `ctx.extension::().is_none()`. - Run (single filter per command — Cargo takes ONE positional filter): + Three explicit atomicity tests (each builds a router, `oneshot`s a GET, then + reads the cell). Use `IntrospectionNeeds { manifest, routes }` literally: + - `manifest_route_injects_only_manifest`: probe `needs { manifest: true, routes: false }`, router `.with_manifest_json("{}").get("/", probe)` → cell == `Some((true, false))`. + - `routes_route_injects_only_routes`: probe `needs { manifest: false, routes: true }`, router `.get("/", probe)` (no `with_manifest_json` needed — `RouteTable` comes from the always-present route index) → cell == `Some((false, true))`. + - `plain_route_injects_neither`: probe `needs IntrospectionNeeds::default()` (or a plain closure), `.with_manifest_json("{}").get("/", probe)` → cell == `Some((false, false))` (manifest is available but NOT injected because the route didn't ask). + Plus keep a **middleware** test: a `manifest`-flagged route + a probe middleware asserting `ctx.extension::().is_some()` before the handler runs (injection precedes the middleware chain). + Run (single positional filter per command — Cargo takes ONE): `cargo test -p edgezero-core router` (fails: `introspection_needs`/`extension`/gating absent). - [ ] **Step 2 — `RouteEntry` flag.** Add `introspection_needs: IntrospectionNeeds` (`Copy`); copy it in the manual `Clone`/`clone_from`. - [ ] **Step 3 — read it in `add_route`:** From be2e9a431951fad83af8f8b1ea243c19fe7f978f Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 10:57:33 -0700 Subject: [PATCH 24/29] Add IntrospectionNeeds + DynHandler::introspection_needs --- crates/edgezero-core/src/handler.rs | 52 +++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/crates/edgezero-core/src/handler.rs b/crates/edgezero-core/src/handler.rs index 17fd4831..ea545424 100644 --- a/crates/edgezero-core/src/handler.rs +++ b/crates/edgezero-core/src/handler.rs @@ -6,8 +6,36 @@ use crate::error::EdgeError; use crate::http::HandlerFuture; use crate::response::IntoResponse; +/// Which introspection payloads a route's handler needs injected at dispatch. +/// +/// Reported per handler via [`DynHandler::introspection_needs`]. Handlers written +/// with `#[action(manifest)]` / `#[action(routes)]` set the matching field(s); +/// every other handler reports the default (all-false). +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct IntrospectionNeeds { + pub manifest: bool, + pub routes: bool, +} + +impl IntrospectionNeeds { + /// Whether this handler needs any introspection payload injected. + #[must_use] + #[inline] + pub fn any(self) -> bool { + self.manifest || self.routes + } +} + pub trait DynHandler: Send + Sync { fn call(&self, ctx: RequestContext) -> HandlerFuture; + + /// Introspection payloads a route bound to this handler needs injected into + /// the request at dispatch. Defaults to none; `#[action(manifest)]` / + /// `#[action(routes)]` handlers override it. + #[inline] + fn introspection_needs(&self) -> IntrospectionNeeds { + IntrospectionNeeds::default() + } } impl DynHandler for F @@ -21,6 +49,13 @@ where let fut = (self)(ctx); Box::pin(async move { fut.await?.into_response() }) } + + // `missing_trait_methods` (deny) forbids relying on the trait default here; + // spell out the same all-false result that fn/closure handlers report. + #[inline] + fn introspection_needs(&self) -> IntrospectionNeeds { + IntrospectionNeeds::default() + } } pub type BoxHandler = Arc; @@ -38,3 +73,20 @@ where Arc::new(self) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fn_handler_reports_default_introspection_needs() { + // A plain closure handler uses the blanket `DynHandler` impl, which must + // report no introspection needs. (`&str: IntoResponse` satisfies the bound.) + let handler = |_ctx: RequestContext| async { Ok::<&'static str, EdgeError>("ok") }; + assert_eq!( + DynHandler::introspection_needs(&handler), + IntrospectionNeeds::default() + ); + assert!(!IntrospectionNeeds::default().any()); + } +} From 8273395d5ab9727b4826819077f94f6dc9caccf3 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:03:33 -0700 Subject: [PATCH 25/29] #[action]: accept atomic (manifest|routes) params; emit capability-carrying handler struct --- crates/edgezero-macros/src/action.rs | 163 ++++++++++++++++++++++----- 1 file changed, 135 insertions(+), 28 deletions(-) diff --git a/crates/edgezero-macros/src/action.rs b/crates/edgezero-macros/src/action.rs index 92a03c63..bd1e8467 100644 --- a/crates/edgezero-macros/src/action.rs +++ b/crates/edgezero-macros/src/action.rs @@ -1,6 +1,12 @@ use proc_macro::TokenStream; use quote::{format_ident, quote}; -use syn::{spanned::Spanned as _, Error, FnArg, ItemFn, Pat, PathArguments, Type}; +use syn::parse::Parser as _; +use syn::punctuated::Punctuated; +use syn::{spanned::Spanned as _, Error, FnArg, ItemFn, Pat, PathArguments, Token, Type}; + +/// `(extract_stmts, arg_idents)` produced from a handler's argument list — the +/// `FromRequest` extraction statements and the idents passed to the inner fn. +type ArgExtractors = (Vec, Vec); pub fn expand_action(attr: TokenStream, item: TokenStream) -> TokenStream { expand_action_impl(&attr.into(), item.into()).into() @@ -10,10 +16,15 @@ fn expand_action_impl( attr: &proc_macro2::TokenStream, item: proc_macro2::TokenStream, ) -> proc_macro2::TokenStream { - if !attr.is_empty() { - return syn::Error::new(attr.span(), "#[action] does not accept arguments") - .to_compile_error(); - } + // `#[action]` takes an optional atomic capability list, e.g. + // `#[action(manifest)]` / `#[action(routes)]` / `#[action(manifest, routes)]`. + // Each names an introspection payload the handler needs injected; a handler + // that opts in is emitted as a capability-carrying struct (see below). + let (manifest_cap, routes_cap) = match parse_action_params(attr) { + Ok(caps) => caps, + Err(err) => return err.to_compile_error(), + }; + let is_capability_handler = manifest_cap || routes_cap; let func: ItemFn = match syn::parse2(item) { Ok(func) => func, @@ -52,6 +63,93 @@ fn expand_action_impl( return err.to_compile_error(); } + let (extract_stmts, arg_idents) = match build_arg_extractors(&func) { + Ok(parts) => parts, + Err(err) => return err.to_compile_error(), + }; + + let output = if is_capability_handler { + // A fn can't carry per-handler data past type-erasure into + // `Arc`, so an opt-in handler becomes a unit struct with + // its own `DynHandler` impl whose `introspection_needs()` reports which + // payloads the router must inject for its route. + quote! { + #inner_fn + + #(#attrs)* + #[allow(non_camel_case_types)] + #vis struct #ident; + + impl ::edgezero_core::handler::DynHandler for #ident { + #[inline] + fn call( + &self, + __ctx: ::edgezero_core::context::RequestContext, + ) -> ::edgezero_core::http::HandlerFuture { + ::std::boxed::Box::pin(async move { + #(#extract_stmts)* + let result = #inner_ident(#(#arg_idents),*).await; + ::edgezero_core::responder::Responder::respond(result) + }) + } + + #[inline] + fn introspection_needs(&self) -> ::edgezero_core::handler::IntrospectionNeeds { + ::edgezero_core::handler::IntrospectionNeeds { + manifest: #manifest_cap, + routes: #routes_cap, + } + } + } + } + } else { + quote! { + #inner_fn + + #(#attrs)* + #vis async fn #ident( + __ctx: ::edgezero_core::context::RequestContext, + ) -> ::std::result::Result<::edgezero_core::http::Response, ::edgezero_core::error::EdgeError> { + #(#extract_stmts)* + let result = #inner_ident(#(#arg_idents),*).await; + ::edgezero_core::responder::Responder::respond(result) + } + } + }; + + output +} + +/// Parse the optional `#[action(...)]` capability list into +/// `(needs_manifest, needs_routes)`. Empty attr → `(false, false)`. Unknown +/// idents are a compile error. Extend the known set as new capabilities land. +fn parse_action_params(attr: &proc_macro2::TokenStream) -> Result<(bool, bool), Error> { + if attr.is_empty() { + return Ok((false, false)); + } + let params = Punctuated::::parse_terminated.parse2(attr.clone())?; + let mut manifest_cap = false; + let mut routes_cap = false; + for param in ¶ms { + if param == "manifest" { + manifest_cap = true; + } else if param == "routes" { + routes_cap = true; + } else { + return Err(Error::new( + param.span(), + format!("unknown #[action] parameter `{param}`; supported: manifest, routes"), + )); + } + } + Ok((manifest_cap, routes_cap)) +} + +/// Build the per-argument extractor statements and the argument idents passed to +/// the inner fn. `RequestContext` arguments map to `__ctx`; every other argument +/// is extracted via `FromRequest`. Returns the `(extract_stmts, arg_idents)` +/// used by both the fn and struct codegen forms. +fn build_arg_extractors(func: &ItemFn) -> Result { let mut extract_stmts = Vec::new(); let mut arg_idents = Vec::new(); let mut has_request_context = false; @@ -60,22 +158,20 @@ fn expand_action_impl( let pat_type = match arg { FnArg::Typed(pat_type) => pat_type, FnArg::Receiver(receiver) => { - return syn::Error::new( + return Err(Error::new( receiver.span(), "#[action] functions cannot have a `self` receiver", - ) - .to_compile_error(); + )); } }; let ty = &pat_type.ty; if is_request_context_type(ty) { if has_request_context { - return syn::Error::new( + return Err(Error::new( ty.span(), "#[action] functions support at most one RequestContext argument", - ) - .to_compile_error(); + )); } has_request_context = true; arg_idents.push(quote! { __ctx }); @@ -89,20 +185,7 @@ fn expand_action_impl( arg_idents.push(quote! { #var_ident }); } - let output = quote! { - #inner_fn - - #(#attrs)* - #vis async fn #ident( - __ctx: ::edgezero_core::context::RequestContext, - ) -> ::std::result::Result<::edgezero_core::http::Response, ::edgezero_core::error::EdgeError> { - #(#extract_stmts)* - let result = #inner_ident(#(#arg_idents),*).await; - ::edgezero_core::responder::Responder::respond(result) - } - }; - - output + Ok((extract_stmts, arg_idents)) } fn is_request_context_type(ty: &Type) -> bool { @@ -209,15 +292,39 @@ mod tests { } #[test] - fn rejects_attribute_arguments() { + fn rejects_unknown_param() { let input = quote! { async fn demo(ctx: ::edgezero_core::context::RequestContext) -> ::edgezero_core::http::Response { unimplemented!() } }; - let output = expand_action_impl("e!(path = "/demo"), input); + let output = expand_action_impl("e!(bogus), input); let rendered = render(&output); - assert!(rendered.contains("does not accept arguments")); + assert!(rendered.contains("unknown #[action] parameter")); + } + + #[test] + fn manifest_param_emits_capability_struct() { + let input = quote! { + async fn manifest( + ManifestJson(json): ManifestJson, + ) -> ::std::result::Result< + ::edgezero_core::http::Response, + ::edgezero_core::error::EdgeError, + > { + let _ = json; + unimplemented!() + } + }; + let output = expand_action_impl("e!(manifest), input); + let collapsed = collapse_whitespace(&render(&output)); + // Opt-in handlers become a capability-carrying struct, not a fn. + assert!(collapsed.contains("structmanifest")); + assert!(collapsed.contains("DynHandlerformanifest")); + assert!(collapsed.contains("fnintrospection_needs")); + // The `manifest` capability field is set true; `routes` false. + assert!(collapsed.contains("manifest:true")); + assert!(collapsed.contains("routes:false")); } #[test] From c30e1e5a09ab90a7269a92fe16932bc87c095978 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:04:49 -0700 Subject: [PATCH 26/29] Opt manifest/routes into introspection via #[action(manifest|routes)] --- crates/edgezero-core/src/introspection.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/edgezero-core/src/introspection.rs b/crates/edgezero-core/src/introspection.rs index 650b17b0..f05133a4 100644 --- a/crates/edgezero-core/src/introspection.rs +++ b/crates/edgezero-core/src/introspection.rs @@ -66,13 +66,13 @@ fn json_response(status: StatusCode, body: Body) -> Result } /// GET — the app manifest as JSON (baked at compile time by `app!`). -#[action] +#[action(manifest)] pub async fn manifest(ManifestJson(json): ManifestJson) -> Result { json_response(StatusCode::OK, Body::text(json.to_string())) } /// GET — `[{ "method", "path" }]` for every registered route. -#[action] +#[action(routes)] pub async fn routes(RouteTable(table): RouteTable) -> Result { let views: Vec = table .iter() From 15949c89c20c507f7eb05ca03fe8a0f6df5c326e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 11:35:05 -0700 Subject: [PATCH 27/29] Gate introspection injection per capability via IntrospectionNeeds --- crates/edgezero-core/src/context.rs | 20 +- crates/edgezero-core/src/introspection.rs | 36 ++-- crates/edgezero-core/src/router.rs | 244 ++++++++++++++-------- 3 files changed, 187 insertions(+), 113 deletions(-) diff --git a/crates/edgezero-core/src/context.rs b/crates/edgezero-core/src/context.rs index d3656e9f..24eebf01 100644 --- a/crates/edgezero-core/src/context.rs +++ b/crates/edgezero-core/src/context.rs @@ -3,7 +3,6 @@ use crate::error::EdgeError; use crate::http::Request; use crate::params::PathParams; use crate::proxy::ProxyHandle; -use crate::router::IntrospectionData; use crate::store_registry::{ BoundConfigStore, BoundKvStore, BoundSecretStore, ConfigRegistry, ConfigStoreBinding, KvRegistry, SecretRegistry, StoreRegistry, @@ -70,6 +69,18 @@ impl RequestContext { .and_then(|registry| registry.default_ref()) } + /// Clone a request extension of type `T`, if present. Used by the + /// introspection extractors (`ManifestJson` / `RouteTable`) to read the + /// payload the router injected for their route. + #[must_use] + #[inline] + pub(crate) fn extension(&self) -> Option + where + T: Clone + Send + Sync + 'static, + { + self.request.extensions().get::().cloned() + } + /// # Errors /// Returns [`EdgeError::bad_request`] if the body cannot be deserialized as form-urlencoded data into `T`, or the body is streaming. #[inline] @@ -91,13 +102,6 @@ impl RequestContext { self.request } - /// The per-request [`IntrospectionData`] injected by the router, if any. - #[must_use] - #[inline] - pub fn introspection(&self) -> Option<&IntrospectionData> { - self.request.extensions().get::() - } - /// # Errors /// Returns [`EdgeError::bad_request`] if the body is not valid JSON for `T`. #[inline] diff --git a/crates/edgezero-core/src/introspection.rs b/crates/edgezero-core/src/introspection.rs index f05133a4..706a8676 100644 --- a/crates/edgezero-core/src/introspection.rs +++ b/crates/edgezero-core/src/introspection.rs @@ -21,39 +21,39 @@ struct RouteView { path: String, } -/// Extractor for the baked manifest JSON carried in the request's -/// [`crate::router::IntrospectionData`]. Errors with 500 if the data is -/// absent (i.e. the router did not inject it). +/// Extractor for the baked manifest JSON. It is also the payload the router +/// injects (via `dispatch`) for a route whose handler is `#[action(manifest)]`; +/// `from_request` clones it back out. Errors with 500 if the route did not opt +/// into the `manifest` capability (or no manifest was baked). +#[derive(Clone)] pub struct ManifestJson(pub Arc); #[async_trait(?Send)] impl FromRequest for ManifestJson { #[inline] async fn from_request(ctx: &RequestContext) -> Result { - ctx.introspection() - .and_then(|data| data.manifest_json.clone()) - .map(ManifestJson) - .ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!("manifest introspection data not available")) - }) + ctx.extension::().ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!("manifest introspection data not available")) + }) } } -/// Extractor for the live route index carried in the request's -/// [`crate::router::IntrospectionData`]. Errors with 500 if the data is absent. +/// Extractor for the live route index. It is also the payload the router injects +/// for a route whose handler is `#[action(routes)]`; `from_request` clones it +/// back out. Errors with 500 if the route did not opt into the `routes` +/// capability. +#[derive(Clone)] pub struct RouteTable(pub Arc<[RouteInfo]>); #[async_trait(?Send)] impl FromRequest for RouteTable { #[inline] async fn from_request(ctx: &RequestContext) -> Result { - ctx.introspection() - .map(|data| RouteTable(Arc::clone(&data.routes))) - .ok_or_else(|| { - EdgeError::internal(anyhow::anyhow!( - "route-table introspection data not available" - )) - }) + ctx.extension::().ok_or_else(|| { + EdgeError::internal(anyhow::anyhow!( + "route-table introspection data not available" + )) + }) } } diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index d5ba3049..7c3b9a16 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -7,25 +7,29 @@ use tower_service::Service; use crate::context::RequestContext; use crate::error::EdgeError; -use crate::handler::{BoxHandler, IntoHandler}; +use crate::handler::{BoxHandler, IntoHandler, IntrospectionNeeds}; use crate::http::{HandlerFuture, Method, Request, Response}; +use crate::introspection::{ManifestJson, RouteTable}; use crate::middleware::{BoxMiddleware, Middleware, Next}; use crate::params::PathParams; use crate::response::IntoResponse as _; struct RouteEntry { handler: BoxHandler, + introspection_needs: IntrospectionNeeds, } impl Clone for RouteEntry { fn clone(&self) -> Self { Self { handler: Arc::clone(&self.handler), + introspection_needs: self.introspection_needs, } } fn clone_from(&mut self, source: &Self) { self.handler = Arc::clone(&source.handler); + self.introspection_needs = source.introspection_needs; } } @@ -57,15 +61,6 @@ impl RouteInfo { } } -/// Per-request introspection payload injected by [`RouterInner::dispatch`]. -#[derive(Clone)] -pub struct IntrospectionData { - /// The app manifest serialized to JSON at compile time by `app!`. - pub manifest_json: Option>, - /// Every registered route, in registration order. - pub routes: Arc<[RouteInfo]>, -} - enum RouteMatch<'route> { Found(&'route RouteEntry, PathParams), MethodNotAllowed(Vec), @@ -91,11 +86,17 @@ impl RouterBuilder { { let router = self.routes.entry(method.clone()).or_default(); + // The handler reports which introspection payloads its route needs; the + // flag is read once here and consulted per request in `dispatch`. + let boxed = handler.into_handler(); + let introspection_needs = boxed.introspection_needs(); + router .insert( path, RouteEntry { - handler: handler.into_handler(), + handler: boxed, + introspection_needs, }, ) .unwrap_or_else(|err| panic!("duplicate route definition for {path}: {err}")); @@ -203,16 +204,26 @@ struct RouterInner { impl RouterInner { async fn dispatch(&self, mut request: Request) -> Result { - request.extensions_mut().insert(IntrospectionData { - manifest_json: self.manifest_json.clone(), - routes: Arc::clone(&self.route_index), - }); - let method = request.method().clone(); let path = request.uri().path().to_owned(); match self.find_route(&method, &path) { RouteMatch::Found(entry, params) => { + // Inject only the introspection payloads this route asked for — + // nothing for the vast majority of routes that need none. + let needs = entry.introspection_needs; + if needs.manifest { + if let Some(json) = &self.manifest_json { + request + .extensions_mut() + .insert(ManifestJson(Arc::clone(json))); + } + } + if needs.routes { + request + .extensions_mut() + .insert(RouteTable(Arc::clone(&self.route_index))); + } let ctx = RequestContext::new(request, params); let next = Next::new(&self.middlewares, entry.handler.as_ref()); next.run(ctx).await @@ -320,6 +331,135 @@ impl RouterService { #[cfg(test)] mod tests { + /// Per-capability introspection injection: a route receives exactly the + /// payloads its handler opted into via `#[action(manifest|routes)]`. + mod introspection_gating { + use super::*; + use crate::handler::DynHandler; + + /// A handler that records which introspection payloads its request + /// carried, as `(manifest_present, routes_present)`, and reports `needs`. + struct CapProbe { + needs: IntrospectionNeeds, + seen: Arc>>, + } + + impl DynHandler for CapProbe { + fn call(&self, ctx: RequestContext) -> HandlerFuture { + let seen = Arc::clone(&self.seen); + Box::pin(async move { + *seen.lock().unwrap() = Some(( + ctx.extension::().is_some(), + ctx.extension::().is_some(), + )); + response_with_body(StatusCode::OK, Body::empty()) + }) + } + fn introspection_needs(&self) -> IntrospectionNeeds { + self.needs + } + } + + #[test] + fn manifest_route_injects_only_manifest() { + // Manifest available AND requested → ManifestJson present, RouteTable absent. + let seen = run_probe( + RouterService::builder().with_manifest_json("{\"app\":{\"name\":\"t\"}}"), + IntrospectionNeeds { + manifest: true, + routes: false, + }, + ); + assert_eq!(seen, (true, false)); + } + + #[test] + fn middleware_sees_injected_manifest() { + // Injection happens before the middleware chain, so a middleware on a + // manifest-flagged route sees the payload. + struct Probe(Arc>>); + #[async_trait::async_trait(?Send)] + impl Middleware for Probe { + async fn handle( + &self, + ctx: RequestContext, + next: Next<'_>, + ) -> Result { + *self.0.lock().unwrap() = Some(ctx.extension::().is_some()); + next.run(ctx).await + } + } + + let saw: Arc>> = Arc::new(Mutex::new(None)); + let router = RouterService::builder() + .with_manifest_json("{\"app\":{\"name\":\"t\"}}") + .middleware(Probe(Arc::clone(&saw))) + .get( + "/", + CapProbe { + needs: IntrospectionNeeds { + manifest: true, + routes: false, + }, + seen: Arc::new(Mutex::new(None)), + }, + ) + .build(); + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .unwrap(); + block_on(router.oneshot(request)).unwrap(); + assert_eq!(*saw.lock().unwrap(), Some(true)); + } + + #[test] + fn plain_route_injects_neither() { + // Manifest IS baked but the route requested nothing → neither injected. + let seen = run_probe( + RouterService::builder().with_manifest_json("{\"app\":{\"name\":\"t\"}}"), + IntrospectionNeeds::default(), + ); + assert_eq!(seen, (false, false)); + } + + #[test] + fn routes_route_injects_only_routes() { + // Only `routes` requested → RouteTable present (from the always-available + // route index), ManifestJson absent (not requested; none baked either). + let seen = run_probe( + RouterService::builder(), + IntrospectionNeeds { + manifest: false, + routes: true, + }, + ); + assert_eq!(seen, (false, true)); + } + + fn run_probe(builder: RouterBuilder, needs: IntrospectionNeeds) -> (bool, bool) { + let seen = Arc::new(Mutex::new(None)); + let router = builder + .get( + "/", + CapProbe { + needs, + seen: Arc::clone(&seen), + }, + ) + .build(); + let request = request_builder() + .method(Method::GET) + .uri("/") + .body(Body::empty()) + .unwrap(); + block_on(router.oneshot(request)).unwrap(); + let observed = *seen.lock().unwrap(); + observed.expect("handler ran") + } + } + use super::*; use crate::body::Body; use crate::context::RequestContext; @@ -533,6 +673,7 @@ mod tests { fn route_entry_clone_copies_handler() { let entry = RouteEntry { handler: ok_handler.into_handler(), + introspection_needs: IntrospectionNeeds::default(), }; let cloned = entry.clone(); @@ -583,77 +724,6 @@ mod tests { assert!(matches!(ready, Poll::Ready(Ok(())))); } - #[test] - fn dispatch_injects_introspection_data() { - let seen: Arc>> = Arc::new(Mutex::new(None)); - let seen_capture = Arc::clone(&seen); - - let handler = move |ctx: RequestContext| { - let seen_inner = Arc::clone(&seen_capture); - async move { - let data = ctx.introspection().expect("introspection data present"); - *seen_inner.lock().unwrap() = - Some((data.manifest_json.is_some(), data.routes.len())); - Ok::<_, EdgeError>("ok") - } - }; - - let router = RouterService::builder() - .with_manifest_json("{\"app\":{\"name\":\"t\"}}") - .get("/", handler) - .build(); - - let request = request_builder() - .method(Method::GET) - .uri("/") - .body(Body::empty()) - .unwrap(); - block_on(router.oneshot(request)).unwrap(); - - let (had_manifest, route_count) = seen.lock().unwrap().expect("handler ran"); - assert!(had_manifest, "manifest_json should be injected"); - assert_eq!(route_count, 1); - } - - #[test] - fn middleware_sees_introspection_data() { - struct Probe(Arc>>); - #[async_trait::async_trait(?Send)] - impl Middleware for Probe { - async fn handle( - &self, - ctx: RequestContext, - next: Next<'_>, - ) -> Result { - *self.0.lock().unwrap() = ctx - .introspection() - .map(|data| (data.manifest_json.is_some(), data.routes.len())); - next.run(ctx).await - } - } - - let saw: Arc>> = Arc::new(Mutex::new(None)); - let router = RouterService::builder() - .with_manifest_json("{\"app\":{\"name\":\"t\"}}") - .middleware(Probe(Arc::clone(&saw))) - .get("/", |_ctx: RequestContext| async { - Ok::<_, EdgeError>("ok") - }) - .build(); - let request = request_builder() - .method(Method::GET) - .uri("/") - .body(Body::empty()) - .unwrap(); - block_on(router.oneshot(request)).unwrap(); - let (had_manifest, route_count) = saw.lock().unwrap().expect("middleware ran"); - assert!(had_manifest, "middleware should see manifest_json"); - assert!( - route_count > 0, - "middleware should see non-empty route list" - ); - } - #[test] fn streams_body_through_router() { use bytes::Bytes; From e68df70b1a856e20c20434472dc22d68769ce18e Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:00:29 -0700 Subject: [PATCH 28/29] Review fixes: combined-caps atomicity test, no-params fn assertion, stale comment --- crates/edgezero-core/src/introspection.rs | 5 +++-- crates/edgezero-core/src/router.rs | 13 +++++++++++++ crates/edgezero-macros/src/action.rs | 6 +++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/edgezero-core/src/introspection.rs b/crates/edgezero-core/src/introspection.rs index 706a8676..2c176016 100644 --- a/crates/edgezero-core/src/introspection.rs +++ b/crates/edgezero-core/src/introspection.rs @@ -204,8 +204,9 @@ mod tests { #[test] fn manifest_without_baked_json_is_500() { - // No `with_manifest_json`: IntrospectionData is still injected, but - // `manifest_json` is None, so the `ManifestJson` extractor errors 500. + // The route opts into `manifest`, but no manifest was baked + // (`with_manifest_json` not called), so `dispatch` injects nothing and + // the `ManifestJson` extractor errors 500. let router = RouterService::builder().get("/m", manifest).build(); let req = request_builder() .method(Method::GET) diff --git a/crates/edgezero-core/src/router.rs b/crates/edgezero-core/src/router.rs index 7c3b9a16..eae25a9f 100644 --- a/crates/edgezero-core/src/router.rs +++ b/crates/edgezero-core/src/router.rs @@ -360,6 +360,19 @@ mod tests { } } + #[test] + fn manifest_and_routes_route_injects_both() { + // Combined `#[action(manifest, routes)]` → both payloads injected. + let seen = run_probe( + RouterService::builder().with_manifest_json("{\"app\":{\"name\":\"t\"}}"), + IntrospectionNeeds { + manifest: true, + routes: true, + }, + ); + assert_eq!(seen, (true, true)); + } + #[test] fn manifest_route_injects_only_manifest() { // Manifest available AND requested → ManifestJson present, RouteTable absent. diff --git a/crates/edgezero-macros/src/action.rs b/crates/edgezero-macros/src/action.rs index bd1e8467..4a9e02d9 100644 --- a/crates/edgezero-macros/src/action.rs +++ b/crates/edgezero-macros/src/action.rs @@ -277,8 +277,12 @@ mod tests { let output = expand_action_impl(&TokenStream::new(), input); let rendered = render(&output); assert!(rendered.contains("__demo_inner")); - assert!(rendered.contains("fn demo")); assert!(rendered.contains("responder :: Responder :: respond")); + // No params → a plain `async fn`, NOT a capability-carrying struct. + let collapsed = collapse_whitespace(&rendered); + assert!(collapsed.contains("asyncfndemo")); + assert!(!collapsed.contains("structdemo")); + assert!(!collapsed.contains("introspection_needs")); } #[test] From 2efa2da69d4bd1d4f20b7d9595440e6bbc416279 Mon Sep 17 00:00:00 2001 From: Aram Grigoryan <132480+aram356@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:11:37 -0700 Subject: [PATCH 29/29] Add macro-level test for combined #[action(manifest, routes)] codegen --- crates/edgezero-macros/src/action.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/crates/edgezero-macros/src/action.rs b/crates/edgezero-macros/src/action.rs index 4a9e02d9..064a90fe 100644 --- a/crates/edgezero-macros/src/action.rs +++ b/crates/edgezero-macros/src/action.rs @@ -331,6 +331,28 @@ mod tests { assert!(collapsed.contains("routes:false")); } + #[test] + fn manifest_and_routes_params_set_both_capabilities() { + let input = quote! { + async fn both( + ManifestJson(json): ManifestJson, + RouteTable(table): RouteTable, + ) -> ::std::result::Result< + ::edgezero_core::http::Response, + ::edgezero_core::error::EdgeError, + > { + let _ = (json, table); + unimplemented!() + } + }; + let output = expand_action_impl("e!(manifest, routes), input); + let collapsed = collapse_whitespace(&render(&output)); + // The combined form emits a struct whose `introspection_needs` sets both. + assert!(collapsed.contains("structboth")); + assert!(collapsed.contains("manifest:true")); + assert!(collapsed.contains("routes:true")); + } + #[test] fn rejects_self_receivers() { let input = quote! {