Skip to main content

blokli_client/client/
mod.rs

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
31/// Schema version sent with every request via `X-Blokli-Schema-Version`.
32pub const SCHEMA_VERSION: u32 = 1;
33
34/// DNS resolution override for the Blokli base URL host.
35///
36/// This pins the configured Blokli URL hostname to a fixed socket address in reqwest without rewriting the request
37/// URL. That keeps HTTP `Host`, TLS SNI, and certificate validation based on the original hostname while avoiding
38/// system DNS lookups for that host.
39#[derive(Clone, Debug, PartialEq, Eq)]
40pub struct BlokliDnsOverride {
41    /// IP address to connect to for the Blokli base URL host.
42    pub ip: IpAddr,
43    /// Optional port override.
44    ///
45    /// When `None`, the request URL port or scheme default is used. When `Some`, the override port is used for
46    /// requests.
47    pub port: Option<u16>,
48}
49
50/// Configuration for the [`BlokliClient`].
51///
52/// The same configuration is used for one-shot GraphQL operations and SSE subscriptions. Non-streaming requests use
53/// [`timeout`](BlokliClientConfig::timeout) as a request timeout. Subscriptions use it as the connection timeout and
54/// then apply the subscription-specific read, reconnect, keepalive, and restart settings.
55#[derive(Clone, Debug, PartialEq, Eq, smart_default::SmartDefault)]
56pub struct BlokliClientConfig {
57    /// General timeout for non-streaming requests and SSE connection establishment.
58    #[default(Duration::from_secs(10))]
59    pub timeout: Duration,
60    /// Maximum delay used by SSE reconnect backoff.
61    #[default(Duration::from_secs(30))]
62    pub stream_reconnect_timeout: Duration,
63    /// Per-read timeout for SSE streams.
64    ///
65    /// If `None`, established streams may stay open indefinitely while waiting for the next event.
66    #[default(Some(Duration::from_secs(60)))]
67    pub subscription_read_timeout: Option<Duration>,
68    /// TCP keepalive interval for SSE streams.
69    #[default(Duration::from_secs(15))]
70    pub subscription_tcp_keepalive: Duration,
71    /// Delay before recreating a completed SSE stream.
72    ///
73    /// If `None`, completed streams are not recreated and the returned subscription stream terminates.
74    #[default(Some(Duration::from_secs(1)))]
75    pub subscription_stream_restart_delay: Option<Duration>,
76    /// Optional DNS override for the Blokli base URL host.
77    ///
78    /// When `None`, the client uses system DNS. When set, both GraphQL requests and SSE subscription connections use
79    /// the override.
80    pub dns_override: Option<BlokliDnsOverride>,
81}
82
83/// Internal state for managing GraphQL subscription streams.
84struct 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/// Client implementation of the Blokli API.
181///
182/// The client implements the following Blokli API traits:
183/// - [`BlokliQueryClient`](crate::api::BlokliQueryClient)
184/// - [`BlokliSubscriptionClient`](crate::api::BlokliSubscriptionClient).
185/// - [`BlokliTransactionClient`](crate::api::BlokliTransactionClient)
186#[derive(Clone, Debug)]
187pub struct BlokliClient {
188    base_url: url::Url,
189    cfg: BlokliClientConfig,
190}
191
192const REDIRECT_LIMIT: usize = 3;
193
194/// Contains all GraphQL queries used by the Blokli client.
195pub struct GraphQlQueries;
196
197impl BlokliClient {
198    /// Creates a new instance given Blokli base URL and configuration.
199    pub fn new(base_url: url::Url, cfg: BlokliClientConfig) -> Self {
200        Self { base_url, cfg }
201    }
202
203    /// Returns the client's base Blokli URL.
204    pub fn base_url(&self) -> &url::Url {
205        &self.base_url
206    }
207
208    /// Returns the client's configuration.
209    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}