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    /// 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}