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