1use faucet_core::{
4 AuthSpec, DEFAULT_BATCH_SIZE, FaucetError, TlsClientConfig, validate_batch_size,
5};
6use reqwest::header::HeaderMap;
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
14#[serde(tag = "type", content = "config", rename_all = "snake_case")]
15pub enum GraphqlAuth {
16 None,
18 Bearer { token: String },
20 Custom { headers: HashMap<String, String> },
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
29pub struct GraphqlPagination {
30 pub has_next_page_path: String,
32 pub cursor_path: String,
34 pub cursor_variable: String,
36 pub page_size_variable: String,
44}
45
46impl Default for GraphqlPagination {
47 fn default() -> Self {
48 Self {
49 has_next_page_path: "$.data.*.pageInfo.hasNextPage".into(),
50 cursor_path: "$.data.*.pageInfo.endCursor".into(),
51 cursor_variable: "after".into(),
52 page_size_variable: "first".into(),
53 }
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
63pub enum OffsetPaginationKind {
64 Offset,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
79#[serde(deny_unknown_fields)]
80pub struct GraphqlOffsetPagination {
81 pub r#type: OffsetPaginationKind,
83 pub offset_variable: String,
88 pub page_size: usize,
93 #[serde(default = "default_true")]
97 pub stop_when_short: bool,
98}
99
100fn default_true() -> bool {
101 true
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
113#[serde(untagged)]
114pub enum GraphqlPaginationSpec {
115 Cursor(GraphqlPagination),
117 Offset(GraphqlOffsetPagination),
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
123pub struct GraphqlStreamConfig {
124 pub endpoint: String,
126 pub query: String,
128 pub variables: Value,
130 pub auth: AuthSpec<GraphqlAuth>,
133 #[serde(skip, default)]
135 pub headers: HeaderMap,
136 pub records_path: Option<String>,
138 pub pagination: Option<GraphqlPaginationSpec>,
142 pub max_pages: Option<usize>,
144 #[serde(default = "default_batch_size")]
155 pub batch_size: usize,
156 #[serde(default)]
160 pub tls: Option<TlsClientConfig>,
161}
162
163fn default_batch_size() -> usize {
164 DEFAULT_BATCH_SIZE
165}
166
167impl GraphqlStreamConfig {
168 pub fn new(endpoint: impl Into<String>, query: impl Into<String>) -> Self {
170 Self {
171 endpoint: endpoint.into(),
172 query: query.into(),
173 variables: Value::Object(Default::default()),
174 auth: AuthSpec::Inline(GraphqlAuth::None),
175 headers: HeaderMap::new(),
176 records_path: None,
177 pagination: None,
178 max_pages: None,
179 batch_size: DEFAULT_BATCH_SIZE,
180 tls: None,
181 }
182 }
183
184 pub fn tls(mut self, tls: TlsClientConfig) -> Self {
188 self.tls = Some(tls);
189 self
190 }
191
192 pub fn variables(mut self, vars: Value) -> Self {
194 self.variables = vars;
195 self
196 }
197
198 pub fn auth(mut self, auth: GraphqlAuth) -> Self {
200 self.auth = AuthSpec::Inline(auth);
201 self
202 }
203
204 pub fn headers(mut self, headers: HeaderMap) -> Self {
206 self.headers = headers;
207 self
208 }
209
210 pub fn records_path(mut self, path: impl Into<String>) -> Self {
212 self.records_path = Some(path.into());
213 self
214 }
215
216 pub fn pagination(mut self, pagination: GraphqlPagination) -> Self {
218 self.pagination = Some(GraphqlPaginationSpec::Cursor(pagination));
219 self
220 }
221
222 pub fn offset_pagination(mut self, pagination: GraphqlOffsetPagination) -> Self {
224 self.pagination = Some(GraphqlPaginationSpec::Offset(pagination));
225 self
226 }
227
228 pub fn max_pages(mut self, max: usize) -> Self {
230 self.max_pages = Some(max);
231 self
232 }
233
234 pub fn with_batch_size(mut self, batch_size: usize) -> Self {
241 self.batch_size = batch_size;
242 self
243 }
244
245 pub fn validate(&self) -> Result<(), FaucetError> {
250 if self.endpoint.trim().is_empty() {
251 return Err(FaucetError::Config(
252 "GraphQL source requires a non-empty `endpoint`".into(),
253 ));
254 }
255 if self.query.trim().is_empty() {
256 return Err(FaucetError::Config(
257 "GraphQL source requires a non-empty `query`".into(),
258 ));
259 }
260 validate_batch_size(self.batch_size)?;
261 if let Some(GraphqlPaginationSpec::Offset(off)) = &self.pagination
262 && off.page_size == 0
263 {
264 return Err(FaucetError::Config(
265 "GraphQL offset pagination requires `page_size` > 0 \
266 (a zero page size never advances the offset)"
267 .into(),
268 ));
269 }
270 if let Some(tls) = &self.tls {
271 tls.validate()?;
272 }
273 Ok(())
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280 use serde_json::json;
281
282 #[test]
283 fn default_config() {
284 let config = GraphqlStreamConfig::new(
285 "https://api.example.com/graphql",
286 "query { users { id name } }",
287 );
288 assert_eq!(config.endpoint, "https://api.example.com/graphql");
289 assert!(config.records_path.is_none());
290 assert!(config.pagination.is_none());
291 assert!(config.max_pages.is_none());
292 }
293
294 #[test]
295 fn builder_methods() {
296 let config =
297 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { users { id } }")
298 .variables(json!({"org": "acme"}))
299 .records_path("$.data.users.edges[*].node")
300 .max_pages(10)
301 .auth(GraphqlAuth::Bearer {
302 token: "token".into(),
303 });
304 assert_eq!(config.variables["org"], "acme");
305 assert_eq!(config.records_path.unwrap(), "$.data.users.edges[*].node");
306 assert_eq!(config.max_pages, Some(10));
307 }
308
309 #[test]
310 fn default_pagination() {
311 let pag = GraphqlPagination::default();
312 assert_eq!(pag.cursor_variable, "after");
313 assert_eq!(pag.page_size_variable, "first");
314 }
315
316 #[test]
317 fn batch_size_defaults_to_default_batch_size() {
318 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }");
319 assert_eq!(config.batch_size, faucet_core::DEFAULT_BATCH_SIZE);
320 }
321
322 #[test]
323 fn with_batch_size_overrides_default() {
324 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
325 .with_batch_size(250);
326 assert_eq!(config.batch_size, 250);
327 }
328
329 #[test]
330 fn batch_size_zero_is_accepted_as_no_batching_sentinel() {
331 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
332 .with_batch_size(0);
333 assert_eq!(config.batch_size, 0);
334 assert!(faucet_core::validate_batch_size(config.batch_size).is_ok());
335 }
336
337 #[test]
338 fn batch_size_above_max_is_rejected_by_validate_batch_size() {
339 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
340 .with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
341 assert!(faucet_core::validate_batch_size(config.batch_size).is_err());
342 }
343
344 #[test]
345 fn batch_size_deserializes_from_json() {
346 let json = r#"{
347 "endpoint": "https://api.example.com/graphql",
348 "query": "query { x }",
349 "variables": {},
350 "auth": {"type": "none"},
351 "records_path": null,
352 "pagination": null,
353 "max_pages": null,
354 "batch_size": 500
355 }"#;
356 let config: GraphqlStreamConfig = serde_json::from_str(json).unwrap();
357 assert_eq!(config.batch_size, 500);
358 }
359
360 #[test]
361 fn validate_accepts_valid_config() {
362 assert!(
363 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
364 .validate()
365 .is_ok()
366 );
367 }
368
369 #[test]
370 fn validate_rejects_oversized_batch_size() {
371 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
372 .with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
373 assert!(matches!(config.validate(), Err(FaucetError::Config(_))));
374 }
375
376 #[test]
377 fn validate_rejects_empty_endpoint() {
378 assert!(matches!(
379 GraphqlStreamConfig::new(" ", "query { x }").validate(),
380 Err(FaucetError::Config(_))
381 ));
382 }
383
384 #[test]
385 fn validate_rejects_empty_query() {
386 assert!(matches!(
387 GraphqlStreamConfig::new("https://api.example.com/graphql", "").validate(),
388 Err(FaucetError::Config(_))
389 ));
390 }
391
392 #[test]
395 fn offset_pagination_deserializes_with_type_tag() {
396 let json = r#"{
397 "type": "Offset",
398 "offset_variable": "q_offset",
399 "page_size": 250,
400 "stop_when_short": true
401 }"#;
402 let spec: GraphqlPaginationSpec = serde_json::from_str(json).unwrap();
403 match spec {
404 GraphqlPaginationSpec::Offset(off) => {
405 assert_eq!(off.r#type, OffsetPaginationKind::Offset);
406 assert_eq!(off.offset_variable, "q_offset");
407 assert_eq!(off.page_size, 250);
408 assert!(off.stop_when_short);
409 }
410 other => panic!("expected Offset variant, got {other:?}"),
411 }
412 }
413
414 #[test]
415 fn offset_pagination_stop_when_short_defaults_true() {
416 let json = r#"{ "type": "Offset", "offset_variable": "q_offset", "page_size": 100 }"#;
417 let spec: GraphqlPaginationSpec = serde_json::from_str(json).unwrap();
418 match spec {
419 GraphqlPaginationSpec::Offset(off) => assert!(
420 off.stop_when_short,
421 "stop_when_short must default to true when omitted"
422 ),
423 other => panic!("expected Offset variant, got {other:?}"),
424 }
425 }
426
427 #[test]
428 fn cursor_pagination_still_deserializes_without_type_tag() {
429 let json = r#"{
432 "has_next_page_path": "$.data.users.pageInfo.hasNextPage",
433 "cursor_path": "$.data.users.pageInfo.endCursor",
434 "cursor_variable": "after",
435 "page_size_variable": "first"
436 }"#;
437 let spec: GraphqlPaginationSpec = serde_json::from_str(json).unwrap();
438 match spec {
439 GraphqlPaginationSpec::Cursor(pag) => {
440 assert_eq!(pag.cursor_variable, "after");
441 assert_eq!(pag.page_size_variable, "first");
442 }
443 other => panic!("expected Cursor variant, got {other:?}"),
444 }
445 }
446
447 #[test]
448 fn full_config_with_offset_pagination_deserializes() {
449 let json = r#"{
450 "endpoint": "https://api.example.com/graphql",
451 "query": "{ orders(first: 250, offset: $q_offset) { id } }",
452 "variables": {},
453 "auth": {"type": "none"},
454 "records_path": "$.data.orders[*]",
455 "pagination": { "type": "Offset", "offset_variable": "q_offset", "page_size": 250 },
456 "max_pages": null,
457 "batch_size": 250
458 }"#;
459 let config: GraphqlStreamConfig = serde_json::from_str(json).unwrap();
460 assert!(matches!(
461 config.pagination,
462 Some(GraphqlPaginationSpec::Offset(_))
463 ));
464 assert!(config.validate().is_ok());
465 }
466
467 #[test]
468 fn offset_pagination_rejects_unknown_field() {
469 let json = r#"{
470 "type": "Offset",
471 "offset_variable": "q_offset",
472 "page_size": 250,
473 "bogus": true
474 }"#;
475 assert!(serde_json::from_str::<GraphqlPaginationSpec>(json).is_err());
478 }
479
480 #[test]
481 fn offset_pagination_builder_wraps_offset_variant() {
482 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
483 .offset_pagination(GraphqlOffsetPagination {
484 r#type: OffsetPaginationKind::Offset,
485 offset_variable: "q_offset".into(),
486 page_size: 250,
487 stop_when_short: true,
488 });
489 assert!(matches!(
490 config.pagination,
491 Some(GraphqlPaginationSpec::Offset(_))
492 ));
493 }
494
495 #[test]
496 fn validate_rejects_zero_page_size_offset() {
497 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
498 .offset_pagination(GraphqlOffsetPagination {
499 r#type: OffsetPaginationKind::Offset,
500 offset_variable: "q_offset".into(),
501 page_size: 0,
502 stop_when_short: true,
503 });
504 assert!(matches!(config.validate(), Err(FaucetError::Config(_))));
505 }
506
507 #[test]
508 fn validate_accepts_nonzero_page_size_offset() {
509 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
510 .offset_pagination(GraphqlOffsetPagination {
511 r#type: OffsetPaginationKind::Offset,
512 offset_variable: "q_offset".into(),
513 page_size: 1,
514 stop_when_short: false,
515 });
516 assert!(config.validate().is_ok());
517 }
518}