diff --git a/actix-web/CHANGES.md b/actix-web/CHANGES.md index a12ab110c..c2b7c1f38 100644 --- a/actix-web/CHANGES.md +++ b/actix-web/CHANGES.md @@ -2,6 +2,7 @@ ## 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. - Minimum supported Rust version (MSRV) is now 1.82. ## 4.12.1 diff --git a/actix-web/Cargo.toml b/actix-web/Cargo.toml index 085e89371..6f9c94fcb 100644 --- a/actix-web/Cargo.toml +++ b/actix-web/Cargo.toml @@ -125,6 +125,9 @@ compat = ["compat-routing-macros-force-pub"] # 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. +experimental-introspection = [] + [dependencies] actix-codec = "0.5" actix-macros = { version = "0.2.3", optional = true } diff --git a/actix-web/examples/introspection.rs b/actix-web/examples/introspection.rs new file mode 100644 index 000000000..04e62daeb --- /dev/null +++ b/actix-web/examples/introspection.rs @@ -0,0 +1,304 @@ +// Example showcasing the experimental introspection feature. +// Run with: `cargo run --features experimental-introspection --example introspection` + +#[actix_web::main] +async fn main() -> std::io::Result<()> { + #[cfg(feature = "experimental-introspection")] + { + use actix_web::{dev::Service, guard, web, App, HttpResponse, HttpServer, Responder}; + use serde::Deserialize; + + // Initialize logging + env_logger::Builder::new() + .filter_level(log::LevelFilter::Debug) + .init(); + + // Custom guard to check if the Content-Type header is present. + struct ContentTypeGuard; + + impl guard::Guard for ContentTypeGuard { + fn check(&self, req: &guard::GuardContext<'_>) -> bool { + req.head() + .headers() + .contains_key(actix_web::http::header::CONTENT_TYPE) + } + } + + // Data structure for endpoints that receive JSON. + #[derive(Deserialize)] + struct UserInfo { + username: String, + age: u8, + } + + // GET /introspection for JSON response + async fn introspection_handler_json( + tree: web::Data, + ) -> impl Responder { + let report = tree.report_as_json(); + HttpResponse::Ok() + .content_type("application/json") + .body(report) + } + + // GET /introspection/externals for external resources report + async fn introspection_handler_externals( + tree: web::Data, + ) -> impl Responder { + let report = tree.report_externals_as_json(); + HttpResponse::Ok() + .content_type("application/json") + .body(report) + } + + // GET /introspection for plain text response + async fn introspection_handler_text( + tree: web::Data, + ) -> impl Responder { + let report = tree.report_as_text(); + HttpResponse::Ok().content_type("text/plain").body(report) + } + + // GET /api/v1/item/{id} and GET /v1/item/{id} + #[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 + #[actix_web::post("/info")] + async fn post_user_info(info: web::Json) -> impl Responder { + HttpResponse::Ok().json(format!( + "User {} with age {} received", + info.username, info.age + )) + } + + // /api/v1/guarded + async fn guarded_handler() -> impl Responder { + HttpResponse::Ok().body("Passed the Content-Type guard!") + } + + // GET /api/v2/hello + async fn hello_v2() -> impl Responder { + HttpResponse::Ok().body("Hello from API v2!") + } + + // GET /admin/dashboard + async fn admin_dashboard() -> impl Responder { + HttpResponse::Ok().body("Welcome to the Admin Dashboard!") + } + + // GET /admin/settings + async fn get_settings() -> impl Responder { + HttpResponse::Ok().body("Current settings: ...") + } + + // POST /admin/settings + async fn update_settings() -> impl Responder { + HttpResponse::Ok().body("Settings have been updated!") + } + + // GET and POST on / + async fn root_index() -> impl Responder { + 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 + fn extra_endpoints(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/extra") + .route( + "/ping", + web::get().to(|| async { HttpResponse::Ok().body("pong") }), // GET /extra/ping + ) + .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 + ) + .service( + web::scope("{entities_id:\\d+}") + .service( + web::scope("/secure") + .route( + "", + 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 + ) + .wrap_fn(|req, srv| { + println!( + "Request to /extra/secure with id: {}", + req.match_info().get("entities_id").unwrap() + ); + let fut = srv.call(req); + async move { + let res = fut.await?; + Ok(res) + } + }), + ), + ); + } + + // 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 + ), + ); + } + + // Create the HTTP server with all the routes and handlers + let server = HttpServer::new(|| { + App::new() + // 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: text/plain' + // curl --location '127.0.0.1:8080/introspection/externals' + .external_resource("app-external", "https://example.com/{id}") + .service( + web::resource("/introspection") + .route( + web::get() + .guard(guard::Header("accept", "application/json")) + .to(introspection_handler_json), + ) + .route( + web::get() + .guard(guard::Header("accept", "text/plain")) + .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 + .service( + web::scope("/api") + .configure(|cfg| { + cfg.external_resource("api-external", "https://api.example/{id}"); + }) + // Endpoints under /api/v1 + .service( + web::scope("/v1") + .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), // /api/v1/guarded + ), + ) + // Endpoints under /api/v2 + .service(web::scope("/v2").route("/hello", web::get().to(hello_v2))), // GET /api/v2/hello + ) + // 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)) // GET /admin/dashboard + .service( + web::resource("/settings") + .route(web::get().to(get_settings)) // GET /admin/settings + .route(web::post().to(update_settings)), // POST /admin/settings + ), + ) + // Root endpoints + .service( + web::resource("/") + .route(web::get().to(root_index)) // GET / + .route(web::post().to(root_index)), // POST / + ) + // 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 with header or POST on /all_guard + .route( + "/all_guard", + web::route() + .guard( + guard::All(guard::Get()) + .and(guard::Header("content-type", "plain/text")) + .and(guard::Any(guard::Post())), + ) + .to(HttpResponse::MethodNotAllowed), + ) + }) + .workers(5) + .bind("127.0.0.1:8080")?; + + server.run().await + } + #[cfg(not(feature = "experimental-introspection"))] + { + eprintln!("This example requires the 'experimental-introspection' feature to be enabled."); + std::process::exit(1); + } +} diff --git a/actix-web/examples/introspection_multi_servers.rs b/actix-web/examples/introspection_multi_servers.rs new file mode 100644 index 000000000..7ed2224e3 --- /dev/null +++ b/actix-web/examples/introspection_multi_servers.rs @@ -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, + ) -> 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(()) +} diff --git a/actix-web/src/app.rs b/actix-web/src/app.rs index f12d39979..1099731f3 100644 --- a/actix-web/src/app.rs +++ b/actix-web/src/app.rs @@ -30,6 +30,8 @@ pub struct App { data_factories: Vec, external: Vec, extensions: Extensions, + #[cfg(feature = "experimental-introspection")] + introspector: Rc>, } impl App { @@ -46,6 +48,10 @@ impl App { 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), } } } diff --git a/actix-web/src/app_service.rs b/actix-web/src/app_service.rs index 7aa16b790..7622bd68a 100644 --- a/actix-web/src/app_service.rs +++ b/actix-web/src/app_service.rs @@ -41,6 +41,8 @@ where pub(crate) default: Option>, pub(crate) factory_ref: Rc>>, pub(crate) external: RefCell>, + #[cfg(feature = "experimental-introspection")] + pub(crate) introspector: Rc>, } impl ServiceFactory for AppInit @@ -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. @@ -98,6 +107,10 @@ where // external resources 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); } @@ -110,6 +123,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(); @@ -130,6 +145,12 @@ where factory.create(&mut app_data); } + #[cfg(feature = "experimental-introspection")] + { + let tree = introspector.borrow_mut().finalize(); + app_data.insert(crate::web::Data::new(tree)); + } + Ok(AppInitService { service, app_data: Rc::new(app_data), diff --git a/actix-web/src/config.rs b/actix-web/src/config.rs index 0e856f574..7ba47b9dc 100644 --- a/actix-web/src/config.rs +++ b/actix-web/src/config.rs @@ -30,6 +30,15 @@ pub struct AppService { Option, Option>, )>, + #[cfg(feature = "experimental-introspection")] + pub current_prefix: String, + #[cfg(feature = "experimental-introspection")] + pub(crate) introspector: + std::rc::Rc>, + #[cfg(feature = "experimental-introspection")] + pub(crate) scope_id_stack: Vec, + #[cfg(feature = "experimental-introspection")] + pending_scope_id: Option, } impl AppService { @@ -40,6 +49,16 @@ impl AppService { default, root: true, 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(), + )), + #[cfg(feature = "experimental-introspection")] + scope_id_stack: Vec::new(), + #[cfg(feature = "experimental-introspection")] + pending_scope_id: None, } } @@ -49,6 +68,24 @@ impl AppService { } #[allow(clippy::type_complexity)] + #[cfg(feature = "experimental-introspection")] + pub(crate) fn into_services( + self, + ) -> ( + AppConfig, + Vec<( + ResourceDef, + BoxedHttpServiceFactory, + Option, + Option>, + )>, + std::rc::Rc>, + ) { + (self.config, self.services, self.introspector) + } + + #[allow(clippy::type_complexity)] + #[cfg(not(feature = "experimental-introspection"))] pub(crate) fn into_services( self, ) -> ( @@ -71,6 +108,14 @@ impl AppService { default: Rc::clone(&self.default), services: Vec::new(), root: false, + #[cfg(feature = "experimental-introspection")] + current_prefix: self.current_prefix.clone(), + #[cfg(feature = "experimental-introspection")] + 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, } } @@ -101,9 +146,90 @@ impl AppService { InitError = (), > + 'static, { + #[cfg(feature = "experimental-introspection")] + { + use std::borrow::Borrow; + + // 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::>(); + let guard_details = crate::introspection::guard_reports_from_iter(guard_list.iter()); + + let is_resource = nested.is_none(); + let full_paths = crate::introspection::expand_patterns(&self.current_prefix, &rdef); + let patterns = rdef + .pattern_iter() + .map(|pattern| pattern.to_string()) + .collect::>(); + let resource_name = rdef.name().map(|name| name.to_string()); + 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 .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) { + let next = ResourceDef::root_prefix(prefix); + + if self.current_prefix.is_empty() { + self.current_prefix = next.pattern().unwrap_or("").to_string(); + return; + } + + 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 + } } /// Application connection config. diff --git a/actix-web/src/guard/acceptable.rs b/actix-web/src/guard/acceptable.rs index 8fa7165c8..de17fe6fa 100644 --- a/actix-web/src/guard/acceptable.rs +++ b/actix-web/src/guard/acceptable.rs @@ -1,4 +1,4 @@ -use super::{Guard, GuardContext}; +use super::{Guard, GuardContext, GuardDetail}; use crate::http::header::Accept; /// A guard that verifies that an `Accept` header is present and it contains a compatible MIME type. @@ -63,6 +63,37 @@ impl Guard for Acceptable { 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::().to_string() + } + } + + fn details(&self) -> Option> { + #[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)] @@ -96,4 +127,28 @@ mod tests { .match_star_star() .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 + ) + ); + } } diff --git a/actix-web/src/guard/host.rs b/actix-web/src/guard/host.rs index 835662346..54cec7522 100644 --- a/actix-web/src/guard/host.rs +++ b/actix-web/src/guard/host.rs @@ -1,6 +1,6 @@ use actix_http::{header, uri::Uri, RequestHead, Version}; -use super::{Guard, GuardContext}; +use super::{Guard, GuardContext, GuardDetail}; /// Creates a guard that matches requests targeting a specific host. /// @@ -117,6 +117,41 @@ impl Guard for HostGuard { // all conditions passed 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::().to_string() + } + } + + fn details(&self) -> Option> { + #[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)] @@ -239,4 +274,23 @@ mod tests { let host = Host("localhost"); 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)"); + } } diff --git a/actix-web/src/guard/mod.rs b/actix-web/src/guard/mod.rs index 41609953a..038ba6590 100644 --- a/actix-web/src/guard/mod.rs +++ b/actix-web/src/guard/mod.rs @@ -11,7 +11,7 @@ //! or handler. This interface is defined by the [`Guard`] trait. //! //! Commonly-used guards are provided in this module as well as a way of creating a guard from a -//! closure ([`fn_guard`]). The [`Not`], [`Any`], and [`All`] guards are noteworthy, as they can be +//! closure ([`fn_guard`]). The [`Not`], [`Any()`], and [`All()`] guards are noteworthy, as they can be //! used to compose other guards in a more flexible and semantic way than calling `.guard(...)` on //! services multiple times (which might have different combining behavior than you want). //! @@ -66,6 +66,17 @@ pub use self::{ host::{Host, HostGuard}, }; +/// Enum to encapsulate various introspection details of a guard. +#[derive(Debug, Clone)] +pub enum GuardDetail { + /// Detail associated with explicit HTTP method guards. + HttpMethods(Vec), + /// Detail associated with headers (header, value). + Headers(Vec<(String, String)>), + /// Generic detail, typically used for compound guard representations. + Generic(String), +} + /// Provides access to request parts that are useful during routing. #[derive(Debug)] pub struct GuardContext<'a> { @@ -124,12 +135,30 @@ impl<'a> GuardContext<'a> { pub trait Guard { /// Returns true if predicate condition is met for a given request. fn check(&self, ctx: &GuardContext<'_>) -> bool; + + /// Returns a nominal representation of the guard. + fn name(&self) -> String { + std::any::type_name::().to_string() + } + + /// Returns detailed introspection information, when available. + /// + /// This is best-effort and may omit complex guard logic. + fn details(&self) -> Option> { + None + } } impl Guard for Rc { fn check(&self, ctx: &GuardContext<'_>) -> bool { (**self).check(ctx) } + fn name(&self) -> String { + (**self).name() + } + fn details(&self) -> Option> { + (**self).details() + } } /// Creates a guard using the given function. @@ -195,7 +224,7 @@ pub fn Any(guard: F) -> AnyGuard { /// /// That is, only one contained guard needs to match in order for the aggregate guard to match. /// -/// Construct an `AnyGuard` using [`Any`]. +/// Construct an `AnyGuard` using [`Any()`]. pub struct AnyGuard { guards: Vec>, } @@ -219,6 +248,24 @@ impl Guard for AnyGuard { false } + fn name(&self) -> String { + format!( + "AnyGuard({})", + self.guards + .iter() + .map(|g| g.name()) + .collect::>() + .join(", ") + ) + } + fn details(&self) -> Option> { + Some( + self.guards + .iter() + .flat_map(|g| g.details().unwrap_or_default()) + .collect(), + ) + } } /// Creates a guard that matches if all added guards match. @@ -247,7 +294,7 @@ pub fn All(guard: F) -> AllGuard { /// /// That is, **all** contained guard needs to match in order for the aggregate guard to match. /// -/// Construct an `AllGuard` using [`All`]. +/// Construct an `AllGuard` using [`All()`]. pub struct AllGuard { guards: Vec>, } @@ -271,6 +318,24 @@ impl Guard for AllGuard { true } + fn name(&self) -> String { + format!( + "AllGuard({})", + self.guards + .iter() + .map(|g| g.name()) + .collect::>() + .join(", ") + ) + } + fn details(&self) -> Option> { + Some( + self.guards + .iter() + .flat_map(|g| g.details().unwrap_or_default()) + .collect(), + ) + } } /// Wraps a guard and inverts the outcome of its `Guard` implementation. @@ -291,6 +356,19 @@ impl Guard for Not { fn check(&self, ctx: &GuardContext<'_>) -> bool { !self.0.check(ctx) } + fn name(&self) -> String { + format!("Not({})", self.0.name()) + } + fn details(&self) -> Option> { + #[cfg(feature = "experimental-introspection")] + { + Some(vec![GuardDetail::Generic(self.name())]) + } + #[cfg(not(feature = "experimental-introspection"))] + { + None + } + } } /// Creates a guard that matches a specified HTTP method. @@ -320,6 +398,12 @@ impl Guard for MethodGuard { ctx.head().method == self.0 } + fn name(&self) -> String { + self.0.to_string() + } + fn details(&self) -> Option> { + Some(vec![GuardDetail::HttpMethods(vec![self.0.to_string()])]) + } } macro_rules! method_guard { @@ -382,6 +466,15 @@ impl Guard for HeaderGuard { false } + fn name(&self) -> String { + format!("Header({}, {})", self.0, self.1.to_str().unwrap_or("")) + } + fn details(&self) -> Option> { + Some(vec![GuardDetail::Headers(vec![( + self.0.to_string(), + self.1.to_str().unwrap_or("").to_string(), + )])]) + } } #[cfg(test)] diff --git a/actix-web/src/introspection.rs b/actix-web/src/introspection.rs new file mode 100644 index 000000000..89690edf1 --- /dev/null +++ b/actix-web/src/introspection.rs @@ -0,0 +1,1166 @@ +//! Experimental route introspection helpers. +//! +//! Enabled with the `experimental-introspection` feature. +//! +//! What it reports: +//! - Configured routes with their patterns, method guards, guard details, and resource metadata +//! (`resource_name`, `resource_type`, `scope_depth`). +//! - Reachability hints for routes that may be shadowed by registration order or conflicting +//! method guards. +//! - External resources (used only for URL generation) in a separate report, including the scope +//! path where they were registered. External resources never participate in request routing. +//! +//! Notes: +//! - Method lists are best-effort and derived only from explicit method guards; an empty list means +//! the route matches any method. +//! - Reachability hints are best-effort and should be treated as diagnostics, not a hard guarantee. +//! +//! This feature is intended for local/non-production use. Avoid exposing introspection endpoints +//! in production, since reports can include sensitive configuration details. + +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt::Write as FmtWrite, +}; + +use serde::Serialize; + +use crate::{ + dev::ResourceDef, + guard::{Guard, GuardDetail}, + http::Method, +}; + +#[derive(Clone)] +pub struct RouteDetail { + methods: Vec, + guards: Vec, + guard_details: Vec, + patterns: Vec, + resource_name: Option, + is_resource: bool, +} + +/// Input data for registering routes with the introspector. +#[derive(Clone)] +pub struct RouteInfo { + full_path: String, + methods: Vec, + guards: Vec, + guard_details: Vec, + patterns: Vec, + resource_name: Option, +} + +impl RouteInfo { + pub fn new( + full_path: String, + methods: Vec, + guards: Vec, + guard_details: Vec, + patterns: Vec, + resource_name: Option, + ) -> Self { + Self { + full_path, + methods, + guards, + guard_details, + patterns, + resource_name, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GuardReport { + pub name: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub details: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum GuardDetailReport { + HttpMethods { methods: Vec }, + Headers { headers: Vec }, + Generic { value: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct HeaderReport { + pub name: String, + pub value: String, +} + +/// A report item for an external resource configured for URL generation. +/// +/// `origin_scope` is the scope path where the external resource was registered. It is informational +/// only and does not affect URL generation or routing; external resources are always global. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ExternalResourceReportItem { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + pub patterns: Vec, + pub origin_scope: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RegistrationKind { + Service, + Route, +} + +#[derive(Clone)] +struct Registration { + order: usize, + kind: RegistrationKind, + scope_id: Option, + parent_scope_id: Option, + full_path: String, + is_prefix: bool, + methods: Vec, + guards: Vec, +} + +#[derive(Clone)] +struct ShadowingContext { + path: String, + order: usize, +} + +/// Node type within an introspection tree. +#[derive(Debug, Clone, Copy)] +pub enum ResourceType { + /// The application root. + App, + /// A scope/prefix path. + Scope, + /// A resource (route) path. + Resource, +} + +fn resource_type_label(kind: ResourceType) -> &'static str { + match kind { + ResourceType::App => "app", + ResourceType::Scope => "scope", + ResourceType::Resource => "resource", + } +} + +/// A node in the introspection tree. +#[derive(Debug, Clone)] +pub struct IntrospectionNode { + /// The node's classification. + pub kind: ResourceType, + /// The path segment used for this node. + pub pattern: String, + /// The full path for this node. + pub full_path: String, + /// HTTP methods derived from explicit method guards. + pub methods: Vec, + /// Guard names attached to this node. + pub guards: Vec, + /// Structured guard details, when available. + pub guard_details: Vec, + /// Resource name, when configured. + pub resource_name: Option, + /// Original patterns used for this resource. + pub patterns: Vec, + /// Child nodes under this prefix. + pub children: Vec, + /// True if the node might be unreachable at runtime. + pub potentially_unreachable: bool, + /// Reasons for potential unreachability. + pub reachability_notes: Vec, +} + +/// A flattened report item for a route. +#[derive(Debug, Clone, Serialize)] +pub struct IntrospectionReportItem { + /// Full path for the route. + pub full_path: String, + /// Methods derived from explicit method guards. + /// + /// An empty list indicates the route matches any method. + pub methods: Vec, + /// Guard names attached to the route. + pub guards: Vec, + /// Structured guard details, when available. + /// + /// Includes method guards even if `guards` filters them out for readability. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub guards_detail: Vec, + /// Resource name, when configured. + #[serde(skip_serializing_if = "Option::is_none")] + pub resource_name: Option, + /// Original patterns used for this resource. + /// + /// These are raw ResourceDef patterns (may be relative to a scope), not expanded full paths. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub patterns: Vec, + /// The type of node represented by the report item. + pub resource_type: String, + /// Depth within the scope tree (root = 0). + pub scope_depth: usize, + /// True if the route might be unreachable at runtime. + #[serde(skip_serializing_if = "is_false")] + pub potentially_unreachable: bool, + /// Reasons for potential unreachability. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub reachability_notes: Vec, +} + +impl IntrospectionNode { + pub fn new(kind: ResourceType, pattern: String, full_path: String) -> Self { + IntrospectionNode { + kind, + pattern, + full_path, + methods: Vec::new(), + guards: Vec::new(), + guard_details: Vec::new(), + resource_name: None, + patterns: Vec::new(), + children: Vec::new(), + potentially_unreachable: false, + reachability_notes: Vec::new(), + } + } +} + +impl From<&IntrospectionNode> for Vec { + fn from(node: &IntrospectionNode) -> Self { + fn collect_report_items( + node: &IntrospectionNode, + report_items: &mut Vec, + depth: usize, + ) { + let include_node = matches!(node.kind, ResourceType::Resource) + || !node.methods.is_empty() + || !node.guards.is_empty() + || node.potentially_unreachable; + + if include_node { + let method_names = node + .methods + .iter() + .map(|m| m.to_string()) + .collect::>(); + let filtered_guards = filter_guard_names(&node.guards, &node.methods); + + report_items.push(IntrospectionReportItem { + full_path: node.full_path.clone(), + methods: method_names, + guards: filtered_guards, + guards_detail: node.guard_details.clone(), + resource_name: node.resource_name.clone(), + patterns: node.patterns.clone(), + resource_type: resource_type_label(node.kind).to_string(), + scope_depth: depth, + potentially_unreachable: node.potentially_unreachable, + reachability_notes: node.reachability_notes.clone(), + }); + } + + for child in &node.children { + collect_report_items(child, report_items, depth + 1); + } + } + + let mut report_items = Vec::new(); + collect_report_items(node, &mut report_items, 0); + report_items + } +} + +/// Collects route details during app configuration. +#[derive(Clone, Default)] +pub struct IntrospectionCollector { + details: BTreeMap, + registrations: Vec, + externals: Vec, + next_registration_order: usize, + next_scope_id: usize, +} + +impl IntrospectionCollector { + /// Creates a new, empty collector. + pub fn new() -> Self { + Self { + details: BTreeMap::new(), + registrations: Vec::new(), + externals: Vec::new(), + next_registration_order: 0, + next_scope_id: 0, + } + } + + pub fn next_scope_id(&mut self) -> usize { + let scope_id = self.next_scope_id; + self.next_scope_id += 1; + scope_id + } + + pub fn register_service( + &mut self, + info: RouteInfo, + is_resource: bool, + is_prefix: bool, + scope_id: Option, + parent_scope_id: Option, + ) { + let full_path = normalize_path(&info.full_path); + + self.register_pattern_detail(&full_path, &info, is_resource); + + self.registrations.push(Registration { + order: self.next_registration_order, + kind: RegistrationKind::Service, + scope_id, + parent_scope_id, + full_path, + is_prefix, + methods: info.methods, + guards: info.guards, + }); + self.next_registration_order += 1; + } + + pub fn register_route(&mut self, info: RouteInfo, scope_id: Option) { + let full_path = normalize_path(&info.full_path); + + self.register_pattern_detail(&full_path, &info, true); + + self.registrations.push(Registration { + order: self.next_registration_order, + kind: RegistrationKind::Route, + scope_id, + parent_scope_id: None, + full_path, + is_prefix: false, + methods: info.methods, + guards: info.guards, + }); + self.next_registration_order += 1; + } + + pub fn register_external(&mut self, rdef: &ResourceDef, origin_scope: &str) { + let report = external_report_from_rdef(rdef, origin_scope); + + if let Some(name) = report.name.as_deref() { + if let Some(existing) = self + .externals + .iter_mut() + .find(|item| item.name.as_deref() == Some(name)) + { + *existing = report; + return; + } + } + + if !self.externals.contains(&report) { + self.externals.push(report); + } + } + + /// Registers details for a route pattern. + pub fn register_pattern_detail( + &mut self, + full_path: &str, + info: &RouteInfo, + is_resource: bool, + ) { + let full_path = normalize_path(full_path); + + self.details + .entry(full_path) + .and_modify(|d| { + update_unique(&mut d.methods, &info.methods); + update_unique(&mut d.guards, &info.guards); + merge_guard_reports(&mut d.guard_details, &info.guard_details); + update_unique(&mut d.patterns, &info.patterns); + if d.resource_name.is_none() { + d.resource_name = info.resource_name.clone(); + } + if !d.is_resource && is_resource { + d.is_resource = true; + } + }) + .or_insert(RouteDetail { + methods: info.methods.clone(), + guards: info.guards.clone(), + guard_details: info.guard_details.clone(), + patterns: info.patterns.clone(), + resource_name: info.resource_name.clone(), + is_resource, + }); + } + + /// Produces the finalized introspection tree. + pub fn finalize(&mut self) -> IntrospectionTree { + let detail_registry = std::mem::take(&mut self.details); + let registrations = std::mem::take(&mut self.registrations); + let externals = std::mem::take(&mut self.externals); + let mut root = IntrospectionNode::new(ResourceType::App, "".into(), "".into()); + + for (full_path, _) in detail_registry.iter() { + let parts = split_path_segments(full_path); + let mut current_node = &mut root; + let mut assembled = String::new(); + + for part in parts.iter() { + assembled.push('/'); + assembled.push_str(part); + + let child_full_path = assembled.clone(); + 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 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 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); + merge_guard_reports(&mut current_node.guard_details, &detail.guard_details); + update_unique(&mut current_node.patterns, &detail.patterns); + if current_node.resource_name.is_none() { + current_node.resource_name = detail.resource_name.clone(); + } + } + } + } + + let reachability = analyze_reachability(®istrations); + apply_reachability(&mut root, &reachability); + + IntrospectionTree { root, externals } + } +} + +/// The finalized introspection tree. +#[derive(Clone)] +pub struct IntrospectionTree { + /// Root node of the tree. + pub root: IntrospectionNode, + /// External resources configured for URL generation. + pub externals: Vec, +} + +impl IntrospectionTree { + /// Returns a formatted, human-readable report. + pub fn report_as_text(&self) -> String { + warn_release_mode_once(); + let report_items: Vec = (&self.root).into(); + + let mut buf = String::new(); + for item in report_items { + let full_path = sanitize_text(&item.full_path); + let methods = item + .methods + .iter() + .map(|method| sanitize_text(method)) + .collect::>(); + let guards = item + .guards + .iter() + .map(|guard| sanitize_text(guard)) + .collect::>(); + writeln!( + buf, + "{} => Methods: {:?} | Guards: {:?}{}", + full_path, + methods, + guards, + format_reachability(&item) + ) + .unwrap(); + } + + buf + } + + /// Returns a JSON report of configured routes. + pub fn report_as_json(&self) -> String { + warn_release_mode_once(); + let report_items: Vec = (&self.root).into(); + serde_json::to_string_pretty(&report_items).unwrap() + } + + /// Returns a JSON report of external resources. + pub fn report_externals_as_json(&self) -> String { + warn_release_mode_once(); + serde_json::to_string_pretty(&self.externals).unwrap() + } +} + +pub(crate) fn guard_reports_from_iter<'a, I>(guards: I) -> Vec +where + I: IntoIterator>, +{ + guards + .into_iter() + .map(|guard| { + let mut details = Vec::new(); + if let Some(guard_details) = guard.details() { + for detail in guard_details { + merge_guard_detail_reports(&mut details, detail.into()); + } + } + GuardReport { + name: guard.name(), + details, + } + }) + .collect() +} + +impl From for GuardDetailReport { + fn from(detail: GuardDetail) -> Self { + match detail { + GuardDetail::HttpMethods(methods) => GuardDetailReport::HttpMethods { methods }, + GuardDetail::Headers(headers) => GuardDetailReport::Headers { + headers: headers + .into_iter() + .map(|(name, value)| HeaderReport { name, value }) + .collect(), + }, + GuardDetail::Generic(value) => GuardDetailReport::Generic { value }, + } + } +} + +pub(crate) fn external_report_from_rdef( + rdef: &ResourceDef, + origin_scope: &str, +) -> ExternalResourceReportItem { + ExternalResourceReportItem { + name: rdef.name().map(|name| name.to_string()), + patterns: rdef + .pattern_iter() + .map(|pattern| pattern.to_string()) + .collect(), + origin_scope: normalize_path(origin_scope), + } +} + +pub(crate) fn expand_patterns(prefix: &str, rdef: &ResourceDef) -> Vec { + let mut full_paths = Vec::new(); + + if prefix.is_empty() { + for pat in rdef.pattern_iter() { + full_paths.push(normalize_path(pat)); + } + + return full_paths; + } + + let joined = ResourceDef::root_prefix(prefix).join(rdef); + + for pat in joined.pattern_iter() { + full_paths.push(normalize_path(pat)); + } + + full_paths +} + +fn analyze_reachability(registrations: &[Registration]) -> BTreeMap> { + let shadowed_scopes = shadowed_scope_context(registrations); + let shadowed_routes = shadowed_route_context(registrations); + + let mut notes_by_path: BTreeMap> = BTreeMap::new(); + + for reg in registrations { + let mut notes = Vec::new(); + + if let Some(scope_id) = reg.scope_id { + if let Some(context) = shadowed_scopes.get(&scope_id) { + notes.push("shadowed_by_scope".to_string()); + notes.push(format!("shadowed_by_path:{}", context.path)); + notes.push(format!("shadowed_by_order:{}", context.order)); + } + } + + if reg.kind == RegistrationKind::Route { + if let Some(context) = shadowed_routes.get(&(reg.scope_id, reg.full_path.clone())) { + notes.push("shadowed_by_route".to_string()); + notes.push(format!("shadowed_by_path:{}", context.path)); + notes.push(format!("shadowed_by_order:{}", context.order)); + } + + if has_conflicting_methods(®.methods, ®.guards) { + notes.push("conflicting_method_guards".to_string()); + } + } + + if !notes.is_empty() { + let entry = notes_by_path.entry(reg.full_path.clone()).or_default(); + for note in notes { + entry.insert(note); + } + } + } + + notes_by_path + .into_iter() + .map(|(path, notes)| (path, notes.into_iter().collect())) + .collect() +} + +fn shadowed_scope_context(registrations: &[Registration]) -> BTreeMap { + let mut groups: BTreeMap<(Option, String), Vec<&Registration>> = BTreeMap::new(); + + for reg in registrations { + if reg.kind != RegistrationKind::Service || !reg.is_prefix { + continue; + } + + if reg.scope_id.is_none() { + continue; + } + + groups + .entry((reg.parent_scope_id, reg.full_path.clone())) + .or_default() + .push(reg); + } + + let mut shadowed = BTreeMap::new(); + + for regs in groups.values_mut() { + regs.sort_by_key(|reg| reg.order); + + let mut shadowing_reg = None; + + for reg in regs.iter() { + if matches_all(®.methods, ®.guards) { + shadowing_reg = Some(*reg); + break; + } + } + + if let Some(shadowing) = shadowing_reg { + for reg in regs.iter() { + if reg.order > shadowing.order { + let scope_id = reg.scope_id.expect("scope_id must exist"); + shadowed.insert( + scope_id, + ShadowingContext { + path: shadowing.full_path.clone(), + order: shadowing.order, + }, + ); + } + } + } + } + + shadowed +} + +fn shadowed_route_context( + registrations: &[Registration], +) -> BTreeMap<(Option, String), ShadowingContext> { + let mut groups: BTreeMap<(Option, String), Vec<&Registration>> = BTreeMap::new(); + + for reg in registrations { + if reg.kind != RegistrationKind::Route { + continue; + } + + groups + .entry((reg.scope_id, reg.full_path.clone())) + .or_default() + .push(reg); + } + + let mut shadowed = BTreeMap::new(); + + for (key, regs) in groups { + let mut regs = regs; + regs.sort_by_key(|reg| reg.order); + + for idx in 1..regs.len() { + let current = regs[idx]; + let current_methods = method_set(¤t.methods); + + if !guards_only_methods(¤t.guards, ¤t.methods) { + continue; + } + + let mut shadowing_reg = None; + + for earlier in ®s[..idx] { + if !guards_only_methods(&earlier.guards, &earlier.methods) { + continue; + } + + if earlier.methods.is_empty() { + shadowing_reg = Some(*earlier); + break; + } + + let earlier_methods = method_set(&earlier.methods); + if !current_methods.is_empty() && current_methods.is_subset(&earlier_methods) { + shadowing_reg = Some(*earlier); + break; + } + } + + if let Some(reg) = shadowing_reg { + shadowed.insert( + key.clone(), + ShadowingContext { + path: reg.full_path.clone(), + order: reg.order, + }, + ); + break; + } + } + } + + shadowed +} + +fn apply_reachability(root: &mut IntrospectionNode, notes: &BTreeMap>) { + fn apply(node: &mut IntrospectionNode, notes: &BTreeMap>) { + if let Some(node_notes) = notes.get(&node.full_path) { + node.potentially_unreachable = true; + node.reachability_notes = node_notes.clone(); + } + + for child in &mut node.children { + apply(child, notes); + } + } + + apply(root, notes); +} + +fn normalize_path(path: &str) -> String { + if path.is_empty() { + return "/".to_string(); + } + + if path.starts_with('/') { + path.to_string() + } else { + let mut buf = String::with_capacity(path.len() + 1); + buf.push('/'); + buf.push_str(path); + buf + } +} + +fn split_path_segments(path: &str) -> Vec<&str> { + let trimmed = path.strip_prefix('/').unwrap_or(path); + + if trimmed.is_empty() { + return vec![""]; + } + + trimmed.split('/').collect() +} + +fn matches_all(methods: &[Method], guards: &[String]) -> bool { + methods.is_empty() && filter_guard_names(guards, methods).is_empty() +} + +fn guards_only_methods(guards: &[String], methods: &[Method]) -> bool { + filter_guard_names(guards, methods).is_empty() +} + +fn has_conflicting_methods(methods: &[Method], guards: &[String]) -> bool { + let method_names = method_set(methods); + if method_names.len() <= 1 { + return false; + } + + let has_any = guards.iter().any(|name| name.starts_with("AnyGuard(")); + let has_all = guards.iter().any(|name| name.starts_with("AllGuard(")); + + if has_all { + return true; + } + + !has_any +} + +fn method_set(methods: &[Method]) -> BTreeSet { + methods.iter().map(|m| m.to_string()).collect() +} + +fn filter_guard_names(guards: &[String], methods: &[Method]) -> Vec { + let method_names = method_set(methods); + guards + .iter() + .filter(|guard| !method_names.iter().any(|method| method == *guard)) + .cloned() + .collect() +} + +fn merge_guard_reports(existing: &mut Vec, incoming: &[GuardReport]) { + for report in incoming { + if let Some(existing_report) = existing.iter_mut().find(|r| r.name == report.name) { + for detail in &report.details { + merge_guard_detail_reports(&mut existing_report.details, detail.clone()); + } + } else { + existing.push(report.clone()); + } + } +} + +fn merge_guard_detail_reports(existing: &mut Vec, incoming: GuardDetailReport) { + match incoming { + GuardDetailReport::HttpMethods { methods } => { + if let Some(existing_methods) = existing.iter_mut().find_map(|detail| { + if let GuardDetailReport::HttpMethods { methods } = detail { + Some(methods) + } else { + None + } + }) { + update_unique(existing_methods, &methods); + } else { + existing.push(GuardDetailReport::HttpMethods { methods }); + } + } + GuardDetailReport::Headers { headers } => { + if let Some(existing_headers) = existing.iter_mut().find_map(|detail| { + if let GuardDetailReport::Headers { headers } = detail { + Some(headers) + } else { + None + } + }) { + update_unique(existing_headers, &headers); + } else { + existing.push(GuardDetailReport::Headers { headers }); + } + } + GuardDetailReport::Generic { value } => { + let detail = GuardDetailReport::Generic { value }; + if !existing.contains(&detail) { + existing.push(detail); + } + } + } +} + +fn update_unique(existing: &mut Vec, new_items: &[T]) { + for item in new_items { + if !existing.contains(item) { + existing.push(item.clone()); + } + } +} + +fn is_false(value: &bool) -> bool { + !*value +} + +fn format_reachability(item: &IntrospectionReportItem) -> String { + if !item.potentially_unreachable { + return String::new(); + } + + if item.reachability_notes.is_empty() { + " | PotentiallyUnreachable".to_string() + } else { + format!( + " | PotentiallyUnreachable | Notes: {:?}", + item.reachability_notes + ) + } +} + +fn sanitize_text(value: &str) -> String { + // Escape control characters to keep the text report format stable in logs/terminals. + let mut buf = String::with_capacity(value.len()); + for ch in value.chars() { + if ch.is_control() { + let code = ch as u32; + if code <= 0xFF { + write!(buf, "\\x{:02x}", code).unwrap(); + } else { + write!(buf, "\\u{{{:x}}}", code).unwrap(); + } + } else { + buf.push(ch); + } + } + buf +} + +fn warn_release_mode_once() { + #[cfg(not(debug_assertions))] + { + use std::sync::Once; + + static WARN_ONCE: Once = Once::new(); + WARN_ONCE.call_once(|| { + log::warn!( + "experimental-introspection is intended for local/non-production use; \ +avoid exposing introspection endpoints in production" + ); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn route_info( + full_path: &str, + methods: Vec, + guards: Vec, + guard_details: Vec, + patterns: Vec, + resource_name: Option, + ) -> RouteInfo { + RouteInfo::new( + full_path.to_string(), + methods, + guards, + guard_details, + patterns, + resource_name, + ) + } + + #[test] + fn report_includes_resources_without_methods() { + let mut collector = IntrospectionCollector::new(); + let info = route_info( + "/no-guards", + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + collector.register_route(info, None); + let tree = collector.finalize(); + let items: Vec = (&tree.root).into(); + + let item = items + .iter() + .find(|item| item.full_path == "/no-guards") + .expect("missing resource without guards"); + + assert!(item.methods.is_empty()); + assert!(item.guards.is_empty()); + assert_eq!(item.resource_type, "resource"); + assert!(!item.potentially_unreachable); + assert!(item.reachability_notes.is_empty()); + } + + #[test] + fn report_includes_guard_details_and_metadata() { + let mut collector = IntrospectionCollector::new(); + let guard_details = vec![GuardReport { + name: "Header(accept, text/plain)".to_string(), + details: vec![GuardDetailReport::Headers { + headers: vec![HeaderReport { + name: "accept".to_string(), + value: "text/plain".to_string(), + }], + }], + }]; + + let info = route_info( + "/meta", + vec![Method::GET], + vec!["Header(accept, text/plain)".to_string()], + guard_details, + vec!["/meta".to_string()], + Some("meta-resource".to_string()), + ); + collector.register_route(info, None); + + let tree = collector.finalize(); + let items: Vec = (&tree.root).into(); + + let item = items + .iter() + .find(|item| item.full_path == "/meta") + .expect("missing metadata route"); + + assert_eq!(item.resource_name.as_deref(), Some("meta-resource")); + assert!(item.patterns.contains(&"/meta".to_string())); + assert_eq!(item.resource_type, "resource"); + assert_eq!(item.scope_depth, 1); + assert_eq!(item.guards_detail.len(), 1); + } + + #[test] + fn expand_patterns_handles_scope_paths() { + let empty = ResourceDef::new(""); + let slash = ResourceDef::new("/"); + + assert_eq!(expand_patterns("/app", &empty), vec!["/app"]); + assert_eq!(expand_patterns("/app", &slash), vec!["/app/"]); + assert_eq!(expand_patterns("/app/", &empty), vec!["/app/"]); + assert_eq!(expand_patterns("/app/", &slash), vec!["/app//"]); + } + + #[test] + fn expand_patterns_handles_multi_patterns() { + let rdef = ResourceDef::new(["/a", "/b"]); + assert_eq!(expand_patterns("/api", &rdef), vec!["/api/a", "/api/b"]); + } + + #[test] + fn conflicting_method_guards_mark_unreachable() { + let mut collector = IntrospectionCollector::new(); + let info = route_info( + "/all-guard", + vec![Method::GET, Method::POST], + vec!["AllGuard(GET, POST)".to_string()], + Vec::new(), + Vec::new(), + None, + ); + collector.register_route(info, None); + let tree = collector.finalize(); + let items: Vec = (&tree.root).into(); + + let item = items + .iter() + .find(|item| item.full_path == "/all-guard") + .expect("missing route"); + + assert!(item.potentially_unreachable); + assert!(item + .reachability_notes + .contains(&"conflicting_method_guards".to_string())); + } + + #[test] + fn shadowed_scopes_mark_routes() { + let mut collector = IntrospectionCollector::new(); + + let scope_a = collector.next_scope_id(); + let info = route_info( + "/extra", + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + collector.register_service(info, true, true, Some(scope_a), None); + let info = route_info( + "/extra/ping", + vec![Method::GET], + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + collector.register_route(info, Some(scope_a)); + + let scope_b = collector.next_scope_id(); + let info = route_info( + "/extra", + Vec::new(), + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + collector.register_service(info, true, true, Some(scope_b), None); + let info = route_info( + "/extra/ping", + vec![Method::POST], + Vec::new(), + Vec::new(), + Vec::new(), + None, + ); + collector.register_route(info, Some(scope_b)); + + let tree = collector.finalize(); + let items: Vec = (&tree.root).into(); + + let item = items + .iter() + .find(|item| item.full_path == "/extra/ping") + .expect("missing route"); + + assert!(item.potentially_unreachable); + assert!(item + .reachability_notes + .contains(&"shadowed_by_scope".to_string())); + assert!(item + .reachability_notes + .contains(&"shadowed_by_path:/extra".to_string())); + assert!(item + .reachability_notes + .contains(&"shadowed_by_order:0".to_string())); + } + + #[test] + fn shadowed_routes_include_context() { + let mut collector = IntrospectionCollector::new(); + + let info = route_info( + "/shadow", + vec![Method::GET], + vec!["GET".to_string()], + Vec::new(), + Vec::new(), + None, + ); + collector.register_route(info, None); + let info = route_info( + "/shadow", + vec![Method::GET], + vec!["GET".to_string()], + Vec::new(), + Vec::new(), + None, + ); + collector.register_route(info, None); + + let tree = collector.finalize(); + let items: Vec = (&tree.root).into(); + + let item = items + .iter() + .find(|item| item.full_path == "/shadow") + .expect("missing route"); + + assert!(item.potentially_unreachable); + assert!(item + .reachability_notes + .contains(&"shadowed_by_route".to_string())); + assert!(item + .reachability_notes + .contains(&"shadowed_by_path:/shadow".to_string())); + assert!(item + .reachability_notes + .contains(&"shadowed_by_order:0".to_string())); + } +} diff --git a/actix-web/src/lib.rs b/actix-web/src/lib.rs index ee251320e..73fa0cbb9 100644 --- a/actix-web/src/lib.rs +++ b/actix-web/src/lib.rs @@ -108,6 +108,9 @@ mod thin_data; pub(crate) mod types; pub mod web; +#[cfg(feature = "experimental-introspection")] +pub mod introspection; + #[doc(inline)] pub use crate::error::Result; pub use crate::{ diff --git a/actix-web/src/resource.rs b/actix-web/src/resource.rs index aee0dff93..441b05bae 100644 --- a/actix-web/src/resource.rs +++ b/actix-web/src/resource.rs @@ -417,6 +417,8 @@ where B: MessageBody + 'static, { fn register(mut self, config: &mut AppService) { + let routes = std::mem::take(&mut self.routes); + let guards = if self.guards.is_empty() { None } else { @@ -428,13 +430,71 @@ where } else { ResourceDef::new(self.rdef.clone()) }; + #[cfg(feature = "experimental-introspection")] + { + 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::>(); + let guards_routes = routes.iter().map(|r| r.guards()).collect::>(); + let scope_id = config.scope_id_stack.last().copied(); + let resource_guards: &[Box] = guards.as_deref().unwrap_or(&[]); + let resource_name = self.name.clone(); + + for route_guards in guards_routes { + // Log the guards and methods for introspection + let mut guard_names = Vec::new(); + let mut methods = Vec::new(); + + for guard in resource_guards.iter().chain(route_guards.iter()) { + guard_names.push(guard.name()); + methods.extend( + guard + .details() + .unwrap_or_default() + .into_iter() + .flat_map(|d| { + if let crate::guard::GuardDetail::HttpMethods(v) = d { + v.into_iter() + .filter_map(|s| s.parse::().ok()) + .collect::>() + } 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); + } + } + } if let Some(ref name) = self.name { rdef.set_name(name); } *self.factory_ref.borrow_mut() = Some(ResourceFactory { - routes: self.routes, + routes, default: self.default, }); diff --git a/actix-web/src/route.rs b/actix-web/src/route.rs index e05e6be52..65d7dcef0 100644 --- a/actix-web/src/route.rs +++ b/actix-web/src/route.rs @@ -159,6 +159,11 @@ impl Route { self } + #[cfg(feature = "experimental-introspection")] + pub(crate) fn guards(&self) -> &Vec> { + &self.guards + } + /// Set handler function, use request extractors for parameters. /// /// # Examples diff --git a/actix-web/src/scope.rs b/actix-web/src/scope.rs index e317349da..c00c51bed 100644 --- a/actix-web/src/scope.rs +++ b/actix-web/src/scope.rs @@ -384,14 +384,32 @@ where // register nested services let mut cfg = config.clone_config(); + + // Update the prefix for the nested scope + #[cfg(feature = "experimental-introspection")] + { + let scope_id = config.prepare_scope_id(); + cfg.scope_id_stack.push(scope_id); + cfg.update_prefix(&self.rdef); + } + self.services .into_iter() .for_each(|mut srv| srv.register(&mut cfg)); let mut rmap = ResourceMap::new(ResourceDef::root_prefix(&self.rdef)); + #[cfg(feature = "experimental-introspection")] + let origin_scope = cfg.current_prefix.clone(); + // external resources 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); } diff --git a/actix-web/tests/introspection.rs b/actix-web/tests/introspection.rs new file mode 100644 index 000000000..d56cc4e7b --- /dev/null +++ b/actix-web/tests/introspection.rs @@ -0,0 +1,167 @@ +#![cfg(feature = "experimental-introspection")] + +use actix_web::{guard, test, web, App, HttpResponse}; + +async fn introspection_handler( + tree: web::Data, +) -> HttpResponse { + HttpResponse::Ok() + .content_type("application/json") + .body(tree.report_as_json()) +} + +async fn externals_handler( + tree: web::Data, +) -> 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::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::>(); + 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::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") + ); +}