Skip to main content

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}