xmtp_api_grpc/grpc_client/
client.rs1use crate::{
7 error::{GrpcBuilderError, GrpcError},
8 streams::EscapableTonicStream,
9};
10use futures::Stream;
11use http::{request, uri::PathAndQuery};
12use pin_project::pin_project;
13use prost::bytes::Bytes;
14use std::{
15 pin::Pin,
16 sync::Arc,
17 task::{Context, Poll, ready},
18};
19use tonic::{
20 Status,
21 client::Grpc,
22 metadata::{self, MetadataMap, MetadataValue},
23};
24use url::Url;
25use xmtp_common::Retry;
26use xmtp_configuration::GRPC_PAYLOAD_LIMIT;
27use xmtp_proto::{
28 api::{ApiClientError, BytesStream, Client, IsConnectedCheck},
29 api_client::{ApiBuilder, NetConnectConfig},
30 codec::TransparentCodec,
31 types::AppVersion,
32};
33
34impl From<GrpcError> for ApiClientError {
35 fn from(source: GrpcError) -> ApiClientError {
36 ApiClientError::client(source)
37 }
38}
39
40trait ToHttp {
42 type Body;
43 fn to_http(self) -> http::Response<Self::Body>;
44}
45
46impl<T> ToHttp for tonic::Response<T> {
48 type Body = T;
49
50 fn to_http(self) -> http::Response<Self::Body> {
51 let (metadata, body, extensions) = self.into_parts();
52 let mut response = http::Response::new(body);
53 if cfg!(target_arch = "wasm32") {
54 *response.version_mut() = http::version::Version::HTTP_11;
55 } else {
56 *response.version_mut() = http::version::Version::HTTP_2;
57 }
58 *response.headers_mut() = metadata.into_headers();
59 *response.extensions_mut() = extensions;
60 response
61 }
62}
63
64#[derive(Clone, Debug)]
65pub struct GrpcClient {
66 inner: tonic::client::Grpc<crate::GrpcService>,
67 host: Arc<str>,
70 app_version: MetadataValue<metadata::Ascii>,
71 libxmtp_version: MetadataValue<metadata::Ascii>,
72}
73
74impl GrpcClient {
75 fn build_tonic_request<B>(
82 &self,
83 request: http::request::Builder,
84 body: B,
85 ) -> Result<tonic::Request<B>, Status> {
86 let request = request
87 .body(body)
88 .map_err(|e| tonic::Status::from_error(Box::new(e)))?;
89 let (mut parts, body) = request.into_parts();
90 xmtp_logging::propagation::inject(&tracing::Span::current(), &mut parts.headers);
91 let mut tonic_request = tonic::Request::from_parts(
92 MetadataMap::from_headers(parts.headers),
93 parts.extensions,
94 body,
95 );
96 let metadata = tonic_request.metadata_mut();
97 metadata.append("x-app-version", self.app_version.clone());
99 metadata.append("x-libxmtp-version", self.libxmtp_version.clone());
100 Ok(tonic_request)
101 }
102
103 async fn wait_for_ready(&self, client: &mut Grpc<crate::GrpcService>) -> Result<(), Status> {
104 client.ready().await.map_err(|e| {
105 tonic::Status::new(tonic::Code::Unknown, format!("Service was not ready: {e}"))
106 })?;
107 Ok(())
108 }
109}
110
111#[pin_project]
115pub struct GrpcStream {
117 #[pin]
118 inner: crate::streams::NonBlocking,
119}
120
121impl From<crate::streams::NonBlocking> for GrpcStream {
122 fn from(value: crate::streams::NonBlocking) -> GrpcStream {
123 GrpcStream { inner: value }
124 }
125}
126
127impl Stream for GrpcStream {
131 type Item = Result<Bytes, ApiClientError>;
132
133 fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
134 let this = self.project();
135 let item = ready!(this.inner.poll_next(cx));
136 Poll::Ready(item.map(|i| i.map_err(|e| ApiClientError::client(GrpcError::from(e)))))
137 }
138}
139
140#[xmtp_common::async_trait]
141impl Client for GrpcClient {
142 fn host(&self) -> &str {
143 &self.host
144 }
145
146 #[tracing::instrument(err, skip_all, fields(operation = "rpc.grpc.request", otel.kind = "client", otel.name = "rpc.grpc.request", path = %path))]
149 async fn request(
150 &self,
151 request: http::request::Builder,
152 path: http::uri::PathAndQuery,
153 body: Bytes,
154 ) -> Result<http::Response<Bytes>, ApiClientError> {
155 let client = &mut self.inner.clone();
156 self.wait_for_ready(client).await.map_err(GrpcError::from)?;
157 let request = self
158 .build_tonic_request(request, body)
159 .map_err(GrpcError::from)?;
160 let codec = TransparentCodec::default();
161 let response = client
162 .unary(request, path, codec)
163 .await
164 .map_err(GrpcError::from)?;
165
166 Ok(response.to_http())
167 }
168
169 #[tracing::instrument(err, skip_all, fields(operation = "rpc.grpc.stream", otel.kind = "client", otel.name = "rpc.grpc.stream", path = %path))]
172 async fn stream(
173 &self,
174 request: request::Builder,
175 path: PathAndQuery,
176 body: Bytes,
177 ) -> Result<http::Response<BytesStream>, ApiClientError> {
178 let this = self.clone();
179 let response = async move {
181 let mut client = this.inner.clone();
182 this.wait_for_ready(&mut client).await?;
183 let request = this.build_tonic_request(request, body)?;
184 let codec = TransparentCodec::default();
185 client.server_streaming(request, path, codec).await
186 };
187 let req = crate::streams::NonBlockingStreamRequest::new(
188 Box::pin(response) as crate::streams::ResponseFuture
189 );
190 let response = crate::streams::send(req).await.map_err(GrpcError::from)?;
191 let response = response.map(|body| {
192 BytesStream::new(GrpcStream {
193 inner: EscapableTonicStream::new(body),
194 })
195 });
196 Ok(response.to_http().map(Into::into))
197 }
198
199 #[cfg(not(target_arch = "wasm32"))]
202 #[tracing::instrument(err, skip_all, fields(operation = "rpc.grpc.bidi_stream", otel.kind = "client", otel.name = "rpc.grpc.bidi_stream", path = %path))]
203 async fn bidi_stream(
204 &self,
205 request: request::Builder,
206 path: PathAndQuery,
207 body: xmtp_common::BoxDynStream<'static, Bytes>,
208 ) -> Result<http::Response<BytesStream>, ApiClientError> {
209 let this = self.clone();
210 let response = async move {
212 let mut client = this.inner.clone();
213 this.wait_for_ready(&mut client).await?;
214 let request = this.build_tonic_request(request, body)?;
215 let codec = TransparentCodec::default();
216 client.streaming(request, path, codec).await
217 };
218 let req = crate::streams::NonBlockingStreamRequest::new(
219 Box::pin(response) as crate::streams::ResponseFuture
220 );
221 let response = crate::streams::send(req).await.map_err(GrpcError::from)?;
222 let response = response.map(|body| {
223 BytesStream::new(GrpcStream {
224 inner: EscapableTonicStream::new(body),
225 })
226 });
227 Ok(response.to_http().map(Into::into))
228 }
229}
230
231#[xmtp_common::async_trait]
232impl IsConnectedCheck for GrpcClient {
233 async fn is_connected(&self) -> bool {
234 self.inner.clone().ready().await.is_ok()
235 }
236}
237
238impl GrpcClient {
239 pub fn builder() -> ClientBuilder {
240 ClientBuilder::default()
241 }
242}
243
244#[derive(Default, Clone)]
245pub struct ClientBuilder {
246 pub host: Option<Url>,
247 pub app_version: Option<MetadataValue<metadata::Ascii>>,
249 pub libxmtp_version: Option<MetadataValue<metadata::Ascii>>,
251 pub limit: Option<u64>,
253 pub retry: Option<Retry>,
255}
256
257impl NetConnectConfig for ClientBuilder {
258 fn set_libxmtp_version(&mut self, version: String) -> Result<(), Self::Error> {
259 self.libxmtp_version = Some(MetadataValue::try_from(&version)?);
260 Ok(())
261 }
262
263 fn set_app_version(&mut self, version: AppVersion) -> Result<(), Self::Error> {
264 self.app_version = Some(MetadataValue::try_from(&version)?);
265 Ok(())
266 }
267
268 fn set_host(&mut self, host: Url) {
269 self.host = Some(host);
270 }
271
272 fn rate_per_minute(&mut self, limit: u32) {
273 self.limit = Some(limit.into());
274 }
275
276 fn port(&self) -> Result<Option<String>, Self::Error> {
277 if let Some(h) = &self.host {
278 Ok(h.port().map(|u| u.to_string()))
279 } else {
280 Err(GrpcBuilderError::MissingHostUrl)
281 }
282 }
283
284 fn host(&self) -> Option<&str> {
285 self.host.as_ref().map(|s| s.as_str())
286 }
287
288 fn set_retry(&mut self, retry: xmtp_common::Retry) {
289 self.retry = Some(retry);
290 }
291}
292
293impl ApiBuilder for ClientBuilder {
294 type Output = crate::GrpcClient;
295 type Error = GrpcBuilderError;
296
297 fn build(self) -> Result<Self::Output, Self::Error> {
298 let host = self.host.ok_or(GrpcBuilderError::MissingHostUrl)?;
299 let host_str: Arc<str> = host.as_str().into();
300 let channel = crate::GrpcService::new(host, self.limit)?;
301 Ok(GrpcClient {
302 inner: tonic::client::Grpc::new(channel)
303 .max_decoding_message_size(GRPC_PAYLOAD_LIMIT)
304 .max_encoding_message_size(GRPC_PAYLOAD_LIMIT),
305 host: host_str,
306 app_version: self
307 .app_version
308 .unwrap_or(MetadataValue::try_from("0.0.0")?),
309 libxmtp_version: self.libxmtp_version.unwrap_or(MetadataValue::try_from(
310 env!("CARGO_PKG_VERSION").to_string(),
311 )?),
312 })
313 }
314}
315
316impl GrpcClient {
317 pub fn create(host: Url) -> Result<Self, GrpcBuilderError> {
318 let mut builder = Self::builder();
319 builder.set_host(host);
320 builder.build()
321 }
322
323 pub fn create_with_version(
325 host: Url,
326 app_version: AppVersion,
327 ) -> Result<Self, GrpcBuilderError> {
328 let mut builder = Self::builder();
329 builder.set_host(host);
330 builder.set_app_version(app_version)?;
331 builder.build()
332 }
333}
334
335#[cfg(test)]
336pub mod tests {
337 use crate::grpc_client::test::BackendTestClient;
338 use prost::Message;
339 use xmtp_proto::api_client::ApiBuilder;
340 use xmtp_proto::backend_v1::PublishRequest;
341 use xmtp_proto::prelude::{NetConnectConfig, XmtpTestClient};
342 use xmtp_proto::types::AppVersion;
343
344 #[xmtp_common::test(unwrap_try = true)]
345 async fn metadata_test() {
346 let mut client = BackendTestClient::create();
347 let app_version = AppVersion::from("test/1.0.0");
348 let libxmtp_version = "0.0.1".to_string();
349 client.set_app_version(app_version.clone())?;
350 client.set_libxmtp_version(libxmtp_version.clone())?;
351 let client = client.build()?;
352 let check = |enabled: bool| {
353 let span = tracing::info_span!(
354 "client",
355 operation = "rpc.grpc.request",
356 otel.kind = "client",
357 otel.name = "rpc.grpc.request"
358 );
359 let _entered = span.enter();
360 let mut expected = http::HeaderMap::new();
361 xmtp_logging::propagation::inject(&span, &mut expected);
362 let request = client
363 .build_tonic_request(
364 Default::default(),
365 prost::bytes::Bytes::from(PublishRequest { envelopes: vec![] }.encode_to_vec()),
366 )
367 .unwrap();
368 let headers = request.metadata();
369 assert_eq!(
370 headers.get("x-app-version").unwrap().to_str().unwrap(),
371 app_version.to_string()
372 );
373 assert_eq!(
374 headers.get("x-libxmtp-version").unwrap().to_str().unwrap(),
375 libxmtp_version
376 );
377 assert_eq!(headers.get("traceparent").is_some(), enabled);
378 assert_eq!(
379 headers.get("traceparent").map(|v| v.to_str().unwrap()),
380 expected.get("traceparent").map(|v| v.to_str().unwrap())
381 );
382 };
383 xmtp_common::if_native! { @
384 for enabled in [false, true] {
385 xmtp_logging::test_logging::with_trace_layer(enabled, || check(enabled));
386 }
387 }
388 xmtp_common::if_wasm! { @ check(false); }
389 }
390}
391
392xmtp_common::if_native! {
393 #[cfg(test)]
394 mod native_tests;
395}