cratestack_core/rpc.rs
1//! RPC binding wire types.
2//!
3//! Both the server binding (`cratestack-axum::rpc`) and every
4//! generated client (`cratestack-client-rust`, the TS / Dart
5//! generators) agree on these shapes. They live in `cratestack-core`
6//! so clients can depend on a single source of truth without pulling
7//! in axum.
8//!
9//! Server-only helpers (codec-aware encoding, axum response
10//! post-processing, batch frame assembly) stay in
11//! `cratestack-axum::rpc`. This module owns only the wire shapes and
12//! the [`CoolError`] → gRPC-style code mapping.
13
14use serde::{Deserialize, Serialize};
15
16use crate::error::{CoolError, CoolErrorResponse};
17
18/// Mount path for unary RPC calls. The trailing segment is the
19/// percent-decoded op id, e.g. `POST /rpc/model.User.list`.
20pub const RPC_UNARY_PATH: &str = "/rpc/{op_id}";
21
22/// Mount path for batched RPC calls. Body is a codec-encoded sequence
23/// of [`RpcRequest`] frames.
24pub const RPC_BATCH_PATH: &str = "/rpc/batch";
25
26/// Mount path for `@@subscribe` SSE subscriptions
27/// (`docs/design/rpc-transport.md` §3.4a, cratestack#390). The trailing
28/// segment is the percent-decoded op id, e.g.
29/// `GET /rpc/subscribe/model.User.subscribe`. Unlike [`RPC_UNARY_PATH`]
30/// this is `GET`-only and carries no request body — auth is header-based
31/// (same as every other HTTP RPC binding), not an upgrade-time HMAC like
32/// the WS path (§3.4).
33pub const RPC_SUBSCRIBE_PATH: &str = "/rpc/subscribe/{op_id}";
34
35/// CBOR tag number reserved for the mid-stream error sentinel described
36/// in `docs/design/rpc-transport.md` §3.3: when a genuinely incremental
37/// `application/cbor-seq` sequence response (a `@stream` procedure, see
38/// cratestack#282/#283) fails partway through, the *last* item of the
39/// sequence is `Tag(RPC_STREAM_ERROR_TAG, RpcErrorBody-as-CBOR-map)` —
40/// CBOR major type 6, this tag number, wrapping [`RpcErrorBody`] encoded
41/// as a plain CBOR map — in place of what would otherwise be the next
42/// unwrapped `out` item. No further items follow it; end of body comes
43/// immediately after.
44///
45/// Not IANA-registered. Picked from the CBOR tags registry's "First Come
46/// First Served" range (32768–18446744073709551615;
47/// <https://www.iana.org/assignments/cbor-tags/cbor-tags.xhtml>) and
48/// confirmed unassigned as of 2026-08-02 — see cratestack#281 for the
49/// verification method and the collision-risk flag for a pre-merge
50/// human double-check.
51pub const RPC_STREAM_ERROR_TAG: u64 = 48900;
52
53/// Wire shape of a single error returned by an RPC call. Maps from
54/// [`CoolError`] via [`rpc_code`] + [`CoolError::public_message`].
55#[derive(Debug, Clone, Serialize, Deserialize)]
56pub struct RpcErrorBody {
57 /// Stable gRPC-style code: `not_found`, `invalid_argument`,
58 /// `permission_denied`, `failed_precondition`, `conflict`,
59 /// `unauthenticated`, `internal`.
60 pub code: String,
61 /// Public, safe-to-expose message.
62 pub message: String,
63 /// Op-defined structured payload (e.g. validation issues).
64 #[serde(default, skip_serializing_if = "Option::is_none")]
65 pub details: Option<serde_json::Value>,
66}
67
68impl RpcErrorBody {
69 pub fn from_cool(error: &CoolError) -> Self {
70 Self {
71 code: rpc_code(error).to_owned(),
72 message: error.public_message().into_owned(),
73 details: None,
74 }
75 }
76
77 /// Translate a REST-style [`CoolErrorResponse`] into the RPC
78 /// error body. The `code` field is mapped from screaming-snake to
79 /// gRPC-style lowercase via [`cool_error_code_to_rpc_code`];
80 /// `message` and `details` flow through verbatim.
81 pub fn from_cool_response(response: CoolErrorResponse) -> Self {
82 let CoolErrorResponse {
83 code,
84 message,
85 details,
86 } = response;
87 Self {
88 code: cool_error_code_to_rpc_code(&code).to_owned(),
89 message,
90 details: details.map(cool_value_to_json),
91 }
92 }
93}
94
95/// Wire shape of a single batch request frame.
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct RpcRequest {
98 /// Client-chosen correlation id, unique within the batch.
99 pub id: u64,
100 /// Dotted op id, e.g. `"model.User.list"` or
101 /// `"procedure.publishPost"`.
102 pub op: String,
103 /// Codec-encoded input payload, kept opaque at the batch envelope
104 /// layer so each frame can be decoded against its own input type.
105 pub input: serde_json::Value,
106 /// Optional idempotency key, per-frame.
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub idem: Option<String>,
109}
110
111/// Wire shape of a single batch response frame.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct RpcResponseFrame {
114 pub id: u64,
115 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub output: Option<serde_json::Value>,
117 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub error: Option<RpcErrorBody>,
119}
120
121impl RpcResponseFrame {
122 pub fn ok(id: u64, output: serde_json::Value) -> Self {
123 Self {
124 id,
125 output: Some(output),
126 error: None,
127 }
128 }
129
130 pub fn err(id: u64, error: &CoolError) -> Self {
131 Self {
132 id,
133 output: None,
134 error: Some(RpcErrorBody::from_cool(error)),
135 }
136 }
137}
138
139/// Map a [`CoolError`] to its stable RPC code (gRPC-style snake_case).
140pub const fn rpc_code(error: &CoolError) -> &'static str {
141 match error {
142 CoolError::BadRequest(_)
143 | CoolError::NotAcceptable(_)
144 | CoolError::UnsupportedMediaType(_)
145 | CoolError::Codec(_)
146 | CoolError::Validation(_) => "invalid_argument",
147 CoolError::Unauthorized(_) => "unauthenticated",
148 CoolError::Forbidden(_) => "permission_denied",
149 CoolError::NotFound(_) => "not_found",
150 CoolError::Conflict(_) => "conflict",
151 CoolError::PreconditionFailed(_) => "failed_precondition",
152 CoolError::Database(_) | CoolError::DatabaseTyped(_) | CoolError::Internal(_) => "internal",
153 CoolError::Unavailable(_) => "unavailable",
154 }
155}
156
157/// Map a `CoolErrorResponse.code` string (screaming-snake, REST-
158/// binding vocabulary) to the stable gRPC-style code the RPC binding
159/// emits.
160pub fn cool_error_code_to_rpc_code(code: &str) -> &'static str {
161 match code {
162 "BAD_REQUEST"
163 | "NOT_ACCEPTABLE"
164 | "UNSUPPORTED_MEDIA_TYPE"
165 | "VALIDATION_ERROR"
166 | "CODEC_ERROR" => "invalid_argument",
167 "UNAUTHORIZED" => "unauthenticated",
168 "FORBIDDEN" => "permission_denied",
169 "NOT_FOUND" => "not_found",
170 "CONFLICT" => "conflict",
171 "PRECONDITION_FAILED" => "failed_precondition",
172 "DATABASE_ERROR" | "INTERNAL_ERROR" => "internal",
173 "UNAVAILABLE" => "unavailable",
174 _ => "internal",
175 }
176}
177
178fn cool_value_to_json(value: crate::Value) -> serde_json::Value {
179 serde_json::to_value(&value).unwrap_or(serde_json::Value::Null)
180}