Compare commits

...

2 Commits

Author SHA1 Message Date
Guillermo Céspedes Tabárez b75fcd8fac Refactor route registration to use RouteInfo struct & cargo clippy 2025-12-31 16:03:43 -03:00
Guillermo Céspedes Tabárez 68f01edb72 Enhance experimental introspection feature with detailed route reporting
- Introduced a new `experimental-introspection` feature that provides comprehensive reports on configured routes, including paths, methods, guards, and resource metadata.
- Added support for reachability hints to identify shadowed or conflicting routes.
- Implemented new endpoints for external resources reporting.
- Updated existing route registration to include detailed introspection data.
- Enhanced guard implementations to provide introspection details.
2025-12-31 15:48:41 -03:00
11 changed files with 1474 additions and 118 deletions

View File

@ -2,7 +2,7 @@
## Unreleased
- Add `experimental-introspection` feature for retrieving configured route paths and HTTP methods.
- Add `experimental-introspection` feature to report configured routes (paths, methods, guards, resource metadata), include reachability hints for shadowed/conflicting routes, and provide a separate report for external resources.
- Minimum supported Rust version (MSRV) is now 1.82.
## 4.12.1

View File

@ -41,6 +41,16 @@ async fn main() -> std::io::Result<()> {
.body(report)
}
// GET /introspection/externals for external resources report
async fn introspection_handler_externals(
tree: web::Data<actix_web::introspection::IntrospectionTree>,
) -> impl Responder {
let report = tree.report_externals_as_json();
HttpResponse::Ok()
.content_type("application/json")
.body(report)
}
// GET /introspection for plain text response
async fn introspection_handler_text(
tree: web::Data<actix_web::introspection::IntrospectionTree>,
@ -95,6 +105,21 @@ async fn main() -> std::io::Result<()> {
HttpResponse::Ok().body("Welcome to the Root Endpoint!")
}
// GET /alpha and /beta (named multi-pattern resource)
async fn multi_pattern() -> impl Responder {
HttpResponse::Ok().body("Hello from multi-pattern resource!")
}
// GET /acceptable (Acceptable guard)
async fn acceptable_guarded() -> impl Responder {
HttpResponse::Ok().body("Acceptable guard matched!")
}
// GET /hosted (Host guard)
async fn host_guarded() -> impl Responder {
HttpResponse::Ok().body("Host guard matched!")
}
// Additional endpoints for /extra
fn extra_endpoints(cfg: &mut web::ServiceConfig) {
cfg.service(
@ -169,6 +194,8 @@ async fn main() -> std::io::Result<()> {
// Get introspection report
// curl --location '127.0.0.1:8080/introspection' --header 'Accept: application/json'
// curl --location '127.0.0.1:8080/introspection' --header 'Accept: text/plain'
// curl --location '127.0.0.1:8080/introspection/externals'
.external_resource("app-external", "https://example.com/{id}")
.service(
web::resource("/introspection")
.route(
@ -182,9 +209,31 @@ async fn main() -> std::io::Result<()> {
.to(introspection_handler_text),
),
)
.service(
web::resource("/introspection/externals")
.route(web::get().to(introspection_handler_externals)),
)
.service(
web::resource(["/alpha", "/beta"])
.name("multi")
.route(web::get().to(multi_pattern)),
)
.route(
"/acceptable",
web::get()
.guard(guard::Acceptable::new(mime::APPLICATION_JSON).match_star_star())
.to(acceptable_guarded),
)
.route(
"/hosted",
web::get().guard(guard::Host("127.0.0.1")).to(host_guarded),
)
// API endpoints under /api
.service(
web::scope("/api")
.configure(|cfg| {
cfg.external_resource("api-external", "https://api.example/{id}");
})
// Endpoints under /api/v1
.service(
web::scope("/v1")

View File

@ -107,6 +107,10 @@ where
// external resources
for mut rdef in mem::take(&mut *self.external.borrow_mut()) {
#[cfg(feature = "experimental-introspection")]
{
self.introspector.borrow_mut().register_external(&rdef, "/");
}
rmap.add(&mut rdef, None);
}

View File

@ -35,6 +35,10 @@ pub struct AppService {
#[cfg(feature = "experimental-introspection")]
pub(crate) introspector:
std::rc::Rc<std::cell::RefCell<crate::introspection::IntrospectionCollector>>,
#[cfg(feature = "experimental-introspection")]
pub(crate) scope_id_stack: Vec<usize>,
#[cfg(feature = "experimental-introspection")]
pending_scope_id: Option<usize>,
}
impl AppService {
@ -51,6 +55,10 @@ impl AppService {
introspector: std::rc::Rc::new(std::cell::RefCell::new(
crate::introspection::IntrospectionCollector::new(),
)),
#[cfg(feature = "experimental-introspection")]
scope_id_stack: Vec::new(),
#[cfg(feature = "experimental-introspection")]
pending_scope_id: None,
}
}
@ -104,6 +112,10 @@ impl AppService {
current_prefix: self.current_prefix.clone(),
#[cfg(feature = "experimental-introspection")]
introspector: std::rc::Rc::clone(&self.introspector),
#[cfg(feature = "experimental-introspection")]
scope_id_stack: self.scope_id_stack.clone(),
#[cfg(feature = "experimental-introspection")]
pending_scope_id: None,
}
}
@ -138,19 +150,6 @@ impl AppService {
{
use std::borrow::Borrow;
// Build the full path for introspection
let pat = rdef.pattern().unwrap_or("").to_string();
let full_path = if self.current_prefix.is_empty() {
pat.clone()
} else {
format!(
"{}/{}",
self.current_prefix.trim_end_matches('/'),
pat.trim_start_matches('/')
)
};
// Extract methods and guards for introspection
let guard_list: &[Box<dyn Guard>] = guards.borrow().as_ref().map_or(&[], |v| &v[..]);
let methods = guard_list
@ -170,15 +169,40 @@ impl AppService {
.iter()
.map(|g| g.name().to_string())
.collect::<Vec<_>>();
let guard_details = crate::introspection::guard_reports_from_iter(guard_list.iter());
// Determine if the registered service is a resource
let is_resource = rdef.pattern().is_some();
self.introspector.borrow_mut().register_pattern_detail(
full_path,
methods,
guard_names,
is_resource,
);
let is_resource = nested.is_none();
let full_paths = crate::introspection::expand_patterns(&self.current_prefix, &rdef);
let patterns = rdef
.pattern_iter()
.map(|pattern| pattern.to_string())
.collect::<Vec<_>>();
let resource_name = rdef.name().map(|name| name.to_string());
let is_prefix = rdef.is_prefix();
let scope_id = if nested.is_some() {
self.pending_scope_id.take()
} else {
None
};
let parent_scope_id = self.scope_id_stack.last().copied();
for full_path in full_paths {
let info = crate::introspection::RouteInfo::new(
full_path,
methods.clone(),
guard_names.clone(),
guard_details.clone(),
patterns.clone(),
resource_name.clone(),
);
self.introspector.borrow_mut().register_service(
info,
is_resource,
is_prefix,
scope_id,
parent_scope_id,
);
}
}
self.services
@ -188,15 +212,23 @@ impl AppService {
/// Update the current path prefix.
#[cfg(feature = "experimental-introspection")]
pub(crate) fn update_prefix(&mut self, prefix: &str) {
self.current_prefix = if self.current_prefix.is_empty() {
prefix.to_string()
} else {
format!(
"{}/{}",
self.current_prefix.trim_end_matches('/'),
prefix.trim_start_matches('/')
)
};
let next = ResourceDef::root_prefix(prefix);
if self.current_prefix.is_empty() {
self.current_prefix = next.pattern().unwrap_or("").to_string();
return;
}
let current = ResourceDef::root_prefix(&self.current_prefix);
let joined = current.join(&next);
self.current_prefix = joined.pattern().unwrap_or("").to_string();
}
#[cfg(feature = "experimental-introspection")]
pub(crate) fn prepare_scope_id(&mut self) -> usize {
let scope_id = self.introspector.borrow_mut().next_scope_id();
self.pending_scope_id = Some(scope_id);
scope_id
}
}

View File

@ -1,4 +1,4 @@
use super::{Guard, GuardContext};
use super::{Guard, GuardContext, GuardDetail};
use crate::http::header::Accept;
/// A guard that verifies that an `Accept` header is present and it contains a compatible MIME type.
@ -63,6 +63,37 @@ impl Guard for Acceptable {
false
}
fn name(&self) -> String {
#[cfg(feature = "experimental-introspection")]
{
if self.match_star_star {
format!("Acceptable({}, match_star_star=true)", self.mime)
} else {
format!("Acceptable({})", self.mime)
}
}
#[cfg(not(feature = "experimental-introspection"))]
{
std::any::type_name::<Self>().to_string()
}
}
fn details(&self) -> Option<Vec<GuardDetail>> {
#[cfg(feature = "experimental-introspection")]
{
let mut details = Vec::new();
details.push(GuardDetail::Generic(format!("mime={}", self.mime)));
if self.match_star_star {
details.push(GuardDetail::Generic("match_star_star=true".to_string()));
}
Some(details)
}
#[cfg(not(feature = "experimental-introspection"))]
{
None
}
}
}
#[cfg(test)]
@ -96,4 +127,28 @@ mod tests {
.match_star_star()
.check(&req.guard_ctx()));
}
#[cfg(feature = "experimental-introspection")]
#[test]
fn acceptable_guard_details_include_mime() {
let guard = Acceptable::new(mime::APPLICATION_JSON).match_star_star();
let details = guard.details().expect("missing guard details");
assert!(details.iter().any(|detail| match detail {
GuardDetail::Generic(value) => value == "match_star_star=true",
_ => false,
}));
let expected = format!("mime={}", mime::APPLICATION_JSON);
assert!(details.iter().any(|detail| match detail {
GuardDetail::Generic(value) => value == &expected,
_ => false,
}));
assert_eq!(
guard.name(),
format!(
"Acceptable({}, match_star_star=true)",
mime::APPLICATION_JSON
)
);
}
}

View File

@ -1,6 +1,6 @@
use actix_http::{header, uri::Uri, RequestHead, Version};
use super::{Guard, GuardContext};
use super::{Guard, GuardContext, GuardDetail};
/// Creates a guard that matches requests targeting a specific host.
///
@ -117,6 +117,41 @@ impl Guard for HostGuard {
// all conditions passed
true
}
fn name(&self) -> String {
#[cfg(feature = "experimental-introspection")]
{
if let Some(ref scheme) = self.scheme {
format!("Host({}, scheme={})", self.host, scheme)
} else {
format!("Host({})", self.host)
}
}
#[cfg(not(feature = "experimental-introspection"))]
{
std::any::type_name::<Self>().to_string()
}
}
fn details(&self) -> Option<Vec<GuardDetail>> {
#[cfg(feature = "experimental-introspection")]
{
let mut details = vec![GuardDetail::Headers(vec![(
"host".to_string(),
self.host.clone(),
)])];
if let Some(ref scheme) = self.scheme {
details.push(GuardDetail::Generic(format!("scheme={scheme}")));
}
Some(details)
}
#[cfg(not(feature = "experimental-introspection"))]
{
None
}
}
}
#[cfg(test)]
@ -239,4 +274,23 @@ mod tests {
let host = Host("localhost");
assert!(!host.check(&req.guard_ctx()));
}
#[cfg(feature = "experimental-introspection")]
#[test]
fn host_guard_details_include_host_and_scheme() {
let host = Host("example.com").scheme("https");
let details = host.details().expect("missing guard details");
assert!(details.iter().any(|detail| match detail {
GuardDetail::Headers(headers) => headers
.iter()
.any(|(name, value)| name == "host" && value == "example.com"),
_ => false,
}));
assert!(details.iter().any(|detail| match detail {
GuardDetail::Generic(value) => value == "scheme=https",
_ => false,
}));
assert_eq!(host.name(), "Host(example.com, scheme=https)");
}
}

View File

@ -66,14 +66,14 @@ pub use self::{
host::{Host, HostGuard},
};
/// Enum to encapsulate various introspection details of a Guard.
/// Enum to encapsulate various introspection details of a guard.
#[derive(Debug, Clone)]
pub enum GuardDetail {
/// Detail associated with HTTP methods.
/// Detail associated with explicit HTTP method guards.
HttpMethods(Vec<String>),
/// Detail associated with headers (header, value).
Headers(Vec<(String, String)>),
/// Generic detail.
/// Generic detail, typically used for compound guard representations.
Generic(String),
}
@ -141,7 +141,9 @@ pub trait Guard {
std::any::type_name::<Self>().to_string()
}
/// Returns detailed introspection information.
/// Returns detailed introspection information, when available.
///
/// This is best-effort and may omit complex guard logic.
fn details(&self) -> Option<Vec<GuardDetail>> {
None
}
@ -358,7 +360,14 @@ impl<G: Guard> Guard for Not<G> {
format!("Not({})", self.0.name())
}
fn details(&self) -> Option<Vec<GuardDetail>> {
self.0.details()
#[cfg(feature = "experimental-introspection")]
{
Some(vec![GuardDetail::Generic(self.name())])
}
#[cfg(not(feature = "experimental-introspection"))]
{
None
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -434,42 +434,58 @@ where
{
use crate::http::Method;
let full_paths = crate::introspection::expand_patterns(&config.current_prefix, &rdef);
let patterns = rdef
.pattern_iter()
.map(|pattern| pattern.to_string())
.collect::<Vec<_>>();
let guards_routes = routes.iter().map(|r| r.guards()).collect::<Vec<_>>();
let pat = rdef.pattern().unwrap_or("").to_string();
let full_path = if config.current_prefix.is_empty() {
pat.clone()
} else {
format!(
"{}/{}",
config.current_prefix.trim_end_matches('/'),
pat.trim_start_matches('/')
)
};
let scope_id = config.scope_id_stack.last().copied();
let resource_guards: &[Box<dyn Guard>] = guards.as_deref().unwrap_or(&[]);
let resource_name = self.name.clone();
for route_guards in guards_routes {
// Log the guards and methods for introspection
let guard_names = route_guards.iter().map(|g| g.name()).collect::<Vec<_>>();
let methods = route_guards
.iter()
.flat_map(|g| g.details().unwrap_or_default())
.flat_map(|d| {
if let crate::guard::GuardDetail::HttpMethods(v) = d {
v.into_iter()
.filter_map(|s| s.parse::<Method>().ok())
.collect::<Vec<_>>()
} else {
Vec::new()
}
})
.collect::<Vec<_>>();
let mut guard_names = Vec::new();
let mut methods = Vec::new();
config.introspector.borrow_mut().register_pattern_detail(
full_path.clone(),
methods,
guard_names,
true,
for guard in resource_guards.iter().chain(route_guards.iter()) {
guard_names.push(guard.name());
methods.extend(
guard
.details()
.unwrap_or_default()
.into_iter()
.flat_map(|d| {
if let crate::guard::GuardDetail::HttpMethods(v) = d {
v.into_iter()
.filter_map(|s| s.parse::<Method>().ok())
.collect::<Vec<_>>()
} else {
Vec::new()
}
}),
);
}
let guard_details = crate::introspection::guard_reports_from_iter(
resource_guards.iter().chain(route_guards.iter()),
);
for full_path in &full_paths {
let info = crate::introspection::RouteInfo::new(
full_path.clone(),
methods.clone(),
guard_names.clone(),
guard_details.clone(),
patterns.clone(),
resource_name.clone(),
);
config
.introspector
.borrow_mut()
.register_route(info, scope_id);
}
}
}

View File

@ -387,7 +387,11 @@ where
// Update the prefix for the nested scope
#[cfg(feature = "experimental-introspection")]
cfg.update_prefix(&self.rdef);
{
let scope_id = config.prepare_scope_id();
cfg.scope_id_stack.push(scope_id);
cfg.update_prefix(&self.rdef);
}
self.services
.into_iter()
@ -395,8 +399,17 @@ where
let mut rmap = ResourceMap::new(ResourceDef::root_prefix(&self.rdef));
#[cfg(feature = "experimental-introspection")]
let origin_scope = cfg.current_prefix.clone();
// external resources
for mut rdef in mem::take(&mut self.external) {
#[cfg(feature = "experimental-introspection")]
{
cfg.introspector
.borrow_mut()
.register_external(&rdef, &origin_scope);
}
rmap.add(&mut rdef, None);
}

View File

@ -0,0 +1,167 @@
#![cfg(feature = "experimental-introspection")]
use actix_web::{guard, test, web, App, HttpResponse};
async fn introspection_handler(
tree: web::Data<actix_web::introspection::IntrospectionTree>,
) -> HttpResponse {
HttpResponse::Ok()
.content_type("application/json")
.body(tree.report_as_json())
}
async fn externals_handler(
tree: web::Data<actix_web::introspection::IntrospectionTree>,
) -> HttpResponse {
HttpResponse::Ok()
.content_type("application/json")
.body(tree.report_externals_as_json())
}
fn find_item<'a>(items: &'a [serde_json::Value], path: &str) -> &'a serde_json::Value {
items
.iter()
.find(|item| item.get("full_path").and_then(|v| v.as_str()) == Some(path))
.unwrap_or_else(|| panic!("missing route for {path}"))
}
fn find_external<'a>(items: &'a [serde_json::Value], name: &str) -> &'a serde_json::Value {
items
.iter()
.find(|item| item.get("name").and_then(|v| v.as_str()) == Some(name))
.unwrap_or_else(|| panic!("missing external resource for {name}"))
}
#[actix_rt::test]
async fn introspection_report_includes_details_and_metadata() {
let app = test::init_service(
App::new()
.external_resource("app-external", "https://example.com/{id}")
.service(
web::resource(["/alpha", "/beta"])
.name("multi")
.route(web::get().to(HttpResponse::Ok)),
)
.service(
web::resource("/guarded")
.guard(guard::Header("accept", "text/plain"))
.route(web::get().to(HttpResponse::Ok)),
)
.service(
web::scope("/scoped")
.guard(guard::Header("x-scope", "1"))
.configure(|cfg| {
cfg.external_resource("scope-external", "https://scope.example/{id}");
})
.service(web::resource("/item").route(web::get().to(HttpResponse::Ok))),
)
.service(web::resource("/introspection").route(web::get().to(introspection_handler)))
.service(
web::resource("/introspection/externals").route(web::get().to(externals_handler)),
),
)
.await;
let req = test::TestRequest::get().uri("/introspection").to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body = test::read_body(resp).await;
let items: Vec<serde_json::Value> =
serde_json::from_slice(&body).expect("invalid introspection json");
let alpha = find_item(&items, "/alpha");
let patterns = alpha
.get("patterns")
.and_then(|v| v.as_array())
.expect("patterns missing");
let patterns = patterns
.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>();
assert!(patterns.contains(&"/alpha"));
assert!(patterns.contains(&"/beta"));
assert_eq!(
alpha.get("resource_name").and_then(|v| v.as_str()),
Some("multi")
);
assert_eq!(
alpha.get("resource_type").and_then(|v| v.as_str()),
Some("resource")
);
let guarded = find_item(&items, "/guarded");
let guards = guarded
.get("guards")
.and_then(|v| v.as_array())
.expect("guards missing");
assert!(guards
.iter()
.any(|v| v.as_str() == Some("Header(accept, text/plain)")));
let guard_details = guarded
.get("guards_detail")
.and_then(|v| v.as_array())
.expect("guards_detail missing");
assert!(!guard_details.is_empty());
let alpha_guards = alpha
.get("guards")
.and_then(|v| v.as_array())
.expect("alpha guards missing");
let alpha_guard_details = alpha
.get("guards_detail")
.and_then(|v| v.as_array())
.expect("alpha guards_detail missing");
assert!(alpha_guards.is_empty());
assert!(!alpha_guard_details.is_empty());
let scoped = find_item(&items, "/scoped");
assert_eq!(
scoped.get("resource_type").and_then(|v| v.as_str()),
Some("scope")
);
let scoped_guards = scoped
.get("guards")
.and_then(|v| v.as_array())
.expect("scoped guards missing");
assert!(scoped_guards
.iter()
.any(|v| v.as_str() == Some("Header(x-scope, 1)")));
let req = test::TestRequest::get()
.uri("/introspection/externals")
.to_request();
let resp = test::call_service(&app, req).await;
assert!(resp.status().is_success());
let body = test::read_body(resp).await;
let externals: Vec<serde_json::Value> =
serde_json::from_slice(&body).expect("invalid externals json");
let app_external = find_external(&externals, "app-external");
let app_patterns = app_external
.get("patterns")
.and_then(|v| v.as_array())
.expect("app external patterns missing");
assert!(app_patterns
.iter()
.any(|v| v.as_str() == Some("https://example.com/{id}")));
assert_eq!(
app_external.get("origin_scope").and_then(|v| v.as_str()),
Some("/")
);
let scope_external = find_external(&externals, "scope-external");
let scope_patterns = scope_external
.get("patterns")
.and_then(|v| v.as_array())
.expect("scope external patterns missing");
assert!(scope_patterns
.iter()
.any(|v| v.as_str() == Some("https://scope.example/{id}")));
assert_eq!(
scope_external.get("origin_scope").and_then(|v| v.as_str()),
Some("/scoped")
);
}