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 // GET /introspection for JSON response
async fn introspection_handler_json() -> impl Responder { async fn introspection_handler_json(
use actix_web::introspection::introspection_report_as_json; tree: web::Data<actix_web::introspection::IntrospectionTree>,
) -> impl Responder {
let report = introspection_report_as_json(); let report = tree.report_as_json();
HttpResponse::Ok() HttpResponse::Ok()
.content_type("application/json") .content_type("application/json")
.body(report) .body(report)
} }
// GET /introspection for plain text response // GET /introspection for plain text response
async fn introspection_handler_text() -> impl Responder { async fn introspection_handler_text(
use actix_web::introspection::introspection_report_as_text; tree: web::Data<actix_web::introspection::IntrospectionTree>,
) -> impl Responder {
let report = introspection_report_as_text(); let report = tree.report_as_text();
HttpResponse::Ok().content_type("text/plain").body(report) HttpResponse::Ok().content_type("text/plain").body(report)
} }
@ -242,7 +242,7 @@ async fn main() -> std::io::Result<()> {
.to(HttpResponse::MethodNotAllowed), .to(HttpResponse::MethodNotAllowed),
) )
}) })
.workers(1) .workers(5)
.bind("127.0.0.1:8080")?; .bind("127.0.0.1:8080")?;
server.run().await 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>, data_factories: Vec<FnDataFactory>,
external: Vec<ResourceDef>, external: Vec<ResourceDef>,
extensions: Extensions, extensions: Extensions,
#[cfg(feature = "experimental-introspection")]
introspector: Rc<RefCell<crate::introspection::IntrospectionCollector>>,
} }
impl App<AppEntry> { impl App<AppEntry> {
@ -46,6 +48,10 @@ impl App<AppEntry> {
factory_ref, factory_ref,
external: Vec::new(), external: Vec::new(),
extensions: Extensions::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, factory_ref: self.factory_ref,
external: self.external, external: self.external,
extensions: self.extensions, extensions: self.extensions,
#[cfg(feature = "experimental-introspection")]
introspector: self.introspector,
} }
} }
@ -429,6 +437,8 @@ where
factory_ref: self.factory_ref, factory_ref: self.factory_ref,
external: self.external, external: self.external,
extensions: self.extensions, extensions: self.extensions,
#[cfg(feature = "experimental-introspection")]
introspector: self.introspector,
} }
} }
} }
@ -453,6 +463,8 @@ where
default: self.default, default: self.default,
factory_ref: self.factory_ref, factory_ref: self.factory_ref,
extensions: RefCell::new(Some(self.extensions)), 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) default: Option<Rc<BoxedHttpServiceFactory>>,
pub(crate) factory_ref: Rc<RefCell<Option<AppRoutingFactory>>>, pub(crate) factory_ref: Rc<RefCell<Option<AppRoutingFactory>>>,
pub(crate) external: RefCell<Vec<ResourceDef>>, 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> impl<T, B> ServiceFactory<Request> for AppInit<T, B>
@ -72,6 +74,10 @@ where
// create App config to pass to child services // create App config to pass to child services
let mut config = AppService::new(config, Rc::clone(&default)); let mut config = AppService::new(config, Rc::clone(&default));
#[cfg(feature = "experimental-introspection")]
{
config.introspector = Rc::clone(&self.introspector);
}
// register services // register services
mem::take(&mut *self.services.borrow_mut()) mem::take(&mut *self.services.borrow_mut())
@ -80,6 +86,9 @@ where
let mut rmap = ResourceMap::new(ResourceDef::prefix("")); 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(); let (config, services) = config.into_services();
// complete pipeline creation. // complete pipeline creation.
@ -110,6 +119,8 @@ where
// construct app service and middleware service factory future. // construct app service and middleware service factory future.
let endpoint_fut = self.endpoint.new_service(()); 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. // take extensions or create new one as app data container.
let mut app_data = self.extensions.borrow_mut().take().unwrap_or_default(); let mut app_data = self.extensions.borrow_mut().take().unwrap_or_default();
@ -132,7 +143,8 @@ where
#[cfg(feature = "experimental-introspection")] #[cfg(feature = "experimental-introspection")]
{ {
crate::introspection::finalize_registry(); let tree = introspector.borrow_mut().finalize();
app_data.insert(crate::web::Data::new(tree));
} }
Ok(AppInitService { Ok(AppInitService {

View File

@ -32,6 +32,9 @@ pub struct AppService {
)>, )>,
#[cfg(feature = "experimental-introspection")] #[cfg(feature = "experimental-introspection")]
pub current_prefix: String, pub current_prefix: String,
#[cfg(feature = "experimental-introspection")]
pub(crate) introspector:
std::rc::Rc<std::cell::RefCell<crate::introspection::IntrospectionCollector>>,
} }
impl AppService { impl AppService {
@ -44,6 +47,10 @@ impl AppService {
services: Vec::new(), services: Vec::new(),
#[cfg(feature = "experimental-introspection")] #[cfg(feature = "experimental-introspection")]
current_prefix: "".to_string(), 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)] #[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( pub(crate) fn into_services(
self, self,
) -> ( ) -> (
@ -77,6 +102,8 @@ impl AppService {
root: false, root: false,
#[cfg(feature = "experimental-introspection")] #[cfg(feature = "experimental-introspection")]
current_prefix: self.current_prefix.clone(), 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 std::borrow::Borrow;
use crate::introspection;
// Build the full path for introspection // Build the full path for introspection
let pat = rdef.pattern().unwrap_or("").to_string(); let pat = rdef.pattern().unwrap_or("").to_string();
@ -148,7 +173,12 @@ impl AppService {
// Determine if the registered service is a resource // Determine if the registered service is a resource
let is_resource = rdef.pattern().is_some(); 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 self.services

View File

@ -1,61 +1,14 @@
use std::{ use std::{collections::HashMap, fmt::Write as FmtWrite};
collections::HashMap,
fmt::Write as FmtWrite,
sync::{
atomic::{AtomicBool, Ordering},
Mutex, OnceLock,
},
thread,
};
use serde::Serialize; use serde::Serialize;
use crate::http::Method; 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)] #[derive(Clone)]
pub struct RouteDetail { pub struct RouteDetail {
methods: Vec<Method>, methods: Vec<Method>,
guards: Vec<String>, guards: Vec<String>,
is_resource: bool, // Indicates if this detail is for a final resource endpoint is_resource: bool,
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
@ -74,6 +27,7 @@ pub struct IntrospectionNode {
pub guards: Vec<String>, pub guards: Vec<String>,
pub children: Vec<IntrospectionNode>, pub children: Vec<IntrospectionNode>,
} }
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
pub struct IntrospectionReportItem { pub struct IntrospectionReportItem {
pub full_path: String, pub full_path: String,
@ -112,16 +66,10 @@ impl From<&IntrospectionNode> for Vec<IntrospectionReportItem> {
}; };
if !node.methods.is_empty() || !node.guards.is_empty() { if !node.methods.is_empty() || !node.guards.is_empty() {
// Filter guards that are already represented in methods
let filtered_guards: Vec<String> = node let filtered_guards: Vec<String> = node
.guards .guards
.iter() .iter()
.filter(|guard| { .filter(|guard| !node.methods.iter().any(|m| m.to_string() == **guard))
!node
.methods
.iter()
.any(|method| method.to_string() == **guard)
})
.cloned() .cloned()
.collect(); .collect();
@ -143,62 +91,113 @@ impl From<&IntrospectionNode> for Vec<IntrospectionReportItem> {
} }
} }
pub(crate) fn finalize_registry() { #[derive(Clone, Default)]
if !is_designated_thread() { pub struct IntrospectionCollector {
return; details: HashMap<String, RouteDetail>,
} }
initialize_registry(); impl IntrospectionCollector {
initialize_detail_registry(); pub fn new() -> Self {
Self {
let detail_registry = get_detail_registry().lock().unwrap(); details: HashMap::new(),
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);
}
}
} }
} }
*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]) { 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")] #[cfg(feature = "experimental-introspection")]
{ {
use crate::{http::Method, introspection}; use crate::http::Method;
let guards_routes = routes.iter().map(|r| r.guards()).collect::<Vec<_>>(); let guards_routes = routes.iter().map(|r| r.guards()).collect::<Vec<_>>();
@ -464,7 +464,7 @@ where
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
introspection::register_pattern_detail( config.introspector.borrow_mut().register_pattern_detail(
full_path.clone(), full_path.clone(),
methods, methods,
guard_names, guard_names,