use crate::config::{GraphqlAuth, GraphqlPagination, GraphqlStreamConfig};
use async_trait::async_trait;
use base64::Engine as _;
use faucet_core::util::{self, DEFAULT_ERROR_BODY_MAX_LEN};
use faucet_core::{AuthSpec, Credential, FaucetError, SharedAuthProvider, Stream, StreamPage};
use jsonpath_rust::JsonPath;
use reqwest::Client;
use serde_json::{Value, json};
use std::collections::HashMap;
use std::pin::Pin;
use std::time::Duration;
const RETRY_MAX_ATTEMPTS: u32 = 3;
const RETRY_BASE_BACKOFF: Duration = Duration::from_millis(500);
pub struct GraphqlStream {
config: GraphqlStreamConfig,
client: Client,
auth_provider: Option<SharedAuthProvider>,
retry_policy: faucet_core::RetryPolicy,
}
fn credential_to_auth(cred: Credential) -> GraphqlAuth {
match cred {
Credential::Bearer(token) => GraphqlAuth::Bearer { token },
Credential::Token(token) => GraphqlAuth::Custom {
headers: HashMap::from([("Authorization".into(), token)]),
},
Credential::Header { name, value } => GraphqlAuth::Custom {
headers: HashMap::from([(name, value)]),
},
Credential::Basic { username, password } => GraphqlAuth::Custom {
headers: HashMap::from([(
"Authorization".into(),
format!(
"Basic {}",
base64::engine::general_purpose::STANDARD
.encode(format!("{username}:{password}"))
),
)]),
},
}
}
impl GraphqlStream {
pub fn new(config: GraphqlStreamConfig) -> Self {
Self {
config,
client: Client::new(),
auth_provider: None,
retry_policy: faucet_core::RetryPolicy {
max_attempts: RETRY_MAX_ATTEMPTS + 1,
backoff: faucet_core::BackoffKind::Exponential,
base: RETRY_BASE_BACKOFF,
max: Duration::from_secs(60),
jitter: true,
retry_on: faucet_core::RetryClassSet::default(),
},
}
}
pub fn with_retry_policy(mut self, policy: faucet_core::RetryPolicy) -> Self {
self.retry_policy = policy;
self
}
pub fn with_auth_provider(mut self, provider: SharedAuthProvider) -> Self {
self.auth_provider = Some(provider);
self
}
pub async fn fetch_all(&self) -> Result<Vec<Value>, FaucetError> {
self.fetch_all_with_context(&std::collections::HashMap::new())
.await
}
async fn fetch_all_with_context(
&self,
context: &std::collections::HashMap<String, Value>,
) -> Result<Vec<Value>, FaucetError> {
let mut all_records = Vec::new();
let mut cursor: Option<String> = None;
let mut pages_fetched = 0usize;
let mut warned_unresolved_has_next = false;
let mut cursor_guard = CursorGuard::new();
loop {
if let Some(max) = self.config.max_pages
&& pages_fetched >= max
{
tracing::warn!("max pages ({max}) reached");
break;
}
let body = self.execute_query(&cursor, context).await?;
let records = self.extract_records(&body)?;
all_records.extend(records);
pages_fetched += 1;
match &self.config.pagination {
Some(pag) => {
let (step, unresolved) = decide_next_page(&body, pag, cursor.as_deref());
if unresolved && !warned_unresolved_has_next {
tracing::warn!(
path = %pag.has_next_page_path,
"GraphQL has_next_page path did not resolve to a boolean; \
deferring to cursor presence to decide pagination"
);
warned_unresolved_has_next = true;
}
match step {
PageStep::Stop => break,
PageStep::StopLoop => {
tracing::warn!("cursor loop detected, stopping pagination");
break;
}
PageStep::Advance(next) => {
if cursor_guard.is_repeat(&next) {
tracing::warn!(
"cursor cycle detected (cursor already seen), stopping pagination"
);
break;
}
cursor = Some(next);
}
}
}
None => break,
}
}
tracing::info!(
records = all_records.len(),
pages = pages_fetched,
"GraphQL fetch complete"
);
Ok(all_records)
}
async fn execute_query(
&self,
cursor: &Option<String>,
context: &std::collections::HashMap<String, Value>,
) -> Result<Value, FaucetError> {
let mut variables = self.config.variables.clone();
if !context.is_empty()
&& let Value::Object(ref mut map) = variables
{
for (key, value) in context {
map.insert(key.clone(), value.clone());
}
}
if let (Some(pag), Some(cursor_val)) = (&self.config.pagination, cursor)
&& let Value::Object(ref mut map) = variables
{
map.insert(pag.cursor_variable.clone(), json!(cursor_val));
}
if let Some(pag) = &self.config.pagination
&& self.config.batch_size != 0
&& let Value::Object(map) = &mut variables
{
map.insert(
pag.page_size_variable.clone(),
json!(self.config.batch_size),
);
}
let payload = json!({
"query": self.config.query,
"variables": variables,
});
let mut req = self
.client
.post(&self.config.endpoint)
.headers(self.config.headers.clone())
.json(&payload);
let effective_auth: GraphqlAuth = if let Some(provider) = &self.auth_provider {
credential_to_auth(provider.credential().await?)
} else {
match &self.config.auth {
AuthSpec::Inline(a) => a.clone(),
AuthSpec::Reference(r) => {
return Err(FaucetError::Auth(format!(
"auth references provider '{}' but no provider was supplied; \
set one via the CLI `auth:` catalog or `with_auth_provider`",
r.name
)));
}
}
};
match effective_auth {
GraphqlAuth::None => {}
GraphqlAuth::Bearer { token } => {
req = req.bearer_auth(token);
}
GraphqlAuth::Custom { headers } => {
let mut hm = reqwest::header::HeaderMap::new();
for (name, value) in &headers {
let n =
reqwest::header::HeaderName::from_bytes(name.as_bytes()).map_err(|e| {
FaucetError::Auth(format!("invalid custom header name {name:?}: {e}"))
})?;
let v = reqwest::header::HeaderValue::from_str(value).map_err(|e| {
FaucetError::Auth(format!("invalid custom header value for {name:?}: {e}"))
})?;
hm.insert(n, v);
}
req = req.headers(hm);
}
}
let body: Value = faucet_core::execute_with_policy(&self.retry_policy, None, || {
let attempt = req.try_clone();
async move {
let req = attempt.ok_or_else(|| {
FaucetError::Source("graphql: request is not cloneable for retry".into())
})?;
let resp = req.send().await.map_err(FaucetError::Http)?;
let resp = util::check_http_response(resp, DEFAULT_ERROR_BODY_MAX_LEN).await?;
resp.json().await.map_err(FaucetError::Http)
}
})
.await?;
if let Some(errors) = body.get("errors")
&& let Some(arr) = errors.as_array()
&& !arr.is_empty()
{
let msg = arr
.iter()
.filter_map(|e| e.get("message").and_then(|m| m.as_str()))
.collect::<Vec<_>>()
.join("; ");
let lower = msg.to_lowercase();
if self.config.batch_size == 0
&& let Some(pag) = &self.config.pagination
{
let var_name = pag.page_size_variable.to_lowercase();
if lower.contains(&var_name)
&& (lower.contains("non-null")
|| lower.contains("non null")
|| lower.contains("must not be null")
|| lower.contains("cannot be null")
|| lower.contains("required"))
{
return Err(FaucetError::Config(format!(
"batch_size = 0 requires the upstream to accept a null {}: argument \
(GraphQL errors: {msg})",
pag.page_size_variable
)));
}
}
return Err(FaucetError::HttpStatus {
status: 200,
url: self.config.endpoint.clone(),
body: format!("GraphQL errors: {msg}"),
});
}
Ok(body)
}
fn extract_records(&self, body: &Value) -> Result<Vec<Value>, FaucetError> {
match &self.config.records_path {
Some(path) => util::extract_records(body, Some(path)),
None => {
match body.get("data") {
Some(Value::Null) | None => Ok(Vec::new()),
Some(data) => Ok(vec![data.clone()]),
}
}
}
}
fn stream_pages_inner(
&self,
context: &std::collections::HashMap<String, Value>,
) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + '_>> {
let owned_context: std::collections::HashMap<String, Value> = context.clone();
Box::pin(async_stream::try_stream! {
let mut cursor: Option<String> = None;
let mut cursor_guard = CursorGuard::new();
let mut pages_fetched = 0usize;
let mut warned_unresolved_has_next = false;
let running_max: Option<Value> = None;
let mut bookmark_emitted = false;
loop {
if let Some(max) = self.config.max_pages
&& pages_fetched >= max
{
tracing::warn!("max pages ({max}) reached");
break;
}
let body = self.execute_query(&cursor, &owned_context).await?;
let records = self.extract_records(&body)?;
pages_fetched += 1;
let has_next = match &self.config.pagination {
Some(pag) => {
let (step, unresolved) =
decide_next_page(&body, pag, cursor.as_deref());
if unresolved && !warned_unresolved_has_next {
tracing::warn!(
path = %pag.has_next_page_path,
"GraphQL has_next_page path did not resolve to a boolean; \
deferring to cursor presence to decide pagination"
);
warned_unresolved_has_next = true;
}
match step {
PageStep::Stop => false,
PageStep::StopLoop => {
tracing::warn!("cursor loop detected, stopping pagination");
false
}
PageStep::Advance(next) => {
if cursor_guard.is_repeat(&next) {
tracing::warn!(
"cursor cycle detected (cursor already seen), stopping pagination"
);
false
} else {
cursor = Some(next);
true
}
}
}
}
None => false,
};
if has_next {
yield StreamPage { records, bookmark: None };
} else {
bookmark_emitted = running_max.is_some();
yield StreamPage {
records,
bookmark: running_max.clone(),
};
break;
}
}
if !bookmark_emitted && running_max.is_some() {
yield StreamPage {
records: Vec::new(),
bookmark: running_max,
};
}
tracing::info!(
pages = pages_fetched,
batch_size = self.config.batch_size,
"GraphQL source stream complete",
);
})
}
}
#[async_trait]
impl faucet_core::Source for GraphqlStream {
async fn fetch_with_context(
&self,
context: &std::collections::HashMap<String, serde_json::Value>,
) -> Result<Vec<Value>, FaucetError> {
self.fetch_all_with_context(context).await
}
fn stream_pages<'a>(
&'a self,
context: &'a std::collections::HashMap<String, Value>,
_batch_size: usize,
) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
self.stream_pages_inner(context)
}
fn config_schema(&self) -> serde_json::Value {
serde_json::to_value(faucet_core::schema_for!(GraphqlStreamConfig))
.expect("schema serialization")
}
fn dataset_uri(&self) -> String {
faucet_core::redact_uri_credentials(&self.config.endpoint)
}
}
fn extract_string(body: &Value, path: &str) -> Option<String> {
let results = body.query(path).ok()?;
match results.first()? {
Value::String(s) => Some(s.clone()),
_ => None,
}
}
fn extract_bool(body: &Value, path: &str) -> Option<bool> {
let results = body.query(path).ok()?;
results.first()?.as_bool()
}
#[derive(Debug, PartialEq)]
enum PageStep {
Stop,
StopLoop,
Advance(String),
}
fn decide_next_page(
body: &Value,
pag: &GraphqlPagination,
prev_cursor: Option<&str>,
) -> (PageStep, bool) {
let (stop, unresolved) = match extract_bool(body, &pag.has_next_page_path) {
Some(false) => (true, false),
Some(true) => (false, false),
None => (false, true),
};
if stop {
return (PageStep::Stop, unresolved);
}
match extract_string(body, &pag.cursor_path) {
None => (PageStep::Stop, unresolved),
Some(next) if Some(next.as_str()) == prev_cursor => (PageStep::StopLoop, unresolved),
Some(next) => (PageStep::Advance(next), unresolved),
}
}
struct CursorGuard {
seen: HashMap<String, ()>,
order: std::collections::VecDeque<String>,
}
impl CursorGuard {
const CAP: usize = 4096;
fn new() -> Self {
Self {
seen: HashMap::new(),
order: std::collections::VecDeque::new(),
}
}
fn is_repeat(&mut self, cursor: &str) -> bool {
if self.seen.contains_key(cursor) {
return true;
}
if self.order.len() >= Self::CAP
&& let Some(old) = self.order.pop_front()
{
self.seen.remove(&old);
}
self.seen.insert(cursor.to_string(), ());
self.order.push_back(cursor.to_string());
false
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_string_from_json() {
let body = json!({"data": {"users": {"pageInfo": {"endCursor": "abc123"}}}});
assert_eq!(
extract_string(&body, "$.data.users.pageInfo.endCursor"),
Some("abc123".into())
);
}
#[test]
fn extract_bool_from_json() {
let body = json!({"data": {"users": {"pageInfo": {"hasNextPage": true}}}});
assert_eq!(
extract_bool(&body, "$.data.users.pageInfo.hasNextPage"),
Some(true)
);
}
fn pageinfo_pagination() -> GraphqlPagination {
GraphqlPagination {
has_next_page_path: "$.data.users.pageInfo.hasNextPage".into(),
cursor_path: "$.data.users.pageInfo.endCursor".into(),
..GraphqlPagination::default()
}
}
#[test]
fn decide_next_page_advances_when_has_next_true() {
let body =
json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c2"}}}});
let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
assert_eq!(step, PageStep::Advance("c2".into()));
assert!(!unresolved);
}
#[test]
fn decide_next_page_stops_when_has_next_false() {
let body =
json!({"data": {"users": {"pageInfo": {"hasNextPage": false, "endCursor": "c2"}}}});
let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
assert_eq!(step, PageStep::Stop);
assert!(!unresolved);
}
#[test]
fn decide_next_page_detects_cursor_loop() {
let body =
json!({"data": {"users": {"pageInfo": {"hasNextPage": true, "endCursor": "c1"}}}});
let (step, _) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
assert_eq!(step, PageStep::StopLoop);
}
#[test]
fn decide_next_page_defers_to_cursor_when_has_next_unresolved() {
let body = json!({"data": {"users": {"pageInfo": {"endCursor": "c2"}}}}); let (step, unresolved) = decide_next_page(&body, &pageinfo_pagination(), Some("c1"));
assert_eq!(
step,
PageStep::Advance("c2".into()),
"unresolved has-next must defer to cursor presence, not stop"
);
assert!(unresolved, "the caller is told to warn once");
let body_no_cursor = json!({"data": {"users": {"pageInfo": {}}}});
let (step, unresolved) =
decide_next_page(&body_no_cursor, &pageinfo_pagination(), Some("c1"));
assert_eq!(step, PageStep::Stop);
assert!(unresolved);
}
#[test]
fn extract_records_with_path() {
let config =
GraphqlStreamConfig::new("https://api.example.com/graphql", "query { users { id } }")
.records_path("$.data.users[*]");
let stream = GraphqlStream::new(config);
let body = json!({"data": {"users": [{"id": 1}, {"id": 2}]}});
let records = stream.extract_records(&body).unwrap();
assert_eq!(records.len(), 2);
assert_eq!(records[0]["id"], 1);
}
#[test]
fn extract_records_without_path_returns_data() {
let config =
GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
let stream = GraphqlStream::new(config);
let body = json!({"data": {"user": {"id": 1}}});
let records = stream.extract_records(&body).unwrap();
assert_eq!(records.len(), 1);
assert_eq!(records[0]["user"]["id"], 1);
}
#[test]
fn extract_records_without_path_null_data_yields_empty() {
let config =
GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
let stream = GraphqlStream::new(config);
let body = json!({ "data": null });
let records = stream.extract_records(&body).unwrap();
assert!(
records.is_empty(),
"expected empty Vec for null `data`, got {records:?}"
);
}
#[test]
fn extract_records_without_path_absent_data_yields_empty() {
let config =
GraphqlStreamConfig::new("https://api.example.com/graphql", "query { user { id } }");
let stream = GraphqlStream::new(config);
let body = json!({ "extensions": { "foo": 1 } });
let records = stream.extract_records(&body).unwrap();
assert!(
records.is_empty(),
"expected empty Vec when `data` is absent, got {records:?}"
);
}
#[test]
fn dataset_uri_returns_endpoint() {
use faucet_core::Source;
let stream = GraphqlStream::new(GraphqlStreamConfig::new(
"https://api.example.com/graphql",
"query { id }",
));
assert_eq!(stream.dataset_uri(), "https://api.example.com/graphql");
}
#[test]
fn dataset_uri_redacts_credentials() {
use faucet_core::Source;
let stream = GraphqlStream::new(GraphqlStreamConfig::new(
"https://user:pw@api.example.com/graphql",
"query { id }",
));
assert_eq!(stream.dataset_uri(), "https://api.example.com/graphql");
}
#[test]
fn default_retry_policy_reproduces_legacy_constants() {
let stream = GraphqlStream::new(GraphqlStreamConfig::new(
"https://api.example.com/graphql",
"query { id }",
));
assert_eq!(stream.retry_policy.max_attempts, RETRY_MAX_ATTEMPTS + 1);
assert_eq!(stream.retry_policy.base, RETRY_BASE_BACKOFF);
}
#[test]
fn with_retry_policy_overrides_the_default() {
let policy = faucet_core::RetryPolicy {
max_attempts: 9,
base: Duration::from_secs(7),
..faucet_core::RetryPolicy::default()
};
let stream = GraphqlStream::new(GraphqlStreamConfig::new(
"https://api.example.com/graphql",
"query { id }",
))
.with_retry_policy(policy);
assert_eq!(stream.retry_policy.max_attempts, 9);
assert_eq!(stream.retry_policy.base, Duration::from_secs(7));
}
#[test]
fn cursor_guard_detects_repeats_and_bounds_memory() {
let mut g = CursorGuard::new();
assert!(!g.is_repeat("a"));
assert!(!g.is_repeat("b"));
assert!(g.is_repeat("a"));
assert!(g.is_repeat("b"));
let mut g = CursorGuard::new();
for i in 0..CursorGuard::CAP {
assert!(!g.is_repeat(&format!("c{i}")));
}
assert_eq!(g.order.len(), CursorGuard::CAP);
assert!(!g.is_repeat("overflow"));
assert_eq!(g.order.len(), CursorGuard::CAP);
assert!(!g.seen.contains_key("c0"), "oldest cursor evicted");
assert!(g.seen.contains_key("overflow"));
}
}