Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 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
c9c6f34
Spec/plan: per-route gated introspection injection (macro auto-flags,…
aram356 Jul 2, 2026
692dd17
Spec/plan: #[action(introspection)] opt-in drives gated injection (ap…
aram356 Jul 2, 2026
9cb3b79
Docs cleanup: single gated architecture in spec; plan status banner +…
aram356 Jul 2, 2026
a9ad7bc
Spec/plan: fully-atomic introspection (IntrospectionNeeds, per-capabi…
aram356 Jul 2, 2026
e7f9d57
Plan: prove per-capability atomicity (3-case probe); atomic header li…
aram356 Jul 2, 2026
be2e9a4
Add IntrospectionNeeds + DynHandler::introspection_needs
aram356 Jul 2, 2026
8273395
#[action]: accept atomic (manifest|routes) params; emit capability-ca…
aram356 Jul 2, 2026
c30e1e5
Opt manifest/routes into introspection via #[action(manifest|routes)]
aram356 Jul 2, 2026
15949c8
Gate introspection injection per capability via IntrospectionNeeds
aram356 Jul 2, 2026
e68df70
Review fixes: combined-caps atomicity test, no-params fn assertion, s…
aram356 Jul 2, 2026
2efa2da
Add macro-level test for combined #[action(manifest, routes)] codegen
aram356 Jul 2, 2026
585cbc5
docs: add State<T> + nested #[secret] design spec
aram356 Jul 2, 2026
304e236
docs: fold second-pass review blockers into spec §8
aram356 Jul 2, 2026
051a9ad
Merge PR #300 (introspection routes) as base for State<T> + nested se…
aram356 Jul 3, 2026
c208524
docs: implementation plans for State<T> + nested secrets; reconcile s…
aram356 Jul 3, 2026
d51e8b9
feat(core): add State<T> extractor for app-owned shared state
aram356 Jul 3, 2026
4487355
feat(core): RouterBuilder::with_state injects app state into request …
aram356 Jul 3, 2026
21c1488
test(macros): prove #[action] composes State<T>; docs: sharing app state
aram356 Jul 3, 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
12 changes: 12 additions & 0 deletions crates/edgezero-core/src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,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<T>(&self) -> Option<T>
where
T: Clone + Send + Sync + 'static,
{
self.request.extensions().get::<T>().cloned()
}

/// # Errors
/// Returns [`EdgeError::bad_request`] if the body cannot be deserialized as form-urlencoded data into `T`, or the body is streaming.
#[inline]
Expand Down
129 changes: 129 additions & 0 deletions crates/edgezero-core/src/extractor.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use std::any;
use std::ops::{Deref, DerefMut};

use async_trait::async_trait;
Expand Down Expand Up @@ -528,6 +529,66 @@ impl Kv {
}
}

/// Extractor for app-owned shared state registered via
/// [`RouterBuilder::with_state`]. Resolves by type from request extensions.
///
/// Typically `T = Arc<AppState>`. The registered value is cloned into every
/// request's extensions before dispatch; registering the same `T` twice is
/// last-write-wins.
///
/// ```ignore
/// use edgezero_core::extractor::State;
/// use std::sync::Arc;
///
/// #[edgezero_core::action]
/// async fn handle(State(state): State<Arc<AppState>>) -> Result<String, edgezero_core::error::EdgeError> {
/// Ok(state.greeting.clone())
/// }
/// ```
///
/// [`RouterBuilder::with_state`]: crate::router::RouterBuilder::with_state
pub struct State<T>(pub T);

#[async_trait(?Send)]
impl<T> FromRequest for State<T>
where
T: Clone + Send + Sync + 'static,
{
#[inline]
async fn from_request(ctx: &RequestContext) -> Result<Self, EdgeError> {
ctx.extension::<T>().map(State).ok_or_else(|| {
EdgeError::internal(anyhow::anyhow!(
"no `State<{}>` registered -- call RouterBuilder::with_state(..) before build()",
any::type_name::<T>()
))
})
}
}

impl<T> Deref for State<T> {
type Target = T;

#[inline]
fn deref(&self) -> &Self::Target {
&self.0
}
}

impl<T> DerefMut for State<T> {
#[inline]
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}

impl<T> State<T> {
/// Consume the extractor and return the inner value.
#[inline]
pub fn into_inner(self) -> T {
self.0
}
}

/// Extractor that yields the per-request [`SecretRegistry`].
///
/// The returned [`BoundSecretStore`] is pre-bound to a platform store name
Expand Down Expand Up @@ -991,6 +1052,11 @@ mod tests {
use std::sync::Arc;
use validator::Validate;

#[derive(Clone, Debug, PartialEq)]
struct AppStateFixture {
name: String,
}

#[derive(Debug, Deserialize, PartialEq)]
struct FormData {
age: Option<u32>,
Expand Down Expand Up @@ -2400,4 +2466,67 @@ mod tests {
);
}
}

#[test]
fn state_extractor_resolves_registered_value() {
let mut request = request_builder()
.method(Method::GET)
.uri("/")
.body(Body::empty())
.expect("request");
request.extensions_mut().insert(Arc::new(AppStateFixture {
name: "demo".to_owned(),
}));
let ctx = RequestContext::new(request, PathParams::default());

let state =
block_on(State::<Arc<AppStateFixture>>::from_request(&ctx)).expect("state present");

// Deref: State<Arc<AppStateFixture>> -> Arc<AppStateFixture> -> AppStateFixture
assert_eq!(state.name, "demo");
}

#[test]
fn state_extractor_missing_registration_is_internal_error() {
let request = request_builder()
.method(Method::GET)
.uri("/")
.body(Body::empty())
.expect("request");
let ctx = RequestContext::new(request, PathParams::default());

// `.err().expect(..)` (not `expect_err`) so we don't require
// `State<T>: Debug` — extractors here mirror Json/Path and omit it.
let err = block_on(State::<Arc<AppStateFixture>>::from_request(&ctx))
.err()
.expect("missing state must surface as an error, not a default");
assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
}

#[test]
fn state_extractor_deref_and_into_inner() {
let mut request = request_builder()
.method(Method::GET)
.uri("/")
.body(Body::empty())
.expect("request");
request.extensions_mut().insert(AppStateFixture {
name: "x".to_owned(),
});
let ctx = RequestContext::new(request, PathParams::default());

let state = block_on(State::<AppStateFixture>::from_request(&ctx)).expect("state present");
assert_eq!(
*state,
AppStateFixture {
name: "x".to_owned()
}
); // Deref
assert_eq!(
state.into_inner(),
AppStateFixture {
name: "x".to_owned()
}
);
}
}
52 changes: 52 additions & 0 deletions crates/edgezero-core/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<F, Fut, Res> DynHandler for F
Expand All @@ -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<dyn DynHandler>;
Expand All @@ -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());
}
}
Loading
Loading