mirror of https://github.com/fafhrd91/actix-web
Enhance experimental introspection feature with detailed route reporting
- Introduced a new `experimental-introspection` feature that provides comprehensive reports on configured routes, including paths, methods, guards, and resource metadata. - Added support for reachability hints to identify shadowed or conflicting routes. - Implemented new endpoints for external resources reporting. - Updated existing route registration to include detailed introspection data. - Enhanced guard implementations to provide introspection details.
This commit is contained in:
parent
895969923c
commit
68f01edb72
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
- Add `experimental-introspection` feature for retrieving configured route paths and HTTP methods.
|
- 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.
|
- Minimum supported Rust version (MSRV) is now 1.82.
|
||||||
|
|
||||||
## 4.12.1
|
## 4.12.1
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,16 @@ async fn main() -> std::io::Result<()> {
|
||||||
.body(report)
|
.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
|
// GET /introspection for plain text response
|
||||||
async fn introspection_handler_text(
|
async fn introspection_handler_text(
|
||||||
tree: web::Data<actix_web::introspection::IntrospectionTree>,
|
tree: web::Data<actix_web::introspection::IntrospectionTree>,
|
||||||
|
|
@ -95,6 +105,21 @@ async fn main() -> std::io::Result<()> {
|
||||||
HttpResponse::Ok().body("Welcome to the Root Endpoint!")
|
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
|
// Additional endpoints for /extra
|
||||||
fn extra_endpoints(cfg: &mut web::ServiceConfig) {
|
fn extra_endpoints(cfg: &mut web::ServiceConfig) {
|
||||||
cfg.service(
|
cfg.service(
|
||||||
|
|
@ -169,6 +194,8 @@ async fn main() -> std::io::Result<()> {
|
||||||
// Get introspection report
|
// 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: application/json'
|
||||||
// curl --location '127.0.0.1:8080/introspection' --header 'Accept: text/plain'
|
// 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(
|
.service(
|
||||||
web::resource("/introspection")
|
web::resource("/introspection")
|
||||||
.route(
|
.route(
|
||||||
|
|
@ -182,9 +209,31 @@ async fn main() -> std::io::Result<()> {
|
||||||
.to(introspection_handler_text),
|
.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
|
// API endpoints under /api
|
||||||
.service(
|
.service(
|
||||||
web::scope("/api")
|
web::scope("/api")
|
||||||
|
.configure(|cfg| {
|
||||||
|
cfg.external_resource("api-external", "https://api.example/{id}");
|
||||||
|
})
|
||||||
// Endpoints under /api/v1
|
// Endpoints under /api/v1
|
||||||
.service(
|
.service(
|
||||||
web::scope("/v1")
|
web::scope("/v1")
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,10 @@ where
|
||||||
|
|
||||||
// external resources
|
// external resources
|
||||||
for mut rdef in mem::take(&mut *self.external.borrow_mut()) {
|
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);
|
rmap.add(&mut rdef, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,10 @@ pub struct AppService {
|
||||||
#[cfg(feature = "experimental-introspection")]
|
#[cfg(feature = "experimental-introspection")]
|
||||||
pub(crate) introspector:
|
pub(crate) introspector:
|
||||||
std::rc::Rc<std::cell::RefCell<crate::introspection::IntrospectionCollector>>,
|
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 {
|
impl AppService {
|
||||||
|
|
@ -51,6 +55,10 @@ impl AppService {
|
||||||
introspector: std::rc::Rc::new(std::cell::RefCell::new(
|
introspector: std::rc::Rc::new(std::cell::RefCell::new(
|
||||||
crate::introspection::IntrospectionCollector::new(),
|
crate::introspection::IntrospectionCollector::new(),
|
||||||
)),
|
)),
|
||||||
|
#[cfg(feature = "experimental-introspection")]
|
||||||
|
scope_id_stack: Vec::new(),
|
||||||
|
#[cfg(feature = "experimental-introspection")]
|
||||||
|
pending_scope_id: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,6 +112,10 @@ impl AppService {
|
||||||
current_prefix: self.current_prefix.clone(),
|
current_prefix: self.current_prefix.clone(),
|
||||||
#[cfg(feature = "experimental-introspection")]
|
#[cfg(feature = "experimental-introspection")]
|
||||||
introspector: std::rc::Rc::clone(&self.introspector),
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -138,19 +150,6 @@ impl AppService {
|
||||||
{
|
{
|
||||||
use std::borrow::Borrow;
|
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
|
// Extract methods and guards for introspection
|
||||||
let guard_list: &[Box<dyn Guard>] = guards.borrow().as_ref().map_or(&[], |v| &v[..]);
|
let guard_list: &[Box<dyn Guard>] = guards.borrow().as_ref().map_or(&[], |v| &v[..]);
|
||||||
let methods = guard_list
|
let methods = guard_list
|
||||||
|
|
@ -170,15 +169,37 @@ impl AppService {
|
||||||
.iter()
|
.iter()
|
||||||
.map(|g| g.name().to_string())
|
.map(|g| g.name().to_string())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
let guard_details = crate::introspection::guard_reports_from_iter(guard_list.iter());
|
||||||
|
|
||||||
// Determine if the registered service is a resource
|
let is_resource = nested.is_none();
|
||||||
let is_resource = rdef.pattern().is_some();
|
let full_paths = crate::introspection::expand_patterns(&self.current_prefix, &rdef);
|
||||||
self.introspector.borrow_mut().register_pattern_detail(
|
let patterns = rdef
|
||||||
full_path,
|
.pattern_iter()
|
||||||
methods,
|
.map(|pattern| pattern.to_string())
|
||||||
guard_names,
|
.collect::<Vec<_>>();
|
||||||
is_resource,
|
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 {
|
||||||
|
self.introspector.borrow_mut().register_service(
|
||||||
|
full_path,
|
||||||
|
methods.clone(),
|
||||||
|
guard_names.clone(),
|
||||||
|
guard_details.clone(),
|
||||||
|
patterns.clone(),
|
||||||
|
resource_name.clone(),
|
||||||
|
is_resource,
|
||||||
|
is_prefix,
|
||||||
|
scope_id,
|
||||||
|
parent_scope_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.services
|
self.services
|
||||||
|
|
@ -188,15 +209,23 @@ impl AppService {
|
||||||
/// Update the current path prefix.
|
/// Update the current path prefix.
|
||||||
#[cfg(feature = "experimental-introspection")]
|
#[cfg(feature = "experimental-introspection")]
|
||||||
pub(crate) fn update_prefix(&mut self, prefix: &str) {
|
pub(crate) fn update_prefix(&mut self, prefix: &str) {
|
||||||
self.current_prefix = if self.current_prefix.is_empty() {
|
let next = ResourceDef::root_prefix(prefix);
|
||||||
prefix.to_string()
|
|
||||||
} else {
|
if self.current_prefix.is_empty() {
|
||||||
format!(
|
self.current_prefix = next.pattern().unwrap_or("").to_string();
|
||||||
"{}/{}",
|
return;
|
||||||
self.current_prefix.trim_end_matches('/'),
|
}
|
||||||
prefix.trim_start_matches('/')
|
|
||||||
)
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use super::{Guard, GuardContext};
|
use super::{Guard, GuardContext, GuardDetail};
|
||||||
use crate::http::header::Accept;
|
use crate::http::header::Accept;
|
||||||
|
|
||||||
/// A guard that verifies that an `Accept` header is present and it contains a compatible MIME type.
|
/// 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
|
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)]
|
#[cfg(test)]
|
||||||
|
|
@ -96,4 +127,28 @@ mod tests {
|
||||||
.match_star_star()
|
.match_star_star()
|
||||||
.check(&req.guard_ctx()));
|
.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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
use actix_http::{header, uri::Uri, RequestHead, Version};
|
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.
|
/// Creates a guard that matches requests targeting a specific host.
|
||||||
///
|
///
|
||||||
|
|
@ -117,6 +117,41 @@ impl Guard for HostGuard {
|
||||||
// all conditions passed
|
// all conditions passed
|
||||||
true
|
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)]
|
#[cfg(test)]
|
||||||
|
|
@ -239,4 +274,23 @@ mod tests {
|
||||||
let host = Host("localhost");
|
let host = Host("localhost");
|
||||||
assert!(!host.check(&req.guard_ctx()));
|
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)");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,14 +66,14 @@ pub use self::{
|
||||||
host::{Host, HostGuard},
|
host::{Host, HostGuard},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Enum to encapsulate various introspection details of a Guard.
|
/// Enum to encapsulate various introspection details of a guard.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum GuardDetail {
|
pub enum GuardDetail {
|
||||||
/// Detail associated with HTTP methods.
|
/// Detail associated with explicit HTTP method guards.
|
||||||
HttpMethods(Vec<String>),
|
HttpMethods(Vec<String>),
|
||||||
/// Detail associated with headers (header, value).
|
/// Detail associated with headers (header, value).
|
||||||
Headers(Vec<(String, String)>),
|
Headers(Vec<(String, String)>),
|
||||||
/// Generic detail.
|
/// Generic detail, typically used for compound guard representations.
|
||||||
Generic(String),
|
Generic(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,7 +141,9 @@ pub trait Guard {
|
||||||
std::any::type_name::<Self>().to_string()
|
std::any::type_name::<Self>().to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns detailed introspection information.
|
/// Returns detailed introspection information, when available.
|
||||||
|
///
|
||||||
|
/// This is best-effort and may omit complex guard logic.
|
||||||
fn details(&self) -> Option<Vec<GuardDetail>> {
|
fn details(&self) -> Option<Vec<GuardDetail>> {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
@ -358,7 +360,14 @@ impl<G: Guard> Guard for Not<G> {
|
||||||
format!("Not({})", self.0.name())
|
format!("Not({})", self.0.name())
|
||||||
}
|
}
|
||||||
fn details(&self) -> Option<Vec<GuardDetail>> {
|
fn details(&self) -> Option<Vec<GuardDetail>> {
|
||||||
self.0.details()
|
#[cfg(feature = "experimental-introspection")]
|
||||||
|
{
|
||||||
|
Some(vec![GuardDetail::Generic(self.name())])
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "experimental-introspection"))]
|
||||||
|
{
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -434,42 +434,55 @@ where
|
||||||
{
|
{
|
||||||
use crate::http::Method;
|
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 guards_routes = routes.iter().map(|r| r.guards()).collect::<Vec<_>>();
|
||||||
|
let scope_id = config.scope_id_stack.last().copied();
|
||||||
let pat = rdef.pattern().unwrap_or("").to_string();
|
let resource_guards: &[Box<dyn Guard>] = guards.as_deref().unwrap_or(&[]);
|
||||||
let full_path = if config.current_prefix.is_empty() {
|
let resource_name = self.name.clone();
|
||||||
pat.clone()
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"{}/{}",
|
|
||||||
config.current_prefix.trim_end_matches('/'),
|
|
||||||
pat.trim_start_matches('/')
|
|
||||||
)
|
|
||||||
};
|
|
||||||
|
|
||||||
for route_guards in guards_routes {
|
for route_guards in guards_routes {
|
||||||
// Log the guards and methods for introspection
|
// Log the guards and methods for introspection
|
||||||
let guard_names = route_guards.iter().map(|g| g.name()).collect::<Vec<_>>();
|
let mut guard_names = Vec::new();
|
||||||
let methods = route_guards
|
let mut methods = Vec::new();
|
||||||
.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::<Method>().ok())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
} else {
|
|
||||||
Vec::new()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
|
|
||||||
config.introspector.borrow_mut().register_pattern_detail(
|
for guard in resource_guards.iter().chain(route_guards.iter()) {
|
||||||
full_path.clone(),
|
guard_names.push(guard.name());
|
||||||
methods,
|
methods.extend(
|
||||||
guard_names,
|
guard
|
||||||
true,
|
.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 {
|
||||||
|
config.introspector.borrow_mut().register_route(
|
||||||
|
full_path.clone(),
|
||||||
|
methods.clone(),
|
||||||
|
guard_names.clone(),
|
||||||
|
guard_details.clone(),
|
||||||
|
patterns.clone(),
|
||||||
|
resource_name.clone(),
|
||||||
|
scope_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -387,7 +387,11 @@ where
|
||||||
|
|
||||||
// Update the prefix for the nested scope
|
// Update the prefix for the nested scope
|
||||||
#[cfg(feature = "experimental-introspection")]
|
#[cfg(feature = "experimental-introspection")]
|
||||||
cfg.update_prefix(&self.rdef);
|
{
|
||||||
|
let scope_id = config.prepare_scope_id();
|
||||||
|
cfg.scope_id_stack.push(scope_id);
|
||||||
|
cfg.update_prefix(&self.rdef);
|
||||||
|
}
|
||||||
|
|
||||||
self.services
|
self.services
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|
@ -395,8 +399,17 @@ where
|
||||||
|
|
||||||
let mut rmap = ResourceMap::new(ResourceDef::root_prefix(&self.rdef));
|
let mut rmap = ResourceMap::new(ResourceDef::root_prefix(&self.rdef));
|
||||||
|
|
||||||
|
#[cfg(feature = "experimental-introspection")]
|
||||||
|
let origin_scope = cfg.current_prefix.clone();
|
||||||
|
|
||||||
// external resources
|
// external resources
|
||||||
for mut rdef in mem::take(&mut self.external) {
|
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);
|
rmap.add(&mut rdef, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
#![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(|| async { HttpResponse::Ok() })),
|
||||||
|
)
|
||||||
|
.service(
|
||||||
|
web::resource("/guarded")
|
||||||
|
.guard(guard::Header("accept", "text/plain"))
|
||||||
|
.route(web::get().to(|| async { 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(|| async { 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")
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue