cratestack_client_rust/rpc/client.rs
1use cratestack_codec_cbor::CborCodec;
2use cratestack_core::rpc::{RPC_BATCH_PATH, RpcRequest, RpcResponseFrame};
3use reqwest::Method;
4use serde::Serialize;
5use serde::de::DeserializeOwned;
6
7use crate::client::{CratestackClient, RouteRef};
8use crate::codec::HttpClientCodec;
9use crate::config::ClientConfig;
10use crate::idempotency::RequestIdempotency;
11use crate::rpc::batch::BatchBuilder;
12use crate::rpc::error::{RpcClientError, client_error_to_rpc, decode_rpc_unary_response};
13use crate::streaming::pump_streamed_response_typed;
14
15// `RPC_BATCH_PATH` from core is the axum-template form `"/rpc/batch"`,
16// so we just reuse it. The unary path is templated (`/rpc/{op_id}`) so
17// we format it per call instead of using the constant directly.
18const RPC_BATCH_PATH_PLAIN: &str = RPC_BATCH_PATH;
19
20/// Thin RPC client built on top of the REST client's transport + codec
21/// plumbing.
22///
23/// Shares a `reqwest::Client` and a codec impl with `CratestackClient`,
24/// but speaks the `/rpc/...` URL space instead of REST routes. Both
25/// clients can be used side-by-side against the same server.
26#[derive(Clone)]
27pub struct RpcClient<C = CborCodec> {
28 pub(crate) inner: CratestackClient<C>,
29}
30
31impl RpcClient<CborCodec> {
32 pub fn cbor(config: ClientConfig) -> Self {
33 Self::new(CratestackClient::cbor(config))
34 }
35}
36
37impl<C> RpcClient<C>
38where
39 C: HttpClientCodec + Clone,
40{
41 /// Build an RPC client on top of an existing REST client. The two
42 /// share their `reqwest::Client`, codec, and state store.
43 pub fn new(inner: CratestackClient<C>) -> Self {
44 Self { inner }
45 }
46
47 /// Underlying REST client. Exposed for callers that want REST + RPC
48 /// side-by-side (e.g. a long migration window between the two).
49 pub fn inner(&self) -> &CratestackClient<C> {
50 &self.inner
51 }
52
53 /// RPC half of the per-call idempotency override — forwards to
54 /// [`CratestackClient::with_idempotency`], which documents the whole
55 /// mechanism.
56 ///
57 /// RPC needs this more than REST does: every RPC call is
58 /// `POST /rpc/{op_id}`, so the method-derived default marks
59 /// *everything* non-idempotent, reads included. The per-op truth is
60 /// the `idempotent_by_default` field on the `OpDescriptor` the
61 /// schema macro emits (`cratestack_core::OpDescriptor`):
62 ///
63 /// ```text
64 /// let response = rpc
65 /// .clone()
66 /// .with_idempotency(RequestIdempotency::new(OP.idempotent_by_default))
67 /// .call::<_, Output>(OP.op_id, &input)
68 /// .await?;
69 /// ```
70 ///
71 /// Streamed calls ([`Self::call_streaming`]) carry the extension
72 /// too, but a partially-consumed stream cannot be replayed from the
73 /// middleware layer — leave those at the non-idempotent default
74 /// unless the middleware only retries before the first byte.
75 pub fn with_idempotency(self, idempotency: RequestIdempotency) -> Self {
76 Self {
77 inner: self.inner.with_idempotency(idempotency),
78 }
79 }
80
81 /// Start a new typed batch. Use with [`BatchableCall::queue`] from
82 /// the macro-generated typed methods (or any hand-built
83 /// [`BatchableCall`]) to compose a heterogeneous batch, then
84 /// `batch.send().await` for a single `POST /rpc/batch` round-trip.
85 pub fn batch_builder(&self) -> BatchBuilder<C> {
86 BatchBuilder::new(self.clone())
87 }
88
89 /// POST /rpc/{op_id} — unary call.
90 ///
91 /// `op_id` is the dotted dispatch key the server emits — `model.X.list`
92 /// / `model.X.get` / `model.X.create` / `model.X.update` /
93 /// `model.X.delete` for CRUD verbs and `procedure.<name>` for procedures.
94 pub async fn call<I, O>(&self, op_id: &str, input: &I) -> Result<O, RpcClientError>
95 where
96 I: Serialize,
97 O: DeserializeOwned,
98 {
99 let body = self
100 .inner
101 .codec
102 .encode(input)
103 .map_err(RpcClientError::Codec)?;
104 let path = format!("/rpc/{}", op_id);
105 let response = self
106 .inner
107 .at(RouteRef::rpc(op_id))
108 .request_raw_with_query_and_accept(Method::POST, &path, Some(body), None, &[], None)
109 .await
110 .map_err(client_error_to_rpc)?;
111 decode_rpc_unary_response(&self.inner.codec, &response)
112 }
113
114 /// POST /rpc/batch — sequence of `RpcRequest` frames in, sequence of
115 /// `RpcResponseFrame` frames out. Per-frame errors do not poison the
116 /// batch (each frame's `output` / `error` is reported independently).
117 pub async fn batch(
118 &self,
119 requests: &[RpcRequest],
120 ) -> Result<Vec<RpcResponseFrame>, RpcClientError> {
121 let body = self
122 .inner
123 .codec
124 .encode(&requests)
125 .map_err(RpcClientError::Codec)?;
126 let response = self
127 .inner
128 .at(RouteRef::rpc("batch"))
129 .request_raw_with_query_and_accept(
130 Method::POST,
131 RPC_BATCH_PATH_PLAIN,
132 Some(body),
133 None,
134 &[],
135 None,
136 )
137 .await
138 .map_err(client_error_to_rpc)?;
139 decode_rpc_unary_response::<C, Vec<RpcResponseFrame>>(&self.inner.codec, &response)
140 }
141
142 /// POST /rpc/{op_id} — sequence response, item-at-a-time.
143 ///
144 /// Returns a bounded `mpsc::Receiver` that yields each cbor-seq
145 /// item as bytes arrive over the network — no full-body buffering
146 /// before the first item reaches the caller. Transport / decode
147 /// failures appear as terminal `Err` items on the channel; the
148 /// receiver returning `None` indicates a clean stream close.
149 ///
150 /// Non-2xx responses are buffered and surfaced as a single
151 /// `RpcClientError::Remote(RpcRemoteError { ... })` from this
152 /// function (the channel is never opened) — same shape as the
153 /// unary `call` path. The server must return `application/cbor-seq`
154 /// for streaming; on a buffered `application/cbor` response this
155 /// method will misframe the body.
156 pub async fn call_streaming<I, O>(
157 &self,
158 op_id: &str,
159 input: &I,
160 ) -> Result<tokio::sync::mpsc::Receiver<Result<O, RpcClientError>>, RpcClientError>
161 where
162 I: Serialize,
163 O: DeserializeOwned + Send + 'static,
164 {
165 let body = self
166 .inner
167 .codec
168 .encode(input)
169 .map_err(RpcClientError::Codec)?;
170 let path = format!("/rpc/{}", op_id);
171 let (response, items) = self
172 .inner
173 .at(RouteRef::rpc(op_id))
174 .request_streamed_compact(
175 Method::POST,
176 &path,
177 Some(body),
178 None,
179 &[],
180 self.inner.codec.sequence_accept_header_value(),
181 true,
182 )
183 .await
184 .map_err(client_error_to_rpc)?;
185
186 // Bounded channel — 16 in-flight items matches the REST
187 // `post_list_streamed` shape and keeps consumer memory tight.
188 let (tx, rx) = tokio::sync::mpsc::channel(16);
189 crate::task::spawn(pump_streamed_response_typed::<C, O, RpcClientError, _>(
190 self.inner.codec.clone(),
191 response,
192 tx,
193 client_error_to_rpc,
194 items,
195 ));
196 Ok(rx)
197 }
198}