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 #[serde(default)]
106 pub substitute_in_query: bool,
107}
108
109fn default_true() -> bool {
110 true
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
122#[serde(untagged)]
123pub enum GraphqlPaginationSpec {
124 Cursor(GraphqlPagination),
126 Offset(GraphqlOffsetPagination),
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
132pub struct GraphqlStreamConfig {
133 pub endpoint: String,
135 pub query: String,
137 pub variables: Value,
139 pub auth: AuthSpec<GraphqlAuth>,
142 #[serde(skip, default)]
144 pub headers: HeaderMap,
145 pub records_path: Option<String>,
147 pub pagination: Option<GraphqlPaginationSpec>,
151 pub max_pages: Option<usize>,
153 #[serde(default = "default_batch_size")]
164 pub batch_size: usize,
165 #[serde(default)]
169 pub tls: Option<TlsClientConfig>,
170}
171
172fn default_batch_size() -> usize {
173 DEFAULT_BATCH_SIZE
174}
175
176impl GraphqlStreamConfig {
177 pub fn new(endpoint: impl Into<String>, query: impl Into<String>) -> Self {
179 Self {
180 endpoint: endpoint.into(),
181 query: query.into(),
182 variables: Value::Object(Default::default()),
183 auth: AuthSpec::Inline(GraphqlAuth::None),
184 headers: HeaderMap::new(),
185 records_path: None,
186 pagination: None,
187 max_pages: None,
188 batch_size: DEFAULT_BATCH_SIZE,
189 tls: None,
190 }
191 }
192
193 pub fn tls(mut self, tls: TlsClientConfig) -> Self {
197 self.tls = Some(tls);
198 self
199 }
200
201 pub fn variables(mut self, vars: Value) -> Self {
203 self.variables = vars;
204 self
205 }
206
207 pub fn auth(mut self, auth: GraphqlAuth) -> Self {
209 self.auth = AuthSpec::Inline(auth);
210 self
211 }
212
213 pub fn headers(mut self, headers: HeaderMap) -> Self {
215 self.headers = headers;
216 self
217 }
218
219 pub fn records_path(mut self, path: impl Into<String>) -> Self {
221 self.records_path = Some(path.into());
222 self
223 }
224
225 pub fn pagination(mut self, pagination: GraphqlPagination) -> Self {
227 self.pagination = Some(GraphqlPaginationSpec::Cursor(pagination));
228 self
229 }
230
231 pub fn offset_pagination(mut self, pagination: GraphqlOffsetPagination) -> Self {
233 self.pagination = Some(GraphqlPaginationSpec::Offset(pagination));
234 self
235 }
236
237 pub fn max_pages(mut self, max: usize) -> Self {
239 self.max_pages = Some(max);
240 self
241 }
242
243 pub fn with_batch_size(mut self, batch_size: usize) -> Self {
250 self.batch_size = batch_size;
251 self
252 }
253
254 pub fn validate(&self) -> Result<(), FaucetError> {
259 if self.endpoint.trim().is_empty() {
260 return Err(FaucetError::Config(
261 "GraphQL source requires a non-empty `endpoint`".into(),
262 ));
263 }
264 if self.query.trim().is_empty() {
265 return Err(FaucetError::Config(
266 "GraphQL source requires a non-empty `query`".into(),
267 ));
268 }
269 validate_batch_size(self.batch_size)?;
270 if let Some(GraphqlPaginationSpec::Offset(off)) = &self.pagination
271 && off.page_size == 0
272 {
273 return Err(FaucetError::Config(
274 "GraphQL offset pagination requires `page_size` > 0 \
275 (a zero page size never advances the offset)"
276 .into(),
277 ));
278 }
279 if let Some(tls) = &self.tls {
280 tls.validate()?;
281 }
282 Ok(())
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use serde_json::json;
290
291 #[test]
292 fn default_config() {
293 let config = GraphqlStreamConfig::new(
294 "https://api.example.com/graphql",
295 "query { users { id name } }",
296 );
297 assert_eq!(config.endpoint, "https://api.example.com/graphql");
298 assert!(config.records_path.is_none());
299 assert!(config.pagination.is_none());
300 assert!(config.max_pages.is_none());
301 }
302
303 #[test]
304 fn builder_methods() {
305 let config =
306 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { users { id } }")
307 .variables(json!({"org": "acme"}))
308 .records_path("$.data.users.edges[*].node")
309 .max_pages(10)
310 .auth(GraphqlAuth::Bearer {
311 token: "token".into(),
312 });
313 assert_eq!(config.variables["org"], "acme");
314 assert_eq!(config.records_path.unwrap(), "$.data.users.edges[*].node");
315 assert_eq!(config.max_pages, Some(10));
316 }
317
318 #[test]
319 fn default_pagination() {
320 let pag = GraphqlPagination::default();
321 assert_eq!(pag.cursor_variable, "after");
322 assert_eq!(pag.page_size_variable, "first");
323 }
324
325 #[test]
326 fn batch_size_defaults_to_default_batch_size() {
327 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }");
328 assert_eq!(config.batch_size, faucet_core::DEFAULT_BATCH_SIZE);
329 }
330
331 #[test]
332 fn with_batch_size_overrides_default() {
333 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
334 .with_batch_size(250);
335 assert_eq!(config.batch_size, 250);
336 }
337
338 #[test]
339 fn batch_size_zero_is_accepted_as_no_batching_sentinel() {
340 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
341 .with_batch_size(0);
342 assert_eq!(config.batch_size, 0);
343 assert!(faucet_core::validate_batch_size(config.batch_size).is_ok());
344 }
345
346 #[test]
347 fn batch_size_above_max_is_rejected_by_validate_batch_size() {
348 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
349 .with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
350 assert!(faucet_core::validate_batch_size(config.batch_size).is_err());
351 }
352
353 #[test]
354 fn batch_size_deserializes_from_json() {
355 let json = r#"{
356 "endpoint": "https://api.example.com/graphql",
357 "query": "query { x }",
358 "variables": {},
359 "auth": {"type": "none"},
360 "records_path": null,
361 "pagination": null,
362 "max_pages": null,
363 "batch_size": 500
364 }"#;
365 let config: GraphqlStreamConfig = serde_json::from_str(json).unwrap();
366 assert_eq!(config.batch_size, 500);
367 }
368
369 #[test]
370 fn validate_accepts_valid_config() {
371 assert!(
372 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
373 .validate()
374 .is_ok()
375 );
376 }
377
378 #[test]
379 fn validate_rejects_oversized_batch_size() {
380 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
381 .with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
382 assert!(matches!(config.validate(), Err(FaucetError::Config(_))));
383 }
384
385 #[test]
386 fn validate_rejects_empty_endpoint() {
387 assert!(matches!(
388 GraphqlStreamConfig::new(" ", "query { x }").validate(),
389 Err(FaucetError::Config(_))
390 ));
391 }
392
393 #[test]
394 fn validate_rejects_empty_query() {
395 assert!(matches!(
396 GraphqlStreamConfig::new("https://api.example.com/graphql", "").validate(),
397 Err(FaucetError::Config(_))
398 ));
399 }
400
401 #[test]
404 fn offset_pagination_deserializes_with_type_tag() {
405 let json = r#"{
406 "type": "Offset",
407 "offset_variable": "q_offset",
408 "page_size": 250,
409 "stop_when_short": true
410 }"#;
411 let spec: GraphqlPaginationSpec = serde_json::from_str(json).unwrap();
412 match spec {
413 GraphqlPaginationSpec::Offset(off) => {
414 assert_eq!(off.r#type, OffsetPaginationKind::Offset);
415 assert_eq!(off.offset_variable, "q_offset");
416 assert_eq!(off.page_size, 250);
417 assert!(off.stop_when_short);
418 }
419 other => panic!("expected Offset variant, got {other:?}"),
420 }
421 }
422
423 #[test]
424 fn offset_pagination_stop_when_short_defaults_true() {
425 let json = r#"{ "type": "Offset", "offset_variable": "q_offset", "page_size": 100 }"#;
426 let spec: GraphqlPaginationSpec = serde_json::from_str(json).unwrap();
427 match spec {
428 GraphqlPaginationSpec::Offset(off) => assert!(
429 off.stop_when_short,
430 "stop_when_short must default to true when omitted"
431 ),
432 other => panic!("expected Offset variant, got {other:?}"),
433 }
434 }
435
436 #[test]
437 fn cursor_pagination_still_deserializes_without_type_tag() {
438 let json = r#"{
441 "has_next_page_path": "$.data.users.pageInfo.hasNextPage",
442 "cursor_path": "$.data.users.pageInfo.endCursor",
443 "cursor_variable": "after",
444 "page_size_variable": "first"
445 }"#;
446 let spec: GraphqlPaginationSpec = serde_json::from_str(json).unwrap();
447 match spec {
448 GraphqlPaginationSpec::Cursor(pag) => {
449 assert_eq!(pag.cursor_variable, "after");
450 assert_eq!(pag.page_size_variable, "first");
451 }
452 other => panic!("expected Cursor variant, got {other:?}"),
453 }
454 }
455
456 #[test]
457 fn full_config_with_offset_pagination_deserializes() {
458 let json = r#"{
459 "endpoint": "https://api.example.com/graphql",
460 "query": "{ orders(first: 250, offset: $q_offset) { id } }",
461 "variables": {},
462 "auth": {"type": "none"},
463 "records_path": "$.data.orders[*]",
464 "pagination": { "type": "Offset", "offset_variable": "q_offset", "page_size": 250 },
465 "max_pages": null,
466 "batch_size": 250
467 }"#;
468 let config: GraphqlStreamConfig = serde_json::from_str(json).unwrap();
469 assert!(matches!(
470 config.pagination,
471 Some(GraphqlPaginationSpec::Offset(_))
472 ));
473 assert!(config.validate().is_ok());
474 }
475
476 #[test]
477 fn offset_pagination_rejects_unknown_field() {
478 let json = r#"{
479 "type": "Offset",
480 "offset_variable": "q_offset",
481 "page_size": 250,
482 "bogus": true
483 }"#;
484 assert!(serde_json::from_str::<GraphqlPaginationSpec>(json).is_err());
487 }
488
489 #[test]
490 fn offset_pagination_builder_wraps_offset_variant() {
491 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
492 .offset_pagination(GraphqlOffsetPagination {
493 r#type: OffsetPaginationKind::Offset,
494 offset_variable: "q_offset".into(),
495 page_size: 250,
496 stop_when_short: true,
497 substitute_in_query: false,
498 });
499 assert!(matches!(
500 config.pagination,
501 Some(GraphqlPaginationSpec::Offset(_))
502 ));
503 }
504
505 #[test]
506 fn validate_rejects_zero_page_size_offset() {
507 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
508 .offset_pagination(GraphqlOffsetPagination {
509 r#type: OffsetPaginationKind::Offset,
510 offset_variable: "q_offset".into(),
511 page_size: 0,
512 stop_when_short: true,
513 substitute_in_query: false,
514 });
515 assert!(matches!(config.validate(), Err(FaucetError::Config(_))));
516 }
517
518 #[test]
519 fn validate_accepts_nonzero_page_size_offset() {
520 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
521 .offset_pagination(GraphqlOffsetPagination {
522 r#type: OffsetPaginationKind::Offset,
523 offset_variable: "q_offset".into(),
524 page_size: 1,
525 stop_when_short: false,
526 substitute_in_query: false,
527 });
528 assert!(config.validate().is_ok());
529 }
530}