diff --git a/Cargo.lock b/Cargo.lock index 8c04b9ad8..273198500 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", @@ -891,18 +891,18 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.37" +version = "4.5.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" +checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.5.37" +version = "4.5.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" +checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" dependencies = [ "anstyle", "clap_lex", @@ -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", @@ -1914,12 +1914,12 @@ checksum = "d750af042f7ef4f724306de029d18836c26c1765a54a6a3f094cbd23a7267ffa" [[package]] name = "libloading" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc2f4eb4bc735547cfed7c0a4922cbd04a4655978c09b54f1f7b228750664c34" +checksum = "6a793df0d7afeac54f95b471d3af7f0d4fb975699f972341a4b76988d49cdf0c" dependencies = [ "cfg-if", - "windows-targets 0.52.6", + "windows-targets 0.53.0", ] [[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" @@ -3657,13 +3657,29 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e4c7e8ceaaf9cb7d7507c974735728ab453b67ef8f18febdd7c11fe59dca8b" +dependencies = [ + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -3676,6 +3692,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -3688,6 +3710,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -3700,12 +3728,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -3718,6 +3758,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -3730,6 +3776,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -3742,6 +3794,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -3754,6 +3812,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" + [[package]] name = "winnow" version = "0.7.10" diff --git a/actix-web/CHANGES.md b/actix-web/CHANGES.md index ab40eea98..4a7c32cb7 100644 --- a/actix-web/CHANGES.md +++ b/actix-web/CHANGES.md @@ -2,6 +2,8 @@ ## Unreleased +- Add `experimental-introspection` feature for retrieving configured route paths and HTTP methods. + ## 4.11.0 - Add `Logger::log_level()` method. diff --git a/actix-web/Cargo.toml b/actix-web/Cargo.toml index 39ffe3341..e94ebef74 100644 --- a/actix-web/Cargo.toml +++ b/actix-web/Cargo.toml @@ -123,6 +123,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..2bca5a3e9 --- /dev/null +++ b/actix-web/examples/introspection.rs @@ -0,0 +1,255 @@ +// 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 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!") + } + + // 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' + .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), + ), + ) + // API endpoints under /api + .service( + web::scope("/api") + // 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..86f6a08b5 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. @@ -110,6 +119,8 @@ where // construct app service and middleware service factory future. let endpoint_fut = self.endpoint.new_service(()); + #[cfg(feature = "experimental-introspection")] + let introspector = Rc::clone(&self.introspector); // take extensions or create new one as app data container. let mut app_data = self.extensions.borrow_mut().take().unwrap_or_default(); @@ -130,6 +141,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..8c5a697ce 100644 --- a/actix-web/src/config.rs +++ b/actix-web/src/config.rs @@ -30,6 +30,11 @@ pub struct AppService { Option, Option>, )>, + #[cfg(feature = "experimental-introspection")] + pub current_prefix: String, + #[cfg(feature = "experimental-introspection")] + pub(crate) introspector: + std::rc::Rc>, } impl AppService { @@ -40,6 +45,12 @@ 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(), + )), } } @@ -49,6 +60,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 +100,10 @@ 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), } } @@ -101,9 +134,70 @@ impl AppService { InitError = (), > + 'static, { + #[cfg(feature = "experimental-introspection")] + { + use std::borrow::Borrow; + + // 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(); + self.introspector.borrow_mut().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/guard/mod.rs b/actix-web/src/guard/mod.rs index 41609953a..97b0f80b6 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 HTTP methods. + HttpMethods(Vec), + /// Detail associated with headers (header, value). + Headers(Vec<(String, String)>), + /// Generic detail. + Generic(String), +} + /// Provides access to request parts that are useful during routing. #[derive(Debug)] pub struct GuardContext<'a> { @@ -124,12 +135,28 @@ 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. + 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 +222,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 +246,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 +292,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 +316,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 +354,12 @@ 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> { + self.0.details() + } } /// Creates a guard that matches a specified HTTP method. @@ -320,6 +389,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 +457,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..c52bea9df --- /dev/null +++ b/actix-web/src/introspection.rs @@ -0,0 +1,209 @@ +use std::{collections::HashMap, fmt::Write as FmtWrite}; + +use serde::Serialize; + +use crate::http::Method; + +#[derive(Clone)] +pub struct RouteDetail { + methods: Vec, + guards: Vec, + is_resource: bool, +} + +#[derive(Debug, Clone, Copy)] +pub enum ResourceType { + App, + Scope, + Resource, +} + +#[derive(Debug, Clone)] +pub struct IntrospectionNode { + pub kind: ResourceType, + pub pattern: String, + pub full_path: String, + pub methods: Vec, + pub guards: Vec, + pub children: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct IntrospectionReportItem { + pub full_path: String, + pub methods: Vec, + pub guards: 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(), + children: Vec::new(), + } + } +} + +impl From<&IntrospectionNode> for Vec { + fn from(node: &IntrospectionNode) -> Self { + fn collect_report_items( + node: &IntrospectionNode, + parent_path: &str, + report_items: &mut Vec, + ) { + 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 filtered_guards: Vec = node + .guards + .iter() + .filter(|guard| !node.methods.iter().any(|m| m.to_string() == **guard)) + .cloned() + .collect(); + + report_items.push(IntrospectionReportItem { + full_path: full_path.clone(), + methods: node.methods.iter().map(|m| m.to_string()).collect(), + guards: filtered_guards, + }); + } + + for child in &node.children { + collect_report_items(child, &full_path, report_items); + } + } + + let mut report_items = Vec::new(); + collect_report_items(node, "/", &mut report_items); + report_items + } +} + +#[derive(Clone, Default)] +pub struct IntrospectionCollector { + details: HashMap, +} + +impl IntrospectionCollector { + pub fn new() -> Self { + Self { + details: HashMap::new(), + } + } + + pub fn register_pattern_detail( + &mut self, + full_path: String, + methods: Vec, + guards: Vec, + is_resource: bool, + ) { + self.details + .entry(full_path) + .and_modify(|d| { + update_unique(&mut d.methods, &methods); + update_unique(&mut d.guards, &guards); + if !d.is_resource && is_resource { + d.is_resource = true; + } + }) + .or_insert(RouteDetail { + methods, + guards, + is_resource, + }); + } + + pub fn finalize(&mut self) -> IntrospectionTree { + let detail_registry = std::mem::take(&mut self.details); + let mut root = IntrospectionNode::new(ResourceType::App, "".into(), "".into()); + + for (full_path, _) in detail_registry.iter() { + let parts: Vec<&str> = full_path.split('/').collect(); + let mut current_node = &mut root; + + for (i, part) in parts.iter().enumerate() { + let existing_child_index = current_node + .children + .iter() + .position(|n| n.pattern == *part); + + let child_index = if let Some(idx) = existing_child_index { + idx + } else { + let child_full_path = parts[..=i].join("/"); + let kind = if detail_registry + .get(&child_full_path) + .is_some_and(|d| d.is_resource) + { + ResourceType::Resource + } else { + ResourceType::Scope + }; + let new_node = IntrospectionNode::new(kind, part.to_string(), child_full_path); + current_node.children.push(new_node); + current_node.children.len() - 1 + }; + + current_node = &mut current_node.children[child_index]; + + if let ResourceType::Resource = current_node.kind { + if let Some(detail) = detail_registry.get(¤t_node.full_path) { + update_unique(&mut current_node.methods, &detail.methods); + update_unique(&mut current_node.guards, &detail.guards); + } + } + } + } + + IntrospectionTree { root } + } +} + +#[derive(Clone)] +pub struct IntrospectionTree { + pub root: IntrospectionNode, +} + +impl IntrospectionTree { + pub fn report_as_text(&self) -> String { + let report_items: Vec = (&self.root).into(); + + let mut buf = String::new(); + for item in report_items { + writeln!( + buf, + "{} => Methods: {:?} | Guards: {:?}", + item.full_path, item.methods, item.guards + ) + .unwrap(); + } + + buf + } + + pub fn report_as_json(&self) -> String { + let report_items: Vec = (&self.root).into(); + serde_json::to_string_pretty(&report_items).unwrap() + } +} + +fn update_unique(existing: &mut Vec, new_items: &[T]) { + for item in new_items { + if !existing.contains(item) { + existing.push(item.clone()); + } + } +} diff --git a/actix-web/src/lib.rs b/actix-web/src/lib.rs index d490706ff..e06301b50 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..7b20727c6 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,55 @@ where } else { ResourceDef::new(self.rdef.clone()) }; + #[cfg(feature = "experimental-introspection")] + { + use crate::http::Method; + + 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::>(); + + config.introspector.borrow_mut().register_pattern_detail( + full_path.clone(), + methods, + guard_names, + true, + ); + } + } 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..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));