1use crate::config::{GraphqlAuth, GraphqlPagination, GraphqlStreamConfig};
4use async_trait::async_trait;
5use base64::Engine as _;
6use faucet_core::util::{self, DEFAULT_ERROR_BODY_MAX_LEN};
7use faucet_core::{AuthSpec, Credential, FaucetError, SharedAuthProvider, Stream, StreamPage};
8use jsonpath_rust::JsonPath;
9use reqwest::Client;
10use serde_json::{Value, json};
11use std::collections::HashMap;
12use std::pin::Pin;
13use std::time::Duration;
14
15const RETRY_MAX_ATTEMPTS: u32 = 3;
17const RETRY_BASE_BACKOFF: Duration = Duration::from_millis(500);
19
20pub struct GraphqlStream {
22 config: GraphqlStreamConfig,
23 client: Client,
24 auth_provider: Option<SharedAuthProvider>,
28 retry_policy: faucet_core::RetryPolicy,
32}
33
34fn credential_to_auth(cred: Credential) -> GraphqlAuth {
37 match cred {
38 Credential::Bearer(token) => GraphqlAuth::Bearer { token },
39 Credential::Token(token) => GraphqlAuth::Custom {
40 headers: HashMap::from([("Authorization".into(), token)]),
41 },
42 Credential::Header { name, value } => GraphqlAuth::Custom {
43 headers: HashMap::from([(name, value)]),
44 },
45 Credential::Basic { username, password } => GraphqlAuth::Custom {
46 headers: HashMap::from([(
47 "Authorization".into(),
48 format!(
49 "Basic {}",
50 base64::engine::general_purpose::STANDARD
51 .encode(format!("{username}:{password}"))
52 ),
53 )]),
54 },
55 }
56}
57
58impl GraphqlStream {
59 pub fn new(config: GraphqlStreamConfig) -> Self {
61 Self {
62 config,
63 client: Client::new(),
64 auth_provider: None,
65 retry_policy: faucet_core::RetryPolicy {
69 max_attempts: RETRY_MAX_ATTEMPTS + 1,
70 backoff: faucet_core::BackoffKind::Exponential,
71 base: RETRY_BASE_BACKOFF,
72 max: Duration::from_secs(60),
73 jitter: true,
74 retry_on: faucet_core::RetryClassSet::default(),
75 },
76 }
77 }
78
79 pub fn with_retry_policy(mut self, policy: faucet_core::RetryPolicy) -> Self {
84 self.retry_policy = policy;
85 self
86 }
87
88 pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
94 self.auth_provider = Some(provider);
95 self
96 }
97
98 pub async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
100 self.fetch_all_with_context(&std::collections::HashMap::new())
101 .await
102 }
103
104 async fn fetch_all_with_context(
106 &self,
107 context: &std::collections::HashMap<String, Value>,
108 ) -> Result<Vec<Value>, FaucetError> {
109 let mut all_records = Vec::new();
110 let mut cursor: Option<String> = None;
111 let mut pages_fetched = 0usize;
112 let mut warned_unresolved_has_next = false;
113 let mut cursor_guard = CursorGuard::new();
114
115 loop {
116 if let Some(max) = self.config.max_pages
117 && pages_fetched >= max
118 {
119 tracing::warn!("max pages ({max}) reached");
120 break;
121 }
122
123 let body = self.execute_query(&cursor, context).await?;
124 let records = self.extract_records(&body)?;
125 all_records.extend(records);
126 pages_fetched += 1;
127
128 match &self.config.pagination {
130 Some(pag) => {
131 let (step, unresolved) = decide_next_page(&body, pag, cursor.as_deref());
132 if unresolved && !warned_unresolved_has_next {
133 tracing::warn!(
134 path = %pag.has_next_page_path,
135 "GraphQL has_next_page path did not resolve to a boolean; \
136 deferring to cursor presence to decide pagination"
137 );
138 warned_unresolved_has_next = true;
139 }
140 match step {
141 PageStep::Stop => break,
142 PageStep::StopLoop => {
143 tracing::warn!("cursor loop detected, stopping pagination");
144 break;
145 }
146 PageStep::Advance(next) => {
147 if cursor_guard.is_repeat(&next) {
148 tracing::warn!(
149 "cursor cycle detected (cursor already seen), stopping pagination"
150 );
151 break;
152 }
153 cursor = Some(next);
154 }
155 }
156 }
157 None => break,
158 }
159 }
160
161 tracing::info!(
162 records = all_records.len(),
163 pages = pages_fetched,
164 "GraphQL fetch complete"
165 );
166 Ok(all_records)
167 }
168
169 async fn execute_query(
171 &self,
172 cursor: &Option<String>,
173 context: &std::collections::HashMap<String, Value>,
174 ) -> Result<Value, FaucetError> {
175 let mut variables = self.config.variables.clone();
176
177 if !context.is_empty()
179 && let Value::Object(ref mut map) = variables
180 {
181 for (key, value) in context {
182 map.insert(key.clone(), value.clone());
183 }
184 }
185
186 if let (Some(pag), Some(cursor_val)) = (&self.config.pagination, cursor)
188 && let Value::Object(ref mut map) = variables
189 {
190 map.insert(pag.cursor_variable.clone(), json!(cursor_val));
191 }
192 if let Some(pag) = &self.config.pagination
196 && self.config.batch_size != 0
197 && let Value::Object(map) = &mut variables
198 {
199 map.insert(
200 pag.page_size_variable.clone(),
201 json!(self.config.batch_size),
202 );
203 }
204
205 let payload = json!({
206 "query": self.config.query,
207 "variables": variables,
208 });
209
210 let mut req = self
211 .client
212 .post(&self.config.endpoint)
213 .headers(self.config.headers.clone())
214 .json(&payload);
215
216 let effective_auth: GraphqlAuth = if let Some(provider) = &self.auth_provider {
220 credential_to_auth(provider.credential().await?)
221 } else {
222 match &self.config.auth {
223 AuthSpec::Inline(a) => a.clone(),
224 AuthSpec::Reference(r) => {
225 return Err(FaucetError::Auth(format!(
226 "auth references provider '{}' but no provider was supplied; \
227 set one via the CLI `auth:` catalog or `with_auth_provider`",
228 r.name
229 )));
230 }
231 }
232 };
233
234 match effective_auth {
236 GraphqlAuth::None => {}
237 GraphqlAuth::Bearer { token } => {
238 req = req.bearer_auth(token);
239 }
240 GraphqlAuth::Custom { headers } => {
241 let mut hm = reqwest::header::HeaderMap::new();
242 for (name, value) in &headers {
243 let n =
244 reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
245 FaucetError::Auth(format!("invalid custom header name {name:?}: {e}"))
246 })?;
247 let v = reqwest::header::HeaderValue::from_str(value).map_err(|e| {
248 FaucetError::Auth(format!("invalid custom header value for {name:?}: {e}"))
249 })?;
250 hm.insert(n, v);
251 }
252 req = req.headers(hm);
253 }
254 }
255
256 let body: Value = faucet_core::execute_with_policy(&self.retry_policy, None, || {
261 let attempt = req.try_clone();
262 async move {
263 let req = attempt.ok_or_else(|| {
264 FaucetError::Source("graphql: request is not cloneable for retry".into())
265 })?;
266 let resp = req.send().await.map_err(FaucetError::Http)?;
267 let resp = util::check_http_response(resp, DEFAULT_ERROR_BODY_MAX_LEN).await?;
268 resp.json().await.map_err(FaucetError::Http)
269 }
270 })
271 .await?;
272
273 if let Some(errors) = body.get("errors")
275 && let Some(arr) = errors.as_array()
276 && !arr.is_empty()
277 {
278 let msg = arr
279 .iter()
280 .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
281 .collect::<Vec<_>>()
282 .join("; ");
283 let lower = msg.to_lowercase();
289 if self.config.batch_size == 0
290 && let Some(pag) = &self.config.pagination
291 {
292 let var_name = pag.page_size_variable.to_lowercase();
293 if lower.contains(&var_name)
294 && (lower.contains("non-null")
295 || lower.contains("non null")
296 || lower.contains("must not be null")
297 || lower.contains("cannot be null")
298 || lower.contains("required"))
299 {
300 return Err(FaucetError::Config(format!(
301 "batch_size = 0 requires the upstream to accept a null {}: argument \
302 (GraphQL errors: {msg})",
303 pag.page_size_variable
304 )));
305 }
306 }
307 return Err(FaucetError::HttpStatus {
308 status: 200,
309 url: self.config.endpoint.clone(),
310 body: format!("GraphQL errors: {msg}"),
311 });
312 }
313
314 Ok(body)
315 }
316
317 fn extract_records(&self, body: &Value) -> Result<Vec<Value>, FaucetError> {
319 match &self.config.records_path {
320 Some(path) => util::extract_records(body, Some(path)),
321 None => {
322 match body.get("data") {
328 Some(Value::Null) | None => Ok(Vec::new()),
329 Some(data) => Ok(vec![data.clone()]),
330 }
331 }
332 }
333 }
334
335 fn stream_pages_inner(
348 &self,
349 context: &std::collections::HashMap<String, Value>,
350 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + '_>> {
351 let owned_context: std::collections::HashMap<String, Value> = context.clone();
353
354 Box::pin(async_stream::try_stream! {
355 let mut cursor: Option<String> = None;
356 let mut cursor_guard = CursorGuard::new();
357 let mut pages_fetched = 0usize;
358 let mut warned_unresolved_has_next = false;
359 let running_max: Option<Value> = None;
364 let mut bookmark_emitted = false;
365
366 loop {
367 if let Some(max) = self.config.max_pages
368 && pages_fetched >= max
369 {
370 tracing::warn!("max pages ({max}) reached");
371 break;
372 }
373
374 let body = self.execute_query(&cursor, &owned_context).await?;
375 let records = self.extract_records(&body)?;
376 pages_fetched += 1;
377
378 let has_next = match &self.config.pagination {
381 Some(pag) => {
382 let (step, unresolved) =
383 decide_next_page(&body, pag, cursor.as_deref());
384 if unresolved && !warned_unresolved_has_next {
385 tracing::warn!(
386 path = %pag.has_next_page_path,
387 "GraphQL has_next_page path did not resolve to a boolean; \
388 deferring to cursor presence to decide pagination"
389 );
390 warned_unresolved_has_next = true;
391 }
392 match step {
393 PageStep::Stop => false,
394 PageStep::StopLoop => {
395 tracing::warn!("cursor loop detected, stopping pagination");
396 false
397 }
398 PageStep::Advance(next) => {
399 if cursor_guard.is_repeat(&next) {
400 tracing::warn!(
401 "cursor cycle detected (cursor already seen), stopping pagination"
402 );
403 false
404 } else {
405 cursor = Some(next);
406 true
407 }
408 }
409 }
410 }
411 None => false,
412 };
413
414 if has_next {
415 yield StreamPage { records, bookmark: None };
417 } else {
418 bookmark_emitted = running_max.is_some();
421 yield StreamPage {
422 records,
423 bookmark: running_max.clone(),
424 };
425 break;
426 }
427 }
428
429 if !bookmark_emitted && running_max.is_some() {
436 yield StreamPage {
437 records: Vec::new(),
438 bookmark: running_max,
439 };
440 }
441
442 tracing::info!(
443 pages = pages_fetched,
444 batch_size = self.config.batch_size,
445 "GraphQL source stream complete",
446 );
447 })
448 }
449}
450
451#[async_trait]
452impl faucet_core::Source for GraphqlStream {
453 async fn fetch_with_context(
454 &self,
455 context: &std::collections::HashMap<String, serde_json::Value>,
456 ) -> Result<Vec<Value>, FaucetError> {
457 self.fetch_all_with_context(context).await
458 }
459
460 fn stream_pages<'a>(
467 &'a self,
468 context: &'a std::collections::HashMap<String, Value>,
469 _batch_size: usize,
470 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
471 self.stream_pages_inner(context)
472 }
473
474 fn connector_name(&self) -> &'static str {
475 "graphql"
476 }
477
478 fn config_schema(&self) -> serde_json::Value {
479 serde_json::to_value(faucet_core::schema_for!(GraphqlStreamConfig))
480 .expect("schema serialization")
481 }
482
483 fn dataset_uri(&self) -> String {
484 faucet_core::redact_uri_credentials(&self.config.endpoint)
485 }
486}
487
488fn extract_string(body: &Value, path: &str) -> Option<String> {
489 let results = body.query(path).ok()?;
490 match results.first()? {
491 Value::String(s) => Some(s.clone()),
492 _ => None,
493 }
494}
495
496fn extract_bool(body: &Value, path: &str) -> Option<bool> {
497 let results = body.query(path).ok()?;
498 results.first()?.as_bool()
499}
500
501#[derive(Debug, PartialEq)]
503enum PageStep {
504 Stop,
506 StopLoop,
509 Advance(String),
511}
512
513fn decide_next_page(
522 body: &Value,
523 pag: &GraphqlPagination,
524 prev_cursor: Option<&str>,
525) -> (PageStep, bool) {
526 let (stop, unresolved) = match extract_bool(body, &pag.has_next_page_path) {
527 Some(false) => (true, false),
528 Some(true) => (false, false),
529 None => (false, true),
531 };
532 if stop {
533 return (PageStep::Stop, unresolved);
534 }
535 match extract_string(body, &pag.cursor_path) {
536 None => (PageStep::Stop, unresolved),
537 Some(next) if Some(next.as_str()) == prev_cursor => (PageStep::StopLoop, unresolved),
538 Some(next) => (PageStep::Advance(next), unresolved),
539 }
540}
541
542struct CursorGuard {
555 seen: HashMap<String, ()>,
556 order: std::collections::VecDeque<String>,
557}
558
559impl CursorGuard {
560 const CAP: usize = 4096;
561
562 fn new() -> Self {
563 Self {
564 seen: HashMap::new(),
565 order: std::collections::VecDeque::new(),
566 }
567 }
568
569 fn is_repeat(&mut self, cursor: &str) -> bool {
571 if self.seen.contains_key(cursor) {
572 return true;
573 }
574 if self.order.len() >= Self::CAP
575 && let Some(old) = self.order.pop_front()
576 {
577 self.seen.remove(&old);
578 }
579 self.seen.insert(cursor.to_string(), ());
580 self.order.push_back(cursor.to_string());
581 false
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588
589 #[test]
590 fn extract_string_from_json() {
591 let body = json!({"data": {"users": {"pageInfo": {"endCursor": "abc123"}}}});
592 assert_eq!(
593 extract_string(&body, "$.data.users.pageInfo.endCursor"),
594 Some("abc123".into())
595 );
596 }
597
598 #[test]
599 fn extract_bool_from_json() {
600 let body = json!({"data": {"users": {"pageInfo": {"hasNextPage": true}}}});
601 assert_eq!(
602 extract_bool(&body, "$.data.users.pageInfo.hasNextPage"),
603 Some(true)
604 );
605 }
606
607 fn pageinfo_pagination() -> GraphqlPagination {
608 GraphqlPagination {
609 has_next_page_path: "$.data.users.pageInfo.hasNextPage".into(),
610 cursor_path: "$.data.users.pageInfo.endCursor".into(),
611 ..GraphqlPagination::default()
612 }
613 }
614
615 #[test]
616 fn decide_next_page_advances_when_has_next_true() {
617 let body =
618 json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c2"}}}});
619 let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
620 assert_eq!(step, PageStep::Advance("c2".into()));
621 assert!(!unresolved);
622 }
623
624 #[test]
625 fn decide_next_page_stops_when_has_next_false() {
626 let body =
627 json!({"data": {"users": {"pageInfo": {"hasNextPage": false, "endCursor": "c2"}}}});
628 let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
629 assert_eq!(step, PageStep::Stop);
630 assert!(!unresolved);
631 }
632
633 #[test]
634 fn decide_next_page_detects_cursor_loop() {
635 let body =
636 json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c1"}}}});
637 let (step, _) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
638 assert_eq!(step, PageStep::StopLoop);
639 }
640
641 #[test]
642 fn decide_next_page_defers_to_cursor_when_has_next_unresolved() {
643 let body = json!({"data": {"users": {"pageInfo": {"endCursor": "c2"}}}}); let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
648 assert_eq!(
649 step,
650 PageStep::Advance("c2".into()),
651 "unresolved has-next must defer to cursor presence, not stop"
652 );
653 assert!(unresolved, "the caller is told to warn once");
654
655 let body_no_cursor = json!({"data": {"users": {"pageInfo": {}}}});
657 let (step, unresolved) =
658 decide_next_page(&body_no_cursor, &pageinfo_pagination(), Some("c1"));
659 assert_eq!(step, PageStep::Stop);
660 assert!(unresolved);
661 }
662
663 #[test]
664 fn extract_records_with_path() {
665 let config =
666 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { users { id } }")
667 .records_path("$.data.users[*]");
668 let stream = GraphqlStream::new(config);
669 let body = json!({"data": {"users": [{"id": 1}, {"id": 2}]}});
670 let records = stream.extract_records(&body).unwrap();
671 assert_eq!(records.len(), 2);
672 assert_eq!(records[0]["id"], 1);
673 }
674
675 #[test]
676 fn extract_records_without_path_returns_data() {
677 let config =
678 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
679 let stream = GraphqlStream::new(config);
680 let body = json!({"data": {"user": {"id": 1}}});
681 let records = stream.extract_records(&body).unwrap();
682 assert_eq!(records.len(), 1);
683 assert_eq!(records[0]["user"]["id"], 1);
684 }
685
686 #[test]
687 fn extract_records_without_path_null_data_yields_empty() {
688 let config =
692 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
693 let stream = GraphqlStream::new(config);
694 let body = json!({ "data": null });
695 let records = stream.extract_records(&body).unwrap();
696 assert!(
697 records.is_empty(),
698 "expected empty Vec for null `data`, got {records:?}"
699 );
700 }
701
702 #[test]
703 fn extract_records_without_path_absent_data_yields_empty() {
704 let config =
707 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
708 let stream = GraphqlStream::new(config);
709 let body = json!({ "extensions": { "foo": 1 } });
710 let records = stream.extract_records(&body).unwrap();
711 assert!(
712 records.is_empty(),
713 "expected empty Vec when `data` is absent, got {records:?}"
714 );
715 }
716
717 #[test]
718 fn dataset_uri_returns_endpoint() {
719 use faucet_core::Source;
720 let stream = GraphqlStream::new(GraphqlStreamConfig::new(
721 "https://api.example.com/graphql",
722 "query { id }",
723 ));
724 assert_eq!(stream.dataset_uri(), "https://api.example.com/graphql");
725 }
726
727 #[test]
728 fn dataset_uri_redacts_credentials() {
729 use faucet_core::Source;
730 let stream = GraphqlStream::new(GraphqlStreamConfig::new(
731 "https://user:pw@api.example.com/graphql",
732 "query { id }",
733 ));
734 assert_eq!(stream.dataset_uri(), "https://api.example.com/graphql");
735 }
736
737 #[test]
738 fn default_retry_policy_reproduces_legacy_constants() {
739 let stream = GraphqlStream::new(GraphqlStreamConfig::new(
740 "https://api.example.com/graphql",
741 "query { id }",
742 ));
743 assert_eq!(stream.retry_policy.max_attempts, RETRY_MAX_ATTEMPTS + 1);
744 assert_eq!(stream.retry_policy.base, RETRY_BASE_BACKOFF);
745 }
746
747 #[test]
748 fn with_retry_policy_overrides_the_default() {
749 let policy = faucet_core::RetryPolicy {
750 max_attempts: 9,
751 base: Duration::from_secs(7),
752 ..faucet_core::RetryPolicy::default()
753 };
754 let stream = GraphqlStream::new(GraphqlStreamConfig::new(
755 "https://api.example.com/graphql",
756 "query { id }",
757 ))
758 .with_retry_policy(policy);
759 assert_eq!(stream.retry_policy.max_attempts, 9);
760 assert_eq!(stream.retry_policy.base, Duration::from_secs(7));
761 }
762
763 #[test]
764 fn cursor_guard_detects_repeats_and_bounds_memory() {
765 let mut g = CursorGuard::new();
766 assert!(!g.is_repeat("a"));
767 assert!(!g.is_repeat("b"));
768 assert!(g.is_repeat("a"));
770 assert!(g.is_repeat("b"));
771
772 let mut g = CursorGuard::new();
775 for i in 0..CursorGuard::CAP {
776 assert!(!g.is_repeat(&format!("c{i}")));
777 }
778 assert_eq!(g.order.len(), CursorGuard::CAP);
779 assert!(!g.is_repeat("overflow"));
781 assert_eq!(g.order.len(), CursorGuard::CAP);
782 assert!(!g.seen.contains_key("c0"), "oldest cursor evicted");
783 assert!(g.seen.contains_key("overflow"));
784 }
785}