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 serde::Serialize;
15use serde_json::Value;
16use vantage_api_pool::resilient::{ResilientClient, TransportEvent, TransportObserver};
17use vantage_core::{Priority, Result, error};
18
19use crate::graphql::condition::FilterDialect;
20use crate::transport::AuthHeader;
21
22/// GraphQL HTTP data source. Cheap to clone — the inner `ResilientClient`
23/// is `Arc`-wrapped.
24///
25/// `dialect` and `filter_arg_name` drive how the `TableSource` impl
26/// renders filter arguments — Hasura's `where:` vs SpaceX-style `find:`,
27/// etc. Both default to the dialect's natural choice (Generic + `find`).
28#[derive(Clone, Debug)]
29pub struct GraphqlApi {
30    endpoint: String,
31    client: ResilientClient,
32    auth_header: AuthHeader,
33    pub(crate) dialect: FilterDialect,
34    pub(crate) filter_arg_name: Option<String>,
35    pub(crate) root_args: Option<Value>,
36    pub(crate) response_path: Vec<String>,
37    pub(crate) supports: Supports,
38}
39
40/// Per-table overrides for what the server will actually accept, each
41/// `None` meaning "use the dialect's default".
42///
43/// These exist because a GraphQL endpoint is not uniform: on one schema
44/// the list field takes `where`/`order_by`/`limit`, on the next it takes
45/// no arguments at all. Spacelift's `stacks` is the second kind, and
46/// pushing a filter at it renders a query the server rejects outright —
47/// so this is what stops equality push-down, which Vista otherwise
48/// assumes every driver supports.
49#[derive(Clone, Copy, Debug, Default)]
50pub struct Supports {
51    pub filter: Option<bool>,
52    pub order: Option<bool>,
53    pub search: Option<bool>,
54    pub paginate: Option<bool>,
55}
56
57impl GraphqlApi {
58    /// Whether conditions may be pushed into the query. Defaults to true —
59    /// most list fields take some filter argument.
60    pub fn can_filter(&self) -> bool {
61        self.supports.filter.unwrap_or(true)
62    }
63
64    /// Whether `order_by:` may be rendered. Only Hasura has a spelling for
65    /// it today, so Generic defaults to sorting client-side.
66    pub fn can_order(&self) -> bool {
67        self.supports
68            .order
69            .unwrap_or(matches!(self.dialect, FilterDialect::Hasura))
70    }
71
72    /// Whether a quicksearch renders as an OR of `_ilike`s. Same story as
73    /// ordering: Hasura only. A search *is* a condition, so it also needs
74    /// filter push-down to be on.
75    pub fn can_search(&self) -> bool {
76        self.can_filter()
77            && self
78                .supports
79                .search
80                .unwrap_or(matches!(self.dialect, FilterDialect::Hasura))
81    }
82
83    /// Whether operators richer than equality can be rendered. Generic
84    /// rejects them at render time, so only Hasura qualifies.
85    pub fn can_filter_operators(&self) -> bool {
86        self.can_filter() && matches!(self.dialect, FilterDialect::Hasura)
87    }
88
89    /// Whether `limit:`/`offset:` may be rendered. Off by default: a
90    /// schema that doesn't take them turns every query into an error, and
91    /// the cost of not paging is one extra round of rows.
92    pub fn can_paginate(&self) -> bool {
93        self.supports.paginate.unwrap_or(false)
94    }
95
96    /// Path walked into the root field's value before rows are read —
97    /// `["edges", "node"]` for a Relay-style connection. Empty means the
98    /// root field's value is the row array itself.
99    pub fn response_path(&self) -> &[String] {
100        &self.response_path
101    }
102
103    /// Literal arguments always passed to the root field.
104    pub fn root_args(&self) -> Option<&Value> {
105        self.root_args.as_ref()
106    }
107}
108
109impl GraphqlApi {
110    /// Connect to a GraphQL endpoint at `endpoint` (e.g.
111    /// `https://api.spacex.land/graphql/`). Uses the default reqwest
112    /// client; for finer control go through [`GraphqlApi::builder`].
113    pub fn new(endpoint: impl Into<String>) -> Self {
114        GraphqlApi::builder(endpoint).build()
115    }
116
117    /// Start configuring a [`GraphqlApi`].
118    pub fn builder(endpoint: impl Into<String>) -> GraphqlApiBuilder {
119        GraphqlApiBuilder::new(endpoint.into())
120    }
121
122    /// Endpoint URL the client posts to.
123    pub fn endpoint(&self) -> &str {
124        &self.endpoint
125    }
126
127    /// Filter dialect — controls how `where:` / `find:` arguments are
128    /// rendered. Defaults to [`FilterDialect::Generic`].
129    pub fn dialect(&self) -> FilterDialect {
130        self.dialect
131    }
132
133    /// What the circuit breaker is doing right now. `None` when the
134    /// underlying client has no breaker configured.
135    pub fn breaker_state(&self) -> Option<crate::BreakerState> {
136        self.client.breaker_state()
137    }
138
139    /// The resilient client backing this API — the pool, breaker and
140    /// observer a caller outside the read path (e.g. an outbox replaying a
141    /// queued write) should share rather than build its own.
142    pub fn client(&self) -> &ResilientClient {
143        &self.client
144    }
145
146    /// Report rows decoded from a response to the observer.
147    pub(crate) fn report_rows(&self, n: usize) {
148        self.client.report(TransportEvent::RowsPulled { n });
149    }
150
151    /// Send a query document with variables. Returns the `data` payload
152    /// from the GraphQL response, or an error if the request failed or
153    /// the response carried a top-level `errors` array.
154    pub async fn post_graphql(
155        &self,
156        query: &str,
157        variables: &serde_json::Map<String, Value>,
158    ) -> Result<Value> {
159        #[derive(Serialize)]
160        struct Body<'a> {
161            query: &'a str,
162            variables: &'a serde_json::Map<String, Value>,
163        }
164
165        let body = Body { query, variables };
166
167        let policy = crate::transport::policy_for(Priority::current());
168        let response = self
169            .client
170            .execute_with(&policy, |http| {
171                let req = http.post(&self.endpoint).json(&body);
172                match self.auth_header.value() {
173                    Some(auth) => req.header(reqwest::header::AUTHORIZATION, auth),
174                    None => req,
175                }
176            })
177            .await
178            .map_err(|e| {
179                crate::transport::client_error(e, "GraphQL request failed", &self.endpoint)
180            })?;
181
182        let mut envelope: Value = response.json().await.map_err(|e| {
183            error!(
184                "Failed to parse GraphQL response as JSON",
185                detail = e.to_string()
186            )
187        })?;
188
189        // GraphQL servers return `{ "data": …, "errors": [...] }`. Surface
190        // any errors as a Vantage error and otherwise hand back `data`.
191        if let Some(errors) = envelope.get("errors")
192            && let Some(arr) = errors.as_array()
193            && !arr.is_empty()
194        {
195            let summary = arr
196                .iter()
197                .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
198                .collect::<Vec<_>>()
199                .join("; ");
200            return Err(error!("GraphQL response carried errors", errors = summary));
201        }
202
203        Ok(envelope
204            .get_mut("data")
205            .map(std::mem::take)
206            .unwrap_or(Value::Null))
207    }
208}
209
210/// Builder for [`GraphqlApi`]. Use [`GraphqlApi::builder`] to start.
211#[derive(Clone, Debug)]
212pub struct GraphqlApiBuilder {
213    endpoint: String,
214    transport: crate::transport::ClientConfig,
215    auth_header: AuthHeader,
216    dialect: FilterDialect,
217    filter_arg_name: Option<String>,
218    root_args: Option<Value>,
219    response_path: Vec<String>,
220    supports: Supports,
221}
222
223impl GraphqlApiBuilder {
224    pub(crate) fn new(endpoint: String) -> Self {
225        Self {
226            endpoint,
227            transport: crate::transport::ClientConfig::default(),
228            auth_header: AuthHeader::default(),
229            dialect: FilterDialect::Generic,
230            filter_arg_name: None,
231            root_args: None,
232            response_path: Vec::new(),
233            supports: Supports::default(),
234        }
235    }
236
237    /// Concurrent requests to this API at most (default 4).
238    pub fn max_parallel(mut self, n: usize) -> Self {
239        self.transport.max_parallel = n.max(1);
240        self
241    }
242
243    /// Requests per second to this API at most.
244    pub fn rate_limit(mut self, per_second: f64) -> Self {
245        self.transport.rate_limit = Some(per_second);
246        self
247    }
248
249    /// Report every attempt, retry and breaker transition under `key`
250    /// (the datasource name).
251    pub fn observer(
252        mut self,
253        key: impl Into<Arc<str>>,
254        observer: Arc<dyn TransportObserver>,
255    ) -> Self {
256        self.transport.observer = Some((key.into(), observer));
257        self
258    }
259
260    /// Literal arguments always passed to the root field, e.g.
261    /// `json!({ "input": {} })` for a mandatory non-null input object.
262    pub fn root_args(mut self, args: Value) -> Self {
263        self.root_args = Some(args);
264        self
265    }
266
267    /// Dotted path walked into the root field's value before rows are
268    /// read — `"edges.node"` unwraps a Relay-style connection.
269    pub fn response_path(mut self, path: impl AsRef<str>) -> Self {
270        self.response_path = split_response_path(path.as_ref());
271        self
272    }
273
274    /// Override what the server accepts; see [`Supports`].
275    pub fn supports(mut self, supports: Supports) -> Self {
276        self.supports = supports;
277        self
278    }
279
280    /// Set the `Authorization` header value (e.g. `"Bearer <token>"`).
281    pub fn auth(mut self, auth: impl Into<String>) -> Self {
282        self.auth_header = AuthHeader::new(auth);
283        self
284    }
285
286    /// Use a pre-configured `reqwest::Client` (e.g. one with custom
287    /// timeouts or a proxy).
288    pub fn client(mut self, client: reqwest::Client) -> Self {
289        self.transport.http = Some(client);
290        self
291    }
292
293    /// Alias for [`Self::client`], matching `RestApiBuilder::http_client`.
294    pub fn http_client(self, client: reqwest::Client) -> Self {
295        self.client(client)
296    }
297
298    /// Pick the filter dialect used to render conditions on tables.
299    /// Defaults to [`FilterDialect::Generic`] — flat-arg schemas like SpaceX.
300    pub fn dialect(mut self, dialect: FilterDialect) -> Self {
301        self.dialect = dialect;
302        self
303    }
304
305    /// Override the filter argument name. Defaults match the dialect:
306    /// `"where"` for Hasura, `"find"` for Generic.
307    pub fn filter_arg_name(mut self, name: impl Into<String>) -> Self {
308        self.filter_arg_name = Some(name.into());
309        self
310    }
311
312    pub fn build(self) -> GraphqlApi {
313        GraphqlApi {
314            endpoint: self.endpoint,
315            client: crate::transport::build_client(self.transport),
316            auth_header: self.auth_header,
317            dialect: self.dialect,
318            filter_arg_name: self.filter_arg_name,
319            root_args: self.root_args,
320            response_path: self.response_path,
321            supports: self.supports,
322        }
323    }
324}
325
326/// Split a dotted response path, dropping empty segments so a stray
327/// leading or trailing dot doesn't produce a lookup for `""`.
328pub(crate) fn split_response_path(path: &str) -> Vec<String> {
329    path.split('.')
330        .filter(|s| !s.is_empty())
331        .map(str::to_string)
332        .collect()
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338
339    #[test]
340    fn new_keeps_endpoint() {
341        let api = GraphqlApi::new("https://api.spacex.land/graphql/");
342        assert_eq!(api.endpoint(), "https://api.spacex.land/graphql/");
343    }
344
345    #[test]
346    fn builder_sets_auth_without_panicking() {
347        // Auth header is private — this just confirms the builder chain
348        // compiles end-to-end and produces a usable client.
349        let api = GraphqlApi::builder("https://example.test/graphql")
350            .auth("Bearer abc")
351            .build();
352        assert_eq!(api.endpoint(), "https://example.test/graphql");
353    }
354
355    #[test]
356    fn debug_masks_auth_header() {
357        let api = GraphqlApi::builder("https://example.test/graphql")
358            .auth("Bearer secret-token")
359            .build();
360        let text = format!("{api:?}");
361        assert!(!text.contains("secret-token"), "{text}");
362        assert!(text.contains("<set>"), "{text}");
363    }
364}