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 ## 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. - Minimum supported Rust version (MSRV) is now 1.82.
## 4.12.1 ## 4.12.1

View File

@ -41,6 +41,16 @@ async fn main() -> std::io::Result<()> {
.body(report) .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 // GET /introspection for plain text response
async fn introspection_handler_text( async fn introspection_handler_text(
tree: web::Data<actix_web::introspection::IntrospectionTree>, 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!") 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 // Additional endpoints for /extra
fn extra_endpoints(cfg: &mut web::ServiceConfig) { fn extra_endpoints(cfg: &mut web::ServiceConfig) {
cfg.service( cfg.service(
@ -169,6 +194,8 @@ async fn main() -> std::io::Result<()> {
// Get introspection report // 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: application/json'
// curl --location '127.0.0.1:8080/introspection' --header 'Accept: text/plain' // 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( .service(
web::resource("/introspection") web::resource("/introspection")
.route( .route(
@ -182,9 +209,31 @@ async fn main() -> std::io::Result<()> {
.to(introspection_handler_text), .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 // API endpoints under /api
.service( .service(
web::scope("/api") web::scope("/api")
.configure(|cfg| {
cfg.external_resource("api-external", "https://api.example/{id}");
})
// Endpoints under /api/v1 // Endpoints under /api/v1
.service( .service(
web::scope("/v1") web::scope("/v1")

View File

@ -107,6 +107,10 @@ where
// external resources // external resources
for mut rdef in mem::take(&mut *self.external.borrow_mut()) { 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); rmap.add(&mut rdef, None);
} }

View File

@ -35,6 +35,10 @@ pub struct AppService {
#[cfg(feature = "experimental-introspection")] #[cfg(feature = "experimental-introspection")]
pub(crate) introspector: pub(crate) introspector:
std::rc::Rc<std::cell::RefCell<crate::introspection::IntrospectionCollector>>, 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 { impl AppService {
@ -51,6 +55,10 @@ impl AppService {
introspector: std::rc::Rc::new(std::cell::RefCell::new( introspector: std::rc::Rc::new(std::cell::RefCell::new(
crate::introspection::IntrospectionCollector::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(), current_prefix: self.current_prefix.clone(),
#[cfg(feature = "experimental-introspection")] #[cfg(feature = "experimental-introspection")]
introspector: std::rc::Rc::clone(&self.introspector), 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; 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 // Extract methods and guards for introspection
let guard_list: &[Box<dyn Guard>] = guards.borrow().as_ref().map_or(&[], |v| &v[..]); let guard_list: &[Box<dyn Guard>] = guards.borrow().as_ref().map_or(&[], |v| &v[..]);
let methods = guard_list let methods = guard_list
@ -170,15 +169,40 @@ impl AppService {
.iter() .iter()
.map(|g| g.name().to_string()) .map(|g| g.name().to_string())
.collect::<Vec<_>>(); .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 = nested.is_none();
let is_resource = rdef.pattern().is_some(); let full_paths = crate::introspection::expand_patterns(&self.current_prefix, &rdef);
self.introspector.borrow_mut().register_pattern_detail( 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, full_path,
methods, methods.clone(),
guard_names, guard_names.clone(),
is_resource, 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 self.services
@ -188,15 +212,23 @@ impl AppService {
/// Update the current path prefix. /// Update the current path prefix.
#[cfg(feature = "experimental-introspection")] #[cfg(feature = "experimental-introspection")]
pub(crate) fn update_prefix(&mut self, prefix: &str) { pub(crate) fn update_prefix(&mut self, prefix: &str) {
self.current_prefix = if self.current_prefix.is_empty() { let next = ResourceDef::root_prefix(prefix);
prefix.to_string()
} else { if self.current_prefix.is_empty() {
format!( self.current_prefix = next.pattern().unwrap_or("").to_string();
"{}/{}", return;
self.current_prefix.trim_end_matches('/'), }
prefix.trim_start_matches('/')
) 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; use crate::http::header::Accept;
/// A guard that verifies that an `Accept` header is present and it contains a compatible MIME type. /// 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 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)] #[cfg(test)]
@ -96,4 +127,28 @@ mod tests {
.match_star_star() .match_star_star()
.check(&req.guard_ctx())); .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 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. /// Creates a guard that matches requests targeting a specific host.
/// ///
@ -117,6 +117,41 @@ impl Guard for HostGuard {
// all conditions passed // all conditions passed
true 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)] #[cfg(test)]
@ -239,4 +274,23 @@ mod tests {
let host = Host("localhost"); let host = Host("localhost");
assert!(!host.check(&req.guard_ctx())); 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}, host::{Host, HostGuard},
}; };
/// Enum to encapsulate various introspection details of a Guard. /// Enum to encapsulate various introspection details of a guard.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum GuardDetail { pub enum GuardDetail {
/// Detail associated with HTTP methods. /// Detail associated with explicit HTTP method guards.
HttpMethods(Vec<String>), HttpMethods(Vec<String>),
/// Detail associated with headers (header, value). /// Detail associated with headers (header, value).
Headers(Vec<(String, String)>), Headers(Vec<(String, String)>),
/// Generic detail. /// Generic detail, typically used for compound guard representations.
Generic(String), Generic(String),
} }
@ -141,7 +141,9 @@ pub trait Guard {
std::any::type_name::<Self>().to_string() 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>> { fn details(&self) -> Option<Vec<GuardDetail>> {
None None
} }
@ -358,7 +360,14 @@ impl<G: Guard> Guard for Not<G> {
format!("Not({})", self.0.name()) format!("Not({})", self.0.name())
} }
fn details(&self) -> Option<Vec<GuardDetail>> { 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,25 +434,28 @@ where
{ {
use crate::http::Method; 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 guards_routes = routes.iter().map(|r| r.guards()).collect::<Vec<_>>();
let scope_id = config.scope_id_stack.last().copied();
let pat = rdef.pattern().unwrap_or("").to_string(); let resource_guards: &[Box<dyn Guard>] = guards.as_deref().unwrap_or(&[]);
let full_path = if config.current_prefix.is_empty() { let resource_name = self.name.clone();
pat.clone()
} else {
format!(
"{}/{}",
config.current_prefix.trim_end_matches('/'),
pat.trim_start_matches('/')
)
};
for route_guards in guards_routes { for route_guards in guards_routes {
// Log the guards and methods for introspection // Log the guards and methods for introspection
let guard_names = route_guards.iter().map(|g| g.name()).collect::<Vec<_>>(); let mut guard_names = Vec::new();
let methods = route_guards let mut methods = Vec::new();
.iter()
.flat_map(|g| g.details().unwrap_or_default()) 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| { .flat_map(|d| {
if let crate::guard::GuardDetail::HttpMethods(v) = d { if let crate::guard::GuardDetail::HttpMethods(v) = d {
v.into_iter() v.into_iter()
@ -461,16 +464,29 @@ where
} else { } else {
Vec::new() Vec::new()
} }
}) }),
.collect::<Vec<_>>();
config.introspector.borrow_mut().register_pattern_detail(
full_path.clone(),
methods,
guard_names,
true,
); );
} }
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);
}
}
} }
if let Some(ref name) = self.name { if let Some(ref name) = self.name {

View File

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