1mod queries;
2mod subscriptions;
3#[cfg(feature = "testing")]
4mod testing;
5mod transactions;
6
7use std::{
8 fmt::Debug,
9 future::Future,
10 net::{IpAddr, SocketAddr},
11 time::Duration,
12};
13
14use cynic::GraphQlResponse;
15use eventsource_client::{Client, ReconnectOptionsBuilder, SSE};
16use futures::{StreamExt, TryFutureExt, TryStreamExt};
17use launchdarkly_sdk_transport::{ByteStream, HttpTransport, ResponseFuture, TransportError};
18use reqwest::redirect::Policy as RedirectPolicy;
19#[cfg(feature = "testing")]
20pub use testing::{
21 BlokliTestClient, BlokliTestState, BlokliTestStateMutator, BlokliTestStateSnapshot, NopStateMutator,
22};
23
24use crate::{
25 api::VERSION,
26 errors::{BlokliClientError, ErrorKind},
27};
28
29const MIN_RECONNECTION_DELAY: Duration = Duration::from_millis(1);
30
31pub const SCHEMA_VERSION: u32 = 1;
33
34#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct BlokliDnsOverride {
41 pub ip: IpAddr,
43 pub port: Option<u16>,
48}
49
50#[derive(Clone, Debug, PartialEq, Eq, smart_default::SmartDefault)]
56pub struct BlokliClientConfig {
57 #[default(Duration::from_secs(10))]
59 pub timeout: Duration,
60 #[default(Duration::from_secs(30))]
62 pub stream_reconnect_timeout: Duration,
63 #[default(Some(Duration::from_secs(60)))]
67 pub subscription_read_timeout: Option<Duration>,
68 #[default(Duration::from_secs(15))]
70 pub subscription_tcp_keepalive: Duration,
71 #[default(Some(Duration::from_secs(1)))]
75 pub subscription_stream_restart_delay: Option<Duration>,
76 pub dns_override: Option<BlokliDnsOverride>,
81}
82
83struct SubscriptionStreamState {
85 graphql_url: url::Url,
86 query: String,
87 cfg: BlokliClientConfig,
88 reqwest_client: reqwest::Client,
89 stream: Option<eventsource_client::BoxStream<eventsource_client::Result<SSE>>>,
90}
91
92impl SubscriptionStreamState {
93 fn new(
94 graphql_url: url::Url,
95 query: String,
96 config: BlokliClientConfig,
97 reqwest_client: reqwest::Client,
98 ) -> Result<Self, BlokliClientError> {
99 let mut instance = Self {
100 graphql_url,
101 query,
102 cfg: config,
103 reqwest_client,
104 stream: None,
105 };
106
107 instance.start_stream()?;
108
109 Ok(instance)
110 }
111
112 fn start_stream(&mut self) -> Result<(), BlokliClientError> {
113 let sse_err = |e| ErrorKind::Subscription(Box::new(e));
114 let initial_reconnect_delay = self
115 .cfg
116 .stream_reconnect_timeout
117 .min(Duration::from_secs(2))
118 .max(MIN_RECONNECTION_DELAY);
119 let client = eventsource_client::ClientBuilder::for_url(self.graphql_url.as_str())
120 .map_err(sse_err)?
121 .header("Accept", "text/event-stream")
122 .map_err(sse_err)?
123 .header("Content-Type", "application/json")
124 .map_err(sse_err)?
125 .header("X-Blokli-Schema-Version", &SCHEMA_VERSION.to_string())
126 .map_err(sse_err)?
127 .method("POST".into())
128 .body(self.query.clone())
129 .redirect_limit(REDIRECT_LIMIT as u32)
130 .reconnect(
131 ReconnectOptionsBuilder::new(true)
132 .retry_initial(true)
133 .delay(initial_reconnect_delay)
134 .backoff_factor(2)
135 .delay_max(self.cfg.stream_reconnect_timeout)
136 .build(),
137 )
138 .build_with_transport(ReqwestTransport::new(self.reqwest_client.clone()));
139
140 self.stream = Some(client.stream());
141 Ok(())
142 }
143}
144
145#[derive(Clone, Debug)]
146pub struct ReqwestTransport {
147 client: reqwest::Client,
148}
149
150impl ReqwestTransport {
151 pub fn new(client: reqwest::Client) -> Self {
152 Self { client }
153 }
154}
155
156impl HttpTransport for ReqwestTransport {
157 fn request(&self, request: http::Request<Option<bytes::Bytes>>) -> ResponseFuture {
158 let client = self.client.clone();
159 Box::pin(async move {
160 let (parts, body) = request.into_parts();
161 let request = http::Request::from_parts(parts, body.map(reqwest::Body::from).unwrap_or_default());
162 let request = reqwest::Request::try_from(request).map_err(TransportError::new)?;
163 let response = client.execute(request).await.map_err(TransportError::new)?;
164
165 let status = response.status();
166 let version = response.version();
167 let headers = response.headers().clone();
168 let body: ByteStream = Box::pin(response.bytes_stream().map_err(TransportError::new));
169
170 let mut response_builder = http::Response::builder().status(status).version(version);
171 if let Some(response_headers) = response_builder.headers_mut() {
172 *response_headers = headers;
173 }
174
175 response_builder.body(body).map_err(TransportError::new)
176 })
177 }
178}
179
180#[derive(Clone, Debug)]
187pub struct BlokliClient {
188 base_url: url::Url,
189 cfg: BlokliClientConfig,
190}
191
192const REDIRECT_LIMIT: usize = 3;
193
194pub struct GraphQlQueries;
196
197impl BlokliClient {
198 pub fn new(base_url: url::Url, cfg: BlokliClientConfig) -> Self {
200 Self { base_url, cfg }
201 }
202
203 pub fn base_url(&self) -> &url::Url {
205 &self.base_url
206 }
207
208 pub fn config(&self) -> &BlokliClientConfig {
210 &self.cfg
211 }
212
213 fn graphql_url(&self) -> Result<url::Url, BlokliClientError> {
214 let mut base = self.base_url.clone();
215 if !base.path().ends_with('/') {
216 base.set_path(&format!("{}/", base.path()));
217 }
218 Ok(base.join("graphql").map_err(ErrorKind::from)?)
219 }
220
221 fn apply_dns_override(&self, builder: reqwest::ClientBuilder) -> Result<reqwest::ClientBuilder, BlokliClientError> {
222 let Some(dns_override) = &self.cfg.dns_override else {
223 return Ok(builder);
224 };
225 let host = self
226 .base_url
227 .host_str()
228 .ok_or(ErrorKind::InvalidInput("DNS override requires a Blokli base URL host"))?;
229 let port = dns_override
230 .port
231 .or_else(|| self.base_url.port_or_known_default())
232 .ok_or(ErrorKind::InvalidInput(
233 "DNS override requires a Blokli base URL port or known default",
234 ))?;
235 let addr = SocketAddr::new(dns_override.ip, port);
236
237 Ok(builder.resolve(host, addr))
238 }
239
240 fn build_reqwest_client(&self) -> Result<reqwest::Client, BlokliClientError> {
241 let client_builder = reqwest::Client::builder()
242 .timeout(self.cfg.timeout)
243 .brotli(true)
244 .gzip(true)
245 .zstd(true)
246 .deflate(true)
247 .user_agent(format!("blokli-client/{}-{}", env!("CARGO_PKG_VERSION"), VERSION))
248 .redirect(RedirectPolicy::limited(REDIRECT_LIMIT));
249
250 Ok(self
251 .apply_dns_override(client_builder)?
252 .build()
253 .map_err(ErrorKind::from)?)
254 }
255
256 fn build_subscription_reqwest_client(&self) -> Result<reqwest::Client, BlokliClientError> {
257 let mut client_builder = reqwest::Client::builder()
258 .connect_timeout(self.cfg.timeout)
259 .tcp_keepalive(self.cfg.subscription_tcp_keepalive)
260 .brotli(true)
261 .gzip(true)
262 .zstd(true)
263 .deflate(true)
264 .user_agent(format!("blokli-client/{}-{}", env!("CARGO_PKG_VERSION"), VERSION))
265 .redirect(RedirectPolicy::limited(REDIRECT_LIMIT));
266
267 if let Some(read_timeout) = self.cfg.subscription_read_timeout {
268 client_builder = client_builder.read_timeout(read_timeout);
269 }
270
271 Ok(self
272 .apply_dns_override(client_builder)?
273 .build()
274 .map_err(ErrorKind::from)?)
275 }
276
277 fn build_subscription_stream<Q, V>(
278 &self,
279 op: cynic::StreamingOperation<Q, V>,
280 ) -> Result<impl futures::Stream<Item = Result<Q, BlokliClientError>> + Send + 'static, BlokliClientError>
281 where
282 Q: cynic::QueryFragment + cynic::serde::de::DeserializeOwned + 'static,
283 V: cynic::QueryVariables + cynic::serde::Serialize,
284 {
285 let query = serde_json::to_string(&op).map_err(ErrorKind::from)?;
286 tracing::debug!(query, "sending SSE query");
287 let graphql_url = self.graphql_url()?;
288 let reqwest_client = self.build_subscription_reqwest_client()?;
289
290 struct PendingSubscriptionState {
291 graphql_url: url::Url,
292 query: String,
293 cfg: BlokliClientConfig,
294 reqwest_client: reqwest::Client,
295 }
296
297 enum SubscriptionState {
298 Pending(Box<PendingSubscriptionState>),
299 Active(Box<SubscriptionStreamState>),
300 }
301
302 Ok(futures::stream::try_unfold(
303 SubscriptionState::Pending(Box::new(PendingSubscriptionState {
304 graphql_url,
305 query,
306 cfg: self.cfg.clone(),
307 reqwest_client,
308 })),
309 move |state| async move {
310 let mut state = state;
311
312 loop {
313 if let SubscriptionState::Pending(pending_state) = state {
314 state = SubscriptionState::Active(Box::new(SubscriptionStreamState::new(
315 pending_state.graphql_url,
316 pending_state.query,
317 pending_state.cfg,
318 pending_state.reqwest_client,
319 )?));
320 continue;
321 }
322
323 let SubscriptionState::Active(mut stream_state) = state else {
324 unreachable!("subscription state must be active");
325 };
326
327 if let Some(stream) = &mut stream_state.stream {
328 match stream.next().await {
329 Some(Ok(SSE::Event(event))) => {
330 tracing::debug!(?event, "SSE event");
331 let response = serde_json::from_str::<GraphQlResponse<Q>>(&event.data)
332 .map_err(BlokliClientError::from)
333 .and_then(response_to_data)?;
334 return Ok(Some((response, SubscriptionState::Active(stream_state))));
335 }
336 Some(Ok(SSE::Comment(comment))) => {
337 tracing::debug!(comment, "SSE comment");
338 state = SubscriptionState::Active(stream_state);
339 }
340 Some(Ok(SSE::Connected(details))) => {
341 tracing::debug!(?details, "SSE connection details");
342 state = SubscriptionState::Active(stream_state);
343 }
344 Some(Err(error)) => {
345 tracing::warn!(%error, "SSE transport issue detected, continuing subscription");
346 state = SubscriptionState::Active(stream_state);
347 }
348 None => {
349 if let Some(delay) = stream_state.cfg.subscription_stream_restart_delay {
350 let actual_delay = delay.max(MIN_RECONNECTION_DELAY);
351 tracing::warn!(
352 ?actual_delay,
353 "SSE stream ended, sleeping before attempting to restart"
354 );
355 futures_time::task::sleep(actual_delay.into()).await;
356 state = SubscriptionState::Pending(Box::new(PendingSubscriptionState {
357 graphql_url: stream_state.graphql_url,
358 query: stream_state.query,
359 cfg: stream_state.cfg,
360 reqwest_client: stream_state.reqwest_client,
361 }));
362 } else {
363 tracing::warn!(
364 "SSE stream ended and no restart delay configured, stopping subscription"
365 );
366 return Ok(None);
367 }
368 }
369 }
370 } else {
371 tracing::warn!("SSE stream missing, stopping subscription");
372 return Ok(None);
373 }
374 }
375 },
376 )
377 .boxed())
378 }
379
380 fn build_raw_operation<Q, V>(
381 &self,
382 op: cynic::Operation<Q, V>,
383 ) -> Result<impl Future<Output = Result<GraphQlResponse<Q>, BlokliClientError>>, BlokliClientError>
384 where
385 Q: cynic::QueryFragment + cynic::serde::de::DeserializeOwned + Debug + 'static,
386 V: cynic::QueryVariables + cynic::serde::Serialize,
387 {
388 let client = self.build_reqwest_client()?;
389 tracing::debug!(query = ?serde_json::to_string(&op), "sending Blokli query");
390
391 Ok(client
392 .post(self.graphql_url()?)
393 .header("Accept", "application/json")
394 .header("X-Blokli-Schema-Version", SCHEMA_VERSION.to_string())
395 .json(&op)
396 .send()
397 .map_err(BlokliClientError::from)
398 .and_then(|resp| async {
399 let body = resp.bytes().await.map_err(BlokliClientError::from)?;
400 tracing::trace!(body = %String::from_utf8_lossy(body.as_ref()), "received Blokli response");
401 serde_json::from_slice(&body).map_err(BlokliClientError::from)
402 })
403 .inspect_ok(|resp| tracing::debug!(?resp, "decoded Blokli response")))
404 }
405
406 fn build_query<Q, V>(
407 &self,
408 op: cynic::Operation<Q, V>,
409 ) -> Result<impl Future<Output = Result<GraphQlResponse<Q>, BlokliClientError>>, BlokliClientError>
410 where
411 Q: cynic::QueryFragment + cynic::serde::de::DeserializeOwned + Debug + 'static,
412 V: cynic::QueryVariables + cynic::serde::Serialize,
413 {
414 self.build_raw_operation(op)
415 }
416}
417
418pub(crate) fn response_to_data<Q>(response: GraphQlResponse<Q>) -> crate::api::Result<Q> {
419 match (response.data, response.errors) {
420 (Some(data), None) => Ok(data),
421 (Some(data), Some(errors)) => {
422 tracing::error!(?errors, "operation succeeded but errors were encountered");
423 Ok(data)
424 }
425 (None, Some(errors)) => Err(errors
426 .into_iter()
427 .reduce(|mut acc, next_err| {
428 acc.message += &format!("{}{}", if acc.message.is_empty() { "" } else { ", " }, next_err.message,);
429
430 if let Some(next_locs) = next_err.locations {
431 acc.locations.get_or_insert_default().extend(next_locs);
432 }
433
434 if let Some(next_paths) = next_err.path {
435 acc.path.get_or_insert_default().extend(next_paths);
436 }
437
438 acc
439 })
440 .map(ErrorKind::GraphQLError)
441 .unwrap_or(ErrorKind::NoData)
442 .into()),
443 (None, None) => Err(ErrorKind::NoData.into()),
444 }
445}
446
447#[cfg(test)]
448mod tests {
449 use cynic::GraphQlResponse;
450 use futures::TryStreamExt;
451 use launchdarkly_sdk_transport::HttpTransport;
452 use mockito::{Matcher, Server};
453 use serde_json::json;
454
455 use super::{BlokliClient, BlokliClientConfig, ReqwestTransport, SCHEMA_VERSION, response_to_data};
456 use crate::{
457 api::{BlokliQueryClient, BlokliTransactionClient},
458 errors::ErrorKind,
459 };
460
461 #[tokio::test]
462 async fn reqwest_transport_returns_streaming_response_body() {
463 let mut server = mockito::Server::new_async().await;
464 let _mock = server
465 .mock("GET", "/sse")
466 .with_status(200)
467 .with_header("Content-Type", "text/event-stream")
468 .with_body("data: hello\n\n")
469 .create_async()
470 .await;
471
472 let transport = ReqwestTransport::new(reqwest::Client::new());
473 let request = launchdarkly_sdk_transport::Request::builder()
474 .method("GET")
475 .uri(format!("{}/sse", server.url()))
476 .body(None)
477 .expect("failed to build request");
478
479 let response = transport.request(request).await.expect("request should succeed");
480 let body_chunks: Vec<bytes::Bytes> = response
481 .into_body()
482 .try_collect()
483 .await
484 .expect("failed to collect response body");
485
486 let body = body_chunks.into_iter().fold(Vec::new(), |mut acc, chunk| {
487 acc.extend_from_slice(&chunk);
488 acc
489 });
490 assert_eq!(body, b"data: hello\n\n");
491 }
492
493 #[tokio::test]
494 async fn reqwest_transport_returns_error_for_invalid_request_uri() {
495 let transport = ReqwestTransport::new(reqwest::Client::new());
496 let request = launchdarkly_sdk_transport::Request::builder()
497 .method("GET")
498 .uri("/relative-only")
499 .body(None)
500 .expect("failed to build request");
501
502 let error = match transport.request(request).await {
503 Ok(_) => panic!("relative URI should fail"),
504 Err(error) => error,
505 };
506
507 assert!(error.to_string().contains("builder error"));
508 }
509
510 #[test]
511 fn graphql_url_appends_graphql_when_base_url_has_no_trailing_slash() {
512 let client = BlokliClient::new(
513 url::Url::parse("http://example.com/api").expect("valid URL"),
514 BlokliClientConfig::default(),
515 );
516
517 let graphql_url = client.graphql_url().expect("graphql URL should be derived");
518
519 assert_eq!(graphql_url.as_str(), "http://example.com/api/graphql");
520 }
521
522 #[test]
523 fn graphql_url_preserves_trailing_slash() {
524 let client = BlokliClient::new(
525 url::Url::parse("http://example.com/api/").expect("valid URL"),
526 BlokliClientConfig::default(),
527 );
528
529 let graphql_url = client.graphql_url().expect("graphql URL should be derived");
530
531 assert_eq!(graphql_url.as_str(), "http://example.com/api/graphql");
532 }
533
534 #[test]
535 fn response_to_data_returns_data_even_when_graphql_errors_are_present() {
536 let response: GraphQlResponse<serde_json::Value> = serde_json::from_value(json!({
537 "data": {
538 "version": "1.2.3"
539 },
540 "errors": [
541 {
542 "message": "partial failure"
543 }
544 ]
545 }))
546 .expect("response should deserialize");
547
548 let data = response_to_data(response).expect("data should still be returned");
549
550 assert_eq!(data["version"], "1.2.3");
551 }
552
553 #[test]
554 fn response_to_data_merges_graphql_errors_when_data_is_missing() {
555 let response: GraphQlResponse<serde_json::Value> = serde_json::from_value(json!({
556 "errors": [
557 {
558 "message": "first problem",
559 "locations": [{ "line": 1, "column": 2 }],
560 "path": ["query", "fieldA"]
561 },
562 {
563 "message": "second problem",
564 "locations": [{ "line": 3, "column": 4 }],
565 "path": ["query", "fieldB"]
566 }
567 ]
568 }))
569 .expect("response should deserialize");
570
571 let error = response_to_data(response).expect_err("missing data should fail");
572
573 match error.kind() {
574 ErrorKind::GraphQLError(graphql_error) => {
575 assert_eq!(graphql_error.message, "first problem, second problem");
576 assert_eq!(graphql_error.locations.as_ref().map(Vec::len), Some(2));
577 assert_eq!(graphql_error.path.as_ref().map(Vec::len), Some(4));
578 }
579 other => panic!("expected GraphQLError, got {other:?}"),
580 }
581 }
582
583 #[test]
584 fn response_to_data_returns_no_data_error_when_response_is_empty() {
585 let response = GraphQlResponse::<serde_json::Value> {
586 data: None,
587 errors: None,
588 };
589
590 let error = response_to_data(response).expect_err("empty response should fail");
591
592 assert!(matches!(error.kind(), ErrorKind::NoData));
593 }
594
595 #[tokio::test]
596 async fn requests_include_schema_version_header() {
597 let mut server = Server::new_async().await;
598 let client = BlokliClient::new(server.url().parse().expect("valid URL"), BlokliClientConfig::default());
599
600 let _mock = server
601 .mock("POST", "/graphql")
602 .match_header("x-blokli-schema-version", SCHEMA_VERSION.to_string().as_str())
603 .with_status(200)
604 .with_header("content-type", "application/json")
605 .with_body(r#"{"data":{"version":"0.19.1"}}"#)
606 .create_async()
607 .await;
608
609 client
610 .query_version()
611 .await
612 .expect("request should include schema version header");
613 }
614
615 #[tokio::test]
616 async fn queries_do_not_preflight_compatibility() {
617 let mut server = Server::new_async().await;
618 let client = BlokliClient::new(server.url().parse().expect("valid URL"), BlokliClientConfig::default());
619
620 let version_mock = server
621 .mock("POST", "/graphql")
622 .match_body(Matcher::Regex("QueryVersion".into()))
623 .expect(2)
624 .with_status(200)
625 .with_header("content-type", "application/json")
626 .with_body(r#"{"data":{"version":"0.19.1"}}"#)
627 .create_async()
628 .await;
629
630 client.query_version().await.expect("first query should succeed");
631 client.query_version().await.expect("second query should succeed");
632
633 version_mock.assert_async().await;
634 }
635
636 #[tokio::test]
637 async fn submit_transaction_does_not_preflight_compatibility() {
638 let mut server = Server::new_async().await;
639 let client = BlokliClient::new(server.url().parse().expect("valid URL"), BlokliClientConfig::default());
640
641 let mutation_mock = server
642 .mock("POST", "/graphql")
643 .match_body(Matcher::Regex("MutateSendTransaction".into()))
644 .expect(1)
645 .with_status(200)
646 .with_header("content-type", "application/json")
647 .with_body(
648 r#"{
649 "data": {
650 "sendTransaction": {
651 "__typename": "SendTransactionSuccess",
652 "transactionHash": "0x0101010101010101010101010101010101010101010101010101010101010101"
653 }
654 }
655 }"#,
656 )
657 .create_async()
658 .await;
659
660 client
661 .submit_transaction(&[0x42; 4])
662 .await
663 .expect("transaction submission should succeed");
664
665 mutation_mock.assert_async().await;
666 }
667}