vantage_api_client/graphql/api.rs
1//! `GraphqlApi` — the data source struct.
2//!
3//! Wraps a single HTTP endpoint and a `reqwest` client. Each query goes
4//! out as one POST with `{ "query": …, "variables": {…} }` and the JSON
5//! `data` payload comes back as `serde_json::Value`. Higher layers
6//! (`GraphqlSelect`, `TableSource`) build the request body and parse the
7//! response.
8//!
9//! The query language itself is handled by the query builder in the
10//! `select` module — `GraphqlApi` is just transport.
11
12use std::sync::Arc;
13
14use indexmap::IndexMap;
15use serde::Serialize;
16use serde_json::Value;
17use vantage_api_pool::resilient::{ResilientClient, TransportEvent, TransportObserver};
18use vantage_core::{Priority, Result, error};
19
20use crate::graphql::condition::FilterDialect;
21use crate::transport::AuthHeader;
22
23/// GraphQL HTTP data source. Cheap to clone — the inner `ResilientClient`
24/// is `Arc`-wrapped.
25///
26/// `dialect` and `filter_arg_name` drive how the `TableSource` impl
27/// renders filter arguments — Hasura's `where:` vs SpaceX-style `find:`,
28/// etc. Both default to the dialect's natural choice (Generic + `find`).
29#[derive(Clone, Debug)]
30pub struct GraphqlApi {
31 endpoint: String,
32 client: ResilientClient,
33 auth_header: AuthHeader,
34 pub(crate) dialect: FilterDialect,
35 pub(crate) filter_arg_name: Option<String>,
36 pub(crate) root_args: Option<Value>,
37 pub(crate) response_path: Vec<String>,
38 pub(crate) supports: Supports,
39 pub(crate) count_field: Option<String>,
40 /// Column name → server field name, from a spec column's
41 /// `graphql: { field: … }`. Set per table, like the overrides above.
42 pub(crate) field_aliases: IndexMap<String, String>,
43}
44
45/// Per-table overrides for what the server will actually accept, each
46/// `None` meaning "use the dialect's default".
47///
48/// These exist because a GraphQL endpoint is not uniform: on one schema
49/// the list field takes `where`/`order_by`/`limit`, on the next it takes
50/// no arguments at all. Spacelift's `stacks` is the second kind, and
51/// pushing a filter at it renders a query the server rejects outright —
52/// so this is what stops equality push-down, which Vista otherwise
53/// assumes every driver supports.
54///
55/// Offset paging is advertised only when both `paginate` and `filter` are
56/// on: with filtering done locally, a server page would be cut down after
57/// the fact and come back short.
58///
59/// Build one from [`Supports::default`] with the `with_*` setters:
60///
61/// ```
62/// use vantage_api_client::{CountMode, Supports};
63///
64/// let supports = Supports::default()
65/// .with_filter(false)
66/// .with_count(CountMode::Aggregate);
67/// assert_eq!(supports.filter, Some(false));
68/// ```
69#[derive(Clone, Copy, Debug, Default)]
70#[non_exhaustive]
71pub struct Supports {
72 /// Conditions may be pushed into the filter argument. Default: on.
73 pub filter: Option<bool>,
74 /// `order_by:` may be rendered. Default: on for Hasura only.
75 pub order: Option<bool>,
76 /// A quicksearch may render as an OR of `_ilike`s. Default: on for
77 /// Hasura only, and never without `filter`.
78 pub search: Option<bool>,
79 /// `limit:`/`offset:` may be rendered. Default: off.
80 pub paginate: Option<bool>,
81 /// How a count is taken; see [`CountMode`].
82 pub count: CountMode,
83}
84
85impl Supports {
86 /// Set [`filter`](Self::filter).
87 pub fn with_filter(mut self, on: bool) -> Self {
88 self.filter = Some(on);
89 self
90 }
91
92 /// Set [`order`](Self::order).
93 pub fn with_order(mut self, on: bool) -> Self {
94 self.order = Some(on);
95 self
96 }
97
98 /// Set [`search`](Self::search).
99 pub fn with_search(mut self, on: bool) -> Self {
100 self.search = Some(on);
101 self
102 }
103
104 /// Set [`paginate`](Self::paginate).
105 pub fn with_paginate(mut self, on: bool) -> Self {
106 self.paginate = Some(on);
107 self
108 }
109
110 /// Set [`count`](Self::count).
111 pub fn with_count(mut self, count: CountMode) -> Self {
112 self.count = count;
113 self
114 }
115}
116
117/// How `get_count` is answered. YAML: `count: list | aggregate`.
118#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize, serde::Serialize)]
119#[serde(rename_all = "lowercase")]
120#[non_exhaustive]
121pub enum CountMode {
122 /// List the rows and count them. Works on every schema and for every
123 /// role.
124 #[default]
125 List,
126 /// Hasura only: one `<count_field> { aggregate { count } }` query.
127 /// Needs a role with `allow_aggregations`; a server that refuses it
128 /// fails the count rather than falling back to listing. On the Generic
129 /// dialect, or with `filter: false`, the count still lists.
130 Aggregate,
131}
132
133impl GraphqlApi {
134 /// Whether conditions may be pushed into the query. Defaults to true —
135 /// most list fields take some filter argument.
136 pub fn can_filter(&self) -> bool {
137 self.supports.filter.unwrap_or(true)
138 }
139
140 /// Whether `order_by:` may be rendered. Only the Hasura dialect renders
141 /// a sort, so Generic defaults to sorting client-side.
142 pub fn can_order(&self) -> bool {
143 self.supports
144 .order
145 .unwrap_or(matches!(self.dialect, FilterDialect::Hasura))
146 }
147
148 /// Whether a quicksearch renders as an OR of `_ilike`s. Same story as
149 /// ordering: Hasura only. A search *is* a condition, so it also needs
150 /// filter push-down to be on.
151 pub fn can_search(&self) -> bool {
152 self.can_filter()
153 && self
154 .supports
155 .search
156 .unwrap_or(matches!(self.dialect, FilterDialect::Hasura))
157 }
158
159 /// Whether operators richer than equality can be rendered. Generic
160 /// rejects them at render time, so only Hasura qualifies.
161 pub fn can_filter_operators(&self) -> bool {
162 self.can_filter() && matches!(self.dialect, FilterDialect::Hasura)
163 }
164
165 /// Whether `limit:`/`offset:` may be rendered. Off by default: a
166 /// schema that doesn't take them turns every query into an error, and
167 /// the cost of not paging is one extra round of rows.
168 pub fn can_paginate(&self) -> bool {
169 self.supports.paginate.unwrap_or(false)
170 }
171
172 /// Whether offset paging can be offered to a consumer: `limit:` and
173 /// `offset:` render, and so do the conditions, so a page is not cut
174 /// down by local filtering after the server has sized it.
175 pub fn can_page(&self) -> bool {
176 self.can_paginate() && self.can_filter()
177 }
178
179 /// Path walked into the root field's value before rows are read —
180 /// `["edges", "node"]` for a Relay-style connection. Empty means the
181 /// root field's value is the row array itself.
182 pub fn response_path(&self) -> &[String] {
183 &self.response_path
184 }
185
186 /// Literal arguments always passed to the root field.
187 pub fn root_args(&self) -> Option<&Value> {
188 self.root_args.as_ref()
189 }
190
191 /// Whether a count runs as a server-side aggregate instead of listing
192 /// every row: `count: aggregate` on Hasura, and only when conditions
193 /// reach the server — otherwise the aggregate would count rows the
194 /// local filter drops.
195 pub(crate) fn can_count_server_side(&self) -> bool {
196 self.supports.count == CountMode::Aggregate
197 && matches!(self.dialect, FilterDialect::Hasura)
198 && self.can_filter()
199 }
200
201 /// Aggregate field queried for a server-side count of `root_field`:
202 /// the configured override, else Hasura's `<root_field>_aggregate`.
203 pub(crate) fn count_field(&self, root_field: &str) -> String {
204 self.count_field
205 .clone()
206 .unwrap_or_else(|| format!("{root_field}_aggregate"))
207 }
208}
209
210impl GraphqlApi {
211 /// Connect to a GraphQL endpoint at `endpoint` (e.g.
212 /// `https://api.spacex.land/graphql/`). Uses the default reqwest
213 /// client; for finer control go through [`GraphqlApi::builder`].
214 pub fn new(endpoint: impl Into<String>) -> Self {
215 GraphqlApi::builder(endpoint).build()
216 }
217
218 /// Start configuring a [`GraphqlApi`].
219 pub fn builder(endpoint: impl Into<String>) -> GraphqlApiBuilder {
220 GraphqlApiBuilder::new(endpoint.into())
221 }
222
223 /// Endpoint URL the client posts to.
224 pub fn endpoint(&self) -> &str {
225 &self.endpoint
226 }
227
228 /// Filter dialect — controls how `where:` / `find:` arguments are
229 /// rendered. Defaults to [`FilterDialect::Generic`].
230 pub fn dialect(&self) -> FilterDialect {
231 self.dialect
232 }
233
234 /// What the circuit breaker is doing right now. `None` when the
235 /// underlying client has no breaker configured.
236 pub fn breaker_state(&self) -> Option<crate::BreakerState> {
237 self.client.breaker_state()
238 }
239
240 /// The resilient client backing this API — the pool, breaker and
241 /// observer a caller outside the read path (e.g. an outbox replaying a
242 /// queued write) should share rather than build its own.
243 pub fn client(&self) -> &ResilientClient {
244 &self.client
245 }
246
247 /// Report rows decoded from a response to the observer.
248 pub(crate) fn report_rows(&self, n: usize) {
249 self.client.report(TransportEvent::rows_pulled(n));
250 }
251
252 /// Send a query document with variables. Returns the `data` payload
253 /// from the GraphQL response, or an error if the request failed or
254 /// the response carried a top-level `errors` array.
255 pub async fn post_graphql(
256 &self,
257 query: &str,
258 variables: &serde_json::Map<String, Value>,
259 ) -> Result<Value> {
260 match self.post_graphql_reply(query, variables).await? {
261 GraphqlReply::Data(data) => Ok(data),
262 GraphqlReply::Errors(summary) => {
263 Err(error!("GraphQL response carried errors", errors = summary))
264 }
265 }
266 }
267
268 /// [`post_graphql`](Self::post_graphql), with a top-level `errors`
269 /// array kept apart from transport and decode failures, so a caller
270 /// can say what a rejected query means.
271 pub(crate) async fn post_graphql_reply(
272 &self,
273 query: &str,
274 variables: &serde_json::Map<String, Value>,
275 ) -> Result<GraphqlReply> {
276 #[derive(Serialize)]
277 struct Body<'a> {
278 query: &'a str,
279 variables: &'a serde_json::Map<String, Value>,
280 }
281
282 let body = Body { query, variables };
283
284 let policy = crate::transport::policy_for(Priority::current());
285 let response = self
286 .client
287 .execute_with(&policy, |http| {
288 let req = http.post(&self.endpoint).json(&body);
289 match self.auth_header.value() {
290 Some(auth) => req.header(reqwest::header::AUTHORIZATION, auth),
291 None => req,
292 }
293 })
294 .await
295 .map_err(|e| {
296 crate::transport::client_error(e, "GraphQL request failed", &self.endpoint)
297 })?;
298
299 let mut envelope: Value = response.json().await.map_err(|e| {
300 error!(
301 "Failed to parse GraphQL response as JSON",
302 detail = e.to_string()
303 )
304 })?;
305
306 // GraphQL servers return `{ "data": …, "errors": [...] }`.
307 if let Some(errors) = envelope.get("errors")
308 && let Some(arr) = errors.as_array()
309 && !arr.is_empty()
310 {
311 let summary = arr
312 .iter()
313 .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
314 .collect::<Vec<_>>()
315 .join("; ");
316 return Ok(GraphqlReply::Errors(summary));
317 }
318
319 Ok(GraphqlReply::Data(
320 envelope
321 .get_mut("data")
322 .map(std::mem::take)
323 .unwrap_or(Value::Null),
324 ))
325 }
326}
327
328/// A GraphQL response that arrived and decoded: its `data`, or the
329/// messages of its top-level `errors` joined with `; `.
330pub(crate) enum GraphqlReply {
331 Data(Value),
332 Errors(String),
333}
334
335/// Builder for [`GraphqlApi`]. Use [`GraphqlApi::builder`] to start.
336#[derive(Clone, Debug)]
337pub struct GraphqlApiBuilder {
338 endpoint: String,
339 transport: crate::transport::ClientConfig,
340 auth_header: AuthHeader,
341 dialect: FilterDialect,
342 filter_arg_name: Option<String>,
343 root_args: Option<Value>,
344 response_path: Vec<String>,
345 supports: Supports,
346 count_field: Option<String>,
347}
348
349impl GraphqlApiBuilder {
350 pub(crate) fn new(endpoint: String) -> Self {
351 Self {
352 endpoint,
353 transport: crate::transport::ClientConfig::default(),
354 auth_header: AuthHeader::default(),
355 dialect: FilterDialect::Generic,
356 filter_arg_name: None,
357 root_args: None,
358 response_path: Vec::new(),
359 supports: Supports::default(),
360 count_field: None,
361 }
362 }
363
364 /// Concurrent requests to this API at most (default 4).
365 pub fn max_parallel(mut self, n: usize) -> Self {
366 self.transport.max_parallel = n.max(1);
367 self
368 }
369
370 /// Requests per second to this API at most.
371 pub fn rate_limit(mut self, per_second: f64) -> Self {
372 self.transport.rate_limit = Some(per_second);
373 self
374 }
375
376 /// Give up on one request after `timeout` (default 30 s). Ignored when a
377 /// client is supplied with [`http_client`](Self::http_client).
378 pub fn request_timeout(mut self, timeout: std::time::Duration) -> Self {
379 self.transport.request_timeout = Some(Some(timeout));
380 self
381 }
382
383 /// Never time a request out. Ignored when a client is supplied.
384 pub fn no_request_timeout(mut self) -> Self {
385 self.transport.request_timeout = Some(None);
386 self
387 }
388
389 /// Report every attempt, retry and breaker transition under `key`
390 /// (the datasource name).
391 pub fn observer(
392 mut self,
393 key: impl Into<Arc<str>>,
394 observer: Arc<dyn TransportObserver>,
395 ) -> Self {
396 self.transport.observer = Some((key.into(), observer));
397 self
398 }
399
400 /// Literal arguments always passed to the root field, e.g.
401 /// `json!({ "input": {} })` for a mandatory non-null input object.
402 pub fn root_args(mut self, args: Value) -> Self {
403 self.root_args = Some(args);
404 self
405 }
406
407 /// Dotted path walked into the root field's value before rows are
408 /// read — `"edges.node"` unwraps a Relay-style connection.
409 pub fn response_path(mut self, path: impl AsRef<str>) -> Self {
410 self.response_path = split_response_path(path.as_ref());
411 self
412 }
413
414 /// Override what the server accepts; see [`Supports`].
415 pub fn supports(mut self, supports: Supports) -> Self {
416 self.supports = supports;
417 self
418 }
419
420 /// Override the Hasura aggregate field used by `count: aggregate`
421 /// (default `<root_field>_aggregate`).
422 pub fn count_field(mut self, field: impl Into<String>) -> Self {
423 self.count_field = Some(field.into());
424 self
425 }
426
427 /// Set the `Authorization` header value (e.g. `"Bearer <token>"`).
428 pub fn auth(mut self, auth: impl Into<String>) -> Self {
429 self.auth_header = AuthHeader::new(auth);
430 self.transport.auth_refresher = None;
431 self
432 }
433
434 /// Get the bearer token from `refresher` instead of a fixed header: it
435 /// is asked on the first request, and again when the API answers `401`.
436 /// A request waits for it, so a refresher may run an interactive
437 /// sign-in. Replaces [`auth`](Self::auth).
438 pub fn auth_refresher(mut self, refresher: crate::AuthRefresher) -> Self {
439 self.auth_header = AuthHeader::default();
440 self.transport.auth_refresher = Some(refresher);
441 self
442 }
443
444 /// A pre-configured `reqwest::Client` (timeouts, proxies, TLS).
445 pub fn http_client(mut self, client: reqwest::Client) -> Self {
446 self.transport.http = Some(client);
447 self
448 }
449
450 /// Pick the filter dialect used to render conditions on tables.
451 /// Defaults to [`FilterDialect::Generic`] — flat-arg schemas like SpaceX.
452 pub fn dialect(mut self, dialect: FilterDialect) -> Self {
453 self.dialect = dialect;
454 self
455 }
456
457 /// Override the filter argument name. Defaults match the dialect:
458 /// `"where"` for Hasura, `"find"` for Generic.
459 pub fn filter_arg_name(mut self, name: impl Into<String>) -> Self {
460 self.filter_arg_name = Some(name.into());
461 self
462 }
463
464 /// Finish the configuration. Builds no HTTP client yet: unless one was
465 /// given with [`http_client`](Self::http_client), the default client is
466 /// created on the first request.
467 pub fn build(self) -> GraphqlApi {
468 GraphqlApi {
469 endpoint: self.endpoint,
470 client: crate::transport::build_client(self.transport),
471 auth_header: self.auth_header,
472 dialect: self.dialect,
473 filter_arg_name: self.filter_arg_name,
474 root_args: self.root_args,
475 response_path: self.response_path,
476 supports: self.supports,
477 count_field: self.count_field,
478 field_aliases: IndexMap::new(),
479 }
480 }
481}
482
483/// Split a dotted response path, dropping empty segments so a stray
484/// leading or trailing dot doesn't produce a lookup for `""`.
485pub(crate) fn split_response_path(path: &str) -> Vec<String> {
486 path.split('.')
487 .filter(|s| !s.is_empty())
488 .map(str::to_string)
489 .collect()
490}
491
492#[cfg(test)]
493mod tests {
494 use super::*;
495
496 #[test]
497 fn new_keeps_endpoint() {
498 let api = GraphqlApi::new("https://api.spacex.land/graphql/");
499 assert_eq!(api.endpoint(), "https://api.spacex.land/graphql/");
500 }
501
502 #[test]
503 fn builder_sets_auth_without_panicking() {
504 // Auth header is private — this just confirms the builder chain
505 // compiles end-to-end and produces a usable client.
506 let api = GraphqlApi::builder("https://example.test/graphql")
507 .auth("Bearer abc")
508 .build();
509 assert_eq!(api.endpoint(), "https://example.test/graphql");
510 }
511
512 #[test]
513 fn debug_masks_auth_header() {
514 let api = GraphqlApi::builder("https://example.test/graphql")
515 .auth("Bearer secret-token")
516 .build();
517 let text = format!("{api:?}");
518 assert!(!text.contains("secret-token"), "{text}");
519 assert!(text.contains("<set>"), "{text}");
520 }
521}