Skip to main content

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}