Skip to main content

cratestack_axum/idempotency/
rest_op_resolver.rs

1//! Op resolver for REST transport: recover the `OpAdmission` facts the
2//! schema declared about the route a request matched.
3//!
4//! Originally shaped after `crate::ratelimit::rest_ops_filter`, which
5//! solved the identical "a `tower::Layer` sits above routing and has to
6//! learn which op it is about to dispatch" problem for cratestack#474.
7//! Since ADR 0015 slice 2 (cratestack#877) the dependency runs the other
8//! way: that filter is now a projection of this resolver, so rate limiting
9//! and idempotency share one lookup. See the filter's module for the
10//! write-up of why `Router::layer` and `Router::route_layer` both populate
11//! `MatchedPath` and how they differ on 404s.
12//!
13//! # A nested mount needs [`build_rest_op_resolver_with_prefix`]
14//!
15//! `MatchedPath` reports the full matched path, so under
16//! `Router::nest("/api", router)` it reads `/api/$procs/notify` while the
17//! generated descriptor says `/$procs/notify`. With the plain constructor
18//! every lookup then misses, every op resolves unresolved, and
19//! `@no_idempotency` silently does nothing. Pass the mount point to the
20//! `_with_prefix` constructor instead. See `super::mount_prefix` for why
21//! the prefix is supplied rather than inferred.
22//!
23//! # The fail-closed direction is inverted here, and that is the point
24//!
25//! The rate-limit filter fails closed by *rate-limiting* on a lookup miss.
26//! This resolver fails closed by *reserving* on a lookup miss — it returns
27//! [`OpAdmission::unresolved`], whose `idempotent_by_default` is `false`.
28//! Both are "when in doubt, apply the protection"; they only look opposite
29//! because the two flags are polarised differently.
30//!
31//! One consequence is worth stating plainly, because it is the whole
32//! byte-identity argument for ADR 0015 slice 1: an `IdempotencyLayer` with
33//! **no** resolver installed treats every request as unresolved, and
34//! therefore reserves exactly the set of requests it reserved before this
35//! crate existed. Installing a resolver is opt-in, and opting out is
36//! bit-for-bit the old behaviour.
37
38use axum::extract::{MatchedPath, Request};
39use cratestack_core::RouteTransportDescriptor;
40use cratestack_exec::OpAdmission;
41
42use super::mount_prefix;
43
44/// Build an op resolver for REST schemas, over the generated
45/// `ROUTE_TRANSPORTS` slice, for a router mounted at the root.
46///
47/// Matches on the route *pattern* (`/widgets/{id}`) rather than the
48/// concrete request path (`/widgets/42`), plus the HTTP method — the two
49/// together are what identify a REST op, since one path serves up to
50/// three verbs.
51///
52/// Returns [`OpAdmission::unresolved`] when `MatchedPath` is absent (the
53/// request hit no route — a 404) or no descriptor matches (a
54/// schema/router mismatch). Both still reserve.
55///
56/// **If the router is nested, use [`build_rest_op_resolver_with_prefix`]**
57/// — this constructor compares the matched path exactly, so a nested mount
58/// misses every lookup.
59pub fn build_rest_op_resolver(
60    routes: &'static [RouteTransportDescriptor],
61) -> impl Fn(&Request) -> OpAdmission + Send + Sync {
62    build_rest_op_resolver_with_prefix("", routes)
63}
64
65/// [`build_rest_op_resolver`] for a router mounted under `prefix`, e.g.
66/// `build_rest_op_resolver_with_prefix("/api", ROUTE_TRANSPORTS)` to match
67/// `Router::nest("/api", router)`.
68///
69/// `prefix` is forgiving about spelling — `"/api"`, `"/api/"` and `"api"`
70/// are the same mount — but strict about boundaries: a path that is not
71/// under the prefix *at a segment boundary* resolves unresolved rather
72/// than being matched on a truncated remainder. `/apiary/...` is not
73/// under `/api`.
74///
75/// The resolver keeps its own normalised copy of `prefix` and borrows
76/// nothing (`use<>`), so the prefix may come from runtime configuration;
77/// without it, edition 2024 captured `prefix`'s lifetime and a `String`
78/// prefix could not meet `with_op_resolver`'s `'static` bound.
79pub fn build_rest_op_resolver_with_prefix(
80    prefix: &str,
81    routes: &'static [RouteTransportDescriptor],
82) -> impl Fn(&Request) -> OpAdmission + Send + Sync + use<> {
83    let prefix = mount_prefix::normalize(prefix);
84    move |req: &Request| {
85        let Some(matched) = req.extensions().get::<MatchedPath>() else {
86            return OpAdmission::unresolved();
87        };
88        let Some(path) = mount_prefix::strip(matched.as_str(), &prefix) else {
89            return OpAdmission::unresolved();
90        };
91        let method = req.method().as_str();
92
93        // Linear search, matching `rest_ops_filter`: the slice is not
94        // sorted, and a schema's route count is small enough that
95        // building an index would cost more than it saves.
96        routes
97            .iter()
98            .find(|route| route.method == method && route.path == path)
99            .map_or_else(OpAdmission::unresolved, OpAdmission::from)
100    }
101}