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 /// A clone whose requests carry the bearer token `source` gives at the
248 /// moment each one goes out, in place of the configured `auth`. The
249 /// clone shares this API's client — connection pool, parallel cap,
250 /// breaker and observer — so one clone per session costs no extra
251 /// connections. See [`BearerSource`](crate::BearerSource).
252 ///
253 /// The client adds an [`auth_refresher`](GraphqlApiBuilder::auth_refresher)'s
254 /// header itself, so a clone can't withdraw it: build the shared API
255 /// without a refresher when its clones bring their own tokens.
256 pub fn with_bearer_source(&self, source: crate::BearerSource) -> Self {
257 Self {
258 auth_header: AuthHeader::Source(source),
259 ..self.clone()
260 }
261 }
262
263 /// Report rows decoded from a response to the observer.
264 pub(crate) fn report_rows(&self, n: usize) {
265 self.client.report(TransportEvent::rows_pulled(n));
266 }
267
268 /// Send a query document with variables. Returns the `data` payload
269 /// from the GraphQL response, or an error if the request failed or
270 /// the response carried a top-level `errors` array.
271 pub async fn post_graphql(
272 &self,
273 query: &str,
274 variables: &serde_json::Map<String, Value>,
275 ) -> Result<Value> {
276 match self.post_graphql_reply(query, variables).await? {
277 GraphqlReply::Data(data) => Ok(data),
278 GraphqlReply::Errors(summary) => {
279 Err(error!("GraphQL response carried errors", errors = summary))
280 }
281 }
282 }
283
284 /// [`post_graphql`](Self::post_graphql), with a top-level `errors`
285 /// array kept apart from transport and decode failures, so a caller
286 /// can say what a rejected query means.
287 pub(crate) async fn post_graphql_reply(
288 &self,
289 query: &str,
290 variables: &serde_json::Map<String, Value>,
291 ) -> Result<GraphqlReply> {
292 #[derive(Serialize)]
293 struct Body<'a> {
294 query: &'a str,
295 variables: &'a serde_json::Map<String, Value>,
296 }
297
298 let body = Body { query, variables };
299
300 let policy = crate::transport::policy_for(Priority::current());
301 let response = self
302 .client
303 .execute_with(&policy, |http| {
304 self.auth_header
305 .apply(http.post(&self.endpoint).json(&body))
306 })
307 .await
308 .map_err(|e| {
309 crate::transport::client_error(e, "GraphQL request failed", &self.endpoint)
310 })?;
311
312 let mut envelope: Value = response.json().await.map_err(|e| {
313 error!(
314 "Failed to parse GraphQL response as JSON",
315 detail = e.to_string()
316 )
317 })?;
318
319 // GraphQL servers return `{ "data": …, "errors": [...] }`.
320 if let Some(errors) = envelope.get("errors")
321 && let Some(arr) = errors.as_array()
322 && !arr.is_empty()
323 {
324 let summary = arr
325 .iter()
326 .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
327 .collect::<Vec<_>>()
328 .join("; ");
329 return Ok(GraphqlReply::Errors(summary));
330 }
331
332 Ok(GraphqlReply::Data(
333 envelope
334 .get_mut("data")
335 .map(std::mem::take)
336 .unwrap_or(Value::Null),
337 ))
338 }
339}
340
341/// A GraphQL response that arrived and decoded: its `data`, or the
342/// messages of its top-level `errors` joined with `; `.
343pub(crate) enum GraphqlReply {
344 Data(Value),
345 Errors(String),
346}
347
348/// Builder for [`GraphqlApi`]. Use [`GraphqlApi::builder`] to start.
349#[derive(Clone, Debug)]
350pub struct GraphqlApiBuilder {
351 endpoint: String,
352 transport: crate::transport::ClientConfig,
353 auth_header: AuthHeader,
354 dialect: FilterDialect,
355 filter_arg_name: Option<String>,
356 root_args: Option<Value>,
357 response_path: Vec<String>,
358 supports: Supports,
359 count_field: Option<String>,
360}
361
362impl GraphqlApiBuilder {
363 pub(crate) fn new(endpoint: String) -> Self {
364 Self {
365 endpoint,
366 transport: crate::transport::ClientConfig::default(),
367 auth_header: AuthHeader::default(),
368 dialect: FilterDialect::Generic,
369 filter_arg_name: None,
370 root_args: None,
371 response_path: Vec::new(),
372 supports: Supports::default(),
373 count_field: None,
374 }
375 }
376
377 /// Concurrent requests to this API at most (default 4).
378 pub fn max_parallel(mut self, n: usize) -> Self {
379 self.transport.max_parallel = n.max(1);
380 self
381 }
382
383 /// Requests per second to this API at most.
384 pub fn rate_limit(mut self, per_second: f64) -> Self {
385 self.transport.rate_limit = Some(per_second);
386 self
387 }
388
389 /// Give up on one request after `timeout` (default 30 s). Ignored when a
390 /// client is supplied with [`http_client`](Self::http_client).
391 pub fn request_timeout(mut self, timeout: std::time::Duration) -> Self {
392 self.transport.request_timeout = Some(Some(timeout));
393 self
394 }
395
396 /// Never time a request out. Ignored when a client is supplied.
397 pub fn no_request_timeout(mut self) -> Self {
398 self.transport.request_timeout = Some(None);
399 self
400 }
401
402 /// Report every attempt, retry and breaker transition under `key`
403 /// (the datasource name).
404 pub fn observer(
405 mut self,
406 key: impl Into<Arc<str>>,
407 observer: Arc<dyn TransportObserver>,
408 ) -> Self {
409 self.transport.observer = Some((key.into(), observer));
410 self
411 }
412
413 /// Literal arguments always passed to the root field, e.g.
414 /// `json!({ "input": {} })` for a mandatory non-null input object.
415 pub fn root_args(mut self, args: Value) -> Self {
416 self.root_args = Some(args);
417 self
418 }
419
420 /// Dotted path walked into the root field's value before rows are
421 /// read — `"edges.node"` unwraps a Relay-style connection.
422 pub fn response_path(mut self, path: impl AsRef<str>) -> Self {
423 self.response_path = split_response_path(path.as_ref());
424 self
425 }
426
427 /// Override what the server accepts; see [`Supports`].
428 pub fn supports(mut self, supports: Supports) -> Self {
429 self.supports = supports;
430 self
431 }
432
433 /// Override the Hasura aggregate field used by `count: aggregate`
434 /// (default `<root_field>_aggregate`).
435 pub fn count_field(mut self, field: impl Into<String>) -> Self {
436 self.count_field = Some(field.into());
437 self
438 }
439
440 /// Set the `Authorization` header value (e.g. `"Bearer <token>"`).
441 pub fn auth(mut self, auth: impl Into<String>) -> Self {
442 self.auth_header = AuthHeader::new(auth);
443 self.transport.auth_refresher = None;
444 self
445 }
446
447 /// Get the bearer token from `refresher` instead of a fixed header: it
448 /// is asked on the first request, and again when the API answers `401`.
449 /// A request waits for it, so a refresher may run an interactive
450 /// sign-in. Replaces [`auth`](Self::auth).
451 pub fn auth_refresher(mut self, refresher: crate::AuthRefresher) -> Self {
452 self.auth_header = AuthHeader::default();
453 self.transport.auth_refresher = Some(refresher);
454 self
455 }
456
457 /// Ask `source` for the bearer token on every request, retries
458 /// included; see [`BearerSource`](crate::BearerSource). Replaces
459 /// [`auth`](Self::auth) and [`auth_refresher`](Self::auth_refresher).
460 pub fn bearer_source(mut self, source: crate::BearerSource) -> Self {
461 self.auth_header = AuthHeader::Source(source);
462 self.transport.auth_refresher = None;
463 self
464 }
465
466 /// A pre-configured `reqwest::Client` (timeouts, proxies, TLS).
467 pub fn http_client(mut self, client: reqwest::Client) -> Self {
468 self.transport.http = Some(client);
469 self
470 }
471
472 /// Pick the filter dialect used to render conditions on tables.
473 /// Defaults to [`FilterDialect::Generic`] — flat-arg schemas like SpaceX.
474 pub fn dialect(mut self, dialect: FilterDialect) -> Self {
475 self.dialect = dialect;
476 self
477 }
478
479 /// Override the filter argument name. Defaults match the dialect:
480 /// `"where"` for Hasura, `"find"` for Generic.
481 pub fn filter_arg_name(mut self, name: impl Into<String>) -> Self {
482 self.filter_arg_name = Some(name.into());
483 self
484 }
485
486 /// Finish the configuration. Builds no HTTP client yet: unless one was
487 /// given with [`http_client`](Self::http_client), the default client is
488 /// created on the first request.
489 pub fn build(self) -> GraphqlApi {
490 GraphqlApi {
491 endpoint: self.endpoint,
492 client: crate::transport::build_client(self.transport),
493 auth_header: self.auth_header,
494 dialect: self.dialect,
495 filter_arg_name: self.filter_arg_name,
496 root_args: self.root_args,
497 response_path: self.response_path,
498 supports: self.supports,
499 count_field: self.count_field,
500 field_aliases: IndexMap::new(),
501 }
502 }
503}
504
505/// Split a dotted response path, dropping empty segments so a stray
506/// leading or trailing dot doesn't produce a lookup for `""`.
507pub(crate) fn split_response_path(path: &str) -> Vec<String> {
508 path.split('.')
509 .filter(|s| !s.is_empty())
510 .map(str::to_string)
511 .collect()
512}