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