feat(resources-introspection): add support for resource metadata retrieval

This commit is contained in:
Guillermo Céspedes Tabárez 2025-03-03 05:27:52 -03:00
parent b8bdee0606
commit 0d87cc53a1
8 changed files with 394 additions and 6 deletions

View File

@ -128,6 +128,9 @@ compat = [
# Opt-out forwards-compatibility for handler visibility inheritance fix.
compat-routing-macros-force-pub = ["actix-web-codegen?/compat-routing-macros-force-pub"]
# Enabling the retrieval of metadata for initialized resources, including path and HTTP method.
resources-introspection = []
[dependencies]
actix-codec = "0.5"
actix-macros = { version = "0.2.3", optional = true }

View File

@ -82,6 +82,9 @@ where
let (config, services) = config.into_services();
#[cfg(feature = "resources-introspection")]
let mut rdef_methods: Vec<(String, Vec<String>)> = Vec::new();
// complete pipeline creation.
*self.factory_ref.borrow_mut() = Some(AppRoutingFactory {
default,
@ -89,6 +92,24 @@ where
.into_iter()
.map(|(mut rdef, srv, guards, nested)| {
rmap.add(&mut rdef, nested);
#[cfg(feature = "resources-introspection")]
{
let http_methods: Vec<String> =
guards.as_ref().map_or_else(Vec::new, |g| {
g.iter()
.flat_map(|g| {
crate::guard::HttpMethodsExtractor::extract_http_methods(
&**g,
)
})
.collect::<Vec<_>>()
});
rdef_methods
.push((rdef.pattern().unwrap_or_default().to_string(), http_methods));
}
(rdef, srv, RefCell::new(guards))
})
.collect::<Vec<_>>()
@ -105,6 +126,11 @@ where
let rmap = Rc::new(rmap);
ResourceMap::finish(&rmap);
#[cfg(feature = "resources-introspection")]
{
crate::introspection::process_introspection(Rc::clone(&rmap), rdef_methods);
}
// construct all async data factory futures
let factory_futs = join_all(self.async_data_factories.iter().map(|f| f()));

View File

@ -50,6 +50,7 @@
//! [`Route`]: crate::Route::guard()
use std::{
any::Any,
cell::{Ref, RefMut},
rc::Rc,
};
@ -121,7 +122,7 @@ impl<'a> GuardContext<'a> {
/// Interface for routing guards.
///
/// See [module level documentation](self) for more.
pub trait Guard {
pub trait Guard: AsAny {
/// Returns true if predicate condition is met for a given request.
fn check(&self, ctx: &GuardContext<'_>) -> bool;
}
@ -146,7 +147,7 @@ impl Guard for Rc<dyn Guard> {
/// ```
pub fn fn_guard<F>(f: F) -> impl Guard
where
F: Fn(&GuardContext<'_>) -> bool,
F: Fn(&GuardContext<'_>) -> bool + 'static,
{
FnGuard(f)
}
@ -155,7 +156,7 @@ struct FnGuard<F: Fn(&GuardContext<'_>) -> bool>(F);
impl<F> Guard for FnGuard<F>
where
F: Fn(&GuardContext<'_>) -> bool,
F: Fn(&GuardContext<'_>) -> bool + 'static,
{
fn check(&self, ctx: &GuardContext<'_>) -> bool {
(self.0)(ctx)
@ -164,7 +165,7 @@ where
impl<F> Guard for F
where
F: Fn(&GuardContext<'_>) -> bool,
F: for<'a> Fn(&'a GuardContext<'a>) -> bool + 'static,
{
fn check(&self, ctx: &GuardContext<'_>) -> bool {
(self)(ctx)
@ -284,7 +285,7 @@ impl Guard for AllGuard {
/// .guard(guard::Not(guard::Get()))
/// .to(|| HttpResponse::Ok());
/// ```
pub struct Not<G>(pub G);
pub struct Not<G: 'static>(pub G);
impl<G: Guard> Guard for Not<G> {
#[inline]
@ -322,6 +323,81 @@ impl Guard for MethodGuard {
}
}
#[cfg(feature = "resources-introspection")]
pub trait HttpMethodsExtractor {
fn extract_http_methods(&self) -> Vec<String>;
}
#[cfg(feature = "resources-introspection")]
impl HttpMethodsExtractor for dyn Guard {
fn extract_http_methods(&self) -> Vec<String> {
if let Some(method_guard) = self.as_any().downcast_ref::<MethodGuard>() {
vec![method_guard.0.to_string()]
} else if let Some(any_guard) = self.as_any().downcast_ref::<AnyGuard>() {
any_guard
.guards
.iter()
.flat_map(|g| g.extract_http_methods())
.collect()
} else if let Some(all_guard) = self.as_any().downcast_ref::<AllGuard>() {
all_guard
.guards
.iter()
.flat_map(|g| g.extract_http_methods())
.collect()
} else {
vec!["UNKNOWN".to_string()]
}
}
}
#[cfg(feature = "resources-introspection")]
impl std::fmt::Display for MethodGuard {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "[{}]", self.0)
}
}
#[cfg(feature = "resources-introspection")]
impl std::fmt::Display for AllGuard {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let methods: Vec<String> = self
.guards
.iter()
.filter_map(|guard| {
guard
.as_any()
.downcast_ref::<MethodGuard>()
.map(|method_guard| method_guard.0.to_string())
})
.collect();
write!(f, "[{}]", methods.join(", "))
}
}
#[cfg(feature = "resources-introspection")]
impl std::fmt::Display for AnyGuard {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let methods: Vec<String> = self
.guards
.iter()
.map(|guard| {
let guard_ref = &**guard;
if let Some(method_guard) = guard_ref.as_any().downcast_ref::<MethodGuard>() {
method_guard.0.to_string()
} else if let Some(all_guard) = guard_ref.as_any().downcast_ref::<AllGuard>() {
all_guard.to_string()
} else {
"UNKNOWN".to_string()
}
})
.collect();
write!(f, "[{}]", methods.join(", "))
}
}
macro_rules! method_guard {
($method_fn:ident, $method_const:ident) => {
#[doc = concat!("Creates a guard that matches the `", stringify!($method_const), "` request method.")]
@ -384,6 +460,16 @@ impl Guard for HeaderGuard {
}
}
pub trait AsAny {
fn as_any(&self) -> &dyn Any;
}
impl<T: Any> AsAny for T {
fn as_any(&self) -> &dyn Any {
self
}
}
#[cfg(test)]
mod tests {
use actix_http::Method;
@ -495,7 +581,7 @@ mod tests {
#[test]
fn function_guard() {
let domain = "rust-lang.org".to_owned();
let guard = fn_guard(|ctx| ctx.head().uri.host().unwrap().ends_with(&domain));
let guard = fn_guard(move |ctx| ctx.head().uri.host().unwrap().ends_with(&domain));
let req = TestRequest::default()
.uri("blog.rust-lang.org")

View File

@ -0,0 +1,177 @@
use std::rc::Rc;
use std::sync::{OnceLock, RwLock};
use crate::dev::ResourceMap;
/// Represents an HTTP resource registered for introspection.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResourceIntrospection {
/// HTTP method (e.g., "GET")
pub method: String,
/// Path (e.g., "/api/v1/test")
pub path: String,
}
/// Temporary registry for partial routes.
static TEMP_REGISTRY: OnceLock<RwLock<Vec<ResourceIntrospection>>> = OnceLock::new();
/// Final registry for complete routes.
static FINAL_REGISTRY: OnceLock<RwLock<Vec<ResourceIntrospection>>> = OnceLock::new();
fn get_temp_registry() -> &'static RwLock<Vec<ResourceIntrospection>> {
TEMP_REGISTRY.get_or_init(|| RwLock::new(Vec::new()))
}
fn get_final_registry() -> &'static RwLock<Vec<ResourceIntrospection>> {
FINAL_REGISTRY.get_or_init(|| RwLock::new(Vec::new()))
}
/// Registers a resource.
pub fn register_resource(resource: ResourceIntrospection, is_complete: bool) {
let registry = if is_complete {
get_final_registry()
} else {
get_temp_registry()
};
let mut reg = registry.write().expect("Failed to acquire lock");
if !reg.iter().any(|r| r == &resource) {
reg.push(resource);
}
}
/// Completes (moves to the final registry) partial routes that match the given marker,
/// applying the prefix. Only affects routes whose path contains `marker`.
pub fn complete_partial_routes_with_marker(marker: &str, prefix: &str) {
let temp_registry = get_temp_registry();
let mut temp = temp_registry
.write()
.expect("Failed to acquire lock TEMP_REGISTRY");
let final_registry = get_final_registry();
let mut final_reg = final_registry
.write()
.expect("Failed to acquire lock FINAL_REGISTRY");
let mut remaining = Vec::new();
for resource in temp.drain(..) {
if resource.path.contains(marker) {
// Concatenate the prefix only if it is not already present.
let full_path = if prefix.is_empty() {
resource.path.clone()
} else if prefix.ends_with("/") || resource.path.starts_with("/") {
format!("{}{}", prefix, resource.path)
} else {
format!("{}/{}", prefix, resource.path)
};
let completed_resource = ResourceIntrospection {
method: resource.method,
path: full_path,
};
if !final_reg.iter().any(|r| r == &completed_resource) {
final_reg.push(completed_resource);
}
} else {
remaining.push(resource);
}
}
*temp = remaining;
}
/// Returns the complete list of registered resources.
pub fn get_registered_resources() -> Vec<ResourceIntrospection> {
let final_registry = get_final_registry();
let final_reg = final_registry
.read()
.expect("Failed to acquire lock FINAL_REGISTRY");
final_reg.clone()
}
/// Processes introspection data for routes and methods.
///
/// # Parameters
/// - `rmap`: A resource map that can be converted to a vector of full route strings.
/// - `rdef_methods`: A vector of tuples `(sub_path, [methods])`.
/// An entry with an empty methods vector is treated as a level marker, indicating that
/// routes registered in a lower level (in TEMP_REGISTRY) should be "completed" (moved to the final registry)
/// using the deduced prefix. For example, if a marker "/api" is found and the corresponding route is "/api/v1/item/{id}",
/// the deduced prefix will be "" (if the marker starts at the beginning) or a non-empty string indicating a higher level.
///
/// # Processing Steps
/// 1. **Marker Processing:**
/// For each entry in `rdef_methods` with an empty methods vector, the function:
/// - Searches `rmap_vec` for a route that contains the `sub_path` (the marker).
/// - Deduces the prefix as the portion of the route before the marker.
/// - Calls `complete_partial_routes_with_marker`, moving all partial routes that contain the marker
/// from TEMP_REGISTRY to FINAL_REGISTRY, applying the deduced prefix.
///
/// 2. **Endpoint Registration:**
/// For each entry in `rdef_methods` with assigned methods:
/// - If `sub_path` is "/", an exact match is used; otherwise, routes ending with `sub_path` are considered.
/// - Among the candidate routes from `rmap_vec`, the candidate that either starts with the deduced prefix
/// (if non-empty) or the shortest candidate (if at root level or no prefix was deduced) is selected.
/// - A single `ResourceIntrospection` is registered with the full route and all methods joined by commas.
///
/// Note: If multiple markers exist in the same block, only the last one processed (and stored in `deduced_prefix`)
/// is used for selecting endpoint candidates. Consider refactoring if independent processing per level is desired.
pub fn process_introspection(rmap: Rc<ResourceMap>, rdef_methods: Vec<(String, Vec<String>)>) {
// Convert the ResourceMap to a vector for easier manipulation.
let rmap_vec = rmap.to_vec();
// If there are no routes or methods, there is nothing to introspect.
if rmap_vec.is_empty() && rdef_methods.is_empty() {
return;
}
// Variable to store the deduced prefix for this introspection (if there is a marker)
let mut deduced_prefix: Option<String> = None;
// First, check the markers (entries with empty methods).
for (sub_path, http_methods) in rdef_methods.iter() {
if http_methods.is_empty() {
if let Some(r) = rmap_vec.iter().find(|r| r.contains(sub_path)) {
if let Some(pos) = r.find(sub_path) {
let prefix = &r[..pos];
deduced_prefix = Some(prefix.to_string());
complete_partial_routes_with_marker(sub_path, prefix);
}
}
}
}
// Then, process the endpoints with assigned methods.
for (sub_path, http_methods) in rdef_methods.iter() {
if !http_methods.is_empty() {
// For the "/" subroute, do an exact match; otherwise, use ends_with.
let candidates: Vec<&String> = if sub_path == "/" {
rmap_vec.iter().filter(|r| r.as_str() == "/").collect()
} else {
rmap_vec.iter().filter(|r| r.ends_with(sub_path)).collect()
};
if !candidates.is_empty() {
let chosen = if let Some(prefix) = &deduced_prefix {
if !prefix.is_empty() {
candidates
.iter()
.find(|&&r| r.starts_with(prefix))
.cloned()
.or_else(|| candidates.iter().min_by_key(|&&r| r.len()).cloned())
} else {
// Root level: if sub_path is "/" we already filtered by equality.
candidates.iter().min_by_key(|&&r| r.len()).cloned()
}
} else {
candidates.iter().min_by_key(|&&r| r.len()).cloned()
};
if let Some(full_route) = chosen {
// Register a single entry with all methods joined.
register_resource(
ResourceIntrospection {
method: http_methods.join(","),
path: full_route.clone(),
},
deduced_prefix.is_some(), // Mark as complete if any marker was detected.
);
}
}
}
}
}

View File

@ -108,6 +108,9 @@ mod thin_data;
pub(crate) mod types;
pub mod web;
#[cfg(feature = "resources-introspection")]
pub mod introspection;
#[doc(inline)]
pub use crate::error::Result;
pub use crate::{

View File

@ -433,6 +433,25 @@ where
rdef.set_name(name);
}
#[cfg(feature = "resources-introspection")]
let mut rdef_methods: Vec<(String, Vec<String>)> = Vec::new();
#[cfg(feature = "resources-introspection")]
let mut rmap = crate::rmap::ResourceMap::new(ResourceDef::prefix(""));
#[cfg(feature = "resources-introspection")]
{
rmap.add(&mut rdef, None);
self.routes.iter_mut().for_each(|r| {
r.take_guards().iter().for_each(|g| {
let http_methods: Vec<String> =
crate::guard::HttpMethodsExtractor::extract_http_methods(&**g);
rdef_methods
.push((rdef.pattern().unwrap_or_default().to_string(), http_methods));
});
});
}
*self.factory_ref.borrow_mut() = Some(ResourceFactory {
routes: self.routes,
default: self.default,
@ -451,6 +470,15 @@ where
async { Ok(fut.await?.map_into_boxed_body()) }
});
#[cfg(feature = "resources-introspection")]
{
println!("resources");
crate::introspection::process_introspection(
Rc::clone(&Rc::new(rmap.clone())),
rdef_methods,
);
}
config.register_service(rdef, guards, endpoint, None)
}
}

View File

@ -38,6 +38,42 @@ impl ResourceMap {
}
}
#[cfg(feature = "resources-introspection")]
/// Returns a list of all paths in the resource map.
pub fn to_vec(&self) -> Vec<String> {
let mut paths = Vec::new();
self.collect_full_paths(String::new(), &mut paths);
paths
}
#[cfg(feature = "resources-introspection")]
/// Recursive function that accumulates the full path and adds it only if the node is an endpoint (leaf).
fn collect_full_paths(&self, current_path: String, paths: &mut Vec<String>) {
// Get the current segment of the pattern
if let Some(segment) = self.pattern.pattern() {
let mut new_path = current_path;
// Add the '/' separator if necessary
if !segment.is_empty() {
if !new_path.ends_with('/') && !new_path.is_empty() && !segment.starts_with('/') {
new_path.push('/');
}
new_path.push_str(segment);
}
// If this node is an endpoint (has no children), add the full path
if self.nodes.is_none() {
paths.push(new_path.clone());
}
// If it has children, iterate over each one
if let Some(children) = &self.nodes {
for child in children {
child.collect_full_paths(new_path.clone(), paths);
}
}
}
}
/// Format resource map as tree structure (unfinished).
#[allow(dead_code)]
pub(crate) fn tree(&self) -> String {

View File

@ -395,6 +395,9 @@ where
rmap.add(&mut rdef, None);
}
#[cfg(feature = "resources-introspection")]
let mut rdef_methods: Vec<(String, Vec<String>)> = Vec::new();
// complete scope pipeline creation
*self.factory_ref.borrow_mut() = Some(ScopeFactory {
default,
@ -404,6 +407,24 @@ where
.into_iter()
.map(|(mut rdef, srv, guards, nested)| {
rmap.add(&mut rdef, nested);
#[cfg(feature = "resources-introspection")]
{
let http_methods: Vec<String> =
guards.as_ref().map_or_else(Vec::new, |g| {
g.iter()
.flat_map(|g| {
crate::guard::HttpMethodsExtractor::extract_http_methods(
&**g,
)
})
.collect::<Vec<_>>()
});
rdef_methods
.push((rdef.pattern().unwrap_or_default().to_string(), http_methods));
}
(rdef, srv, RefCell::new(guards))
})
.collect::<Vec<_>>()
@ -431,6 +452,14 @@ where
async { Ok(fut.await?.map_into_boxed_body()) }
});
#[cfg(feature = "resources-introspection")]
{
crate::introspection::process_introspection(
Rc::clone(&Rc::new(rmap.clone())),
rdef_methods,
);
}
// register final service
config.register_service(
ResourceDef::root_prefix(&self.rdef),