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 config_schema(&self) -> serde_json::Value {
475 serde_json::to_value(faucet_core::schema_for!(GraphqlStreamConfig))
476 .expect("schema serialization")
477 }
478
479 fn dataset_uri(&self) -> String {
480 faucet_core::redact_uri_credentials(&self.config.endpoint)
481 }
482}
483
484fn extract_string(body: &Value, path: &str) -> Option<String> {
485 let results = body.query(path).ok()?;
486 match results.first()? {
487 Value::String(s) => Some(s.clone()),
488 _ => None,
489 }
490}
491
492fn extract_bool(body: &Value, path: &str) -> Option<bool> {
493 let results = body.query(path).ok()?;
494 results.first()?.as_bool()
495}
496
497#[derive(Debug, PartialEq)]
499enum PageStep {
500 Stop,
502 StopLoop,
505 Advance(String),
507}
508
509fn decide_next_page(
518 body: &Value,
519 pag: &GraphqlPagination,
520 prev_cursor: Option<&str>,
521) -> (PageStep, bool) {
522 let (stop, unresolved) = match extract_bool(body, &pag.has_next_page_path) {
523 Some(false) => (true, false),
524 Some(true) => (false, false),
525 None => (false, true),
527 };
528 if stop {
529 return (PageStep::Stop, unresolved);
530 }
531 match extract_string(body, &pag.cursor_path) {
532 None => (PageStep::Stop, unresolved),
533 Some(next) if Some(next.as_str()) == prev_cursor => (PageStep::StopLoop, unresolved),
534 Some(next) => (PageStep::Advance(next), unresolved),
535 }
536}
537
538struct CursorGuard {
551 seen: HashMap<String, ()>,
552 order: std::collections::VecDeque<String>,
553}
554
555impl CursorGuard {
556 const CAP: usize = 4096;
557
558 fn new() -> Self {
559 Self {
560 seen: HashMap::new(),
561 order: std::collections::VecDeque::new(),
562 }
563 }
564
565 fn is_repeat(&mut self, cursor: &str) -> bool {
567 if self.seen.contains_key(cursor) {
568 return true;
569 }
570 if self.order.len() >= Self::CAP
571 && let Some(old) = self.order.pop_front()
572 {
573 self.seen.remove(&old);
574 }
575 self.seen.insert(cursor.to_string(), ());
576 self.order.push_back(cursor.to_string());
577 false
578 }
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 #[test]
586 fn extract_string_from_json() {
587 let body = json!({"data": {"users": {"pageInfo": {"endCursor": "abc123"}}}});
588 assert_eq!(
589 extract_string(&body, "$.data.users.pageInfo.endCursor"),
590 Some("abc123".into())
591 );
592 }
593
594 #[test]
595 fn extract_bool_from_json() {
596 let body = json!({"data": {"users": {"pageInfo": {"hasNextPage": true}}}});
597 assert_eq!(
598 extract_bool(&body, "$.data.users.pageInfo.hasNextPage"),
599 Some(true)
600 );
601 }
602
603 fn pageinfo_pagination() -> GraphqlPagination {
604 GraphqlPagination {
605 has_next_page_path: "$.data.users.pageInfo.hasNextPage".into(),
606 cursor_path: "$.data.users.pageInfo.endCursor".into(),
607 ..GraphqlPagination::default()
608 }
609 }
610
611 #[test]
612 fn decide_next_page_advances_when_has_next_true() {
613 let body =
614 json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c2"}}}});
615 let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
616 assert_eq!(step, PageStep::Advance("c2".into()));
617 assert!(!unresolved);
618 }
619
620 #[test]
621 fn decide_next_page_stops_when_has_next_false() {
622 let body =
623 json!({"data": {"users": {"pageInfo": {"hasNextPage": false, "endCursor": "c2"}}}});
624 let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
625 assert_eq!(step, PageStep::Stop);
626 assert!(!unresolved);
627 }
628
629 #[test]
630 fn decide_next_page_detects_cursor_loop() {
631 let body =
632 json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c1"}}}});
633 let (step, _) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
634 assert_eq!(step, PageStep::StopLoop);
635 }
636
637 #[test]
638 fn decide_next_page_defers_to_cursor_when_has_next_unresolved() {
639 let body = json!({"data": {"users": {"pageInfo": {"endCursor": "c2"}}}}); let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
644 assert_eq!(
645 step,
646 PageStep::Advance("c2".into()),
647 "unresolved has-next must defer to cursor presence, not stop"
648 );
649 assert!(unresolved, "the caller is told to warn once");
650
651 let body_no_cursor = json!({"data": {"users": {"pageInfo": {}}}});
653 let (step, unresolved) =
654 decide_next_page(&body_no_cursor, &pageinfo_pagination(), Some("c1"));
655 assert_eq!(step, PageStep::Stop);
656 assert!(unresolved);
657 }
658
659 #[test]
660 fn extract_records_with_path() {
661 let config =
662 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { users { id } }")
663 .records_path("$.data.users[*]");
664 let stream = GraphqlStream::new(config);
665 let body = json!({"data": {"users": [{"id": 1}, {"id": 2}]}});
666 let records = stream.extract_records(&body).unwrap();
667 assert_eq!(records.len(), 2);
668 assert_eq!(records[0]["id"], 1);
669 }
670
671 #[test]
672 fn extract_records_without_path_returns_data() {
673 let config =
674 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
675 let stream = GraphqlStream::new(config);
676 let body = json!({"data": {"user": {"id": 1}}});
677 let records = stream.extract_records(&body).unwrap();
678 assert_eq!(records.len(), 1);
679 assert_eq!(records[0]["user"]["id"], 1);
680 }
681
682 #[test]
683 fn extract_records_without_path_null_data_yields_empty() {
684 let config =
688 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
689 let stream = GraphqlStream::new(config);
690 let body = json!({ "data": null });
691 let records = stream.extract_records(&body).unwrap();
692 assert!(
693 records.is_empty(),
694 "expected empty Vec for null `data`, got {records:?}"
695 );
696 }
697
698 #[test]
699 fn extract_records_without_path_absent_data_yields_empty() {
700 let config =
703 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
704 let stream = GraphqlStream::new(config);
705 let body = json!({ "extensions": { "foo": 1 } });
706 let records = stream.extract_records(&body).unwrap();
707 assert!(
708 records.is_empty(),
709 "expected empty Vec when `data` is absent, got {records:?}"
710 );
711 }
712
713 #[test]
714 fn dataset_uri_returns_endpoint() {
715 use faucet_core::Source;
716 let stream = GraphqlStream::new(GraphqlStreamConfig::new(
717 "https://api.example.com/graphql",
718 "query { id }",
719 ));
720 assert_eq!(stream.dataset_uri(), "https://api.example.com/graphql");
721 }
722
723 #[test]
724 fn dataset_uri_redacts_credentials() {
725 use faucet_core::Source;
726 let stream = GraphqlStream::new(GraphqlStreamConfig::new(
727 "https://user:pw@api.example.com/graphql",
728 "query { id }",
729 ));
730 assert_eq!(stream.dataset_uri(), "https://api.example.com/graphql");
731 }
732
733 #[test]
734 fn default_retry_policy_reproduces_legacy_constants() {
735 let stream = GraphqlStream::new(GraphqlStreamConfig::new(
736 "https://api.example.com/graphql",
737 "query { id }",
738 ));
739 assert_eq!(stream.retry_policy.max_attempts, RETRY_MAX_ATTEMPTS + 1);
740 assert_eq!(stream.retry_policy.base, RETRY_BASE_BACKOFF);
741 }
742
743 #[test]
744 fn with_retry_policy_overrides_the_default() {
745 let policy = faucet_core::RetryPolicy {
746 max_attempts: 9,
747 base: Duration::from_secs(7),
748 ..faucet_core::RetryPolicy::default()
749 };
750 let stream = GraphqlStream::new(GraphqlStreamConfig::new(
751 "https://api.example.com/graphql",
752 "query { id }",
753 ))
754 .with_retry_policy(policy);
755 assert_eq!(stream.retry_policy.max_attempts, 9);
756 assert_eq!(stream.retry_policy.base, Duration::from_secs(7));
757 }
758
759 #[test]
760 fn cursor_guard_detects_repeats_and_bounds_memory() {
761 let mut g = CursorGuard::new();
762 assert!(!g.is_repeat("a"));
763 assert!(!g.is_repeat("b"));
764 assert!(g.is_repeat("a"));
766 assert!(g.is_repeat("b"));
767
768 let mut g = CursorGuard::new();
771 for i in 0..CursorGuard::CAP {
772 assert!(!g.is_repeat(&format!("c{i}")));
773 }
774 assert_eq!(g.order.len(), CursorGuard::CAP);
775 assert!(!g.is_repeat("overflow"));
777 assert_eq!(g.order.len(), CursorGuard::CAP);
778 assert!(!g.seen.contains_key("c0"), "oldest cursor evicted");
779 assert!(g.seen.contains_key("overflow"));
780 }
781}