From 0a9f6c19552325d995f013984c5788131b08ce94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillermo=20C=C3=A9spedes=20Tab=C3=A1rez?= Date: Sun, 18 May 2025 21:19:34 -0300 Subject: [PATCH] feat(introspection): enhance introspection feature with detailed route registration and full path tracking --- Cargo.lock | 38 +++--- actix-web/examples/introspection.rs | 179 ++++++++++++++++++++-------- actix-web/src/app_service.rs | 29 ----- actix-web/src/config.rs | 64 ++++++++++ actix-web/src/introspection.rs | 165 +++++++++++++++++-------- actix-web/src/resource.rs | 62 ++++++---- actix-web/src/rmap.rs | 7 +- actix-web/src/route.rs | 25 +--- actix-web/src/scope.rs | 40 +------ 9 files changed, 377 insertions(+), 232 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 82f9024f3..64fb7d579 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9,7 +9,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "de7fa236829ba0841304542f7614c42b80fca007455315c45c785ccfa873a85b" dependencies = [ "actix-rt", - "bitflags 2.9.0", + "bitflags 2.9.1", "bytes", "crossbeam-channel", "futures-core", @@ -31,7 +31,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "bytes", "futures-core", "futures-sink", @@ -53,7 +53,7 @@ dependencies = [ "actix-test", "actix-utils", "actix-web", - "bitflags 2.9.0", + "bitflags 2.9.1", "bytes", "derive_more", "env_logger", @@ -83,7 +83,7 @@ dependencies = [ "actix-web", "async-stream", "base64 0.22.1", - "bitflags 2.9.0", + "bitflags 2.9.1", "brotli", "bytes", "bytestring", @@ -723,7 +723,7 @@ version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "cexpr", "clang-sys", "itertools 0.12.1", @@ -748,9 +748,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" [[package]] name = "block-buffer" @@ -817,9 +817,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.22" +version = "1.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32db95edf998450acc7881c932f94cd9b05c87b4b2599e8bab064753da4acfd1" +checksum = "5f4ac86a9e5bc1e2b3449ab9d7d3a6a405e3d1bb28d7b9be8614f55846ae3766" dependencies = [ "jobserver", "libc", @@ -1299,9 +1299,9 @@ checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" [[package]] name = "errno" -version = "0.3.11" +version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "976dd42dc7e85965fe702eb8164f21f450704bdde31faefd6471dba214cb594e" +checksum = "cea14ef9355e3beab063703aa9dab15afd25f0667c341310c1e5274bb1d0da18" dependencies = [ "libc", "windows-sys 0.59.0", @@ -2122,7 +2122,7 @@ version = "0.10.72" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "cfg-if", "foreign-types", "libc", @@ -2448,7 +2448,7 @@ version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "928fca9cf2aa042393a8325b9ead81d2f0df4cb12e1e24cef072922ccd99c5af" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", ] [[package]] @@ -2488,9 +2488,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "resolv-conf" -version = "0.7.3" +version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7c8f7f733062b66dc1c63f9db168ac0b97a9210e247fa90fdc9ad08f51b302" +checksum = "95325155c684b1c89f7765e30bc1c42e4a6da51ca513615660cb8a62ef9a88e3" [[package]] name = "ring" @@ -2539,7 +2539,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2552,7 +2552,7 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c71e83d6afe7ff64890ec6b71d6a69bb8a610ab78ce364b3352876bb4c801266" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "errno", "libc", "linux-raw-sys 0.9.4", @@ -2734,7 +2734,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "core-foundation", "core-foundation-sys", "libc", @@ -3846,7 +3846,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", ] [[package]] diff --git a/actix-web/examples/introspection.rs b/actix-web/examples/introspection.rs index 1de9cfec6..db633b276 100644 --- a/actix-web/examples/introspection.rs +++ b/actix-web/examples/introspection.rs @@ -1,10 +1,72 @@ -// NOTE: This is a work-in-progress example being used to test the new implementation -// of the experimental introspection feature. -// `cargo run --features experimental-introspection --example introspection` - +// Example showcasing the experimental introspection feature. +// Run with: `cargo run --features experimental-introspection --example introspection` use actix_web::{dev::Service, guard, web, App, HttpResponse, HttpServer, Responder}; use serde::Deserialize; -// Custom guard that checks if the Content-Type header is present. + +#[cfg(feature = "experimental-introspection")] +#[actix_web::get("/introspection")] +async fn introspection_handler() -> impl Responder { + use std::fmt::Write; + + use actix_web::introspection::{get_registry, initialize_registry}; + + initialize_registry(); + let registry = get_registry(); + let node = registry.lock().unwrap(); + + let mut buf = String::new(); + if node.children.is_empty() { + writeln!(buf, "No routes registered or introspection tree is empty.").unwrap(); + } else { + fn write_display( + node: &actix_web::introspection::IntrospectionNode, + parent_path: &str, + buf: &mut String, + ) { + let full_path = if parent_path.is_empty() { + node.pattern.clone() + } else { + format!( + "{}/{}", + parent_path.trim_end_matches('/'), + node.pattern.trim_start_matches('/') + ) + }; + if !node.methods.is_empty() || !node.guards.is_empty() { + let methods = if node.methods.is_empty() { + "".to_string() + } else { + format!("Methods: {:?}", node.methods) + }; + + let method_strings: Vec = + node.methods.iter().map(|m| m.to_string()).collect(); + + let filtered_guards: Vec<_> = node + .guards + .iter() + .filter(|guard| !method_strings.contains(&guard.to_string())) + .collect(); + + let guards = if filtered_guards.is_empty() { + "".to_string() + } else { + format!("Guards: {:?}", filtered_guards) + }; + + let _ = writeln!(buf, "{} {} {}", full_path, methods, guards); + } + for child in &node.children { + write_display(child, &full_path, buf); + } + } + write_display(&node, "/", &mut buf); + } + + HttpResponse::Ok().content_type("text/plain").body(buf) +} + +// Custom guard to check if the Content-Type header is present. struct ContentTypeGuard; impl guard::Guard for ContentTypeGuard { @@ -24,54 +86,62 @@ struct UserInfo { #[actix_web::main] async fn main() -> std::io::Result<()> { + // Initialize logging + env_logger::Builder::new() + .filter_level(log::LevelFilter::Debug) + .init(); + let server = HttpServer::new(|| { - let app = App::new() + let mut app = App::new() + // API endpoints under /api .service( web::scope("/api") + // Endpoints under /api/v1 .service( web::scope("/v1") - // GET /api/v1/item/{id}: returns the item id from the path. - .service(get_item) - // POST /api/v1/info: accepts JSON and returns user info. - .service(post_user_info) - // /api/v1/guarded: only accessible if Content-Type header is present. + .service(get_item) // GET /api/v1/item/{id} + .service(post_user_info) // POST /api/v1/info .route( "/guarded", - web::route().guard(ContentTypeGuard).to(guarded_handler), + web::route().guard(ContentTypeGuard).to(guarded_handler), // /api/v1/guarded ), ) - // API scope /api/v2: additional endpoint. - .service(web::scope("/v2").route("/hello", web::get().to(hello_v2))), + // Endpoints under /api/v2 + .service(web::scope("/v2").route("/hello", web::get().to(hello_v2))), // GET /api/v2/hello ) - // Scope /v1 outside /api: exposes only GET /v1/item/{id}. - .service(web::scope("/v1").service(get_item)) - // Scope /admin: admin endpoints with different HTTP methods. + // Endpoints under /v1 (outside /api) + .service(web::scope("/v1").service(get_item)) // GET /v1/item/{id} + // Admin endpoints under /admin .service( web::scope("/admin") - .route("/dashboard", web::get().to(admin_dashboard)) - // Single route handling multiple methods using separate handlers. + .route("/dashboard", web::get().to(admin_dashboard)) // GET /admin/dashboard .service( web::resource("/settings") - .route(web::get().to(get_settings)) - .route(web::post().to(update_settings)), + .route(web::get().to(get_settings)) // GET /admin/settings + .route(web::post().to(update_settings)), // POST /admin/settings ), ) - // Root resource: supports GET and POST on "/". + // Root endpoints .service( web::resource("/") - .route(web::get().to(root_index)) - .route(web::post().to(root_index)), + .route(web::get().to(root_index)) // GET / + .route(web::post().to(root_index)), // POST / ) - // Additional endpoints configured in a separate function. - .configure(extra_endpoints) - // Endpoint that rejects GET on /not_guard (allows other methods). + // Endpoints under /bar + .service(web::scope("/bar").configure(extra_endpoints)) // /bar/extra/ping, /bar/extra/multi, etc. + // Endpoints under /foo + .service(web::scope("/foo").configure(other_endpoints)) // /foo/extra/ping with POST and DELETE + // Additional endpoints under /extra + .configure(extra_endpoints) // /extra/ping, /extra/multi, etc. + .configure(other_endpoints) + // Endpoint that rejects GET on /not_guard (allows other methods) .route( "/not_guard", web::route() .guard(guard::Not(guard::Get())) .to(HttpResponse::MethodNotAllowed), ) - // Endpoint that requires GET, content-type: plain/text header, and/or POST on /all_guard. + // Endpoint that requires GET with header or POST on /all_guard .route( "/all_guard", web::route() @@ -83,29 +153,27 @@ async fn main() -> std::io::Result<()> { .to(HttpResponse::MethodNotAllowed), ); - /*#[cfg(feature = "experimental-introspection")] + // Register the introspection handler if the feature is enabled. + #[cfg(feature = "experimental-introspection")] { - actix_web::introspection::introspect(); - }*/ - // TODO: Enable introspection without the feature flag. + app = app.service(introspection_handler); // GET /introspection + } app }) - .workers(5) + .workers(1) .bind("127.0.0.1:8080")?; server.run().await } // GET /api/v1/item/{id} and GET /v1/item/{id} -// Returns a message with the provided id. -#[actix_web::get("/item/{id:\\d+}")] +#[actix_web::get("/item/{id}")] async fn get_item(path: web::Path) -> impl Responder { let id = path.into_inner(); HttpResponse::Ok().body(format!("Requested item with id: {}", id)) } // POST /api/v1/info -// Expects JSON and responds with the received user info. #[actix_web::post("/info")] async fn post_user_info(info: web::Json) -> impl Responder { HttpResponse::Ok().json(format!( @@ -115,63 +183,54 @@ async fn post_user_info(info: web::Json) -> impl Responder { } // /api/v1/guarded -// Uses a custom guard that requires the Content-Type header. async fn guarded_handler() -> impl Responder { HttpResponse::Ok().body("Passed the Content-Type guard!") } // GET /api/v2/hello -// Simple greeting endpoint. async fn hello_v2() -> impl Responder { HttpResponse::Ok().body("Hello from API v2!") } // GET /admin/dashboard -// Returns a message for the admin dashboard. async fn admin_dashboard() -> impl Responder { HttpResponse::Ok().body("Welcome to the Admin Dashboard!") } // GET /admin/settings -// Returns the current admin settings. async fn get_settings() -> impl Responder { HttpResponse::Ok().body("Current settings: ...") } // POST /admin/settings -// Updates the admin settings. async fn update_settings() -> impl Responder { HttpResponse::Ok().body("Settings have been updated!") } // GET and POST on / -// Generic root endpoint. async fn root_index() -> impl Responder { HttpResponse::Ok().body("Welcome to the Root Endpoint!") } -// Additional endpoints configured in a separate function. +// Additional endpoints for /extra fn extra_endpoints(cfg: &mut web::ServiceConfig) { cfg.service( web::scope("/extra") - // GET /extra/ping: simple ping endpoint. .route( "/ping", - web::get().to(|| async { HttpResponse::Ok().body("pong") }), + web::get().to(|| async { HttpResponse::Ok().body("pong") }), // GET /extra/ping ) - // /extra/multi: resource that supports GET and POST. .service( web::resource("/multi") .route( web::get().to(|| async { HttpResponse::Ok().body("GET response from /extra/multi") }), - ) + ) // GET /extra/multi .route(web::post().to(|| async { HttpResponse::Ok().body("POST response from /extra/multi") - })), + })), // POST /extra/multi ) - // /extra/{entities_id}/secure: nested scope with GET and POST, prints the received id. .service( web::scope("{entities_id:\\d+}") .service( @@ -181,15 +240,14 @@ fn extra_endpoints(cfg: &mut web::ServiceConfig) { web::get().to(|| async { HttpResponse::Ok().body("GET response from /extra/secure") }), - ) + ) // GET /extra/{entities_id}/secure/ .route( - "", + "/post", web::post().to(|| async { HttpResponse::Ok().body("POST response from /extra/secure") }), - ), + ), // POST /extra/{entities_id}/secure/post ) - // Middleware that prints the id received in the route. .wrap_fn(|req, srv| { println!( "Request to /extra/secure with id: {}", @@ -204,3 +262,18 @@ fn extra_endpoints(cfg: &mut web::ServiceConfig) { ), ); } + +// Additional endpoints for /foo +fn other_endpoints(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/extra") + .route( + "/ping", + web::post().to(|| async { HttpResponse::Ok().body("post from /extra/ping") }), // POST /foo/extra/ping + ) + .route( + "/ping", + web::delete().to(|| async { HttpResponse::Ok().body("delete from /extra/ping") }), // DELETE /foo/extra/ping + ), + ); +} diff --git a/actix-web/src/app_service.rs b/actix-web/src/app_service.rs index dd76c3d2d..8dc3af7a0 100644 --- a/actix-web/src/app_service.rs +++ b/actix-web/src/app_service.rs @@ -89,35 +89,6 @@ where .into_iter() .map(|(mut rdef, srv, guards, nested)| { rmap.add(&mut rdef, nested); - #[cfg(feature = "experimental-introspection")] - { - use std::borrow::Borrow; - let pat = rdef.pattern().unwrap_or("").to_string(); - let mut methods = Vec::new(); - let mut guard_names = Vec::new(); - if let Some(gs) = guards.borrow().as_ref() { - for g in gs.iter() { - let name = g.name().to_string(); - if !guard_names.contains(&name) { - guard_names.push(name.clone()); - } - if let Some(details) = g.details() { - for d in details { - if let crate::guard::GuardDetail::HttpMethods(v) = d { - for s in v { - if let Ok(m) = s.parse() { - if !methods.contains(&m) { - methods.push(m); - } - } - } - } - } - } - } - } - crate::introspection::register_pattern_detail(pat, methods, guard_names); - } (rdef, srv, RefCell::new(guards)) }) .collect::>() diff --git a/actix-web/src/config.rs b/actix-web/src/config.rs index 0e856f574..1d486b807 100644 --- a/actix-web/src/config.rs +++ b/actix-web/src/config.rs @@ -30,6 +30,8 @@ pub struct AppService { Option, Option>, )>, + #[cfg(feature = "experimental-introspection")] + pub current_prefix: String, } impl AppService { @@ -40,6 +42,8 @@ impl AppService { default, root: true, services: Vec::new(), + #[cfg(feature = "experimental-introspection")] + current_prefix: "".to_string(), } } @@ -71,6 +75,8 @@ impl AppService { default: Rc::clone(&self.default), services: Vec::new(), root: false, + #[cfg(feature = "experimental-introspection")] + current_prefix: self.current_prefix.clone(), } } @@ -101,9 +107,67 @@ impl AppService { InitError = (), > + 'static, { + #[cfg(feature = "experimental-introspection")] + { + use std::borrow::Borrow; + + use crate::introspection; + + // 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 + let guard_list: &[Box] = guards.borrow().as_ref().map_or(&[], |v| &v[..]); + let methods = guard_list + .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().ok()) + .collect::>() + } else { + Vec::new() + } + }) + .collect::>(); + let guard_names = guard_list + .iter() + .map(|g| g.name().to_string()) + .collect::>(); + + // 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.services .push((rdef, boxed::factory(factory.into_factory()), guards, nested)); } + + /// Update the current path prefix. + #[cfg(feature = "experimental-introspection")] + pub(crate) fn update_prefix(&mut self, prefix: &str) { + self.current_prefix = if self.current_prefix.is_empty() { + prefix.to_string() + } else { + format!( + "{}/{}", + self.current_prefix.trim_end_matches('/'), + prefix.trim_start_matches('/') + ) + }; + } } /// Application connection config. diff --git a/actix-web/src/introspection.rs b/actix-web/src/introspection.rs index 71e61eccd..53efc4d00 100644 --- a/actix-web/src/introspection.rs +++ b/actix-web/src/introspection.rs @@ -15,7 +15,13 @@ static DESIGNATED_THREAD: OnceLock = OnceLock::new(); static IS_INITIALIZED: AtomicBool = AtomicBool::new(false); pub fn initialize_registry() { - REGISTRY.get_or_init(|| Mutex::new(IntrospectionNode::new(ResourceType::App, "".into()))); + REGISTRY.get_or_init(|| { + Mutex::new(IntrospectionNode::new( + ResourceType::App, + "".into(), + "".into(), + )) + }); } pub fn get_registry() -> &'static Mutex { @@ -36,6 +42,7 @@ pub fn get_detail_registry() -> &'static Mutex> { pub struct RouteDetail { methods: Vec, guards: Vec, + is_resource: bool, // Indicates if this detail is for a final resource endpoint } #[derive(Debug, Clone, Copy)] @@ -48,35 +55,48 @@ pub enum ResourceType { #[derive(Debug, Clone)] pub struct IntrospectionNode { pub kind: ResourceType, - pub pattern: String, + pub pattern: String, // Local pattern + pub full_path: String, // Full path pub methods: Vec, pub guards: Vec, pub children: Vec, } impl IntrospectionNode { - pub fn new(kind: ResourceType, pattern: String) -> Self { + pub fn new(kind: ResourceType, pattern: String, full_path: String) -> Self { IntrospectionNode { kind, pattern, + full_path, methods: Vec::new(), guards: Vec::new(), children: Vec::new(), } } - pub fn display(&self, indent: usize, parent_path: &str) { - let full_path = if parent_path.is_empty() { - self.pattern.clone() - } else { - format!( - "{}/{}", - parent_path.trim_end_matches('/'), - self.pattern.trim_start_matches('/') - ) - }; + pub fn display(&self, indent: usize) -> String { + let mut result = String::new(); - if !self.methods.is_empty() || !self.guards.is_empty() { + // Helper function to determine if a node should be highlighted + let should_highlight = + |methods: &Vec, guards: &Vec| !methods.is_empty() || !guards.is_empty(); + + // Add the full path for all nodes + if !self.full_path.is_empty() { + if should_highlight(&self.methods, &self.guards) { + // Highlight full_path with yellow if it has methods or guards + result.push_str(&format!( + "{}\x1b[1;33m{}\x1b[0m", + " ".repeat(indent), + self.full_path + )); + } else { + result.push_str(&format!("{}{}", " ".repeat(indent), self.full_path)); + } + } + + // Only add methods and guards for resource nodes + if let ResourceType::Resource = self.kind { let methods = if self.methods.is_empty() { "".to_string() } else { @@ -88,39 +108,18 @@ impl IntrospectionNode { format!(" Guards: {:?}", self.guards) }; - println!("{}{}{}{}", " ".repeat(indent), full_path, methods, guards); + // Highlight final endpoints with ANSI codes for bold and green color + result.push_str(&format!("\x1b[1;32m{}{}\x1b[0m\n", methods, guards)); + } else { + // For non-resource nodes, just add a newline + result.push('\n'); } for child in &self.children { - child.display(indent, &full_path); + result.push_str(&child.display(indent + 2)); // Increase indent for children } - } -} -fn build_tree(node: &mut IntrospectionNode, rmap: &ResourceMap) { - initialize_detail_registry(); - let detail_registry = get_detail_registry(); - if let Some(ref children) = rmap.nodes { - for child_rc in children { - let child = child_rc; - let pat = child.pattern.pattern().unwrap_or("").to_string(); - let kind = if child.nodes.is_some() { - ResourceType::Scope - } else { - ResourceType::Resource - }; - let mut new_node = IntrospectionNode::new(kind, pat.clone()); - - if let ResourceType::Resource = new_node.kind { - if let Some(d) = detail_registry.lock().unwrap().get(&pat) { - new_node.methods = d.methods.clone(); - new_node.guards = d.guards.clone(); - } - } - - build_tree(&mut new_node, child); - node.children.push(new_node); - } + result } } @@ -134,19 +133,70 @@ fn is_designated_thread() -> bool { *DESIGNATED_THREAD.get().unwrap() == current_id } -pub fn register_rmap(rmap: &ResourceMap) { +pub fn register_rmap(_rmap: &ResourceMap) { if !is_designated_thread() { return; } initialize_registry(); - let mut root = IntrospectionNode::new(ResourceType::App, "".into()); - build_tree(&mut root, rmap); + 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(¤t_node.full_path) { + update_unique(&mut current_node.methods, &detail.methods); + update_unique(&mut current_node.guards, &detail.guards); + } + } + } + } + *get_registry().lock().unwrap() = root; - // WIP. Display the introspection tree - let reg = get_registry().lock().unwrap(); - reg.display(0, ""); + // Display the introspection tree + let registry = get_registry().lock().unwrap(); + let tree_representation = registry.display(0); + log::debug!( + "Introspection Tree:\n{}", + tree_representation.trim_matches('\n').to_string() + ); } fn update_unique(existing: &mut Vec, new_items: &[T]) { @@ -157,16 +207,29 @@ fn update_unique(existing: &mut Vec, new_items: &[T]) { } } -pub fn register_pattern_detail(pattern: String, methods: Vec, guards: Vec) { +pub fn register_pattern_detail( + full_path: String, + methods: Vec, + guards: Vec, + is_resource: bool, +) { if !is_designated_thread() { return; } initialize_detail_registry(); let mut reg = get_detail_registry().lock().unwrap(); - reg.entry(pattern) + 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 }); + .or_insert(RouteDetail { + methods, + guards, + is_resource, + }); } diff --git a/actix-web/src/resource.rs b/actix-web/src/resource.rs index e6f3c0933..df5f05fb0 100644 --- a/actix-web/src/resource.rs +++ b/actix-web/src/resource.rs @@ -430,31 +430,53 @@ where } else { ResourceDef::new(self.rdef.clone()) }; + #[cfg(feature = "experimental-introspection")] + { + use crate::{http::Method, introspection}; + + let guards_routes = routes.iter().map(|r| r.guards()).collect::>(); + + let pat = rdef.pattern().unwrap_or("").to_string(); + 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 { + // Log the guards and methods for introspection + let guard_names = route_guards.iter().map(|g| g.name()).collect::>(); + 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::().ok()) + .collect::>() + } else { + Vec::new() + } + }) + .collect::>(); + + introspection::register_pattern_detail( + full_path.clone(), + methods, + guard_names, + true, + ); + } + } if let Some(ref name) = self.name { rdef.set_name(name); } - #[cfg(feature = "experimental-introspection")] - { - let pat = rdef.pattern().unwrap_or("").to_string(); - let mut methods = Vec::new(); - let mut guard_names = Vec::new(); - for route in &routes { - if let Some(m) = route.get_method() { - if !methods.contains(&m) { - methods.push(m); - } - } - for name in route.guard_names() { - if !guard_names.contains(&name) { - guard_names.push(name.clone()); - } - } - } - crate::introspection::register_pattern_detail(pat, methods, guard_names); - } - *self.factory_ref.borrow_mut() = Some(ResourceFactory { routes, default: self.default, diff --git a/actix-web/src/rmap.rs b/actix-web/src/rmap.rs index cf2336f78..b445687ac 100644 --- a/actix-web/src/rmap.rs +++ b/actix-web/src/rmap.rs @@ -15,13 +15,16 @@ const AVG_PATH_LEN: usize = 24; #[derive(Clone, Debug)] pub struct ResourceMap { - pub(crate) pattern: ResourceDef, + pattern: ResourceDef, + /// Named resources within the tree or, for external resources, it points to isolated nodes /// outside the tree. named: FoldHashMap>, + parent: RefCell>, + /// Must be `None` for "edge" nodes. - pub(crate) nodes: Option>>, + nodes: Option>>, } impl ResourceMap { diff --git a/actix-web/src/route.rs b/actix-web/src/route.rs index d502534a6..65d7dcef0 100644 --- a/actix-web/src/route.rs +++ b/actix-web/src/route.rs @@ -65,11 +65,6 @@ impl Route { pub(crate) fn take_guards(&mut self) -> Vec> { mem::take(Rc::get_mut(&mut self.guards).unwrap()) } - /// Get the names of all guards applied to this route. - #[cfg(feature = "experimental-introspection")] - pub fn guard_names(&self) -> Vec { - self.guards.iter().map(|g| g.name()).collect() - } } impl ServiceFactory for Route { @@ -145,23 +140,6 @@ impl Route { self } - #[cfg(feature = "experimental-introspection")] - /// Get the first HTTP method guard applied to this route (if any). - /// WIP. - pub(crate) fn get_method(&self) -> Option { - self.guards.iter().find_map(|g| { - g.details().and_then(|details| { - details.into_iter().find_map(|d| { - if let crate::guard::GuardDetail::HttpMethods(mut m) = d { - m.pop().and_then(|s| s.parse().ok()) - } else { - None - } - }) - }) - }) - } - /// Add guard to the route. /// /// # Examples @@ -181,7 +159,8 @@ impl Route { self } - pub fn guards(&self) -> &Vec> { + #[cfg(feature = "experimental-introspection")] + pub(crate) fn guards(&self) -> &Vec> { &self.guards } diff --git a/actix-web/src/scope.rs b/actix-web/src/scope.rs index 2c1727779..979aad308 100644 --- a/actix-web/src/scope.rs +++ b/actix-web/src/scope.rs @@ -384,6 +384,11 @@ where // register nested services let mut cfg = config.clone_config(); + + // Update the prefix for the nested scope + #[cfg(feature = "experimental-introspection")] + cfg.update_prefix(&self.rdef); + self.services .into_iter() .for_each(|mut srv| srv.register(&mut cfg)); @@ -404,41 +409,6 @@ where .into_iter() .map(|(mut rdef, srv, guards, nested)| { rmap.add(&mut rdef, nested); - - #[cfg(feature = "experimental-introspection")] - { - use std::borrow::Borrow; - - // Get the pattern stored in ResourceMap - let pat = rdef.pattern().unwrap_or("").to_string(); - let guard_list: &[Box] = - guards.borrow().as_ref().map_or(&[], |v| &v[..]); - - // Extract HTTP methods from guards - let methods = guard_list - .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().ok()) - .collect::>() - } else { - Vec::new() - } - }) - .collect::>(); - - // Extract guard names - let guard_names = guard_list - .iter() - .map(|g| g.name().to_string()) - .collect::>(); - - // Register route details for introspection - crate::introspection::register_pattern_detail(pat, methods, guard_names); - } - (rdef, srv, RefCell::new(guards)) }) .collect::>()