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}
32
33impl GraphqlApi {
34    /// Connect to a GraphQL endpoint at `endpoint` (e.g.
35    /// `https://api.spacex.land/graphql/`). Uses the default reqwest
36    /// client; for finer control go through [`GraphqlApi::builder`].
37    pub fn new(endpoint: impl Into<String>) -> Self {
38        GraphqlApi::builder(endpoint).build()
39    }
40
41    /// Start configuring a [`GraphqlApi`].
42    pub fn builder(endpoint: impl Into<String>) -> GraphqlApiBuilder {
43        GraphqlApiBuilder::new(endpoint.into())
44    }
45
46    /// Endpoint URL the client posts to.
47    pub fn endpoint(&self) -> &str {
48        &self.endpoint
49    }
50
51    /// Filter dialect — controls how `where:` / `find:` arguments are
52    /// rendered. Defaults to [`FilterDialect::Generic`].
53    pub fn dialect(&self) -> FilterDialect {
54        self.dialect
55    }
56
57    /// Send a query document with variables. Returns the `data` payload
58    /// from the GraphQL response, or an error if the request failed or
59    /// the response carried a top-level `errors` array.
60    pub async fn post_graphql(
61        &self,
62        query: &str,
63        variables: &serde_json::Map<String, Value>,
64    ) -> Result<Value> {
65        #[derive(Serialize)]
66        struct Body<'a> {
67            query: &'a str,
68            variables: &'a serde_json::Map<String, Value>,
69        }
70
71        let body = Body { query, variables };
72
73        let mut req = self.client.post(&self.endpoint).json(&body);
74        if let Some(ref auth) = self.auth_header {
75            req = req.header("Authorization", auth);
76        }
77
78        let response = req.send().await.map_err(|e| {
79            error!(
80                "GraphQL request failed",
81                endpoint = self.endpoint.clone(),
82                detail = e.to_string()
83            )
84        })?;
85
86        if !response.status().is_success() {
87            return Err(error!(
88                "GraphQL endpoint returned error status",
89                endpoint = self.endpoint.clone(),
90                status = response.status().as_u16()
91            ));
92        }
93
94        let mut envelope: Value = response.json().await.map_err(|e| {
95            error!(
96                "Failed to parse GraphQL response as JSON",
97                detail = e.to_string()
98            )
99        })?;
100
101        // GraphQL servers return `{ "data": …, "errors": [...] }`. Surface
102        // any errors as a Vantage error and otherwise hand back `data`.
103        if let Some(errors) = envelope.get("errors")
104            && let Some(arr) = errors.as_array()
105            && !arr.is_empty()
106        {
107            let summary = arr
108                .iter()
109                .filter_map(|e| e.get("message").and_then(|m| m.as_str()))
110                .collect::<Vec<_>>()
111                .join("; ");
112            return Err(error!("GraphQL response carried errors", errors = summary));
113        }
114
115        Ok(envelope
116            .get_mut("data")
117            .map(std::mem::take)
118            .unwrap_or(Value::Null))
119    }
120}
121
122/// Builder for [`GraphqlApi`]. Use [`GraphqlApi::builder`] to start.
123#[derive(Debug, Clone)]
124pub struct GraphqlApiBuilder {
125    endpoint: String,
126    client: Option<reqwest::Client>,
127    auth_header: Option<String>,
128    dialect: FilterDialect,
129    filter_arg_name: Option<String>,
130}
131
132impl GraphqlApiBuilder {
133    pub(crate) fn new(endpoint: String) -> Self {
134        Self {
135            endpoint,
136            client: None,
137            auth_header: None,
138            dialect: FilterDialect::Generic,
139            filter_arg_name: None,
140        }
141    }
142
143    /// Set the `Authorization` header value (e.g. `"Bearer <token>"`).
144    pub fn auth(mut self, auth: impl Into<String>) -> Self {
145        self.auth_header = Some(auth.into());
146        self
147    }
148
149    /// Use a pre-configured `reqwest::Client` (e.g. one with custom
150    /// timeouts or a proxy).
151    pub fn client(mut self, client: reqwest::Client) -> Self {
152        self.client = Some(client);
153        self
154    }
155
156    /// Pick the filter dialect used to render conditions on tables.
157    /// Defaults to [`FilterDialect::Generic`] — flat-arg schemas like SpaceX.
158    pub fn dialect(mut self, dialect: FilterDialect) -> Self {
159        self.dialect = dialect;
160        self
161    }
162
163    /// Override the filter argument name. Defaults match the dialect:
164    /// `"where"` for Hasura, `"find"` for Generic.
165    pub fn filter_arg_name(mut self, name: impl Into<String>) -> Self {
166        self.filter_arg_name = Some(name.into());
167        self
168    }
169
170    pub fn build(self) -> GraphqlApi {
171        GraphqlApi {
172            endpoint: self.endpoint,
173            client: self.client.unwrap_or_default(),
174            auth_header: self.auth_header,
175            dialect: self.dialect,
176            filter_arg_name: self.filter_arg_name,
177        }
178    }
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn new_keeps_endpoint() {
187        let api = GraphqlApi::new("https://api.spacex.land/graphql/");
188        assert_eq!(api.endpoint(), "https://api.spacex.land/graphql/");
189    }
190
191    #[test]
192    fn builder_sets_auth_without_panicking() {
193        // Auth header is private — this just confirms the builder chain
194        // compiles end-to-end and produces a usable client.
195        let api = GraphqlApi::builder("https://example.test/graphql")
196            .auth("Bearer abc")
197            .build();
198        assert_eq!(api.endpoint(), "https://example.test/graphql");
199    }
200}