Compare commits

..

No commits in common. "b75fcd8fac4b1f49488c080499dd8516de5ba268" and "895969923ce97f1fdcf95cf7b36753aa1f18d4cd" have entirely different histories.

11 changed files with 110 additions and 1466 deletions

View File

@ -2,7 +2,7 @@
## Unreleased ## Unreleased
- 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. - Add `experimental-introspection` feature for retrieving configured route paths and HTTP methods.
- 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,16 +41,6 @@ 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>,
@ -105,21 +95,6 @@ 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(
@ -194,8 +169,6 @@ 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(
@ -209,31 +182,9 @@ 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,10 +107,6 @@ 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,10 +35,6 @@ 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 {
@ -55,10 +51,6 @@ 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,
} }
} }
@ -112,10 +104,6 @@ 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,
} }
} }
@ -150,6 +138,19 @@ 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
@ -169,40 +170,15 @@ 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());
let is_resource = nested.is_none(); // Determine if the registered service is a resource
let full_paths = crate::introspection::expand_patterns(&self.current_prefix, &rdef); let is_resource = rdef.pattern().is_some();
let patterns = rdef self.introspector.borrow_mut().register_pattern_detail(
.pattern_iter() full_path,
.map(|pattern| pattern.to_string()) methods,
.collect::<Vec<_>>(); guard_names,
let resource_name = rdef.name().map(|name| name.to_string()); is_resource,
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 self.services
@ -212,23 +188,15 @@ 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) {
let next = ResourceDef::root_prefix(prefix); self.current_prefix = if self.current_prefix.is_empty() {
prefix.to_string()
if self.current_prefix.is_empty() { } else {
self.current_prefix = next.pattern().unwrap_or("").to_string(); format!(
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, GuardDetail}; use super::{Guard, GuardContext};
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,37 +63,6 @@ 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)]
@ -127,28 +96,4 @@ 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, GuardDetail}; use super::{Guard, GuardContext};
/// Creates a guard that matches requests targeting a specific host. /// Creates a guard that matches requests targeting a specific host.
/// ///
@ -117,41 +117,6 @@ 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)]
@ -274,23 +239,4 @@ 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 explicit HTTP method guards. /// Detail associated with HTTP methods.
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, typically used for compound guard representations. /// Generic detail.
Generic(String), Generic(String),
} }
@ -141,9 +141,7 @@ pub trait Guard {
std::any::type_name::<Self>().to_string() std::any::type_name::<Self>().to_string()
} }
/// Returns detailed introspection information, when available. /// Returns detailed introspection information.
///
/// This is best-effort and may omit complex guard logic.
fn details(&self) -> Option<Vec<GuardDetail>> { fn details(&self) -> Option<Vec<GuardDetail>> {
None None
} }
@ -360,14 +358,7 @@ 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>> {
#[cfg(feature = "experimental-introspection")] self.0.details()
{
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,58 +434,42 @@ 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 resource_guards: &[Box<dyn Guard>] = guards.as_deref().unwrap_or(&[]); let pat = rdef.pattern().unwrap_or("").to_string();
let resource_name = self.name.clone(); let full_path = if config.current_prefix.is_empty() {
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 mut guard_names = Vec::new(); let guard_names = route_guards.iter().map(|g| g.name()).collect::<Vec<_>>();
let mut methods = Vec::new(); 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<_>>();
for guard in resource_guards.iter().chain(route_guards.iter()) { config.introspector.borrow_mut().register_pattern_detail(
guard_names.push(guard.name()); full_path.clone(),
methods.extend( methods,
guard guard_names,
.details() true,
.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,11 +387,7 @@ where
// Update the prefix for the nested scope // Update the prefix for the nested scope
#[cfg(feature = "experimental-introspection")] #[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 self.services
.into_iter() .into_iter()
@ -399,17 +395,8 @@ 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

@ -1,167 +0,0 @@
#![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")
);
}