feat(introspection): implement experimental introspection feature with multiple App instances

This commit is contained in:
dertin 2025-06-11 17:37:26 -03:00
parent 2b52a60bc2
commit 7ff7768dc4
7 changed files with 228 additions and 176 deletions

View File

@ -32,20 +32,20 @@ async fn main() -> std::io::Result<()> {
}
// GET /introspection for JSON response
async fn introspection_handler_json() -> impl Responder {
use actix_web::introspection::introspection_report_as_json;
let report = introspection_report_as_json();
async fn introspection_handler_json(
tree: web::Data<actix_web::introspection::IntrospectionTree>,
) -> impl Responder {
let report = tree.report_as_json();
HttpResponse::Ok()
.content_type("application/json")
.body(report)
}
// GET /introspection for plain text response
async fn introspection_handler_text() -> impl Responder {
use actix_web::introspection::introspection_report_as_text;
let report = introspection_report_as_text();
async fn introspection_handler_text(
tree: web::Data<actix_web::introspection::IntrospectionTree>,
) -> impl Responder {
let report = tree.report_as_text();
HttpResponse::Ok().content_type("text/plain").body(report)
}
@ -242,7 +242,7 @@ async fn main() -> std::io::Result<()> {
.to(HttpResponse::MethodNotAllowed),
)
})
.workers(1)
.workers(5)
.bind("127.0.0.1:8080")?;
server.run().await

View File

@ -0,0 +1,52 @@
// Example showcasing the experimental introspection feature with multiple App instances.
// Run with: `cargo run --features experimental-introspection --example introspection_multi_servers`
#[actix_web::main]
async fn main() -> std::io::Result<()> {
#[cfg(feature = "experimental-introspection")]
{
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
use futures_util::future;
async fn introspection_handler(
tree: web::Data<actix_web::introspection::IntrospectionTree>,
) -> impl Responder {
HttpResponse::Ok()
.content_type("text/plain")
.body(tree.report_as_text())
}
async fn index() -> impl Responder {
HttpResponse::Ok().body("Hello from app")
}
let srv1 = HttpServer::new(|| {
App::new()
.service(web::resource("/a").route(web::get().to(index)))
.service(
web::resource("/introspection").route(web::get().to(introspection_handler)),
)
})
.workers(8)
.bind("127.0.0.1:8081")?
.run();
let srv2 = HttpServer::new(|| {
App::new()
.service(web::resource("/b").route(web::get().to(index)))
.service(
web::resource("/introspection").route(web::get().to(introspection_handler)),
)
})
.workers(3)
.bind("127.0.0.1:8082")?
.run();
future::try_join(srv1, srv2).await?;
}
#[cfg(not(feature = "experimental-introspection"))]
{
eprintln!("This example requires the 'experimental-introspection' feature to be enabled.");
}
Ok(())
}

View File

@ -30,6 +30,8 @@ pub struct App<T> {
data_factories: Vec<FnDataFactory>,
external: Vec<ResourceDef>,
extensions: Extensions,
#[cfg(feature = "experimental-introspection")]
introspector: Rc<RefCell<crate::introspection::IntrospectionCollector>>,
}
impl App<AppEntry> {
@ -46,6 +48,10 @@ impl App<AppEntry> {
factory_ref,
external: Vec::new(),
extensions: Extensions::new(),
#[cfg(feature = "experimental-introspection")]
introspector: Rc::new(RefCell::new(
crate::introspection::IntrospectionCollector::new(),
)),
}
}
}
@ -366,6 +372,8 @@ where
factory_ref: self.factory_ref,
external: self.external,
extensions: self.extensions,
#[cfg(feature = "experimental-introspection")]
introspector: self.introspector,
}
}
@ -429,6 +437,8 @@ where
factory_ref: self.factory_ref,
external: self.external,
extensions: self.extensions,
#[cfg(feature = "experimental-introspection")]
introspector: self.introspector,
}
}
}
@ -453,6 +463,8 @@ where
default: self.default,
factory_ref: self.factory_ref,
extensions: RefCell::new(Some(self.extensions)),
#[cfg(feature = "experimental-introspection")]
introspector: Rc::clone(&self.introspector),
}
}
}

View File

@ -41,6 +41,8 @@ where
pub(crate) default: Option<Rc<BoxedHttpServiceFactory>>,
pub(crate) factory_ref: Rc<RefCell<Option<AppRoutingFactory>>>,
pub(crate) external: RefCell<Vec<ResourceDef>>,
#[cfg(feature = "experimental-introspection")]
pub(crate) introspector: Rc<RefCell<crate::introspection::IntrospectionCollector>>,
}
impl<T, B> ServiceFactory<Request> for AppInit<T, B>
@ -72,6 +74,10 @@ where
// create App config to pass to child services
let mut config = AppService::new(config, Rc::clone(&default));
#[cfg(feature = "experimental-introspection")]
{
config.introspector = Rc::clone(&self.introspector);
}
// register services
mem::take(&mut *self.services.borrow_mut())
@ -80,6 +86,9 @@ where
let mut rmap = ResourceMap::new(ResourceDef::prefix(""));
#[cfg(feature = "experimental-introspection")]
let (config, services, _) = config.into_services();
#[cfg(not(feature = "experimental-introspection"))]
let (config, services) = config.into_services();
// complete pipeline creation.
@ -110,6 +119,8 @@ where
// construct app service and middleware service factory future.
let endpoint_fut = self.endpoint.new_service(());
#[cfg(feature = "experimental-introspection")]
let introspector = Rc::clone(&self.introspector);
// take extensions or create new one as app data container.
let mut app_data = self.extensions.borrow_mut().take().unwrap_or_default();
@ -132,7 +143,8 @@ where
#[cfg(feature = "experimental-introspection")]
{
crate::introspection::finalize_registry();
let tree = introspector.borrow_mut().finalize();
app_data.insert(crate::web::Data::new(tree));
}
Ok(AppInitService {

View File

@ -32,6 +32,9 @@ pub struct AppService {
)>,
#[cfg(feature = "experimental-introspection")]
pub current_prefix: String,
#[cfg(feature = "experimental-introspection")]
pub(crate) introspector:
std::rc::Rc<std::cell::RefCell<crate::introspection::IntrospectionCollector>>,
}
impl AppService {
@ -44,6 +47,10 @@ impl AppService {
services: Vec::new(),
#[cfg(feature = "experimental-introspection")]
current_prefix: "".to_string(),
#[cfg(feature = "experimental-introspection")]
introspector: std::rc::Rc::new(std::cell::RefCell::new(
crate::introspection::IntrospectionCollector::new(),
)),
}
}
@ -53,6 +60,24 @@ impl AppService {
}
#[allow(clippy::type_complexity)]
#[cfg(feature = "experimental-introspection")]
pub(crate) fn into_services(
self,
) -> (
AppConfig,
Vec<(
ResourceDef,
BoxedHttpServiceFactory,
Option<Guards>,
Option<Rc<ResourceMap>>,
)>,
std::rc::Rc<std::cell::RefCell<crate::introspection::IntrospectionCollector>>,
) {
(self.config, self.services, self.introspector)
}
#[allow(clippy::type_complexity)]
#[cfg(not(feature = "experimental-introspection"))]
pub(crate) fn into_services(
self,
) -> (
@ -77,6 +102,8 @@ impl AppService {
root: false,
#[cfg(feature = "experimental-introspection")]
current_prefix: self.current_prefix.clone(),
#[cfg(feature = "experimental-introspection")]
introspector: std::rc::Rc::clone(&self.introspector),
}
}
@ -111,8 +138,6 @@ impl AppService {
{
use std::borrow::Borrow;
use crate::introspection;
// Build the full path for introspection
let pat = rdef.pattern().unwrap_or("").to_string();
@ -148,7 +173,12 @@ impl AppService {
// Determine if the registered service is a resource
let is_resource = rdef.pattern().is_some();
introspection::register_pattern_detail(full_path, methods, guard_names, is_resource);
self.introspector.borrow_mut().register_pattern_detail(
full_path,
methods,
guard_names,
is_resource,
);
}
self.services

View File

@ -1,61 +1,14 @@
use std::{
collections::HashMap,
fmt::Write as FmtWrite,
sync::{
atomic::{AtomicBool, Ordering},
Mutex, OnceLock,
},
thread,
};
use std::{collections::HashMap, fmt::Write as FmtWrite};
use serde::Serialize;
use crate::http::Method;
static REGISTRY: OnceLock<Mutex<IntrospectionNode>> = OnceLock::new();
static DETAIL_REGISTRY: OnceLock<Mutex<HashMap<String, RouteDetail>>> = OnceLock::new();
static DESIGNATED_THREAD: OnceLock<thread::ThreadId> = OnceLock::new();
static IS_INITIALIZED: AtomicBool = AtomicBool::new(false);
fn initialize_registry() {
REGISTRY.get_or_init(|| {
Mutex::new(IntrospectionNode::new(
ResourceType::App,
"".into(),
"".into(),
))
});
}
fn get_registry() -> &'static Mutex<IntrospectionNode> {
REGISTRY.get().expect("Registry not initialized")
}
fn initialize_detail_registry() {
DETAIL_REGISTRY.get_or_init(|| Mutex::new(HashMap::new()));
}
fn get_detail_registry() -> &'static Mutex<HashMap<String, RouteDetail>> {
DETAIL_REGISTRY
.get()
.expect("Detail registry not initialized")
}
fn is_designated_thread() -> bool {
let current_id = thread::current().id();
DESIGNATED_THREAD.get_or_init(|| {
IS_INITIALIZED.store(true, Ordering::SeqCst);
current_id // Assign the first thread that calls this function
});
*DESIGNATED_THREAD.get().unwrap() == current_id
}
#[derive(Clone)]
pub struct RouteDetail {
methods: Vec<Method>,
guards: Vec<String>,
is_resource: bool, // Indicates if this detail is for a final resource endpoint
is_resource: bool,
}
#[derive(Debug, Clone, Copy)]
@ -74,6 +27,7 @@ pub struct IntrospectionNode {
pub guards: Vec<String>,
pub children: Vec<IntrospectionNode>,
}
#[derive(Debug, Clone, Serialize)]
pub struct IntrospectionReportItem {
pub full_path: String,
@ -112,16 +66,10 @@ impl From<&IntrospectionNode> for Vec<IntrospectionReportItem> {
};
if !node.methods.is_empty() || !node.guards.is_empty() {
// Filter guards that are already represented in methods
let filtered_guards: Vec<String> = node
.guards
.iter()
.filter(|guard| {
!node
.methods
.iter()
.any(|method| method.to_string() == **guard)
})
.filter(|guard| !node.methods.iter().any(|m| m.to_string() == **guard))
.cloned()
.collect();
@ -143,62 +91,113 @@ impl From<&IntrospectionNode> for Vec<IntrospectionReportItem> {
}
}
pub(crate) fn finalize_registry() {
if !is_designated_thread() {
return;
}
#[derive(Clone, Default)]
pub struct IntrospectionCollector {
details: HashMap<String, RouteDetail>,
}
initialize_registry();
initialize_detail_registry();
let detail_registry = get_detail_registry().lock().unwrap();
let mut root = IntrospectionNode::new(ResourceType::App, "".into(), "".into());
// Build the introspection tree directly from the detail registry
for (full_path, _detail) in detail_registry.iter() {
let parts: Vec<&str> = full_path.split('/').collect();
let mut current_node = &mut root;
for (i, part) in parts.iter().enumerate() {
// Find the index of the existing child
let existing_child_index = current_node
.children
.iter()
.position(|n| n.pattern == *part);
let child_index = if let Some(child_index) = existing_child_index {
child_index
} else {
// If it doesn't exist, create a new node and get its index
let child_full_path = parts[..=i].join("/");
// Determine the kind based on whether this path exists as a resource in the detail registry
let kind = if detail_registry
.get(&child_full_path)
.is_some_and(|d| d.is_resource)
{
ResourceType::Resource
} else {
ResourceType::Scope
};
let new_node = IntrospectionNode::new(kind, part.to_string(), child_full_path);
current_node.children.push(new_node);
current_node.children.len() - 1
};
// Get a mutable reference to the child node
current_node = &mut current_node.children[child_index];
// If this node is marked as a resource, update its methods and guards
if let ResourceType::Resource = current_node.kind {
if let Some(detail) = detail_registry.get(&current_node.full_path) {
update_unique(&mut current_node.methods, &detail.methods);
update_unique(&mut current_node.guards, &detail.guards);
}
}
impl IntrospectionCollector {
pub fn new() -> Self {
Self {
details: HashMap::new(),
}
}
*get_registry().lock().unwrap() = root;
pub fn register_pattern_detail(
&mut self,
full_path: String,
methods: Vec<Method>,
guards: Vec<String>,
is_resource: bool,
) {
self.details
.entry(full_path)
.and_modify(|d| {
update_unique(&mut d.methods, &methods);
update_unique(&mut d.guards, &guards);
if !d.is_resource && is_resource {
d.is_resource = true;
}
})
.or_insert(RouteDetail {
methods,
guards,
is_resource,
});
}
pub fn finalize(&mut self) -> IntrospectionTree {
let detail_registry = std::mem::take(&mut self.details);
let mut root = IntrospectionNode::new(ResourceType::App, "".into(), "".into());
for (full_path, _) in detail_registry.iter() {
let parts: Vec<&str> = full_path.split('/').collect();
let mut current_node = &mut root;
for (i, part) in parts.iter().enumerate() {
let existing_child_index = current_node
.children
.iter()
.position(|n| n.pattern == *part);
let child_index = if let Some(idx) = existing_child_index {
idx
} else {
let child_full_path = parts[..=i].join("/");
let kind = if detail_registry
.get(&child_full_path)
.is_some_and(|d| d.is_resource)
{
ResourceType::Resource
} else {
ResourceType::Scope
};
let new_node = IntrospectionNode::new(kind, part.to_string(), child_full_path);
current_node.children.push(new_node);
current_node.children.len() - 1
};
current_node = &mut current_node.children[child_index];
if let ResourceType::Resource = current_node.kind {
if let Some(detail) = detail_registry.get(&current_node.full_path) {
update_unique(&mut current_node.methods, &detail.methods);
update_unique(&mut current_node.guards, &detail.guards);
}
}
}
}
IntrospectionTree { root }
}
}
#[derive(Clone)]
pub struct IntrospectionTree {
pub root: IntrospectionNode,
}
impl IntrospectionTree {
pub fn report_as_text(&self) -> String {
let report_items: Vec<IntrospectionReportItem> = (&self.root).into();
let mut buf = String::new();
for item in report_items {
writeln!(
buf,
"{} => Methods: {:?} | Guards: {:?}",
item.full_path, item.methods, item.guards
)
.unwrap();
}
buf
}
pub fn report_as_json(&self) -> String {
let report_items: Vec<IntrospectionReportItem> = (&self.root).into();
serde_json::to_string_pretty(&report_items).unwrap()
}
}
fn update_unique<T: Clone + PartialEq>(existing: &mut Vec<T>, new_items: &[T]) {
@ -208,56 +207,3 @@ fn update_unique<T: Clone + PartialEq>(existing: &mut Vec<T>, new_items: &[T]) {
}
}
}
pub(crate) fn register_pattern_detail(
full_path: String,
methods: Vec<Method>,
guards: Vec<String>,
is_resource: bool,
) {
if !is_designated_thread() {
return;
}
initialize_detail_registry();
let mut reg = get_detail_registry().lock().unwrap();
reg.entry(full_path)
.and_modify(|d| {
update_unique(&mut d.methods, &methods);
update_unique(&mut d.guards, &guards);
// If the existing entry was not a resource but the new one is, update the kind
if !d.is_resource && is_resource {
d.is_resource = true;
}
})
.or_insert(RouteDetail {
methods,
guards,
is_resource,
});
}
pub fn introspection_report_as_text() -> String {
let registry = get_registry();
let node = registry.lock().unwrap();
let report_items: Vec<IntrospectionReportItem> = (&*node).into();
let mut buf = String::new();
for item in report_items {
writeln!(
buf,
"{} => Methods: {:?} | Guards: {:?}",
item.full_path, item.methods, item.guards
)
.unwrap();
}
buf
}
pub fn introspection_report_as_json() -> String {
let registry = get_registry();
let node = registry.lock().unwrap();
let report_items: Vec<IntrospectionReportItem> = (&*node).into();
serde_json::to_string_pretty(&report_items).unwrap()
}

View File

@ -432,7 +432,7 @@ where
};
#[cfg(feature = "experimental-introspection")]
{
use crate::{http::Method, introspection};
use crate::http::Method;
let guards_routes = routes.iter().map(|r| r.guards()).collect::<Vec<_>>();
@ -464,7 +464,7 @@ where
})
.collect::<Vec<_>>();
introspection::register_pattern_detail(
config.introspector.borrow_mut().register_pattern_detail(
full_path.clone(),
methods,
guard_names,