mirror of https://github.com/fafhrd91/actix-web
Compare commits
5 Commits
9a0190a1cc
...
f2b353c39f
| Author | SHA1 | Date |
|---|---|---|
|
|
f2b353c39f | |
|
|
9f679990ed | |
|
|
7e192b6079 | |
|
|
a1a26e9b53 | |
|
|
1facfec04b |
|
|
@ -149,10 +149,16 @@ async fn h2_content_length() {
|
|||
|
||||
{
|
||||
let req = srv.request(Method::HEAD, srv.surl("/0")).send();
|
||||
req.await.expect_err("should timeout on recv 1xx frame");
|
||||
actix_rt::time::timeout(Duration::from_secs(15), req)
|
||||
.await
|
||||
.expect("request future stalled on recv 1xx frame")
|
||||
.expect_err("should timeout on recv 1xx frame");
|
||||
|
||||
let req = srv.request(Method::GET, srv.surl("/0")).send();
|
||||
req.await.expect_err("should timeout on recv 1xx frame");
|
||||
actix_rt::time::timeout(Duration::from_secs(15), req)
|
||||
.await
|
||||
.expect("request future stalled on recv 1xx frame")
|
||||
.expect_err("should timeout on recv 1xx frame");
|
||||
|
||||
let req = srv.request(Method::GET, srv.surl("/1")).send();
|
||||
let response = req.await.unwrap();
|
||||
|
|
|
|||
|
|
@ -219,13 +219,19 @@ async fn h2_content_length() {
|
|||
let req = srv
|
||||
.request(Method::HEAD, srv.surl(&format!("/{}", i)))
|
||||
.send();
|
||||
let _response = req.await.expect_err("should timeout on recv 1xx frame");
|
||||
actix_rt::time::timeout(Duration::from_secs(15), req)
|
||||
.await
|
||||
.expect("request future stalled on recv 1xx frame")
|
||||
.expect_err("should timeout on recv 1xx frame");
|
||||
// assert_eq!(response.headers().get(&header), None);
|
||||
|
||||
let req = srv
|
||||
.request(Method::GET, srv.surl(&format!("/{}", i)))
|
||||
.send();
|
||||
let _response = req.await.expect_err("should timeout on recv 1xx frame");
|
||||
actix_rt::time::timeout(Duration::from_secs(15), req)
|
||||
.await
|
||||
.expect("request future stalled on recv 1xx frame")
|
||||
.expect_err("should timeout on recv 1xx frame");
|
||||
// assert_eq!(response.headers().get(&header), None);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ compat = ["compat-routing-macros-force-pub"]
|
|||
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 = []
|
||||
experimental-introspection = ["serde/derive"]
|
||||
|
||||
[dependencies]
|
||||
actix-codec = "0.5"
|
||||
|
|
|
|||
|
|
@ -165,10 +165,7 @@ impl AppService {
|
|||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let guard_names = guard_list
|
||||
.iter()
|
||||
.map(|g| g.name().to_string())
|
||||
.collect::<Vec<_>>();
|
||||
let guard_names = guard_list.iter().map(|g| g.name()).collect::<Vec<_>>();
|
||||
let guard_details = crate::introspection::guard_reports_from_iter(guard_list.iter());
|
||||
|
||||
let is_resource = nested.is_none();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use super::{Guard, GuardContext, GuardDetail};
|
||||
use super::{Guard, GuardContext};
|
||||
use crate::http::header::Accept;
|
||||
|
||||
/// A guard that verifies that an `Accept` header is present and it contains a compatible MIME type.
|
||||
|
|
@ -64,35 +64,25 @@ impl Guard for Acceptable {
|
|||
false
|
||||
}
|
||||
|
||||
#[cfg(feature = "experimental-introspection")]
|
||||
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()
|
||||
if self.match_star_star {
|
||||
format!("Acceptable({}, match_star_star=true)", self.mime)
|
||||
} else {
|
||||
format!("Acceptable({})", self.mime)
|
||||
}
|
||||
}
|
||||
|
||||
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(feature = "experimental-introspection")]
|
||||
fn details(&self) -> Option<Vec<super::GuardDetail>> {
|
||||
let mut details = Vec::new();
|
||||
details.push(super::GuardDetail::Generic(format!("mime={}", self.mime)));
|
||||
if self.match_star_star {
|
||||
details.push(super::GuardDetail::Generic(
|
||||
"match_star_star=true".to_string(),
|
||||
));
|
||||
}
|
||||
Some(details)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -135,12 +125,12 @@ mod tests {
|
|||
let details = guard.details().expect("missing guard details");
|
||||
|
||||
assert!(details.iter().any(|detail| match detail {
|
||||
GuardDetail::Generic(value) => value == "match_star_star=true",
|
||||
crate::guard::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,
|
||||
crate::guard::GuardDetail::Generic(value) => value == &expected,
|
||||
_ => false,
|
||||
}));
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
use actix_http::{header, uri::Uri, RequestHead, Version};
|
||||
|
||||
use super::{Guard, GuardContext, GuardDetail};
|
||||
use super::{Guard, GuardContext};
|
||||
|
||||
/// Creates a guard that matches requests targeting a specific host.
|
||||
///
|
||||
|
|
@ -118,39 +118,27 @@ impl Guard for HostGuard {
|
|||
true
|
||||
}
|
||||
|
||||
#[cfg(feature = "experimental-introspection")]
|
||||
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()
|
||||
if let Some(ref scheme) = self.scheme {
|
||||
format!("Host({}, scheme={})", self.host, scheme)
|
||||
} else {
|
||||
format!("Host({})", self.host)
|
||||
}
|
||||
}
|
||||
|
||||
fn details(&self) -> Option<Vec<GuardDetail>> {
|
||||
#[cfg(feature = "experimental-introspection")]
|
||||
{
|
||||
let mut details = vec![GuardDetail::Headers(vec![(
|
||||
"host".to_string(),
|
||||
self.host.clone(),
|
||||
)])];
|
||||
#[cfg(feature = "experimental-introspection")]
|
||||
fn details(&self) -> Option<Vec<super::GuardDetail>> {
|
||||
let mut details = vec![super::GuardDetail::Headers(vec![(
|
||||
"host".to_string(),
|
||||
self.host.clone(),
|
||||
)])];
|
||||
|
||||
if let Some(ref scheme) = self.scheme {
|
||||
details.push(GuardDetail::Generic(format!("scheme={scheme}")));
|
||||
}
|
||||
if let Some(ref scheme) = self.scheme {
|
||||
details.push(super::GuardDetail::Generic(format!("scheme={scheme}")));
|
||||
}
|
||||
|
||||
Some(details)
|
||||
}
|
||||
#[cfg(not(feature = "experimental-introspection"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
Some(details)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -282,13 +270,13 @@ mod tests {
|
|||
let details = host.details().expect("missing guard details");
|
||||
|
||||
assert!(details.iter().any(|detail| match detail {
|
||||
GuardDetail::Headers(headers) => headers
|
||||
crate::guard::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",
|
||||
crate::guard::GuardDetail::Generic(value) => value == "scheme=https",
|
||||
_ => false,
|
||||
}));
|
||||
assert_eq!(host.name(), "Host(example.com, scheme=https)");
|
||||
|
|
|
|||
|
|
@ -67,6 +67,8 @@ pub use self::{
|
|||
};
|
||||
|
||||
/// Enum to encapsulate various introspection details of a guard.
|
||||
#[cfg(feature = "experimental-introspection")]
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum GuardDetail {
|
||||
/// Detail associated with explicit HTTP method guards.
|
||||
|
|
@ -137,6 +139,7 @@ pub trait Guard {
|
|||
fn check(&self, ctx: &GuardContext<'_>) -> bool;
|
||||
|
||||
/// Returns a nominal representation of the guard.
|
||||
#[cfg(feature = "experimental-introspection")]
|
||||
fn name(&self) -> String {
|
||||
std::any::type_name::<Self>().to_string()
|
||||
}
|
||||
|
|
@ -144,6 +147,7 @@ pub trait Guard {
|
|||
/// Returns detailed introspection information, when available.
|
||||
///
|
||||
/// This is best-effort and may omit complex guard logic.
|
||||
#[cfg(feature = "experimental-introspection")]
|
||||
fn details(&self) -> Option<Vec<GuardDetail>> {
|
||||
None
|
||||
}
|
||||
|
|
@ -153,9 +157,13 @@ impl Guard for Rc<dyn Guard> {
|
|||
fn check(&self, ctx: &GuardContext<'_>) -> bool {
|
||||
(**self).check(ctx)
|
||||
}
|
||||
|
||||
#[cfg(feature = "experimental-introspection")]
|
||||
fn name(&self) -> String {
|
||||
(**self).name()
|
||||
}
|
||||
|
||||
#[cfg(feature = "experimental-introspection")]
|
||||
fn details(&self) -> Option<Vec<GuardDetail>> {
|
||||
(**self).details()
|
||||
}
|
||||
|
|
@ -248,6 +256,8 @@ impl Guard for AnyGuard {
|
|||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(feature = "experimental-introspection")]
|
||||
fn name(&self) -> String {
|
||||
format!(
|
||||
"AnyGuard({})",
|
||||
|
|
@ -258,6 +268,8 @@ impl Guard for AnyGuard {
|
|||
.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "experimental-introspection")]
|
||||
fn details(&self) -> Option<Vec<GuardDetail>> {
|
||||
Some(
|
||||
self.guards
|
||||
|
|
@ -318,6 +330,8 @@ impl Guard for AllGuard {
|
|||
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(feature = "experimental-introspection")]
|
||||
fn name(&self) -> String {
|
||||
format!(
|
||||
"AllGuard({})",
|
||||
|
|
@ -328,6 +342,8 @@ impl Guard for AllGuard {
|
|||
.join(", ")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(feature = "experimental-introspection")]
|
||||
fn details(&self) -> Option<Vec<GuardDetail>> {
|
||||
Some(
|
||||
self.guards
|
||||
|
|
@ -356,18 +372,15 @@ impl<G: Guard> Guard for Not<G> {
|
|||
fn check(&self, ctx: &GuardContext<'_>) -> bool {
|
||||
!self.0.check(ctx)
|
||||
}
|
||||
|
||||
#[cfg(feature = "experimental-introspection")]
|
||||
fn name(&self) -> String {
|
||||
format!("Not({})", self.0.name())
|
||||
}
|
||||
|
||||
#[cfg(feature = "experimental-introspection")]
|
||||
fn details(&self) -> Option<Vec<GuardDetail>> {
|
||||
#[cfg(feature = "experimental-introspection")]
|
||||
{
|
||||
Some(vec![GuardDetail::Generic(self.name())])
|
||||
}
|
||||
#[cfg(not(feature = "experimental-introspection"))]
|
||||
{
|
||||
None
|
||||
}
|
||||
Some(vec![GuardDetail::Generic(self.name())])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -398,9 +411,13 @@ impl Guard for MethodGuard {
|
|||
|
||||
ctx.head().method == self.0
|
||||
}
|
||||
|
||||
#[cfg(feature = "experimental-introspection")]
|
||||
fn name(&self) -> String {
|
||||
self.0.to_string()
|
||||
}
|
||||
|
||||
#[cfg(feature = "experimental-introspection")]
|
||||
fn details(&self) -> Option<Vec<GuardDetail>> {
|
||||
Some(vec![GuardDetail::HttpMethods(vec![self.0.to_string()])])
|
||||
}
|
||||
|
|
@ -466,9 +483,13 @@ impl Guard for HeaderGuard {
|
|||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(feature = "experimental-introspection")]
|
||||
fn name(&self) -> String {
|
||||
format!("Header({}, {})", self.0, self.1.to_str().unwrap_or(""))
|
||||
}
|
||||
|
||||
#[cfg(feature = "experimental-introspection")]
|
||||
fn details(&self) -> Option<Vec<GuardDetail>> {
|
||||
Some(vec![GuardDetail::Headers(vec![(
|
||||
self.0.to_string(),
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@
|
|||
//!
|
||||
//! Notes:
|
||||
//! - Method lists are best-effort and derived only from explicit method guards; an empty list means
|
||||
//! the route matches any method.
|
||||
//! no explicit method guards were observed for the node.
|
||||
//! - Guard and method lists are aggregated per `full_path` and do not preserve per-route
|
||||
//! correlations when multiple routes/services share the same path.
|
||||
//! - Reachability hints are best-effort and should be treated as diagnostics, not a hard guarantee.
|
||||
//!
|
||||
//! This feature is intended for local/non-production use. Avoid exposing introspection endpoints
|
||||
|
|
@ -32,7 +34,7 @@ use crate::{
|
|||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RouteDetail {
|
||||
struct RouteDetail {
|
||||
methods: Vec<Method>,
|
||||
guards: Vec<String>,
|
||||
guard_details: Vec<GuardReport>,
|
||||
|
|
@ -43,7 +45,7 @@ pub struct RouteDetail {
|
|||
|
||||
/// Input data for registering routes with the introspector.
|
||||
#[derive(Clone)]
|
||||
pub struct RouteInfo {
|
||||
pub(crate) struct RouteInfo {
|
||||
full_path: String,
|
||||
methods: Vec<Method>,
|
||||
guards: Vec<String>,
|
||||
|
|
@ -53,7 +55,7 @@ pub struct RouteInfo {
|
|||
}
|
||||
|
||||
impl RouteInfo {
|
||||
pub fn new(
|
||||
pub(crate) fn new(
|
||||
full_path: String,
|
||||
methods: Vec<Method>,
|
||||
guards: Vec<String>,
|
||||
|
|
@ -72,6 +74,7 @@ impl RouteInfo {
|
|||
}
|
||||
}
|
||||
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct GuardReport {
|
||||
pub name: String,
|
||||
|
|
@ -79,6 +82,7 @@ pub struct GuardReport {
|
|||
pub details: Vec<GuardDetailReport>,
|
||||
}
|
||||
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case")]
|
||||
pub enum GuardDetailReport {
|
||||
|
|
@ -87,6 +91,7 @@ pub enum GuardDetailReport {
|
|||
Generic { value: String },
|
||||
}
|
||||
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct HeaderReport {
|
||||
pub name: String,
|
||||
|
|
@ -97,6 +102,7 @@ pub struct HeaderReport {
|
|||
///
|
||||
/// `origin_scope` is the scope path where the external resource was registered. It is informational
|
||||
/// only and does not affect URL generation or routing; external resources are always global.
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct ExternalResourceReportItem {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
|
|
@ -130,6 +136,7 @@ struct ShadowingContext {
|
|||
}
|
||||
|
||||
/// Node type within an introspection tree.
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum ResourceType {
|
||||
/// The application root.
|
||||
|
|
@ -149,6 +156,7 @@ fn resource_type_label(kind: ResourceType) -> &'static str {
|
|||
}
|
||||
|
||||
/// A node in the introspection tree.
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IntrospectionNode {
|
||||
/// The node's classification.
|
||||
|
|
@ -176,15 +184,19 @@ pub struct IntrospectionNode {
|
|||
}
|
||||
|
||||
/// A flattened report item for a route.
|
||||
#[non_exhaustive]
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct IntrospectionReportItem {
|
||||
/// Full path for the route.
|
||||
pub full_path: String,
|
||||
/// Methods derived from explicit method guards.
|
||||
///
|
||||
/// An empty list indicates the route matches any method.
|
||||
/// An empty list indicates no explicit method guards were observed for the node.
|
||||
pub methods: Vec<String>,
|
||||
/// Guard names attached to the route.
|
||||
///
|
||||
/// This is aggregated per `full_path` and does not necessarily represent a single matching
|
||||
/// condition when multiple routes/services share the same path.
|
||||
pub guards: Vec<String>,
|
||||
/// Structured guard details, when available.
|
||||
///
|
||||
|
|
@ -201,7 +213,10 @@ pub struct IntrospectionReportItem {
|
|||
pub patterns: Vec<String>,
|
||||
/// The type of node represented by the report item.
|
||||
pub resource_type: String,
|
||||
/// Depth within the scope tree (root = 0).
|
||||
/// Depth within this report tree (root = 0).
|
||||
///
|
||||
/// This currently corresponds to the number of path segments (for example, `/foo` has depth 1
|
||||
/// and `/foo/bar` has depth 2).
|
||||
pub scope_depth: usize,
|
||||
/// True if the route might be unreachable at runtime.
|
||||
#[serde(skip_serializing_if = "is_false")]
|
||||
|
|
@ -276,7 +291,7 @@ impl From<&IntrospectionNode> for Vec<IntrospectionReportItem> {
|
|||
|
||||
/// Collects route details during app configuration.
|
||||
#[derive(Clone, Default)]
|
||||
pub struct IntrospectionCollector {
|
||||
pub(crate) struct IntrospectionCollector {
|
||||
details: BTreeMap<String, RouteDetail>,
|
||||
registrations: Vec<Registration>,
|
||||
externals: Vec<ExternalResourceReportItem>,
|
||||
|
|
@ -286,7 +301,7 @@ pub struct IntrospectionCollector {
|
|||
|
||||
impl IntrospectionCollector {
|
||||
/// Creates a new, empty collector.
|
||||
pub fn new() -> Self {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
details: BTreeMap::new(),
|
||||
registrations: Vec::new(),
|
||||
|
|
@ -296,13 +311,13 @@ impl IntrospectionCollector {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn next_scope_id(&mut self) -> usize {
|
||||
pub(crate) fn next_scope_id(&mut self) -> usize {
|
||||
let scope_id = self.next_scope_id;
|
||||
self.next_scope_id += 1;
|
||||
scope_id
|
||||
}
|
||||
|
||||
pub fn register_service(
|
||||
pub(crate) fn register_service(
|
||||
&mut self,
|
||||
info: RouteInfo,
|
||||
is_resource: bool,
|
||||
|
|
@ -327,7 +342,7 @@ impl IntrospectionCollector {
|
|||
self.next_registration_order += 1;
|
||||
}
|
||||
|
||||
pub fn register_route(&mut self, info: RouteInfo, scope_id: Option<usize>) {
|
||||
pub(crate) fn register_route(&mut self, info: RouteInfo, scope_id: Option<usize>) {
|
||||
let full_path = normalize_path(&info.full_path);
|
||||
|
||||
self.register_pattern_detail(&full_path, &info, true);
|
||||
|
|
@ -345,7 +360,7 @@ impl IntrospectionCollector {
|
|||
self.next_registration_order += 1;
|
||||
}
|
||||
|
||||
pub fn register_external(&mut self, rdef: &ResourceDef, origin_scope: &str) {
|
||||
pub(crate) fn register_external(&mut self, rdef: &ResourceDef, origin_scope: &str) {
|
||||
let report = external_report_from_rdef(rdef, origin_scope);
|
||||
|
||||
if let Some(name) = report.name.as_deref() {
|
||||
|
|
@ -365,12 +380,7 @@ impl IntrospectionCollector {
|
|||
}
|
||||
|
||||
/// Registers details for a route pattern.
|
||||
pub fn register_pattern_detail(
|
||||
&mut self,
|
||||
full_path: &str,
|
||||
info: &RouteInfo,
|
||||
is_resource: bool,
|
||||
) {
|
||||
fn register_pattern_detail(&mut self, full_path: &str, info: &RouteInfo, is_resource: bool) {
|
||||
let full_path = normalize_path(full_path);
|
||||
|
||||
self.details
|
||||
|
|
@ -398,7 +408,7 @@ impl IntrospectionCollector {
|
|||
}
|
||||
|
||||
/// Produces the finalized introspection tree.
|
||||
pub fn finalize(&mut self) -> IntrospectionTree {
|
||||
pub(crate) fn finalize(&mut self) -> IntrospectionTree {
|
||||
let detail_registry = std::mem::take(&mut self.details);
|
||||
let registrations = std::mem::take(&mut self.registrations);
|
||||
let externals = std::mem::take(&mut self.externals);
|
||||
|
|
@ -457,6 +467,7 @@ impl IntrospectionCollector {
|
|||
}
|
||||
|
||||
/// The finalized introspection tree.
|
||||
#[non_exhaustive]
|
||||
#[derive(Clone)]
|
||||
pub struct IntrospectionTree {
|
||||
/// Root node of the tree.
|
||||
|
|
@ -790,19 +801,122 @@ fn guards_only_methods(guards: &[String], methods: &[Method]) -> bool {
|
|||
}
|
||||
|
||||
fn has_conflicting_methods(methods: &[Method], guards: &[String]) -> bool {
|
||||
let method_names = method_set(methods);
|
||||
if method_names.len() <= 1 {
|
||||
// This check is best-effort: it tries to determine if the conjunction of method guards can
|
||||
// match any single HTTP method. It relies on guard names since introspection details flatten
|
||||
// guard structure.
|
||||
if method_set(methods).len() <= 1 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let has_any = guards.iter().any(|name| name.starts_with("AnyGuard("));
|
||||
let has_all = guards.iter().any(|name| name.starts_with("AllGuard("));
|
||||
fn split_top_level_args(mut args: &str) -> Vec<&str> {
|
||||
args = args.trim();
|
||||
if args.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if has_all {
|
||||
return true;
|
||||
let mut parts = Vec::new();
|
||||
let mut depth = 0usize;
|
||||
let mut start = 0usize;
|
||||
|
||||
for (idx, ch) in args.char_indices() {
|
||||
match ch {
|
||||
'(' => depth += 1,
|
||||
')' => depth = depth.saturating_sub(1),
|
||||
',' if depth == 0 => {
|
||||
parts.push(args[start..idx].trim());
|
||||
start = idx + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
parts.push(args[start..].trim());
|
||||
parts.into_iter().filter(|s| !s.is_empty()).collect()
|
||||
}
|
||||
|
||||
!has_any
|
||||
fn parse_method(name: &str) -> Option<BTreeSet<String>> {
|
||||
name.trim().parse::<Method>().ok().map(|method| {
|
||||
let mut set = BTreeSet::new();
|
||||
set.insert(method.to_string());
|
||||
set
|
||||
})
|
||||
}
|
||||
|
||||
fn union_methods(
|
||||
left: Option<BTreeSet<String>>,
|
||||
right: Option<BTreeSet<String>>,
|
||||
) -> Option<BTreeSet<String>> {
|
||||
match (left, right) {
|
||||
// If any branch doesn't constrain methods, the disjunction doesn't either.
|
||||
(None, _) | (_, None) => None,
|
||||
(Some(mut a), Some(b)) => {
|
||||
a.extend(b);
|
||||
Some(a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn intersect_methods(
|
||||
left: Option<BTreeSet<String>>,
|
||||
right: Option<BTreeSet<String>>,
|
||||
) -> Option<BTreeSet<String>> {
|
||||
match (left, right) {
|
||||
(None, x) | (x, None) => x,
|
||||
(Some(a), Some(b)) => Some(a.intersection(&b).cloned().collect()),
|
||||
}
|
||||
}
|
||||
|
||||
fn guard_possible_methods(name: &str) -> Option<BTreeSet<String>> {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if let Some(set) = parse_method(name) {
|
||||
return Some(set);
|
||||
}
|
||||
|
||||
if let Some(inner) = name
|
||||
.strip_prefix("AnyGuard(")
|
||||
.and_then(|s| s.strip_suffix(')'))
|
||||
{
|
||||
let mut acc = Some(BTreeSet::new());
|
||||
for arg in split_top_level_args(inner) {
|
||||
acc = union_methods(acc, guard_possible_methods(arg));
|
||||
if acc.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
if let Some(inner) = name
|
||||
.strip_prefix("AllGuard(")
|
||||
.and_then(|s| s.strip_suffix(')'))
|
||||
{
|
||||
let mut acc = None;
|
||||
for arg in split_top_level_args(inner) {
|
||||
acc = intersect_methods(acc, guard_possible_methods(arg));
|
||||
if matches!(acc, Some(ref set) if set.is_empty()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
|
||||
// `Not(...)` (and unknown/custom guard names) are treated as not restricting methods.
|
||||
None
|
||||
}
|
||||
|
||||
let mut possible = None;
|
||||
for guard in guards {
|
||||
possible = intersect_methods(possible, guard_possible_methods(guard));
|
||||
if matches!(possible, Some(ref set) if set.is_empty()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn method_set(methods: &[Method]) -> BTreeSet<String> {
|
||||
|
|
@ -887,10 +1001,12 @@ fn format_reachability(item: &IntrospectionReportItem) -> String {
|
|||
if item.reachability_notes.is_empty() {
|
||||
" | PotentiallyUnreachable".to_string()
|
||||
} else {
|
||||
format!(
|
||||
" | PotentiallyUnreachable | Notes: {:?}",
|
||||
item.reachability_notes
|
||||
)
|
||||
let notes = item
|
||||
.reachability_notes
|
||||
.iter()
|
||||
.map(|note| sanitize_text(note))
|
||||
.collect::<Vec<_>>();
|
||||
format!(" | PotentiallyUnreachable | Notes: {:?}", notes)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1057,6 +1173,61 @@ mod tests {
|
|||
.contains(&"conflicting_method_guards".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allguard_anyguard_does_not_mark_conflict_when_methods_are_feasible() {
|
||||
let mut collector = IntrospectionCollector::new();
|
||||
let info = route_info(
|
||||
"/feasible",
|
||||
vec![Method::GET, Method::POST],
|
||||
vec![
|
||||
"AllGuard(AnyGuard(GET, POST), Header(x, y))".to_string(),
|
||||
"Header(x, y)".to_string(),
|
||||
],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
None,
|
||||
);
|
||||
collector.register_route(info, None);
|
||||
let tree = collector.finalize();
|
||||
let items: Vec<IntrospectionReportItem> = (&tree.root).into();
|
||||
|
||||
let item = items
|
||||
.iter()
|
||||
.find(|item| item.full_path == "/feasible")
|
||||
.expect("missing route");
|
||||
|
||||
assert!(!item.potentially_unreachable);
|
||||
assert!(!item
|
||||
.reachability_notes
|
||||
.contains(&"conflicting_method_guards".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allguard_anyguard_marks_conflict_when_methods_are_impossible() {
|
||||
let mut collector = IntrospectionCollector::new();
|
||||
let info = route_info(
|
||||
"/impossible",
|
||||
vec![Method::GET, Method::POST],
|
||||
vec!["AllGuard(GET, AnyGuard(POST))".to_string()],
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
None,
|
||||
);
|
||||
collector.register_route(info, None);
|
||||
let tree = collector.finalize();
|
||||
let items: Vec<IntrospectionReportItem> = (&tree.root).into();
|
||||
|
||||
let item = items
|
||||
.iter()
|
||||
.find(|item| item.full_path == "/impossible")
|
||||
.expect("missing route");
|
||||
|
||||
assert!(item.potentially_unreachable);
|
||||
assert!(item
|
||||
.reachability_notes
|
||||
.contains(&"conflicting_method_guards".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shadowed_scopes_mark_routes() {
|
||||
let mut collector = IntrospectionCollector::new();
|
||||
|
|
|
|||
Loading…
Reference in New Issue