This commit is contained in:
Guillermo Céspedes Tabárez 2026-01-08 07:31:12 -03:00 committed by GitHub
commit 9ac2309248
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 2146 additions and 6 deletions

View File

@ -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

View File

@ -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 }

View File

@ -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<actix_web::introspection::IntrospectionTree>,
) -> 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<actix_web::introspection::IntrospectionTree>,
) -> 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<actix_web::introspection::IntrospectionTree>,
) -> 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<u32>) -> 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<UserInfo>) -> 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);
}
}

View File

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

View File

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

View File

@ -41,6 +41,8 @@ where
pub(crate) default: Option<Rc<BoxedHttpServiceFactory>>,
pub(crate) factory_ref: Rc<RefCell<Option<AppRoutingFactory>>>,
pub(crate) external: RefCell<Vec<ResourceDef>>,
#[cfg(feature = "experimental-introspection")]
pub(crate) introspector: Rc<RefCell<crate::introspection::IntrospectionCollector>>,
}
impl<T, B> ServiceFactory<Request> for AppInit<T, B>
@ -72,6 +74,10 @@ where
// create App config to pass to child services
let mut config = AppService::new(config, Rc::clone(&default));
#[cfg(feature = "experimental-introspection")]
{
config.introspector = Rc::clone(&self.introspector);
}
// register services
mem::take(&mut *self.services.borrow_mut())
@ -80,6 +86,9 @@ where
let mut rmap = ResourceMap::new(ResourceDef::prefix(""));
#[cfg(feature = "experimental-introspection")]
let (config, services, _) = config.into_services();
#[cfg(not(feature = "experimental-introspection"))]
let (config, services) = config.into_services();
// complete pipeline creation.
@ -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),

View File

@ -30,6 +30,15 @@ pub struct AppService {
Option<Guards>,
Option<Rc<ResourceMap>>,
)>,
#[cfg(feature = "experimental-introspection")]
pub current_prefix: String,
#[cfg(feature = "experimental-introspection")]
pub(crate) introspector:
std::rc::Rc<std::cell::RefCell<crate::introspection::IntrospectionCollector>>,
#[cfg(feature = "experimental-introspection")]
pub(crate) scope_id_stack: Vec<usize>,
#[cfg(feature = "experimental-introspection")]
pending_scope_id: Option<usize>,
}
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<Guards>,
Option<Rc<ResourceMap>>,
)>,
std::rc::Rc<std::cell::RefCell<crate::introspection::IntrospectionCollector>>,
) {
(self.config, self.services, self.introspector)
}
#[allow(clippy::type_complexity)]
#[cfg(not(feature = "experimental-introspection"))]
pub(crate) fn into_services(
self,
) -> (
@ -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<dyn Guard>] = 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::<Vec<_>>()
} else {
Vec::new()
}
})
.collect::<Vec<_>>();
let guard_names = guard_list
.iter()
.map(|g| g.name().to_string())
.collect::<Vec<_>>();
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::<Vec<_>>();
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.

View File

@ -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::<Self>().to_string()
}
}
fn details(&self) -> Option<Vec<GuardDetail>> {
#[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
)
);
}
}

View File

@ -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::<Self>().to_string()
}
}
fn details(&self) -> Option<Vec<GuardDetail>> {
#[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)");
}
}

View File

@ -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<String>),
/// 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::<Self>().to_string()
}
/// Returns detailed introspection information, when available.
///
/// This is best-effort and may omit complex guard logic.
fn details(&self) -> Option<Vec<GuardDetail>> {
None
}
}
impl Guard for Rc<dyn Guard> {
fn check(&self, ctx: &GuardContext<'_>) -> bool {
(**self).check(ctx)
}
fn name(&self) -> String {
(**self).name()
}
fn details(&self) -> Option<Vec<GuardDetail>> {
(**self).details()
}
}
/// Creates a guard using the given function.
@ -195,7 +224,7 @@ pub fn Any<F: Guard + 'static>(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<Box<dyn Guard>>,
}
@ -219,6 +248,24 @@ impl Guard for AnyGuard {
false
}
fn name(&self) -> String {
format!(
"AnyGuard({})",
self.guards
.iter()
.map(|g| g.name())
.collect::<Vec<_>>()
.join(", ")
)
}
fn details(&self) -> Option<Vec<GuardDetail>> {
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<F: Guard + 'static>(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<Box<dyn Guard>>,
}
@ -271,6 +318,24 @@ impl Guard for AllGuard {
true
}
fn name(&self) -> String {
format!(
"AllGuard({})",
self.guards
.iter()
.map(|g| g.name())
.collect::<Vec<_>>()
.join(", ")
)
}
fn details(&self) -> Option<Vec<GuardDetail>> {
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<G: Guard> Guard for Not<G> {
fn check(&self, ctx: &GuardContext<'_>) -> bool {
!self.0.check(ctx)
}
fn name(&self) -> String {
format!("Not({})", self.0.name())
}
fn details(&self) -> Option<Vec<GuardDetail>> {
#[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<Vec<GuardDetail>> {
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<Vec<GuardDetail>> {
Some(vec![GuardDetail::Headers(vec![(
self.0.to_string(),
self.1.to_str().unwrap_or("").to_string(),
)])])
}
}
#[cfg(test)]

File diff suppressed because it is too large Load Diff

View File

@ -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::{

View File

@ -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::<Vec<_>>();
let guards_routes = routes.iter().map(|r| r.guards()).collect::<Vec<_>>();
let scope_id = config.scope_id_stack.last().copied();
let resource_guards: &[Box<dyn Guard>] = 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::<Method>().ok())
.collect::<Vec<_>>()
} 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,
});

View File

@ -159,6 +159,11 @@ impl Route {
self
}
#[cfg(feature = "experimental-introspection")]
pub(crate) fn guards(&self) -> &Vec<Box<dyn Guard>> {
&self.guards
}
/// Set handler function, use request extractors for parameters.
///
/// # Examples

View File

@ -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);
}

View File

@ -0,0 +1,167 @@
#![cfg(feature = "experimental-introspection")]
use actix_web::{guard, test, web, App, HttpResponse};
async fn introspection_handler(
tree: web::Data<actix_web::introspection::IntrospectionTree>,
) -> HttpResponse {
HttpResponse::Ok()
.content_type("application/json")
.body(tree.report_as_json())
}
async fn externals_handler(
tree: web::Data<actix_web::introspection::IntrospectionTree>,
) -> 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::Value> =
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::<Vec<_>>();
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::Value> =
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")
);
}