Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b082c3c
Add design spec for pluggable introspection routes
aram356 Jul 2, 2026
2e04b26
Add implementation plan for introspection routes
aram356 Jul 2, 2026
98d225a
Revise introspection plan per review: enum serialization, imports, co…
aram356 Jul 2, 2026
7301bce
Revise plan (round 2): real body assertions, oneshot config tests, bo…
aram356 Jul 2, 2026
4254ed4
Revise plan (round 3): single-filter test commands, scoped doc greps,…
aram356 Jul 2, 2026
9011684
Plan: stage roadmap.md alongside routing.md in Task 7 commit
aram356 Jul 2, 2026
5ab3546
Make Manifest serializable with secret-value redaction
aram356 Jul 2, 2026
d09c068
Inject IntrospectionData at router dispatch chokepoint
aram356 Jul 2, 2026
beed0a4
Add edgezero_core::introspection handlers (manifest/config/routes)
aram356 Jul 2, 2026
83dd8c4
app! macro: bake manifest JSON into build_router via with_manifest_json
aram356 Jul 2, 2026
5ed4e8c
Use workspace deps for edgezero-macros proc-macro2/quote/syn
aram356 Jul 2, 2026
9b3b820
Sync app-demo Cargo.lock for edgezero-macros serde_json dep
aram356 Jul 2, 2026
2cd9b0c
Remove legacy route-listing machinery and /__edgezero/routes
aram356 Jul 2, 2026
6869389
Wire default introspection triggers into app-demo and generated apps
aram356 Jul 2, 2026
adbc94d
Docs: replace route-listing with introspection routes
aram356 Jul 2, 2026
8999623
Track manifest as build input; strengthen introspection tests; doc ca…
aram356 Jul 2, 2026
56cca34
Spec/plan: introspection access via independent extractors (no gating)
aram356 Jul 2, 2026
0feb194
Expose introspection via ManifestJson/RouteTable extractors
aram356 Jul 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions crates/edgezero-cli/src/templates/root/edgezero.toml.hbs
Original file line number Diff line number Diff line change
Expand Up @@ -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.<kind>]` declares logical store ids only. `default` is required
Expand Down
8 changes: 8 additions & 0 deletions crates/edgezero-core/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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::<IntrospectionData>()
}

/// # Errors
/// Returns [`EdgeError::bad_request`] if the body is not valid JSON for `T`.
#[inline]
Expand Down
311 changes: 311 additions & 0 deletions crates/edgezero-core/src/introspection.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,311 @@
//! 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::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 {
method: String,
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<str>);

#[async_trait(?Send)]
impl FromRequest for ManifestJson {
#[inline]
async fn from_request(ctx: &RequestContext) -> Result<Self, EdgeError> {
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<Self, EdgeError> {
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, EdgeError> {
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(ManifestJson(json): ManifestJson) -> Result<Response, EdgeError> {
json_response(StatusCode::OK, Body::text(json.to_string()))
}

/// GET — `[{ "method", "path" }]` for every registered route.
#[action]
pub async fn routes(RouteTable(table): RouteTable) -> Result<Response, EdgeError> {
let views: Vec<RouteView> = table
.iter()
.map(|route| RouteView {
method: route.method().as_str().to_owned(),
path: route.path().to_owned(),
})
.collect();
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<Response, EdgeError> {
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<Option<String>, ConfigStoreError>);
#[async_trait(?Send)]
impl ConfigStore for StubStore {
async fn get(&self, _key: &str) -> Result<Option<String>, 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::<BTreeMap<_, _>>(),
"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 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();
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);
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");
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);
}
}
Loading