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