cratestack_axum/ratelimit/rest_ops_filter.rs
1//! Rate-limit filter for REST transport: check if a route is exempt
2//! from rate limiting based on its `RouteTransportDescriptor.rate_limited_by_default`.
3//!
4//! REST resolves op identity very differently from RPC (`rpc_ops_filter`):
5//! there is no single `/rpc/{op_id}` path segment to read, so this filter
6//! keys off [`axum::extract::MatchedPath`] — the route *pattern* the
7//! request matched (e.g. `/widgets/{id}`), not the concrete request path
8//! (e.g. `/widgets/42`). `RouteTransportDescriptor::path` is emitted in
9//! that same `{param}` shape (see `cratestack-macros/src/transport/rest.rs`),
10//! so the two compare directly with no path-param parsing needed.
11//!
12//! # `Router::layer` and `Router::route_layer` both work
13//!
14//! Unlike some middleware, `MatchedPath` is populated for either mount
15//! method — axum's `Router::layer` applies the middleware to each
16//! endpoint's `Route` individually (same as `route_layer`), it just
17//! *also* wraps the router's fallback (404) service, which `route_layer`
18//! does not. Practically: with `route_layer`, a request that matches no
19//! route skips this filter (and the rate limiter) entirely; with `layer`,
20//! it still runs the filter, finds no match, and fails closed (rate-limits
21//! the 404). Either is safe — pick based on whether 404s should count
22//! against the budget.
23use axum::extract::Request;
24use cratestack_core::RouteTransportDescriptor;
25
26use crate::idempotency::build_rest_op_resolver;
27
28/// Build a rate-limit filter function for REST schemas.
29///
30/// Returns a function that:
31/// - Reads the matched route pattern via [`axum::extract::MatchedPath`]
32/// (populated by axum under both `Router::layer` and
33/// `Router::route_layer` — see module docs).
34/// - Looks up the route (matched pattern + HTTP method) in the provided
35/// descriptors.
36/// - Returns `false` (exempt) if `rate_limited_by_default` is false.
37/// - Returns `true` (apply rate limit) if the route participates, or if
38/// lookup fails for any reason.
39///
40/// Fails closed: if `MatchedPath` is absent (the request matched no
41/// route — a 404) or the route isn't found in `routes` (a schema/router
42/// mismatch), the request is rate-limited. This prevents accidental
43/// exemptions from missing data or misconfiguration.
44///
45/// Since ADR 0015 slice 2 (cratestack#877) this is a projection of
46/// [`build_rest_op_resolver`] — one lookup shared with idempotency, whose
47/// miss (`OpAdmission::unresolved`) is `rate_limited_by_default: true`,
48/// i.e. exactly the fail-closed answer above. For a router mounted with
49/// `Router::nest`, pass `build_rest_op_resolver_with_prefix` to
50/// [`super::RateLimitLayer::with_op_resolver`] instead.
51pub fn build_rest_ops_filter(
52 routes: &'static [RouteTransportDescriptor],
53) -> impl Fn(&Request) -> bool + Send + Sync {
54 let resolve = build_rest_op_resolver(routes);
55 move |req: &Request| resolve(req).rate_limited_by_default
56}
57
58#[cfg(test)]
59mod tests {
60 use axum::Router;
61 use axum::body::Body;
62 use axum::extract::ConnectInfo;
63 use axum::http::{Request as HttpRequest, StatusCode};
64 use axum::routing::{get, post};
65 use cratestack_core::{RouteTransportCapabilities, RouteTransportDescriptor};
66 use tower::ServiceExt;
67
68 use super::build_rest_ops_filter;
69 use crate::ratelimit::{InMemoryRateLimitStore, RateLimitConfig, RateLimitLayer};
70
71 /// Every request in this module that actually reaches the rate-limit
72 /// store (i.e. isn't exempted by the filter under test) needs a
73 /// verifiable caller identity — cratestack#416 made the default key fn
74 /// refuse requests with neither an `Authorization` header nor a
75 /// `ConnectInfo<SocketAddr>` peer, and `oneshot` never populates
76 /// `ConnectInfo` on its own.
77 fn with_peer(mut req: HttpRequest<Body>) -> HttpRequest<Body> {
78 let peer: std::net::SocketAddr = "192.0.2.50:1".parse().unwrap();
79 req.extensions_mut().insert(ConnectInfo(peer));
80 req
81 }
82
83 const CAPS: RouteTransportCapabilities = RouteTransportCapabilities {
84 request_types: &[],
85 response_types: &[],
86 default_response_type: "",
87 supports_sequence_response: false,
88 };
89
90 const ROUTES: &[RouteTransportDescriptor] = &[
91 RouteTransportDescriptor {
92 name: "createPayment",
93 method: "POST",
94 path: "/$procs/createPayment",
95 capabilities: CAPS,
96 idempotent_by_default: false,
97 rate_limited_by_default: false,
98 },
99 RouteTransportDescriptor {
100 name: "Widget",
101 method: "GET",
102 path: "/widgets/{id}",
103 capabilities: CAPS,
104 idempotent_by_default: true,
105 rate_limited_by_default: true,
106 },
107 ];
108
109 async fn ok() -> &'static str {
110 "ok"
111 }
112
113 fn app() -> Router {
114 Router::new()
115 .route("/$procs/createPayment", post(ok))
116 .route("/widgets/{id}", get(ok))
117 .route_layer(
118 RateLimitLayer::new(
119 std::sync::Arc::new(InMemoryRateLimitStore::default()),
120 RateLimitConfig::new(1, 0.001),
121 )
122 .with_should_rate_limit_fn(build_rest_ops_filter(ROUTES)),
123 )
124 }
125
126 /// AC1/AC2 mirrored for REST: an exempt route survives past its
127 /// burst, an un-annotated route (with a path param, proving
128 /// `MatchedPath` — not the concrete request path — is what's
129 /// compared) is throttled.
130 #[tokio::test]
131 async fn route_layer_exempts_annotated_route_and_throttles_others() {
132 let router = app();
133
134 for _ in 0..3 {
135 let resp = router
136 .clone()
137 .oneshot(
138 HttpRequest::post("/$procs/createPayment")
139 .body(Body::empty())
140 .unwrap(),
141 )
142 .await
143 .unwrap();
144 assert_eq!(
145 resp.status(),
146 StatusCode::OK,
147 "@no_rate_limit route should never be throttled"
148 );
149 }
150
151 let first = router
152 .clone()
153 .oneshot(with_peer(
154 HttpRequest::get("/widgets/42").body(Body::empty()).unwrap(),
155 ))
156 .await
157 .unwrap();
158 assert_eq!(first.status(), StatusCode::OK, "first request within burst");
159
160 let second = router
161 .clone()
162 .oneshot(with_peer(
163 HttpRequest::get("/widgets/7").body(Body::empty()).unwrap(),
164 ))
165 .await
166 .unwrap();
167 assert_eq!(
168 second.status(),
169 StatusCode::TOO_MANY_REQUESTS,
170 "un-annotated route is throttled regardless of the concrete id in the path, \
171 proving the comparison is against the matched route pattern"
172 );
173 }
174
175 /// `Router::layer` (unlike `route_layer`) also wraps the fallback
176 /// (404) service, so a request that matches no route still runs
177 /// through the filter, finds no `MatchedPath`, and fails closed —
178 /// consuming budget even for a 404. This proves the filter never
179 /// fails open on an unmatched path.
180 #[tokio::test]
181 async fn plain_layer_fails_closed_and_throttles_unmatched_paths() {
182 let router = Router::new()
183 .route("/$procs/createPayment", post(ok))
184 .layer(
185 RateLimitLayer::new(
186 std::sync::Arc::new(InMemoryRateLimitStore::default()),
187 RateLimitConfig::new(1, 0.001),
188 )
189 .with_should_rate_limit_fn(build_rest_ops_filter(ROUTES)),
190 );
191
192 let first = router
193 .clone()
194 .oneshot(with_peer(
195 HttpRequest::get("/does/not/exist")
196 .body(Body::empty())
197 .unwrap(),
198 ))
199 .await
200 .unwrap();
201 assert_eq!(
202 first.status(),
203 StatusCode::NOT_FOUND,
204 "first 404 within burst still reaches the fallback"
205 );
206
207 let second = router
208 .clone()
209 .oneshot(with_peer(
210 HttpRequest::get("/does/not/exist")
211 .body(Body::empty())
212 .unwrap(),
213 ))
214 .await
215 .unwrap();
216 assert_eq!(
217 second.status(),
218 StatusCode::TOO_MANY_REQUESTS,
219 "Router::layer wraps the fallback too, so an unmatched path has no \
220 MatchedPath, fails closed, and is throttled just like any other route"
221 );
222 }
223
224 /// `Router::route_layer`'s counterpart: it does *not* wrap the
225 /// fallback, so unmatched paths bypass the rate limiter (and this
226 /// filter) entirely — every 404 stays a 404, never a 429, and never
227 /// consumes budget.
228 #[tokio::test]
229 async fn route_layer_does_not_throttle_unmatched_paths() {
230 let router = Router::new()
231 .route("/$procs/createPayment", post(ok))
232 .route_layer(
233 RateLimitLayer::new(
234 std::sync::Arc::new(InMemoryRateLimitStore::default()),
235 RateLimitConfig::new(1, 0.001),
236 )
237 .with_should_rate_limit_fn(build_rest_ops_filter(ROUTES)),
238 );
239
240 for _ in 0..5 {
241 let resp = router
242 .clone()
243 .oneshot(
244 HttpRequest::get("/does/not/exist")
245 .body(Body::empty())
246 .unwrap(),
247 )
248 .await
249 .unwrap();
250 assert_eq!(
251 resp.status(),
252 StatusCode::NOT_FOUND,
253 "route_layer skips the fallback entirely, so repeated unmatched \
254 requests are always 404, never 429"
255 );
256 }
257 }
258}