cratestack_axum/idempotency/rpc_op_resolver.rs
1//! Op resolver for RPC transport: recover the `OpAdmission` facts the
2//! schema declared about the op named in a `/rpc/{op_id}` path.
3//!
4//! Originally shaped after `crate::ratelimit::rpc_ops_filter`, which
5//! solved the identical lookup for cratestack#474 — same `/rpc/` prefix
6//! strip, same `batch`/`subscribe/` exclusions, same linear search over an
7//! unsorted slice. Since ADR 0015 slice 2 (cratestack#877) that filter is a
8//! projection of this resolver, so the two concerns share one lookup
9//! implementation. See [`build_rest_op_resolver`] for why this module's
10//! fail-closed direction is the inverse of the rate-limit filter's.
11//!
12//! [`build_rest_op_resolver`]: super::build_rest_op_resolver
13//!
14//! # A nested mount needs [`build_rpc_op_resolver_with_prefix`]
15//!
16//! This reads the raw request path, so under `Router::nest("/api", router)`
17//! it sees `/api/rpc/procedure.notify`, fails the `/rpc/` prefix test, and
18//! resolves everything unresolved — safe, but `@no_idempotency` silently
19//! does nothing. Pass the mount point in. See `super::mount_prefix`.
20//!
21//! # `/rpc/batch` is not per-op, and the consequence is benign here
22//!
23//! `POST /rpc/batch` carries a sequence of ops in one body
24//! (`docs/design/rpc-transport.md`). This resolver runs before the body is
25//! decoded, so it cannot see inside — exactly the limitation
26//! `rpc_ops_filter` documents. For rate limiting that means a batch is
27//! always throttled wholesale; here it means a batch always **reserves**,
28//! which is the conservative answer and is also what a batch does today
29//! with no resolver installed at all. An op author who needs
30//! `@no_idempotency` honoured must call it at `/rpc/{op_id}`.
31//!
32//! `/rpc/subscribe/{op_id}` is excluded for the same reason it is
33//! excluded there: it is a framework dispatch point, not an op invocation.
34//! It is also a `GET`, so `is_idempotent_target_method` has already
35//! short-circuited it long before this resolver runs — the exclusion is
36//! belt-and-braces, kept for symmetry with the filter it mirrors.
37
38use axum::extract::Request;
39use cratestack_core::OpDescriptor;
40use cratestack_exec::OpAdmission;
41
42use super::mount_prefix;
43
44/// Build an op resolver for `transport rpc` schemas, over the generated
45/// `OPS` slice, for a router mounted at the root.
46///
47/// Returns [`OpAdmission::unresolved`] — which reserves — for a non-RPC
48/// path, for `/rpc/batch`, for `/rpc/subscribe/...`, and for any op id
49/// absent from `ops`.
50///
51/// **If the router is nested, use [`build_rpc_op_resolver_with_prefix`].**
52pub fn build_rpc_op_resolver(
53 ops: &'static [OpDescriptor],
54) -> impl Fn(&Request) -> OpAdmission + Send + Sync {
55 build_rpc_op_resolver_with_prefix("", ops)
56}
57
58/// [`build_rpc_op_resolver`] for a router mounted under `prefix`, e.g.
59/// `build_rpc_op_resolver_with_prefix("/api", OPS)` to match
60/// `Router::nest("/api", router)`.
61///
62/// Same forgiving-spelling / strict-boundary rules as the REST twin, and
63/// the same `use<>`: the prefix may be a runtime `String`.
64pub fn build_rpc_op_resolver_with_prefix(
65 prefix: &str,
66 ops: &'static [OpDescriptor],
67) -> impl Fn(&Request) -> OpAdmission + Send + Sync + use<> {
68 let prefix = mount_prefix::normalize(prefix);
69 move |req: &Request| {
70 let Some(path) = mount_prefix::strip(req.uri().path(), &prefix) else {
71 return OpAdmission::unresolved();
72 };
73 let Some(op_id) = path.strip_prefix("/rpc/") else {
74 return OpAdmission::unresolved();
75 };
76 if op_id == "batch" || op_id.starts_with("subscribe/") {
77 return OpAdmission::unresolved();
78 }
79
80 ops.iter()
81 .find(|op| op.op_id == op_id)
82 .map_or_else(OpAdmission::unresolved, OpAdmission::from)
83 }
84}