Skip to main content

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/// CBOR tag number reserved for the mid-stream error sentinel described
27/// in `docs/design/rpc-transport.md` §3.3: when a genuinely incremental
28/// `application/cbor-seq` sequence response (a `@stream` procedure, see
29/// cratestack#282/#283) fails partway through, the *last* item of the
30/// sequence is `Tag(RPC_STREAM_ERROR_TAG, RpcErrorBody-as-CBOR-map)` —
31/// CBOR major type 6, this tag number, wrapping [`RpcErrorBody`] encoded
32/// as a plain CBOR map — in place of what would otherwise be the next
33/// unwrapped `out` item. No further items follow it; end of body comes
34/// immediately after.
35///
36/// Not IANA-registered. Picked from the CBOR tags registry's "First Come
37/// First Served" range (32768–18446744073709551615;
38/// <https://www.iana.org/assignments/cbor-tags/cbor-tags.xhtml>) and
39/// confirmed unassigned as of 2026-08-02 — see cratestack#281 for the
40/// verification method and the collision-risk flag for a pre-merge
41/// human double-check.
42pub const RPC_STREAM_ERROR_TAG: u64 = 48900;
43
44/// Wire shape of a single error returned by an RPC call. Maps from
45/// [`CoolError`] via [`rpc_code`] + [`CoolError::public_message`].
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct RpcErrorBody {
48    /// Stable gRPC-style code: `not_found`, `invalid_argument`,
49    /// `permission_denied`, `failed_precondition`, `conflict`,
50    /// `unauthenticated`, `internal`.
51    pub code: String,
52    /// Public, safe-to-expose message.
53    pub message: String,
54    /// Op-defined structured payload (e.g. validation issues).
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub details: Option<serde_json::Value>,
57}
58
59impl RpcErrorBody {
60    pub fn from_cool(error: &CoolError) -> Self {
61        Self {
62            code: rpc_code(error).to_owned(),
63            message: error.public_message().into_owned(),
64            details: None,
65        }
66    }
67
68    /// Translate a REST-style [`CoolErrorResponse`] into the RPC
69    /// error body. The `code` field is mapped from screaming-snake to
70    /// gRPC-style lowercase via [`cool_error_code_to_rpc_code`];
71    /// `message` and `details` flow through verbatim.
72    pub fn from_cool_response(response: CoolErrorResponse) -> Self {
73        let CoolErrorResponse {
74            code,
75            message,
76            details,
77        } = response;
78        Self {
79            code: cool_error_code_to_rpc_code(&code).to_owned(),
80            message,
81            details: details.map(cool_value_to_json),
82        }
83    }
84}
85
86/// Wire shape of a single batch request frame.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct RpcRequest {
89    /// Client-chosen correlation id, unique within the batch.
90    pub id: u64,
91    /// Dotted op id, e.g. `"model.User.list"` or
92    /// `"procedure.publishPost"`.
93    pub op: String,
94    /// Codec-encoded input payload, kept opaque at the batch envelope
95    /// layer so each frame can be decoded against its own input type.
96    pub input: serde_json::Value,
97    /// Optional idempotency key, per-frame.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub idem: Option<String>,
100}
101
102/// Wire shape of a single batch response frame.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct RpcResponseFrame {
105    pub id: u64,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub output: Option<serde_json::Value>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub error: Option<RpcErrorBody>,
110}
111
112impl RpcResponseFrame {
113    pub fn ok(id: u64, output: serde_json::Value) -> Self {
114        Self {
115            id,
116            output: Some(output),
117            error: None,
118        }
119    }
120
121    pub fn err(id: u64, error: &CoolError) -> Self {
122        Self {
123            id,
124            output: None,
125            error: Some(RpcErrorBody::from_cool(error)),
126        }
127    }
128}
129
130/// Map a [`CoolError`] to its stable RPC code (gRPC-style snake_case).
131pub const fn rpc_code(error: &CoolError) -> &'static str {
132    match error {
133        CoolError::BadRequest(_)
134        | CoolError::NotAcceptable(_)
135        | CoolError::UnsupportedMediaType(_)
136        | CoolError::Codec(_)
137        | CoolError::Validation(_) => "invalid_argument",
138        CoolError::Unauthorized(_) => "unauthenticated",
139        CoolError::Forbidden(_) => "permission_denied",
140        CoolError::NotFound(_) => "not_found",
141        CoolError::Conflict(_) => "conflict",
142        CoolError::PreconditionFailed(_) => "failed_precondition",
143        CoolError::Database(_) | CoolError::DatabaseTyped(_) | CoolError::Internal(_) => "internal",
144    }
145}
146
147/// Map a `CoolErrorResponse.code` string (screaming-snake, REST-
148/// binding vocabulary) to the stable gRPC-style code the RPC binding
149/// emits.
150pub fn cool_error_code_to_rpc_code(code: &str) -> &'static str {
151    match code {
152        "BAD_REQUEST"
153        | "NOT_ACCEPTABLE"
154        | "UNSUPPORTED_MEDIA_TYPE"
155        | "VALIDATION_ERROR"
156        | "CODEC_ERROR" => "invalid_argument",
157        "UNAUTHORIZED" => "unauthenticated",
158        "FORBIDDEN" => "permission_denied",
159        "NOT_FOUND" => "not_found",
160        "CONFLICT" => "conflict",
161        "PRECONDITION_FAILED" => "failed_precondition",
162        "DATABASE_ERROR" | "INTERNAL_ERROR" => "internal",
163        _ => "internal",
164    }
165}
166
167fn cool_value_to_json(value: crate::Value) -> serde_json::Value {
168    serde_json::to_value(&value).unwrap_or(serde_json::Value::Null)
169}