use faucet_core::{
AuthSpec, DEFAULT_BATCH_SIZE, FaucetError, TlsClientConfig, validate_batch_size,
};
use reqwest::header::HeaderMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "type", content = "config", rename_all = "snake_case")]
pub enum GraphqlAuth {
None,
Bearer { token: String },
Custom { headers: HashMap<String, String> },
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GraphqlPagination {
pub has_next_page_path: String,
pub cursor_path: String,
pub cursor_variable: String,
pub page_size_variable: String,
}
impl Default for GraphqlPagination {
fn default() -> Self {
Self {
has_next_page_path: "$.data.*.pageInfo.hasNextPage".into(),
cursor_path: "$.data.*.pageInfo.endCursor".into(),
cursor_variable: "after".into(),
page_size_variable: "first".into(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum OffsetPaginationKind {
Offset,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct GraphqlOffsetPagination {
pub r#type: OffsetPaginationKind,
pub offset_variable: String,
pub page_size: usize,
#[serde(default = "default_true")]
pub stop_when_short: bool,
#[serde(default)]
pub substitute_in_query: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum GraphqlPaginationSpec {
Cursor(GraphqlPagination),
Offset(GraphqlOffsetPagination),
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GraphqlStreamConfig {
pub endpoint: String,
pub query: String,
pub variables: Value,
pub auth: AuthSpec<GraphqlAuth>,
#[serde(skip, default)]
pub headers: HeaderMap,
pub records_path: Option<String>,
pub pagination: Option<GraphqlPaginationSpec>,
pub max_pages: Option<usize>,
#[serde(default = "default_batch_size")]
pub batch_size: usize,
#[serde(default)]
pub tls: Option<TlsClientConfig>,
}
fn default_batch_size() -> usize {
DEFAULT_BATCH_SIZE
}
impl GraphqlStreamConfig {
pub fn new(endpoint: impl Into<String>, query: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
query: query.into(),
variables: Value::Object(Default::default()),
auth: AuthSpec::Inline(GraphqlAuth::None),
headers: HeaderMap::new(),
records_path: None,
pagination: None,
max_pages: None,
batch_size: DEFAULT_BATCH_SIZE,
tls: None,
}
}
pub fn tls(mut self, tls: TlsClientConfig) -> Self {
self.tls = Some(tls);
self
}
pub fn variables(mut self, vars: Value) -> Self {
self.variables = vars;
self
}
pub fn auth(mut self, auth: GraphqlAuth) -> Self {
self.auth = AuthSpec::Inline(auth);
self
}
pub fn headers(mut self, headers: HeaderMap) -> Self {
self.headers = headers;
self
}
pub fn records_path(mut self, path: impl Into<String>) -> Self {
self.records_path = Some(path.into());
self
}
pub fn pagination(mut self, pagination: GraphqlPagination) -> Self {
self.pagination = Some(GraphqlPaginationSpec::Cursor(pagination));
self
}
pub fn offset_pagination(mut self, pagination: GraphqlOffsetPagination) -> Self {
self.pagination = Some(GraphqlPaginationSpec::Offset(pagination));
self
}
pub fn max_pages(mut self, max: usize) -> Self {
self.max_pages = Some(max);
self
}
pub fn with_batch_size(mut self, batch_size: usize) -> Self {
self.batch_size = batch_size;
self
}
pub fn validate(&self) -> Result<(), FaucetError> {
if self.endpoint.trim().is_empty() {
return Err(FaucetError::Config(
"GraphQL source requires a non-empty `endpoint`".into(),
));
}
if self.query.trim().is_empty() {
return Err(FaucetError::Config(
"GraphQL source requires a non-empty `query`".into(),
));
}
validate_batch_size(self.batch_size)?;
if let Some(GraphqlPaginationSpec::Offset(off)) = &self.pagination
&& off.page_size == 0
{
return Err(FaucetError::Config(
"GraphQL offset pagination requires `page_size` > 0 \
(a zero page size never advances the offset)"
.into(),
));
}
if let Some(tls) = &self.tls {
tls.validate()?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn default_config() {
let config = GraphqlStreamConfig::new(
"https://api.example.com/graphql",
"query { users { id name } }",
);
assert_eq!(config.endpoint, "https://api.example.com/graphql");
assert!(config.records_path.is_none());
assert!(config.pagination.is_none());
assert!(config.max_pages.is_none());
}
#[test]
fn builder_methods() {
let config =
GraphqlStreamConfig::new("https://api.example.com/graphql", "query { users { id } }")
.variables(json!({"org": "acme"}))
.records_path("$.data.users.edges[*].node")
.max_pages(10)
.auth(GraphqlAuth::Bearer {
token: "token".into(),
});
assert_eq!(config.variables["org"], "acme");
assert_eq!(config.records_path.unwrap(), "$.data.users.edges[*].node");
assert_eq!(config.max_pages, Some(10));
}
#[test]
fn default_pagination() {
let pag = GraphqlPagination::default();
assert_eq!(pag.cursor_variable, "after");
assert_eq!(pag.page_size_variable, "first");
}
#[test]
fn batch_size_defaults_to_default_batch_size() {
let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }");
assert_eq!(config.batch_size, faucet_core::DEFAULT_BATCH_SIZE);
}
#[test]
fn with_batch_size_overrides_default() {
let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
.with_batch_size(250);
assert_eq!(config.batch_size, 250);
}
#[test]
fn batch_size_zero_is_accepted_as_no_batching_sentinel() {
let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
.with_batch_size(0);
assert_eq!(config.batch_size, 0);
assert!(faucet_core::validate_batch_size(config.batch_size).is_ok());
}
#[test]
fn batch_size_above_max_is_rejected_by_validate_batch_size() {
let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
.with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
assert!(faucet_core::validate_batch_size(config.batch_size).is_err());
}
#[test]
fn batch_size_deserializes_from_json() {
let json = r#"{
"endpoint": "https://api.example.com/graphql",
"query": "query { x }",
"variables": {},
"auth": {"type": "none"},
"records_path": null,
"pagination": null,
"max_pages": null,
"batch_size": 500
}"#;
let config: GraphqlStreamConfig = serde_json::from_str(json).unwrap();
assert_eq!(config.batch_size, 500);
}
#[test]
fn validate_accepts_valid_config() {
assert!(
GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
.validate()
.is_ok()
);
}
#[test]
fn validate_rejects_oversized_batch_size() {
let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
.with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
assert!(matches!(config.validate(), Err(FaucetError::Config(_))));
}
#[test]
fn validate_rejects_empty_endpoint() {
assert!(matches!(
GraphqlStreamConfig::new(" ", "query { x }").validate(),
Err(FaucetError::Config(_))
));
}
#[test]
fn validate_rejects_empty_query() {
assert!(matches!(
GraphqlStreamConfig::new("https://api.example.com/graphql", "").validate(),
Err(FaucetError::Config(_))
));
}
#[test]
fn offset_pagination_deserializes_with_type_tag() {
let json = r#"{
"type": "Offset",
"offset_variable": "q_offset",
"page_size": 250,
"stop_when_short": true
}"#;
let spec: GraphqlPaginationSpec = serde_json::from_str(json).unwrap();
match spec {
GraphqlPaginationSpec::Offset(off) => {
assert_eq!(off.r#type, OffsetPaginationKind::Offset);
assert_eq!(off.offset_variable, "q_offset");
assert_eq!(off.page_size, 250);
assert!(off.stop_when_short);
}
other => panic!("expected Offset variant, got {other:?}"),
}
}
#[test]
fn offset_pagination_stop_when_short_defaults_true() {
let json = r#"{ "type": "Offset", "offset_variable": "q_offset", "page_size": 100 }"#;
let spec: GraphqlPaginationSpec = serde_json::from_str(json).unwrap();
match spec {
GraphqlPaginationSpec::Offset(off) => assert!(
off.stop_when_short,
"stop_when_short must default to true when omitted"
),
other => panic!("expected Offset variant, got {other:?}"),
}
}
#[test]
fn cursor_pagination_still_deserializes_without_type_tag() {
let json = r#"{
"has_next_page_path": "$.data.users.pageInfo.hasNextPage",
"cursor_path": "$.data.users.pageInfo.endCursor",
"cursor_variable": "after",
"page_size_variable": "first"
}"#;
let spec: GraphqlPaginationSpec = serde_json::from_str(json).unwrap();
match spec {
GraphqlPaginationSpec::Cursor(pag) => {
assert_eq!(pag.cursor_variable, "after");
assert_eq!(pag.page_size_variable, "first");
}
other => panic!("expected Cursor variant, got {other:?}"),
}
}
#[test]
fn full_config_with_offset_pagination_deserializes() {
let json = r#"{
"endpoint": "https://api.example.com/graphql",
"query": "{ orders(first: 250, offset: $q_offset) { id } }",
"variables": {},
"auth": {"type": "none"},
"records_path": "$.data.orders[*]",
"pagination": { "type": "Offset", "offset_variable": "q_offset", "page_size": 250 },
"max_pages": null,
"batch_size": 250
}"#;
let config: GraphqlStreamConfig = serde_json::from_str(json).unwrap();
assert!(matches!(
config.pagination,
Some(GraphqlPaginationSpec::Offset(_))
));
assert!(config.validate().is_ok());
}
#[test]
fn offset_pagination_rejects_unknown_field() {
let json = r#"{
"type": "Offset",
"offset_variable": "q_offset",
"page_size": 250,
"bogus": true
}"#;
assert!(serde_json::from_str::<GraphqlPaginationSpec>(json).is_err());
}
#[test]
fn offset_pagination_builder_wraps_offset_variant() {
let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
.offset_pagination(GraphqlOffsetPagination {
r#type: OffsetPaginationKind::Offset,
offset_variable: "q_offset".into(),
page_size: 250,
stop_when_short: true,
substitute_in_query: false,
});
assert!(matches!(
config.pagination,
Some(GraphqlPaginationSpec::Offset(_))
));
}
#[test]
fn validate_rejects_zero_page_size_offset() {
let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
.offset_pagination(GraphqlOffsetPagination {
r#type: OffsetPaginationKind::Offset,
offset_variable: "q_offset".into(),
page_size: 0,
stop_when_short: true,
substitute_in_query: false,
});
assert!(matches!(config.validate(), Err(FaucetError::Config(_))));
}
#[test]
fn validate_accepts_nonzero_page_size_offset() {
let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
.offset_pagination(GraphqlOffsetPagination {
r#type: OffsetPaginationKind::Offset,
offset_variable: "q_offset".into(),
page_size: 1,
stop_when_short: false,
substitute_in_query: false,
});
assert!(config.validate().is_ok());
}
}