cratestack_axum/envelope_layer/resolver_rpc.rs
1//! [`RpcBindingResolver`]: a `transport rpc` router's three routes.
2
3use cratestack_core::rpc::{RPC_BATCH_PATH, RPC_SUBSCRIBE_PATH, RPC_UNARY_PATH};
4use http::Method;
5
6use super::resolver::{BindingResolver, Resolution, ResolvedRoute, RouteRequest};
7use crate::idempotency::mount_prefix;
8use crate::rpc::RPC_BINDING_CAPABILITIES;
9
10/// RPC: the op id axum decoded from `/rpc/{op_id}`, `batch` for
11/// `/rpc/batch` (decision D11), and `subscribe/<op id>` for a subscription.
12/// The op id and the body digest bind the call, so path parameters are
13/// empty (ADR 0006 ยง4), except under a parameterised mount
14/// (`nest("/t/{tenant}", ..)`), whose values are bound for the same reason
15/// as [`super::RestBindingResolver`]'s.
16///
17/// **Query (for cratestack#1007's client):** an RPC call binds the
18/// request's canonical query like a REST one does. Generated RPC clients
19/// send none, so it is `null`; a client that adds one (a cache-buster, a
20/// gateway's routing parameter) must bind it too, or the call is a `401`.
21///
22/// **The op id is validated, not looked up in `OPS`** (decision B2): the
23/// unary template binds only an op id a generated op can have, one with a
24/// `.` and no `/` that is not `batch`. Anything else (`/rpc/%62atch`, which
25/// axum decodes to `batch`, or `/rpc/a%2Fb`) is [`Resolution::NotAnOp`]:
26/// a COSE body is refused, a plain one passes to the router, whose `404` it
27/// is. A well-formed but unknown op id is bound anyway, so the router's
28/// `404` for it is sealed like any other error. Only `POST` is an op on the
29/// unary and batch routes, only `GET` (and `HEAD`, which axum routes to
30/// it) on the subscription route.
31#[derive(Debug, Clone)]
32pub struct RpcBindingResolver {
33 prefix: String,
34}
35
36impl RpcBindingResolver {
37 /// For a router mounted at `prefix`, as for
38 /// [`super::RestBindingResolver::new`].
39 pub fn new(prefix: &str) -> Self {
40 Self {
41 prefix: mount_prefix::normalize(prefix),
42 }
43 }
44}
45
46/// Every generated op id is dotted (`model.<Model>.<verb>`,
47/// `procedure.<name>`), none contains `/`, and none is `batch`. Checked on
48/// the value axum decoded, so no percent-encoding gets around it.
49fn is_op_id(op_id: &str) -> bool {
50 op_id != "batch" && op_id.contains('.') && !op_id.contains('/')
51}
52
53impl BindingResolver for RpcBindingResolver {
54 fn resolve(&self, request: &RouteRequest<'_>) -> Resolution {
55 let Some(template) = request
56 .matched_path()
57 .and_then(|matched| mount_prefix::strip(matched, &self.prefix))
58 else {
59 return Resolution::Unresolved;
60 };
61 let values = |params: &[(String, String)]| -> Vec<String> {
62 params.iter().map(|(_, value)| value.clone()).collect()
63 };
64 let (expected, route) = if template == RPC_BATCH_PATH {
65 (Method::POST, None)
66 } else if template == RPC_UNARY_PATH {
67 (Method::POST, Some(""))
68 } else if template == RPC_SUBSCRIBE_PATH {
69 (Method::GET, Some("subscribe/"))
70 } else {
71 return Resolution::Unresolved;
72 };
73 // axum answers `HEAD` with the `GET` route, so a `HEAD` to the
74 // subscription route runs its handler: it is that op, bound with
75 // its real method, as in the REST resolver (security finding SF-2
76 // of the second review). As `MethodNotAllowed` it passed unsigned
77 // whenever the policy's `unresolved_mode` was not `Required`.
78 let head_as_get = expected == Method::GET && request.method() == Method::HEAD;
79 if request.method() != expected && !head_as_get {
80 return Resolution::MethodNotAllowed(vec![expected]);
81 }
82 let Some(route_prefix) = route else {
83 return Resolution::Op(ResolvedRoute::new("batch", values(request.path_params())));
84 };
85 // The op id is the last parameter (a parameterised mount's come
86 // first), bound as the router decoded it, which is what it runs.
87 let Some(((_, op_id), mount)) = request.path_params().split_last() else {
88 return Resolution::Unresolved;
89 };
90 if !is_op_id(op_id) {
91 return Resolution::NotAnOp;
92 }
93 Resolution::Op(
94 ResolvedRoute::new(format!("{route_prefix}{op_id}"), values(mount)).with_payload_types(
95 RPC_BINDING_CAPABILITIES.request_types,
96 RPC_BINDING_CAPABILITIES.response_types,
97 ),
98 )
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::is_op_id;
105
106 #[test]
107 fn only_a_generated_shape_of_op_id_is_an_op() {
108 for op in ["procedure.ping", "model.Widget.list"] {
109 assert!(is_op_id(op), "{op}");
110 }
111 for not in ["batch", "ping", "", "a/b.c", "procedure.a/b", "subscribe/x"] {
112 assert!(!is_op_id(not), "{not}");
113 }
114 }
115}