1use faucet_core::{AuthSpec, DEFAULT_BATCH_SIZE, FaucetError, validate_batch_size};
4use reqwest::header::HeaderMap;
5use schemars::JsonSchema;
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8use std::collections::HashMap;
9
10#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
12#[serde(tag = "type", content = "config", rename_all = "snake_case")]
13pub enum GraphqlAuth {
14 None,
16 Bearer { token: String },
18 Custom { headers: HashMap<String, String> },
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
27pub struct GraphqlPagination {
28 pub has_next_page_path: String,
30 pub cursor_path: String,
32 pub cursor_variable: String,
34 pub page_size_variable: String,
42}
43
44impl Default for GraphqlPagination {
45 fn default() -> Self {
46 Self {
47 has_next_page_path: "$.data.*.pageInfo.hasNextPage".into(),
48 cursor_path: "$.data.*.pageInfo.endCursor".into(),
49 cursor_variable: "after".into(),
50 page_size_variable: "first".into(),
51 }
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
57pub struct GraphqlStreamConfig {
58 pub endpoint: String,
60 pub query: String,
62 pub variables: Value,
64 pub auth: AuthSpec<GraphqlAuth>,
67 #[serde(skip, default)]
69 pub headers: HeaderMap,
70 pub records_path: Option<String>,
72 pub pagination: Option<GraphqlPagination>,
74 pub max_pages: Option<usize>,
76 #[serde(default = "default_batch_size")]
87 pub batch_size: usize,
88}
89
90fn default_batch_size() -> usize {
91 DEFAULT_BATCH_SIZE
92}
93
94impl GraphqlStreamConfig {
95 pub fn new(endpoint: impl Into<String>, query: impl Into<String>) -> Self {
97 Self {
98 endpoint: endpoint.into(),
99 query: query.into(),
100 variables: Value::Object(Default::default()),
101 auth: AuthSpec::Inline(GraphqlAuth::None),
102 headers: HeaderMap::new(),
103 records_path: None,
104 pagination: None,
105 max_pages: None,
106 batch_size: DEFAULT_BATCH_SIZE,
107 }
108 }
109
110 pub fn variables(mut self, vars: Value) -> Self {
112 self.variables = vars;
113 self
114 }
115
116 pub fn auth(mut self, auth: GraphqlAuth) -> Self {
118 self.auth = AuthSpec::Inline(auth);
119 self
120 }
121
122 pub fn headers(mut self, headers: HeaderMap) -> Self {
124 self.headers = headers;
125 self
126 }
127
128 pub fn records_path(mut self, path: impl Into<String>) -> Self {
130 self.records_path = Some(path.into());
131 self
132 }
133
134 pub fn pagination(mut self, pagination: GraphqlPagination) -> Self {
136 self.pagination = Some(pagination);
137 self
138 }
139
140 pub fn max_pages(mut self, max: usize) -> Self {
142 self.max_pages = Some(max);
143 self
144 }
145
146 pub fn with_batch_size(mut self, batch_size: usize) -> Self {
153 self.batch_size = batch_size;
154 self
155 }
156
157 pub fn validate(&self) -> Result<(), FaucetError> {
162 if self.endpoint.trim().is_empty() {
163 return Err(FaucetError::Config(
164 "GraphQL source requires a non-empty `endpoint`".into(),
165 ));
166 }
167 if self.query.trim().is_empty() {
168 return Err(FaucetError::Config(
169 "GraphQL source requires a non-empty `query`".into(),
170 ));
171 }
172 validate_batch_size(self.batch_size)?;
173 Ok(())
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180 use serde_json::json;
181
182 #[test]
183 fn default_config() {
184 let config = GraphqlStreamConfig::new(
185 "https://api.example.com/graphql",
186 "query { users { id name } }",
187 );
188 assert_eq!(config.endpoint, "https://api.example.com/graphql");
189 assert!(config.records_path.is_none());
190 assert!(config.pagination.is_none());
191 assert!(config.max_pages.is_none());
192 }
193
194 #[test]
195 fn builder_methods() {
196 let config =
197 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { users { id } }")
198 .variables(json!({"org": "acme"}))
199 .records_path("$.data.users.edges[*].node")
200 .max_pages(10)
201 .auth(GraphqlAuth::Bearer {
202 token: "token".into(),
203 });
204 assert_eq!(config.variables["org"], "acme");
205 assert_eq!(config.records_path.unwrap(), "$.data.users.edges[*].node");
206 assert_eq!(config.max_pages, Some(10));
207 }
208
209 #[test]
210 fn default_pagination() {
211 let pag = GraphqlPagination::default();
212 assert_eq!(pag.cursor_variable, "after");
213 assert_eq!(pag.page_size_variable, "first");
214 }
215
216 #[test]
217 fn batch_size_defaults_to_default_batch_size() {
218 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }");
219 assert_eq!(config.batch_size, faucet_core::DEFAULT_BATCH_SIZE);
220 }
221
222 #[test]
223 fn with_batch_size_overrides_default() {
224 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
225 .with_batch_size(250);
226 assert_eq!(config.batch_size, 250);
227 }
228
229 #[test]
230 fn batch_size_zero_is_accepted_as_no_batching_sentinel() {
231 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
232 .with_batch_size(0);
233 assert_eq!(config.batch_size, 0);
234 assert!(faucet_core::validate_batch_size(config.batch_size).is_ok());
235 }
236
237 #[test]
238 fn batch_size_above_max_is_rejected_by_validate_batch_size() {
239 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
240 .with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
241 assert!(faucet_core::validate_batch_size(config.batch_size).is_err());
242 }
243
244 #[test]
245 fn batch_size_deserializes_from_json() {
246 let json = r#"{
247 "endpoint": "https://api.example.com/graphql",
248 "query": "query { x }",
249 "variables": {},
250 "auth": {"type": "none"},
251 "records_path": null,
252 "pagination": null,
253 "max_pages": null,
254 "batch_size": 500
255 }"#;
256 let config: GraphqlStreamConfig = serde_json::from_str(json).unwrap();
257 assert_eq!(config.batch_size, 500);
258 }
259
260 #[test]
261 fn validate_accepts_valid_config() {
262 assert!(
263 GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
264 .validate()
265 .is_ok()
266 );
267 }
268
269 #[test]
270 fn validate_rejects_oversized_batch_size() {
271 let config = GraphqlStreamConfig::new("https://api.example.com/graphql", "query { x }")
272 .with_batch_size(faucet_core::MAX_BATCH_SIZE + 1);
273 assert!(matches!(config.validate(), Err(FaucetError::Config(_))));
274 }
275
276 #[test]
277 fn validate_rejects_empty_endpoint() {
278 assert!(matches!(
279 GraphqlStreamConfig::new(" ", "query { x }").validate(),
280 Err(FaucetError::Config(_))
281 ));
282 }
283
284 #[test]
285 fn validate_rejects_empty_query() {
286 assert!(matches!(
287 GraphqlStreamConfig::new("https://api.example.com/graphql", "").validate(),
288 Err(FaucetError::Config(_))
289 ));
290 }
291}