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/// Wire shape of a single error returned by an RPC call. Maps from
27/// [`CoolError`] via [`rpc_code`] + [`CoolError::public_message`].
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct RpcErrorBody {
30    /// Stable gRPC-style code: `not_found`, `invalid_argument`,
31    /// `permission_denied`, `failed_precondition`, `conflict`,
32    /// `unauthenticated`, `internal`.
33    pub code: String,
34    /// Public, safe-to-expose message.
35    pub message: String,
36    /// Op-defined structured payload (e.g. validation issues).
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub details: Option<serde_json::Value>,
39}
40
41impl RpcErrorBody {
42    pub fn from_cool(error: &CoolError) -> Self {
43        Self {
44            code: rpc_code(error).to_owned(),
45            message: error.public_message().into_owned(),
46            details: None,
47        }
48    }
49
50    /// Translate a REST-style [`CoolErrorResponse`] into the RPC
51    /// error body. The `code` field is mapped from screaming-snake to
52    /// gRPC-style lowercase via [`cool_error_code_to_rpc_code`];
53    /// `message` and `details` flow through verbatim.
54    pub fn from_cool_response(response: CoolErrorResponse) -> Self {
55        let CoolErrorResponse {
56            code,
57            message,
58            details,
59        } = response;
60        Self {
61            code: cool_error_code_to_rpc_code(&code).to_owned(),
62            message,
63            details: details.map(cool_value_to_json),
64        }
65    }
66}
67
68/// Wire shape of a single batch request frame.
69#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct RpcRequest {
71    /// Client-chosen correlation id, unique within the batch.
72    pub id: u64,
73    /// Dotted op id, e.g. `"model.User.list"` or
74    /// `"procedure.publishPost"`.
75    pub op: String,
76    /// Codec-encoded input payload, kept opaque at the batch envelope
77    /// layer so each frame can be decoded against its own input type.
78    pub input: serde_json::Value,
79    /// Optional idempotency key, per-frame.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub idem: Option<String>,
82}
83
84/// Wire shape of a single batch response frame.
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct RpcResponseFrame {
87    pub id: u64,
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub output: Option<serde_json::Value>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub error: Option<RpcErrorBody>,
92}
93
94impl RpcResponseFrame {
95    pub fn ok(id: u64, output: serde_json::Value) -> Self {
96        Self {
97            id,
98            output: Some(output),
99            error: None,
100        }
101    }
102
103    pub fn err(id: u64, error: &CoolError) -> Self {
104        Self {
105            id,
106            output: None,
107            error: Some(RpcErrorBody::from_cool(error)),
108        }
109    }
110}
111
112/// Map a [`CoolError`] to its stable RPC code (gRPC-style snake_case).
113pub const fn rpc_code(error: &CoolError) -> &'static str {
114    match error {
115        CoolError::BadRequest(_)
116        | CoolError::NotAcceptable(_)
117        | CoolError::UnsupportedMediaType(_)
118        | CoolError::Codec(_)
119        | CoolError::Validation(_) => "invalid_argument",
120        CoolError::Unauthorized(_) => "unauthenticated",
121        CoolError::Forbidden(_) => "permission_denied",
122        CoolError::NotFound(_) => "not_found",
123        CoolError::Conflict(_) => "conflict",
124        CoolError::PreconditionFailed(_) => "failed_precondition",
125        CoolError::Database(_) | CoolError::DatabaseTyped(_) | CoolError::Internal(_) => "internal",
126    }
127}
128
129/// Map a `CoolErrorResponse.code` string (screaming-snake, REST-
130/// binding vocabulary) to the stable gRPC-style code the RPC binding
131/// emits.
132pub fn cool_error_code_to_rpc_code(code: &str) -> &'static str {
133    match code {
134        "BAD_REQUEST"
135        | "NOT_ACCEPTABLE"
136        | "UNSUPPORTED_MEDIA_TYPE"
137        | "VALIDATION_ERROR"
138        | "CODEC_ERROR" => "invalid_argument",
139        "UNAUTHORIZED" => "unauthenticated",
140        "FORBIDDEN" => "permission_denied",
141        "NOT_FOUND" => "not_found",
142        "CONFLICT" => "conflict",
143        "PRECONDITION_FAILED" => "failed_precondition",
144        "DATABASE_ERROR" | "INTERNAL_ERROR" => "internal",
145        _ => "internal",
146    }
147}
148
149fn cool_value_to_json(value: crate::Value) -> serde_json::Value {
150    serde_json::to_value(&value).unwrap_or(serde_json::Value::Null)
151}