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 let mut query = self.config.query.clone();
286
287 match &self.config.pagination {
289 Some(GraphqlPaginationSpec::Cursor(pag)) => {
293 if let (Some(cursor_val), Value::Object(map)) = (cursor, &mut variables) {
294 map.insert(pag.cursor_variable.clone(), json!(cursor_val));
295 }
296 if self.config.batch_size != 0
297 && let Value::Object(map) = &mut variables
298 {
299 map.insert(
300 pag.page_size_variable.clone(),
301 json!(self.config.batch_size),
302 );
303 }
304 }
305 Some(GraphqlPaginationSpec::Offset(off)) => {
310 if off.substitute_in_query {
311 let token = format!("${{{}}}", off.offset_variable);
312 query = query.replace(&token, &offset.to_string());
313 } else if let Value::Object(map) = &mut variables {
314 map.insert(off.offset_variable.clone(), json!(offset));
315 }
316 }
317 None => {}
318 }
319
320 let payload = json!({
321 "query": query,
322 "variables": variables,
323 });
324
325 let mut req = self
326 .client
327 .post(&self.config.endpoint)
328 .headers(self.config.headers.clone())
329 .json(&payload);
330
331 let effective_auth: GraphqlAuth = if let Some(provider) = &self.auth_provider {
335 credential_to_auth(provider.credential().await?)
336 } else {
337 match &self.config.auth {
338 AuthSpec::Inline(a) => a.clone(),
339 AuthSpec::Reference(r) => {
340 return Err(FaucetError::Auth(format!(
341 "auth references provider '{}' but no provider was supplied; \
342 set one via the CLI `auth:` catalog or `with_auth_provider`",
343 r.name
344 )));
345 }
346 }
347 };
348
349 match effective_auth {
351 GraphqlAuth::None => {}
352 GraphqlAuth::Bearer { token } => {
353 req = req.bearer_auth(token);
354 }
355 GraphqlAuth::Custom { headers } => {
356 let mut hm = reqwest::header::HeaderMap::new();
357 for (name, value) in &headers {
358 let n =
359 reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
360 FaucetError::Auth(format!("invalid custom header name {name:?}: {e}"))
361 })?;
362 let v = reqwest::header::HeaderValue::from_str(value).map_err(|e| {
363 FaucetError::Auth(format!("invalid custom header value for {name:?}: {e}"))
364 })?;
365 hm.insert(n, v);
366 }
367 req = req.headers(hm);
368 }
369 }
370
371 let body: Value = faucet_core::execute_with_policy(&self.retry_policy, None, || {
376 let attempt = req.try_clone();
377 async move {
378 let req = attempt.ok_or_else(|| {
379 FaucetError::Source("graphql: request is not cloneable for retry".into())
380 })?;
381 let resp = req.send().await.map_err(FaucetError::Http)?;
382 let resp = util::check_http_response(resp, DEFAULT_ERROR_BODY_MAX_LEN).await?;
383 resp.json().await.map_err(FaucetError::Http)
384 }
385 })
386 .await?;
387
388 if let Some(errors) = body.get("errors")
390 && let Some(arr) = errors.as_array()
391 && !arr.is_empty()
392 {
393 let msg = arr
394 .iter()
395 .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
396 .collect::<Vec<_>>()
397 .join("; ");
398 let lower = msg.to_lowercase();
404 if self.config.batch_size == 0
405 && let Some(GraphqlPaginationSpec::Cursor(pag)) = &self.config.pagination
406 {
407 let var_name = pag.page_size_variable.to_lowercase();
408 if lower.contains(&var_name)
409 && (lower.contains("non-null")
410 || lower.contains("non null")
411 || lower.contains("must not be null")
412 || lower.contains("cannot be null")
413 || lower.contains("required"))
414 {
415 return Err(FaucetError::Config(format!(
416 "batch_size = 0 requires the upstream to accept a null {}: argument \
417 (GraphQL errors: {msg})",
418 pag.page_size_variable
419 )));
420 }
421 }
422 return Err(FaucetError::HttpStatus {
423 status: 200,
424 url: self.config.endpoint.clone(),
425 body: format!("GraphQL errors: {msg}"),
426 });
427 }
428
429 Ok(body)
430 }
431
432 fn extract_records(&self, body: &Value) -> Result<Vec<Value>, FaucetError> {
434 match &self.config.records_path {
435 Some(path) => util::extract_records(body, Some(path)),
436 None => {
437 match body.get("data") {
443 Some(Value::Null) | None => Ok(Vec::new()),
444 Some(data) => Ok(vec![data.clone()]),
445 }
446 }
447 }
448 }
449
450 fn stream_pages_inner(
463 &self,
464 context: &std::collections::HashMap<String, Value>,
465 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + '_>> {
466 let owned_context: std::collections::HashMap<String, Value> = context.clone();
468
469 Box::pin(async_stream::try_stream! {
470 let mut cursor: Option<String> = None;
471 let mut offset = 0usize;
472 let mut cursor_guard = CursorGuard::new();
473 let mut pages_fetched = 0usize;
474 let mut warned_unresolved_has_next = false;
475 let running_max: Option<Value> = None;
480 let mut bookmark_emitted = false;
481
482 loop {
483 if let Some(max) = self.config.max_pages
484 && pages_fetched >= max
485 {
486 tracing::warn!("max pages ({max}) reached");
487 break;
488 }
489
490 let body = self.execute_query(&cursor, offset, &owned_context).await?;
491 let records = self.extract_records(&body)?;
492 let records_in_page = records.len();
493 pages_fetched += 1;
494
495 let has_next = match &self.config.pagination {
498 Some(GraphqlPaginationSpec::Cursor(pag)) => {
499 let (step, unresolved) =
500 decide_next_page(&body, pag, cursor.as_deref());
501 if unresolved && !warned_unresolved_has_next {
502 tracing::warn!(
503 path = %pag.has_next_page_path,
504 "GraphQL has_next_page path did not resolve to a boolean; \
505 deferring to cursor presence to decide pagination"
506 );
507 warned_unresolved_has_next = true;
508 }
509 match step {
510 PageStep::Stop => false,
511 PageStep::StopLoop => {
512 tracing::warn!("cursor loop detected, stopping pagination");
513 false
514 }
515 PageStep::Advance(next) => {
516 if cursor_guard.is_repeat(&next) {
517 tracing::warn!(
518 "cursor cycle detected (cursor already seen), stopping pagination"
519 );
520 false
521 } else {
522 cursor = Some(next);
523 true
524 }
525 }
526 }
527 }
528 Some(GraphqlPaginationSpec::Offset(off)) => {
529 let advance = offset_should_continue(records_in_page, off);
530 if advance {
531 offset += off.page_size;
532 }
533 advance
534 }
535 None => false,
536 };
537
538 if has_next {
539 yield StreamPage { records, bookmark: None };
541 } else {
542 bookmark_emitted = running_max.is_some();
545 yield StreamPage {
546 records,
547 bookmark: running_max.clone(),
548 };
549 break;
550 }
551 }
552
553 if !bookmark_emitted && running_max.is_some() {
560 yield StreamPage {
561 records: Vec::new(),
562 bookmark: running_max,
563 };
564 }
565
566 tracing::info!(
567 pages = pages_fetched,
568 batch_size = self.config.batch_size,
569 "GraphQL source stream complete",
570 );
571 })
572 }
573}
574
575#[async_trait]
576impl faucet_core::Source for GraphqlStream {
577 async fn fetch_with_context(
578 &self,
579 context: &std::collections::HashMap<String, serde_json::Value>,
580 ) -> Result<Vec<Value>, FaucetError> {
581 self.fetch_all_with_context(context).await
582 }
583
584 fn stream_pages<'a>(
591 &'a self,
592 context: &'a std::collections::HashMap<String, Value>,
593 _batch_size: usize,
594 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
595 self.stream_pages_inner(context)
596 }
597
598 fn connector_name(&self) -> &'static str {
599 "graphql"
600 }
601
602 fn config_schema(&self) -> serde_json::Value {
603 serde_json::to_value(faucet_core::schema_for!(GraphqlStreamConfig))
604 .expect("schema serialization")
605 }
606
607 fn dataset_uri(&self) -> String {
608 faucet_core::redact_uri_credentials(&self.config.endpoint)
609 }
610}
611
612fn extract_string(body: &Value, path: &str) -> Option<String> {
613 let results = body.query(path).ok()?;
614 match results.first()? {
615 Value::String(s) => Some(s.clone()),
616 _ => None,
617 }
618}
619
620fn extract_bool(body: &Value, path: &str) -> Option<bool> {
621 let results = body.query(path).ok()?;
622 results.first()?.as_bool()
623}
624
625#[derive(Debug, PartialEq)]
627enum PageStep {
628 Stop,
630 StopLoop,
633 Advance(String),
635}
636
637fn decide_next_page(
646 body: &Value,
647 pag: &GraphqlPagination,
648 prev_cursor: Option<&str>,
649) -> (PageStep, bool) {
650 let (stop, unresolved) = match extract_bool(body, &pag.has_next_page_path) {
651 Some(false) => (true, false),
652 Some(true) => (false, false),
653 None => (false, true),
655 };
656 if stop {
657 return (PageStep::Stop, unresolved);
658 }
659 match extract_string(body, &pag.cursor_path) {
660 None => (PageStep::Stop, unresolved),
661 Some(next) if Some(next.as_str()) == prev_cursor => (PageStep::StopLoop, unresolved),
662 Some(next) => (PageStep::Advance(next), unresolved),
663 }
664}
665
666fn offset_should_continue(records_in_page: usize, off: &GraphqlOffsetPagination) -> bool {
678 if records_in_page == 0 {
679 return false;
680 }
681 if off.stop_when_short && records_in_page < off.page_size {
682 return false;
683 }
684 true
685}
686
687struct CursorGuard {
700 seen: HashMap<String, ()>,
701 order: std::collections::VecDeque<String>,
702}
703
704impl CursorGuard {
705 const CAP: usize = 4096;
706
707 fn new() -> Self {
708 Self {
709 seen: HashMap::new(),
710 order: std::collections::VecDeque::new(),
711 }
712 }
713
714 fn is_repeat(&mut self, cursor: &str) -> bool {
716 if self.seen.contains_key(cursor) {
717 return true;
718 }
719 if self.order.len() >= Self::CAP
720 && let Some(old) = self.order.pop_front()
721 {
722 self.seen.remove(&old);
723 }
724 self.seen.insert(cursor.to_string(), ());
725 self.order.push_back(cursor.to_string());
726 false
727 }
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733
734 #[test]
735 fn extract_string_from_json() {
736 let body = json!({"data": {"users": {"pageInfo": {"endCursor": "abc123"}}}});
737 assert_eq!(
738 extract_string(&body, "$.data.users.pageInfo.endCursor"),
739 Some("abc123".into())
740 );
741 }
742
743 #[test]
744 fn extract_bool_from_json() {
745 let body = json!({"data": {"users": {"pageInfo": {"hasNextPage": true}}}});
746 assert_eq!(
747 extract_bool(&body, "$.data.users.pageInfo.hasNextPage"),
748 Some(true)
749 );
750 }
751
752 fn pageinfo_pagination() -> GraphqlPagination {
753 GraphqlPagination {
754 has_next_page_path: "$.data.users.pageInfo.hasNextPage".into(),
755 cursor_path: "$.data.users.pageInfo.endCursor".into(),
756 ..GraphqlPagination::default()
757 }
758 }
759
760 #[test]
761 fn decide_next_page_advances_when_has_next_true() {
762 let body =
763 json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c2"}}}});
764 let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
765 assert_eq!(step, PageStep::Advance("c2".into()));
766 assert!(!unresolved);
767 }
768
769 #[test]
770 fn decide_next_page_stops_when_has_next_false() {
771 let body =
772 json!({"data": {"users": {"pageInfo": {"hasNextPage": false, "endCursor": "c2"}}}});
773 let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
774 assert_eq!(step, PageStep::Stop);
775 assert!(!unresolved);
776 }
777
778 #[test]
779 fn decide_next_page_detects_cursor_loop() {
780 let body =
781 json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c1"}}}});
782 let (step, _) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
783 assert_eq!(step, PageStep::StopLoop);
784 }
785
786 #[test]
787 fn decide_next_page_defers_to_cursor_when_has_next_unresolved() {
788 let body = json!({"data": {"users": {"pageInfo": {"endCursor": "c2"}}}}); let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
793 assert_eq!(
794 step,
795 PageStep::Advance("c2".into()),
796 "unresolved has-next must defer to cursor presence, not stop"
797 );
798 assert!(unresolved, "the caller is told to warn once");
799
800 let body_no_cursor = json!({"data": {"users": {"pageInfo": {}}}});
802 let (step, unresolved) =
803 decide_next_page(&body_no_cursor, &pageinfo_pagination(), Some("c1"));
804 assert_eq!(step, PageStep::Stop);
805 assert!(unresolved);
806 }
807
808 fn offset_pagination(page_size: usize, stop_when_short: bool) -> GraphqlOffsetPagination {
809 GraphqlOffsetPagination {
810 r#type: crate::config::OffsetPaginationKind::Offset,
811 offset_variable: "q_offset".into(),
812 page_size,
813 stop_when_short,
814 substitute_in_query: false,
815 }
816 }
817
818 #[test]
819 fn offset_continues_on_full_page() {
820 assert!(offset_should_continue(250, &offset_pagination(250, true)));
822 }
823
824 #[test]
825 fn offset_stops_on_short_page_when_stop_when_short() {
826 assert!(!offset_should_continue(100, &offset_pagination(250, true)));
828 }
829
830 #[test]
831 fn offset_continues_on_short_page_when_not_stop_when_short() {
832 assert!(offset_should_continue(100, &offset_pagination(250, false)));
834 }
835
836 #[test]
837 fn offset_always_stops_on_empty_page() {
838 assert!(!offset_should_continue(0, &offset_pagination(250, true)));
840 assert!(!offset_should_continue(0, &offset_pagination(250, false)));
841 }
842
843 #[test]
844 fn offset_exact_page_size_is_full_not_short() {
845 assert!(offset_should_continue(1, &offset_pagination(1, true)));
847 }
848
849 #[test]
850 fn extract_records_with_path() {
851 let config =
852 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { users { id } }")
853 .records_path("$.data.users[*]");
854 let stream = GraphqlStream::new(config);
855 let body = json!({"data": {"users": [{"id": 1}, {"id": 2}]}});
856 let records = stream.extract_records(&body).unwrap();
857 assert_eq!(records.len(), 2);
858 assert_eq!(records[0]["id"], 1);
859 }
860
861 #[test]
862 fn extract_records_without_path_returns_data() {
863 let config =
864 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
865 let stream = GraphqlStream::new(config);
866 let body = json!({"data": {"user": {"id": 1}}});
867 let records = stream.extract_records(&body).unwrap();
868 assert_eq!(records.len(), 1);
869 assert_eq!(records[0]["user"]["id"], 1);
870 }
871
872 #[test]
873 fn extract_records_without_path_null_data_yields_empty() {
874 let config =
878 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
879 let stream = GraphqlStream::new(config);
880 let body = json!({ "data": null });
881 let records = stream.extract_records(&body).unwrap();
882 assert!(
883 records.is_empty(),
884 "expected empty Vec for null `data`, got {records:?}"
885 );
886 }
887
888 #[test]
889 fn extract_records_without_path_absent_data_yields_empty() {
890 let config =
893 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
894 let stream = GraphqlStream::new(config);
895 let body = json!({ "extensions": { "foo": 1 } });
896 let records = stream.extract_records(&body).unwrap();
897 assert!(
898 records.is_empty(),
899 "expected empty Vec when `data` is absent, got {records:?}"
900 );
901 }
902
903 #[test]
904 fn dataset_uri_returns_endpoint() {
905 use faucet_core::Source;
906 let stream = GraphqlStream::new(GraphqlStreamConfig::new(
907 "https://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 dataset_uri_redacts_credentials() {
915 use faucet_core::Source;
916 let stream = GraphqlStream::new(GraphqlStreamConfig::new(
917 "https://user:pw@api.example.com/graphql",
918 "query { id }",
919 ));
920 assert_eq!(stream.dataset_uri(), "https://api.example.com/graphql");
921 }
922
923 #[test]
924 fn default_retry_policy_reproduces_legacy_constants() {
925 let stream = GraphqlStream::new(GraphqlStreamConfig::new(
926 "https://api.example.com/graphql",
927 "query { id }",
928 ));
929 assert_eq!(stream.retry_policy.max_attempts, RETRY_MAX_ATTEMPTS + 1);
930 assert_eq!(stream.retry_policy.base, RETRY_BASE_BACKOFF);
931 }
932
933 #[test]
934 fn with_retry_policy_overrides_the_default() {
935 let policy = faucet_core::RetryPolicy {
936 max_attempts: 9,
937 base: Duration::from_secs(7),
938 ..faucet_core::RetryPolicy::default()
939 };
940 let stream = GraphqlStream::new(GraphqlStreamConfig::new(
941 "https://api.example.com/graphql",
942 "query { id }",
943 ))
944 .with_retry_policy(policy);
945 assert_eq!(stream.retry_policy.max_attempts, 9);
946 assert_eq!(stream.retry_policy.base, Duration::from_secs(7));
947 }
948
949 #[test]
950 fn cursor_guard_detects_repeats_and_bounds_memory() {
951 let mut g = CursorGuard::new();
952 assert!(!g.is_repeat("a"));
953 assert!(!g.is_repeat("b"));
954 assert!(g.is_repeat("a"));
956 assert!(g.is_repeat("b"));
957
958 let mut g = CursorGuard::new();
961 for i in 0..CursorGuard::CAP {
962 assert!(!g.is_repeat(&format!("c{i}")));
963 }
964 assert_eq!(g.order.len(), CursorGuard::CAP);
965 assert!(!g.is_repeat("overflow"));
967 assert_eq!(g.order.len(), CursorGuard::CAP);
968 assert!(!g.seen.contains_key("c0"), "oldest cursor evicted");
969 assert!(g.seen.contains_key("overflow"));
970 }
971}
972
973#[cfg(all(test, feature = "mtls"))]
975mod mtls_tests {
976 use super::*;
977 use faucet_core::TlsClientConfig;
978
979 const CERT: &str = include_str!("../tests/fixtures/mtls/cert.pem");
980 const KEY: &str = include_str!("../tests/fixtures/mtls/key.pem");
981
982 fn pem() -> TlsClientConfig {
983 TlsClientConfig {
984 client_cert: Some(CERT.to_string()),
985 client_key: Some(KEY.to_string()),
986 ..Default::default()
987 }
988 }
989
990 fn cfg(tls: TlsClientConfig) -> GraphqlStreamConfig {
991 GraphqlStreamConfig::new("https://x.test/graphql", "{ ping }").tls(tls)
992 }
993
994 #[test]
995 fn pem_identity_builds() {
996 assert!(GraphqlStream::try_new(cfg(pem())).is_ok());
997 }
998
999 #[test]
1000 fn min_version_branches_are_exercised() {
1001 let mut tls = pem();
1002 tls.min_version = Some("1.2".into());
1003 assert!(GraphqlStream::try_new(cfg(tls)).is_ok());
1004 let mut tls = pem();
1007 tls.min_version = Some("1.3".into());
1008 let _ = GraphqlStream::try_new(cfg(tls));
1009 }
1010
1011 #[test]
1012 fn pkcs12_identity_builds() {
1013 let p12 = concat!(
1014 env!("CARGO_MANIFEST_DIR"),
1015 "/tests/fixtures/mtls/identity.p12"
1016 );
1017 let tls = TlsClientConfig {
1018 client_identity_pkcs12: Some(p12.to_string()),
1019 pkcs12_password: Some("changeit".into()),
1020 ..Default::default()
1021 };
1022 assert!(GraphqlStream::try_new(cfg(tls)).is_ok());
1023 }
1024
1025 #[test]
1026 fn invalid_pem_errors_without_leaking_key() {
1027 let tls = TlsClientConfig {
1028 client_cert: Some("-----BEGIN CERTIFICATE-----\nbad\n-----END CERTIFICATE-----".into()),
1029 client_key: Some("SUPERSECRETKEY".into()),
1030 ..Default::default()
1031 };
1032 let err = GraphqlStream::try_new(cfg(tls))
1033 .map(|_| ())
1034 .expect_err("bad PEM must error");
1035 assert!(!err.to_string().contains("SUPERSECRETKEY"));
1036 }
1037
1038 #[test]
1039 fn invalid_tls_shape_errors() {
1040 let mut tls = pem();
1041 tls.client_identity_pkcs12 = Some("/x.p12".into());
1042 assert!(GraphqlStream::try_new(cfg(tls)).is_err());
1043 }
1044
1045 #[test]
1046 fn missing_pkcs12_file_errors() {
1047 let tls = TlsClientConfig {
1048 client_identity_pkcs12: Some("/no/such.p12".into()),
1049 pkcs12_password: Some("x".into()),
1050 ..Default::default()
1051 };
1052 assert!(GraphqlStream::try_new(cfg(tls)).is_err());
1053 }
1054
1055 #[test]
1056 fn config_validate_checks_tls() {
1057 assert!(cfg(pem()).validate().is_ok());
1058 let mut bad = pem();
1059 bad.client_identity_pkcs12 = Some("/x.p12".into());
1060 assert!(cfg(bad).validate().is_err());
1061 }
1062}