Skip to main content

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}