1use crate::config::{
4 GraphqlAuth, GraphqlOffsetPagination, GraphqlPagination, GraphqlPaginationSpec,
5 GraphqlStreamConfig,
6};
7use async_trait::async_trait;
8use base64::Engine as _;
9use faucet_core::util::{self, DEFAULT_ERROR_BODY_MAX_LEN};
10use faucet_core::{AuthSpec, Credential, FaucetError, SharedAuthProvider, Stream, StreamPage};
11use jsonpath_rust::JsonPath;
12use reqwest::Client;
13use serde_json::{Value, json};
14use std::collections::HashMap;
15use std::pin::Pin;
16use std::time::Duration;
17
18const RETRY_MAX_ATTEMPTS: u32 = 3;
20const RETRY_BASE_BACKOFF: Duration = Duration::from_millis(500);
22
23pub struct GraphqlStream {
25 config: GraphqlStreamConfig,
26 client: Client,
27 auth_provider: Option<SharedAuthProvider>,
31 retry_policy: faucet_core::RetryPolicy,
35}
36
37#[cfg(feature = "mtls")]
41fn apply_client_tls(
42 builder: reqwest::ClientBuilder,
43 tls: &faucet_core::TlsClientConfig,
44) -> Result<reqwest::ClientBuilder, FaucetError> {
45 let identity = build_identity(tls)?;
46 let mut builder = builder.identity(identity).use_native_tls();
47 if let Some(v) = &tls.min_version {
48 let version = if v == "1.3" {
50 reqwest::tls::Version::TLS_1_3
51 } else {
52 reqwest::tls::Version::TLS_1_2
53 };
54 builder = builder.min_tls_version(version);
55 }
56 Ok(builder)
57}
58
59#[cfg(not(feature = "mtls"))]
60fn apply_client_tls(
61 _builder: reqwest::ClientBuilder,
62 _tls: &faucet_core::TlsClientConfig,
63) -> Result<reqwest::ClientBuilder, FaucetError> {
64 Err(FaucetError::Config(
65 "a `tls:` (mutual-TLS) block is configured, but this build of \
66 faucet-source-graphql lacks the `mtls` feature; rebuild with `--features mtls`"
67 .into(),
68 ))
69}
70
71#[cfg(feature = "mtls")]
74fn build_identity(tls: &faucet_core::TlsClientConfig) -> Result<reqwest::Identity, FaucetError> {
75 if let Some(p12_path) = &tls.client_identity_pkcs12 {
76 let der = std::fs::read(p12_path).map_err(|e| {
77 FaucetError::Config(format!(
78 "tls: could not read PKCS#12 file {p12_path:?}: {e}"
79 ))
80 })?;
81 let password = tls.pkcs12_password.as_deref().unwrap_or("");
82 reqwest::Identity::from_pkcs12_der(&der, password)
83 .map_err(|e| FaucetError::Config(format!("tls: invalid PKCS#12 identity: {e}")))
84 } else {
85 let cert = tls.client_cert.as_deref().unwrap_or_default();
86 let key = tls.client_key.as_deref().unwrap_or_default();
87 reqwest::Identity::from_pkcs8_pem(cert.as_bytes(), key.as_bytes())
88 .map_err(|e| FaucetError::Config(format!("tls: invalid PEM client identity: {e}")))
89 }
90}
91
92fn credential_to_auth(cred: Credential) -> GraphqlAuth {
95 match cred {
96 Credential::Bearer(token) => GraphqlAuth::Bearer { token },
97 Credential::Token(token) => GraphqlAuth::Custom {
98 headers: HashMap::from([("Authorization".into(), token)]),
99 },
100 Credential::Header { name, value } => GraphqlAuth::Custom {
101 headers: HashMap::from([(name, value)]),
102 },
103 Credential::Basic { username, password } => GraphqlAuth::Custom {
104 headers: HashMap::from([(
105 "Authorization".into(),
106 format!(
107 "Basic {}",
108 base64::engine::general_purpose::STANDARD
109 .encode(format!("{username}:{password}"))
110 ),
111 )]),
112 },
113 }
114}
115
116impl GraphqlStream {
117 pub fn new(config: GraphqlStreamConfig) -> Self {
124 Self::try_new(config).expect(
125 "GraphqlStream::new: client build failed; use try_new() for fallible construction",
126 )
127 }
128
129 pub fn try_new(config: GraphqlStreamConfig) -> Result<Self, FaucetError> {
136 let mut builder = Client::builder();
137 if let Some(tls) = &config.tls {
138 tls.validate()?;
139 builder = apply_client_tls(builder, tls)?;
140 }
141 let client = builder.build().map_err(|e| {
142 FaucetError::Config(format!("graphql: failed to build HTTP client: {e}"))
143 })?;
144 Ok(Self {
145 config,
146 client,
147 auth_provider: None,
148 retry_policy: faucet_core::RetryPolicy {
152 max_attempts: RETRY_MAX_ATTEMPTS + 1,
153 backoff: faucet_core::BackoffKind::Exponential,
154 base: RETRY_BASE_BACKOFF,
155 max: Duration::from_secs(60),
156 jitter: true,
157 retry_on: faucet_core::RetryClassSet::default(),
158 },
159 })
160 }
161
162 pub fn with_retry_policy(mut self, policy: faucet_core::RetryPolicy) -> Self {
167 self.retry_policy = policy;
168 self
169 }
170
171 pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
177 self.auth_provider = Some(provider);
178 self
179 }
180
181 pub async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
183 self.fetch_all_with_context(&std::collections::HashMap::new())
184 .await
185 }
186
187 async fn fetch_all_with_context(
189 &self,
190 context: &std::collections::HashMap<String, Value>,
191 ) -> Result<Vec<Value>, FaucetError> {
192 let mut all_records = Vec::new();
193 let mut cursor: Option<String> = None;
194 let mut offset = 0usize;
195 let mut pages_fetched = 0usize;
196 let mut warned_unresolved_has_next = false;
197 let mut cursor_guard = CursorGuard::new();
198
199 loop {
200 if let Some(max) = self.config.max_pages
201 && pages_fetched >= max
202 {
203 tracing::warn!("max pages ({max}) reached");
204 break;
205 }
206
207 let body = self.execute_query(&cursor, offset, context).await?;
208 let records = self.extract_records(&body)?;
209 let records_in_page = records.len();
210 all_records.extend(records);
211 pages_fetched += 1;
212
213 match &self.config.pagination {
215 Some(GraphqlPaginationSpec::Cursor(pag)) => {
216 let (step, unresolved) = decide_next_page(&body, pag, cursor.as_deref());
217 if unresolved && !warned_unresolved_has_next {
218 tracing::warn!(
219 path = %pag.has_next_page_path,
220 "GraphQL has_next_page path did not resolve to a boolean; \
221 deferring to cursor presence to decide pagination"
222 );
223 warned_unresolved_has_next = true;
224 }
225 match step {
226 PageStep::Stop => break,
227 PageStep::StopLoop => {
228 tracing::warn!("cursor loop detected, stopping pagination");
229 break;
230 }
231 PageStep::Advance(next) => {
232 if cursor_guard.is_repeat(&next) {
233 tracing::warn!(
234 "cursor cycle detected (cursor already seen), stopping pagination"
235 );
236 break;
237 }
238 cursor = Some(next);
239 }
240 }
241 }
242 Some(GraphqlPaginationSpec::Offset(off)) => {
243 if offset_should_continue(records_in_page, off) {
244 offset += off.page_size;
245 } else {
246 break;
247 }
248 }
249 None => break,
250 }
251 }
252
253 tracing::info!(
254 records = all_records.len(),
255 pages = pages_fetched,
256 "GraphQL fetch complete"
257 );
258 Ok(all_records)
259 }
260
261 async fn execute_query(
267 &self,
268 cursor: &Option<String>,
269 offset: usize,
270 context: &std::collections::HashMap<String, Value>,
271 ) -> Result<Value, FaucetError> {
272 let mut variables = self.config.variables.clone();
273
274 if !context.is_empty()
276 && let Value::Object(ref mut map) = variables
277 {
278 for (key, value) in context {
279 map.insert(key.clone(), value.clone());
280 }
281 }
282
283 match &self.config.pagination {
285 Some(GraphqlPaginationSpec::Cursor(pag)) => {
289 if let (Some(cursor_val), Value::Object(map)) = (cursor, &mut variables) {
290 map.insert(pag.cursor_variable.clone(), json!(cursor_val));
291 }
292 if self.config.batch_size != 0
293 && let Value::Object(map) = &mut variables
294 {
295 map.insert(
296 pag.page_size_variable.clone(),
297 json!(self.config.batch_size),
298 );
299 }
300 }
301 Some(GraphqlPaginationSpec::Offset(off)) => {
304 if let Value::Object(map) = &mut variables {
305 map.insert(off.offset_variable.clone(), json!(offset));
306 }
307 }
308 None => {}
309 }
310
311 let payload = json!({
312 "query": self.config.query,
313 "variables": variables,
314 });
315
316 let mut req = self
317 .client
318 .post(&self.config.endpoint)
319 .headers(self.config.headers.clone())
320 .json(&payload);
321
322 let effective_auth: GraphqlAuth = if let Some(provider) = &self.auth_provider {
326 credential_to_auth(provider.credential().await?)
327 } else {
328 match &self.config.auth {
329 AuthSpec::Inline(a) => a.clone(),
330 AuthSpec::Reference(r) => {
331 return Err(FaucetError::Auth(format!(
332 "auth references provider '{}' but no provider was supplied; \
333 set one via the CLI `auth:` catalog or `with_auth_provider`",
334 r.name
335 )));
336 }
337 }
338 };
339
340 match effective_auth {
342 GraphqlAuth::None => {}
343 GraphqlAuth::Bearer { token } => {
344 req = req.bearer_auth(token);
345 }
346 GraphqlAuth::Custom { headers } => {
347 let mut hm = reqwest::header::HeaderMap::new();
348 for (name, value) in &headers {
349 let n =
350 reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
351 FaucetError::Auth(format!("invalid custom header name {name:?}: {e}"))
352 })?;
353 let v = reqwest::header::HeaderValue::from_str(value).map_err(|e| {
354 FaucetError::Auth(format!("invalid custom header value for {name:?}: {e}"))
355 })?;
356 hm.insert(n, v);
357 }
358 req = req.headers(hm);
359 }
360 }
361
362 let body: Value = faucet_core::execute_with_policy(&self.retry_policy, None, || {
367 let attempt = req.try_clone();
368 async move {
369 let req = attempt.ok_or_else(|| {
370 FaucetError::Source("graphql: request is not cloneable for retry".into())
371 })?;
372 let resp = req.send().await.map_err(FaucetError::Http)?;
373 let resp = util::check_http_response(resp, DEFAULT_ERROR_BODY_MAX_LEN).await?;
374 resp.json().await.map_err(FaucetError::Http)
375 }
376 })
377 .await?;
378
379 if let Some(errors) = body.get("errors")
381 && let Some(arr) = errors.as_array()
382 && !arr.is_empty()
383 {
384 let msg = arr
385 .iter()
386 .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
387 .collect::<Vec<_>>()
388 .join("; ");
389 let lower = msg.to_lowercase();
395 if self.config.batch_size == 0
396 && let Some(GraphqlPaginationSpec::Cursor(pag)) = &self.config.pagination
397 {
398 let var_name = pag.page_size_variable.to_lowercase();
399 if lower.contains(&var_name)
400 && (lower.contains("non-null")
401 || lower.contains("non null")
402 || lower.contains("must not be null")
403 || lower.contains("cannot be null")
404 || lower.contains("required"))
405 {
406 return Err(FaucetError::Config(format!(
407 "batch_size = 0 requires the upstream to accept a null {}: argument \
408 (GraphQL errors: {msg})",
409 pag.page_size_variable
410 )));
411 }
412 }
413 return Err(FaucetError::HttpStatus {
414 status: 200,
415 url: self.config.endpoint.clone(),
416 body: format!("GraphQL errors: {msg}"),
417 });
418 }
419
420 Ok(body)
421 }
422
423 fn extract_records(&self, body: &Value) -> Result<Vec<Value>, FaucetError> {
425 match &self.config.records_path {
426 Some(path) => util::extract_records(body, Some(path)),
427 None => {
428 match body.get("data") {
434 Some(Value::Null) | None => Ok(Vec::new()),
435 Some(data) => Ok(vec![data.clone()]),
436 }
437 }
438 }
439 }
440
441 fn stream_pages_inner(
454 &self,
455 context: &std::collections::HashMap<String, Value>,
456 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + '_>> {
457 let owned_context: std::collections::HashMap<String, Value> = context.clone();
459
460 Box::pin(async_stream::try_stream! {
461 let mut cursor: Option<String> = None;
462 let mut offset = 0usize;
463 let mut cursor_guard = CursorGuard::new();
464 let mut pages_fetched = 0usize;
465 let mut warned_unresolved_has_next = false;
466 let running_max: Option<Value> = None;
471 let mut bookmark_emitted = false;
472
473 loop {
474 if let Some(max) = self.config.max_pages
475 && pages_fetched >= max
476 {
477 tracing::warn!("max pages ({max}) reached");
478 break;
479 }
480
481 let body = self.execute_query(&cursor, offset, &owned_context).await?;
482 let records = self.extract_records(&body)?;
483 let records_in_page = records.len();
484 pages_fetched += 1;
485
486 let has_next = match &self.config.pagination {
489 Some(GraphqlPaginationSpec::Cursor(pag)) => {
490 let (step, unresolved) =
491 decide_next_page(&body, pag, cursor.as_deref());
492 if unresolved && !warned_unresolved_has_next {
493 tracing::warn!(
494 path = %pag.has_next_page_path,
495 "GraphQL has_next_page path did not resolve to a boolean; \
496 deferring to cursor presence to decide pagination"
497 );
498 warned_unresolved_has_next = true;
499 }
500 match step {
501 PageStep::Stop => false,
502 PageStep::StopLoop => {
503 tracing::warn!("cursor loop detected, stopping pagination");
504 false
505 }
506 PageStep::Advance(next) => {
507 if cursor_guard.is_repeat(&next) {
508 tracing::warn!(
509 "cursor cycle detected (cursor already seen), stopping pagination"
510 );
511 false
512 } else {
513 cursor = Some(next);
514 true
515 }
516 }
517 }
518 }
519 Some(GraphqlPaginationSpec::Offset(off)) => {
520 let advance = offset_should_continue(records_in_page, off);
521 if advance {
522 offset += off.page_size;
523 }
524 advance
525 }
526 None => false,
527 };
528
529 if has_next {
530 yield StreamPage { records, bookmark: None };
532 } else {
533 bookmark_emitted = running_max.is_some();
536 yield StreamPage {
537 records,
538 bookmark: running_max.clone(),
539 };
540 break;
541 }
542 }
543
544 if !bookmark_emitted && running_max.is_some() {
551 yield StreamPage {
552 records: Vec::new(),
553 bookmark: running_max,
554 };
555 }
556
557 tracing::info!(
558 pages = pages_fetched,
559 batch_size = self.config.batch_size,
560 "GraphQL source stream complete",
561 );
562 })
563 }
564}
565
566#[async_trait]
567impl faucet_core::Source for GraphqlStream {
568 async fn fetch_with_context(
569 &self,
570 context: &std::collections::HashMap<String, serde_json::Value>,
571 ) -> Result<Vec<Value>, FaucetError> {
572 self.fetch_all_with_context(context).await
573 }
574
575 fn stream_pages<'a>(
582 &'a self,
583 context: &'a std::collections::HashMap<String, Value>,
584 _batch_size: usize,
585 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
586 self.stream_pages_inner(context)
587 }
588
589 fn connector_name(&self) -> &'static str {
590 "graphql"
591 }
592
593 fn config_schema(&self) -> serde_json::Value {
594 serde_json::to_value(faucet_core::schema_for!(GraphqlStreamConfig))
595 .expect("schema serialization")
596 }
597
598 fn dataset_uri(&self) -> String {
599 faucet_core::redact_uri_credentials(&self.config.endpoint)
600 }
601}
602
603fn extract_string(body: &Value, path: &str) -> Option<String> {
604 let results = body.query(path).ok()?;
605 match results.first()? {
606 Value::String(s) => Some(s.clone()),
607 _ => None,
608 }
609}
610
611fn extract_bool(body: &Value, path: &str) -> Option<bool> {
612 let results = body.query(path).ok()?;
613 results.first()?.as_bool()
614}
615
616#[derive(Debug, PartialEq)]
618enum PageStep {
619 Stop,
621 StopLoop,
624 Advance(String),
626}
627
628fn decide_next_page(
637 body: &Value,
638 pag: &GraphqlPagination,
639 prev_cursor: Option<&str>,
640) -> (PageStep, bool) {
641 let (stop, unresolved) = match extract_bool(body, &pag.has_next_page_path) {
642 Some(false) => (true, false),
643 Some(true) => (false, false),
644 None => (false, true),
646 };
647 if stop {
648 return (PageStep::Stop, unresolved);
649 }
650 match extract_string(body, &pag.cursor_path) {
651 None => (PageStep::Stop, unresolved),
652 Some(next) if Some(next.as_str()) == prev_cursor => (PageStep::StopLoop, unresolved),
653 Some(next) => (PageStep::Advance(next), unresolved),
654 }
655}
656
657fn offset_should_continue(records_in_page: usize, off: &GraphqlOffsetPagination) -> bool {
669 if records_in_page == 0 {
670 return false;
671 }
672 if off.stop_when_short && records_in_page < off.page_size {
673 return false;
674 }
675 true
676}
677
678struct CursorGuard {
691 seen: HashMap<String, ()>,
692 order: std::collections::VecDeque<String>,
693}
694
695impl CursorGuard {
696 const CAP: usize = 4096;
697
698 fn new() -> Self {
699 Self {
700 seen: HashMap::new(),
701 order: std::collections::VecDeque::new(),
702 }
703 }
704
705 fn is_repeat(&mut self, cursor: &str) -> bool {
707 if self.seen.contains_key(cursor) {
708 return true;
709 }
710 if self.order.len() >= Self::CAP
711 && let Some(old) = self.order.pop_front()
712 {
713 self.seen.remove(&old);
714 }
715 self.seen.insert(cursor.to_string(), ());
716 self.order.push_back(cursor.to_string());
717 false
718 }
719}
720
721#[cfg(test)]
722mod tests {
723 use super::*;
724
725 #[test]
726 fn extract_string_from_json() {
727 let body = json!({"data": {"users": {"pageInfo": {"endCursor": "abc123"}}}});
728 assert_eq!(
729 extract_string(&body, "$.data.users.pageInfo.endCursor"),
730 Some("abc123".into())
731 );
732 }
733
734 #[test]
735 fn extract_bool_from_json() {
736 let body = json!({"data": {"users": {"pageInfo": {"hasNextPage": true}}}});
737 assert_eq!(
738 extract_bool(&body, "$.data.users.pageInfo.hasNextPage"),
739 Some(true)
740 );
741 }
742
743 fn pageinfo_pagination() -> GraphqlPagination {
744 GraphqlPagination {
745 has_next_page_path: "$.data.users.pageInfo.hasNextPage".into(),
746 cursor_path: "$.data.users.pageInfo.endCursor".into(),
747 ..GraphqlPagination::default()
748 }
749 }
750
751 #[test]
752 fn decide_next_page_advances_when_has_next_true() {
753 let body =
754 json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c2"}}}});
755 let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
756 assert_eq!(step, PageStep::Advance("c2".into()));
757 assert!(!unresolved);
758 }
759
760 #[test]
761 fn decide_next_page_stops_when_has_next_false() {
762 let body =
763 json!({"data": {"users": {"pageInfo": {"hasNextPage": false, "endCursor": "c2"}}}});
764 let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
765 assert_eq!(step, PageStep::Stop);
766 assert!(!unresolved);
767 }
768
769 #[test]
770 fn decide_next_page_detects_cursor_loop() {
771 let body =
772 json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c1"}}}});
773 let (step, _) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
774 assert_eq!(step, PageStep::StopLoop);
775 }
776
777 #[test]
778 fn decide_next_page_defers_to_cursor_when_has_next_unresolved() {
779 let body = json!({"data": {"users": {"pageInfo": {"endCursor": "c2"}}}}); let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
784 assert_eq!(
785 step,
786 PageStep::Advance("c2".into()),
787 "unresolved has-next must defer to cursor presence, not stop"
788 );
789 assert!(unresolved, "the caller is told to warn once");
790
791 let body_no_cursor = json!({"data": {"users": {"pageInfo": {}}}});
793 let (step, unresolved) =
794 decide_next_page(&body_no_cursor, &pageinfo_pagination(), Some("c1"));
795 assert_eq!(step, PageStep::Stop);
796 assert!(unresolved);
797 }
798
799 fn offset_pagination(page_size: usize, stop_when_short: bool) -> GraphqlOffsetPagination {
800 GraphqlOffsetPagination {
801 r#type: crate::config::OffsetPaginationKind::Offset,
802 offset_variable: "q_offset".into(),
803 page_size,
804 stop_when_short,
805 }
806 }
807
808 #[test]
809 fn offset_continues_on_full_page() {
810 assert!(offset_should_continue(250, &offset_pagination(250, true)));
812 }
813
814 #[test]
815 fn offset_stops_on_short_page_when_stop_when_short() {
816 assert!(!offset_should_continue(100, &offset_pagination(250, true)));
818 }
819
820 #[test]
821 fn offset_continues_on_short_page_when_not_stop_when_short() {
822 assert!(offset_should_continue(100, &offset_pagination(250, false)));
824 }
825
826 #[test]
827 fn offset_always_stops_on_empty_page() {
828 assert!(!offset_should_continue(0, &offset_pagination(250, true)));
830 assert!(!offset_should_continue(0, &offset_pagination(250, false)));
831 }
832
833 #[test]
834 fn offset_exact_page_size_is_full_not_short() {
835 assert!(offset_should_continue(1, &offset_pagination(1, true)));
837 }
838
839 #[test]
840 fn extract_records_with_path() {
841 let config =
842 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { users { id } }")
843 .records_path("$.data.users[*]");
844 let stream = GraphqlStream::new(config);
845 let body = json!({"data": {"users": [{"id": 1}, {"id": 2}]}});
846 let records = stream.extract_records(&body).unwrap();
847 assert_eq!(records.len(), 2);
848 assert_eq!(records[0]["id"], 1);
849 }
850
851 #[test]
852 fn extract_records_without_path_returns_data() {
853 let config =
854 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
855 let stream = GraphqlStream::new(config);
856 let body = json!({"data": {"user": {"id": 1}}});
857 let records = stream.extract_records(&body).unwrap();
858 assert_eq!(records.len(), 1);
859 assert_eq!(records[0]["user"]["id"], 1);
860 }
861
862 #[test]
863 fn extract_records_without_path_null_data_yields_empty() {
864 let config =
868 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
869 let stream = GraphqlStream::new(config);
870 let body = json!({ "data": null });
871 let records = stream.extract_records(&body).unwrap();
872 assert!(
873 records.is_empty(),
874 "expected empty Vec for null `data`, got {records:?}"
875 );
876 }
877
878 #[test]
879 fn extract_records_without_path_absent_data_yields_empty() {
880 let config =
883 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
884 let stream = GraphqlStream::new(config);
885 let body = json!({ "extensions": { "foo": 1 } });
886 let records = stream.extract_records(&body).unwrap();
887 assert!(
888 records.is_empty(),
889 "expected empty Vec when `data` is absent, got {records:?}"
890 );
891 }
892
893 #[test]
894 fn dataset_uri_returns_endpoint() {
895 use faucet_core::Source;
896 let stream = GraphqlStream::new(GraphqlStreamConfig::new(
897 "https://api.example.com/graphql",
898 "query { id }",
899 ));
900 assert_eq!(stream.dataset_uri(), "https://api.example.com/graphql");
901 }
902
903 #[test]
904 fn dataset_uri_redacts_credentials() {
905 use faucet_core::Source;
906 let stream = GraphqlStream::new(GraphqlStreamConfig::new(
907 "https://user:pw@api.example.com/graphql",
908 "query { id }",
909 ));
910 assert_eq!(stream.dataset_uri(), "https://api.example.com/graphql");
911 }
912
913 #[test]
914 fn default_retry_policy_reproduces_legacy_constants() {
915 let stream = GraphqlStream::new(GraphqlStreamConfig::new(
916 "https://api.example.com/graphql",
917 "query { id }",
918 ));
919 assert_eq!(stream.retry_policy.max_attempts, RETRY_MAX_ATTEMPTS + 1);
920 assert_eq!(stream.retry_policy.base, RETRY_BASE_BACKOFF);
921 }
922
923 #[test]
924 fn with_retry_policy_overrides_the_default() {
925 let policy = faucet_core::RetryPolicy {
926 max_attempts: 9,
927 base: Duration::from_secs(7),
928 ..faucet_core::RetryPolicy::default()
929 };
930 let stream = GraphqlStream::new(GraphqlStreamConfig::new(
931 "https://api.example.com/graphql",
932 "query { id }",
933 ))
934 .with_retry_policy(policy);
935 assert_eq!(stream.retry_policy.max_attempts, 9);
936 assert_eq!(stream.retry_policy.base, Duration::from_secs(7));
937 }
938
939 #[test]
940 fn cursor_guard_detects_repeats_and_bounds_memory() {
941 let mut g = CursorGuard::new();
942 assert!(!g.is_repeat("a"));
943 assert!(!g.is_repeat("b"));
944 assert!(g.is_repeat("a"));
946 assert!(g.is_repeat("b"));
947
948 let mut g = CursorGuard::new();
951 for i in 0..CursorGuard::CAP {
952 assert!(!g.is_repeat(&format!("c{i}")));
953 }
954 assert_eq!(g.order.len(), CursorGuard::CAP);
955 assert!(!g.is_repeat("overflow"));
957 assert_eq!(g.order.len(), CursorGuard::CAP);
958 assert!(!g.seen.contains_key("c0"), "oldest cursor evicted");
959 assert!(g.seen.contains_key("overflow"));
960 }
961}
962
963#[cfg(all(test, feature = "mtls"))]
965mod mtls_tests {
966 use super::*;
967 use faucet_core::TlsClientConfig;
968
969 const CERT: &str = include_str!("../tests/fixtures/mtls/cert.pem");
970 const KEY: &str = include_str!("../tests/fixtures/mtls/key.pem");
971
972 fn pem() -> TlsClientConfig {
973 TlsClientConfig {
974 client_cert: Some(CERT.to_string()),
975 client_key: Some(KEY.to_string()),
976 ..Default::default()
977 }
978 }
979
980 fn cfg(tls: TlsClientConfig) -> GraphqlStreamConfig {
981 GraphqlStreamConfig::new("https://x.test/graphql", "{ ping }").tls(tls)
982 }
983
984 #[test]
985 fn pem_identity_builds() {
986 assert!(GraphqlStream::try_new(cfg(pem())).is_ok());
987 }
988
989 #[test]
990 fn min_version_branches_are_exercised() {
991 let mut tls = pem();
992 tls.min_version = Some("1.2".into());
993 assert!(GraphqlStream::try_new(cfg(tls)).is_ok());
994 let mut tls = pem();
997 tls.min_version = Some("1.3".into());
998 let _ = GraphqlStream::try_new(cfg(tls));
999 }
1000
1001 #[test]
1002 fn pkcs12_identity_builds() {
1003 let p12 = concat!(
1004 env!("CARGO_MANIFEST_DIR"),
1005 "/tests/fixtures/mtls/identity.p12"
1006 );
1007 let tls = TlsClientConfig {
1008 client_identity_pkcs12: Some(p12.to_string()),
1009 pkcs12_password: Some("changeit".into()),
1010 ..Default::default()
1011 };
1012 assert!(GraphqlStream::try_new(cfg(tls)).is_ok());
1013 }
1014
1015 #[test]
1016 fn invalid_pem_errors_without_leaking_key() {
1017 let tls = TlsClientConfig {
1018 client_cert: Some("-----BEGIN CERTIFICATE-----\nbad\n-----END CERTIFICATE-----".into()),
1019 client_key: Some("SUPERSECRETKEY".into()),
1020 ..Default::default()
1021 };
1022 let err = GraphqlStream::try_new(cfg(tls))
1023 .map(|_| ())
1024 .expect_err("bad PEM must error");
1025 assert!(!err.to_string().contains("SUPERSECRETKEY"));
1026 }
1027
1028 #[test]
1029 fn invalid_tls_shape_errors() {
1030 let mut tls = pem();
1031 tls.client_identity_pkcs12 = Some("/x.p12".into());
1032 assert!(GraphqlStream::try_new(cfg(tls)).is_err());
1033 }
1034
1035 #[test]
1036 fn missing_pkcs12_file_errors() {
1037 let tls = TlsClientConfig {
1038 client_identity_pkcs12: Some("/no/such.p12".into()),
1039 pkcs12_password: Some("x".into()),
1040 ..Default::default()
1041 };
1042 assert!(GraphqlStream::try_new(cfg(tls)).is_err());
1043 }
1044
1045 #[test]
1046 fn config_validate_checks_tls() {
1047 assert!(cfg(pem()).validate().is_ok());
1048 let mut bad = pem();
1049 bad.client_identity_pkcs12 = Some("/x.p12".into());
1050 assert!(cfg(bad).validate().is_err());
1051 }
1052}