Skip to content

Commit 84ddfe7

Browse files
dertindevdertinJohnTitor
authored
feat(web): initial support for route and HTTP method introspection (#3594)
* feat(resources-introspection): add support for resource metadata retrieval * misc: remove debug print * style: cargo fmt * fix(guards): replace take_guards with get_guards to prevent guard removal and fix test failures * ci: downgrade for msrv litemap to version 0.7.4 in justfile * chore: update changelog and fix docs for CI * ci: downgrade for msrv zerofrom to version 0.1.5 in justfile * refactor: improve thread safety and add unit tests for introspection process * fix(introspection): add conditional arbiter creation for io-uring support * fix(introspection): add conditional arbiter creation for io-uring support * refactor(introspection): add GuardDetail enum and remove downcast_ref usage - Added `GuardDetail` enum to encapsulate various introspection details of a guard. - Refactored `HttpMethodsExtractor` implementation to use `GuardDetail` instead of `downcast_ref`. * refactor(introspection): add GuardDetail enum and remove downcast_ref usage - Added `GuardDetail` enum to encapsulate various introspection details of a guard. - Refactored `HttpMethodsExtractor` implementation to use `GuardDetail` instead of `downcast_ref`. * feat(introspection): rename feature from `resources-introspection` to `experimental-introspection` - Refactored introspection logic. - Enhanced route introspection to register HTTP methods and guard names. - Added example for testing the experimental introspection feature. * fix Cargo.lock * feat(introspection): enhance introspection feature with detailed route registration and full path tracking * optimize debug log and apply clippy/fmt suggestions * feat(introspection): enhance introspection handlers for JSON and plain text responses * feat(introspection): implement experimental introspection feature with multiple App instances * 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. * Refactor route registration to use RouteInfo struct & cargo clippy * put all the items behind feature gate * tweak * fmt --------- Co-authored-by: Guillermo Céspedes Tabárez <gcespedes@prexcard.com> Co-authored-by: Yuki Okushi <huyuumi.dev@gmail.com>
1 parent 9f67999 commit 84ddfe7

16 files changed

Lines changed: 2312 additions & 4 deletions

actix-web/CHANGES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
- Minimum supported Rust version (MSRV) is now 1.88.
66
- Add `HttpRequest::url_for_map` and `HttpRequest::url_for_iter` methods for named URL parameters. [#3895]
77
- Ignore unparsable cookies in `Cookie` request header.
8+
- Add `experimental-introspection` feature to report configured routes [#3594]
89

910
[#3895]: https://github.com/actix/actix-web/pull/3895
11+
[#3594]: https://github.com/actix/actix-web/pull/3594
1012

1113
## 4.12.1
1214

actix-web/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,9 @@ compat = ["compat-routing-macros-force-pub"]
125125
# Opt-out forwards-compatibility for handler visibility inheritance fix.
126126
compat-routing-macros-force-pub = ["actix-web-codegen?/compat-routing-macros-force-pub"]
127127

128+
# Enabling the retrieval of metadata for initialized resources, including path and HTTP method.
129+
experimental-introspection = ["serde/derive"]
130+
128131
[dependencies]
129132
actix-codec = "0.5"
130133
actix-macros = { version = "0.2.3", optional = true }
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
// Example showcasing the experimental introspection feature.
2+
// Run with: `cargo run --features experimental-introspection --example introspection`
3+
4+
#[actix_web::main]
5+
async fn main() -> std::io::Result<()> {
6+
#[cfg(feature = "experimental-introspection")]
7+
{
8+
use actix_web::{dev::Service, guard, web, App, HttpResponse, HttpServer, Responder};
9+
use serde::Deserialize;
10+
11+
// Initialize logging
12+
env_logger::Builder::new()
13+
.filter_level(log::LevelFilter::Debug)
14+
.init();
15+
16+
// Custom guard to check if the Content-Type header is present.
17+
struct ContentTypeGuard;
18+
19+
impl guard::Guard for ContentTypeGuard {
20+
fn check(&self, req: &guard::GuardContext<'_>) -> bool {
21+
req.head()
22+
.headers()
23+
.contains_key(actix_web::http::header::CONTENT_TYPE)
24+
}
25+
}
26+
27+
// Data structure for endpoints that receive JSON.
28+
#[derive(Deserialize)]
29+
struct UserInfo {
30+
username: String,
31+
age: u8,
32+
}
33+
34+
// GET /introspection for JSON response
35+
async fn introspection_handler_json(
36+
tree: web::Data<actix_web::introspection::IntrospectionTree>,
37+
) -> impl Responder {
38+
let report = tree.report_as_json();
39+
HttpResponse::Ok()
40+
.content_type("application/json")
41+
.body(report)
42+
}
43+
44+
// GET /introspection/externals for external resources report
45+
async fn introspection_handler_externals(
46+
tree: web::Data<actix_web::introspection::IntrospectionTree>,
47+
) -> impl Responder {
48+
let report = tree.report_externals_as_json();
49+
HttpResponse::Ok()
50+
.content_type("application/json")
51+
.body(report)
52+
}
53+
54+
// GET /introspection for plain text response
55+
async fn introspection_handler_text(
56+
tree: web::Data<actix_web::introspection::IntrospectionTree>,
57+
) -> impl Responder {
58+
let report = tree.report_as_text();
59+
HttpResponse::Ok().content_type("text/plain").body(report)
60+
}
61+
62+
// GET /api/v1/item/{id} and GET /v1/item/{id}
63+
#[actix_web::get("/item/{id}")]
64+
async fn get_item(path: web::Path<u32>) -> impl Responder {
65+
let id = path.into_inner();
66+
HttpResponse::Ok().body(format!("Requested item with id: {}", id))
67+
}
68+
69+
// POST /api/v1/info
70+
#[actix_web::post("/info")]
71+
async fn post_user_info(info: web::Json<UserInfo>) -> impl Responder {
72+
HttpResponse::Ok().json(format!(
73+
"User {} with age {} received",
74+
info.username, info.age
75+
))
76+
}
77+
78+
// /api/v1/guarded
79+
async fn guarded_handler() -> impl Responder {
80+
HttpResponse::Ok().body("Passed the Content-Type guard!")
81+
}
82+
83+
// GET /api/v2/hello
84+
async fn hello_v2() -> impl Responder {
85+
HttpResponse::Ok().body("Hello from API v2!")
86+
}
87+
88+
// GET /admin/dashboard
89+
async fn admin_dashboard() -> impl Responder {
90+
HttpResponse::Ok().body("Welcome to the Admin Dashboard!")
91+
}
92+
93+
// GET /admin/settings
94+
async fn get_settings() -> impl Responder {
95+
HttpResponse::Ok().body("Current settings: ...")
96+
}
97+
98+
// POST /admin/settings
99+
async fn update_settings() -> impl Responder {
100+
HttpResponse::Ok().body("Settings have been updated!")
101+
}
102+
103+
// GET and POST on /
104+
async fn root_index() -> impl Responder {
105+
HttpResponse::Ok().body("Welcome to the Root Endpoint!")
106+
}
107+
108+
// GET /alpha and /beta (named multi-pattern resource)
109+
async fn multi_pattern() -> impl Responder {
110+
HttpResponse::Ok().body("Hello from multi-pattern resource!")
111+
}
112+
113+
// GET /acceptable (Acceptable guard)
114+
async fn acceptable_guarded() -> impl Responder {
115+
HttpResponse::Ok().body("Acceptable guard matched!")
116+
}
117+
118+
// GET /hosted (Host guard)
119+
async fn host_guarded() -> impl Responder {
120+
HttpResponse::Ok().body("Host guard matched!")
121+
}
122+
123+
// Additional endpoints for /extra
124+
fn extra_endpoints(cfg: &mut web::ServiceConfig) {
125+
cfg.service(
126+
web::scope("/extra")
127+
.route(
128+
"/ping",
129+
web::get().to(|| async { HttpResponse::Ok().body("pong") }), // GET /extra/ping
130+
)
131+
.service(
132+
web::resource("/multi")
133+
.route(web::get().to(|| async {
134+
HttpResponse::Ok().body("GET response from /extra/multi")
135+
})) // GET /extra/multi
136+
.route(web::post().to(|| async {
137+
HttpResponse::Ok().body("POST response from /extra/multi")
138+
})), // POST /extra/multi
139+
)
140+
.service(
141+
web::scope("{entities_id:\\d+}")
142+
.service(
143+
web::scope("/secure")
144+
.route(
145+
"",
146+
web::get().to(|| async {
147+
HttpResponse::Ok()
148+
.body("GET response from /extra/secure")
149+
}),
150+
) // GET /extra/{entities_id}/secure/
151+
.route(
152+
"/post",
153+
web::post().to(|| async {
154+
HttpResponse::Ok()
155+
.body("POST response from /extra/secure")
156+
}),
157+
), // POST /extra/{entities_id}/secure/post
158+
)
159+
.wrap_fn(|req, srv| {
160+
println!(
161+
"Request to /extra/secure with id: {}",
162+
req.match_info().get("entities_id").unwrap()
163+
);
164+
let fut = srv.call(req);
165+
async move {
166+
let res = fut.await?;
167+
Ok(res)
168+
}
169+
}),
170+
),
171+
);
172+
}
173+
174+
// Additional endpoints for /foo
175+
fn other_endpoints(cfg: &mut web::ServiceConfig) {
176+
cfg.service(
177+
web::scope("/extra")
178+
.route(
179+
"/ping",
180+
web::post()
181+
.to(|| async { HttpResponse::Ok().body("post from /extra/ping") }), // POST /foo/extra/ping
182+
)
183+
.route(
184+
"/ping",
185+
web::delete()
186+
.to(|| async { HttpResponse::Ok().body("delete from /extra/ping") }), // DELETE /foo/extra/ping
187+
),
188+
);
189+
}
190+
191+
// Create the HTTP server with all the routes and handlers
192+
let server = HttpServer::new(|| {
193+
App::new()
194+
// Get introspection report
195+
// curl --location '127.0.0.1:8080/introspection' --header 'Accept: application/json'
196+
// curl --location '127.0.0.1:8080/introspection' --header 'Accept: text/plain'
197+
// curl --location '127.0.0.1:8080/introspection/externals'
198+
.external_resource("app-external", "https://example.com/{id}")
199+
.service(
200+
web::resource("/introspection")
201+
.route(
202+
web::get()
203+
.guard(guard::Header("accept", "application/json"))
204+
.to(introspection_handler_json),
205+
)
206+
.route(
207+
web::get()
208+
.guard(guard::Header("accept", "text/plain"))
209+
.to(introspection_handler_text),
210+
),
211+
)
212+
.service(
213+
web::resource("/introspection/externals")
214+
.route(web::get().to(introspection_handler_externals)),
215+
)
216+
.service(
217+
web::resource(["/alpha", "/beta"])
218+
.name("multi")
219+
.route(web::get().to(multi_pattern)),
220+
)
221+
.route(
222+
"/acceptable",
223+
web::get()
224+
.guard(guard::Acceptable::new(mime::APPLICATION_JSON).match_star_star())
225+
.to(acceptable_guarded),
226+
)
227+
.route(
228+
"/hosted",
229+
web::get().guard(guard::Host("127.0.0.1")).to(host_guarded),
230+
)
231+
// API endpoints under /api
232+
.service(
233+
web::scope("/api")
234+
.configure(|cfg| {
235+
cfg.external_resource("api-external", "https://api.example/{id}");
236+
})
237+
// Endpoints under /api/v1
238+
.service(
239+
web::scope("/v1")
240+
.service(get_item) // GET /api/v1/item/{id}
241+
.service(post_user_info) // POST /api/v1/info
242+
.route(
243+
"/guarded",
244+
web::route().guard(ContentTypeGuard).to(guarded_handler), // /api/v1/guarded
245+
),
246+
)
247+
// Endpoints under /api/v2
248+
.service(web::scope("/v2").route("/hello", web::get().to(hello_v2))), // GET /api/v2/hello
249+
)
250+
// Endpoints under /v1 (outside /api)
251+
.service(web::scope("/v1").service(get_item)) // GET /v1/item/{id}
252+
// Admin endpoints under /admin
253+
.service(
254+
web::scope("/admin")
255+
.route("/dashboard", web::get().to(admin_dashboard)) // GET /admin/dashboard
256+
.service(
257+
web::resource("/settings")
258+
.route(web::get().to(get_settings)) // GET /admin/settings
259+
.route(web::post().to(update_settings)), // POST /admin/settings
260+
),
261+
)
262+
// Root endpoints
263+
.service(
264+
web::resource("/")
265+
.route(web::get().to(root_index)) // GET /
266+
.route(web::post().to(root_index)), // POST /
267+
)
268+
// Endpoints under /bar
269+
.service(web::scope("/bar").configure(extra_endpoints)) // /bar/extra/ping, /bar/extra/multi, etc.
270+
// Endpoints under /foo
271+
.service(web::scope("/foo").configure(other_endpoints)) // /foo/extra/ping with POST and DELETE
272+
// Additional endpoints under /extra
273+
.configure(extra_endpoints) // /extra/ping, /extra/multi, etc.
274+
.configure(other_endpoints)
275+
// Endpoint that rejects GET on /not_guard (allows other methods)
276+
.route(
277+
"/not_guard",
278+
web::route()
279+
.guard(guard::Not(guard::Get()))
280+
.to(HttpResponse::MethodNotAllowed),
281+
)
282+
// Endpoint that requires GET with header or POST on /all_guard
283+
.route(
284+
"/all_guard",
285+
web::route()
286+
.guard(
287+
guard::All(guard::Get())
288+
.and(guard::Header("content-type", "plain/text"))
289+
.and(guard::Any(guard::Post())),
290+
)
291+
.to(HttpResponse::MethodNotAllowed),
292+
)
293+
})
294+
.workers(5)
295+
.bind("127.0.0.1:8080")?;
296+
297+
server.run().await
298+
}
299+
#[cfg(not(feature = "experimental-introspection"))]
300+
{
301+
eprintln!("This example requires the 'experimental-introspection' feature to be enabled.");
302+
std::process::exit(1);
303+
}
304+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
// Example showcasing the experimental introspection feature with multiple App instances.
2+
// Run with: `cargo run --features experimental-introspection --example introspection_multi_servers`
3+
4+
#[actix_web::main]
5+
async fn main() -> std::io::Result<()> {
6+
#[cfg(feature = "experimental-introspection")]
7+
{
8+
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
9+
use futures_util::future;
10+
11+
async fn introspection_handler(
12+
tree: web::Data<actix_web::introspection::IntrospectionTree>,
13+
) -> impl Responder {
14+
HttpResponse::Ok()
15+
.content_type("text/plain")
16+
.body(tree.report_as_text())
17+
}
18+
19+
async fn index() -> impl Responder {
20+
HttpResponse::Ok().body("Hello from app")
21+
}
22+
23+
let srv1 = HttpServer::new(|| {
24+
App::new()
25+
.service(web::resource("/a").route(web::get().to(index)))
26+
.service(
27+
web::resource("/introspection").route(web::get().to(introspection_handler)),
28+
)
29+
})
30+
.workers(8)
31+
.bind("127.0.0.1:8081")?
32+
.run();
33+
34+
let srv2 = HttpServer::new(|| {
35+
App::new()
36+
.service(web::resource("/b").route(web::get().to(index)))
37+
.service(
38+
web::resource("/introspection").route(web::get().to(introspection_handler)),
39+
)
40+
})
41+
.workers(3)
42+
.bind("127.0.0.1:8082")?
43+
.run();
44+
45+
future::try_join(srv1, srv2).await?;
46+
}
47+
#[cfg(not(feature = "experimental-introspection"))]
48+
{
49+
eprintln!("This example requires the 'experimental-introspection' feature to be enabled.");
50+
}
51+
Ok(())
52+
}

0 commit comments

Comments
 (0)