vantage_api_client/graphql/
api.rs1use 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#[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#[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 pub fn can_filter(&self) -> bool {
61 self.supports.filter.unwrap_or(true)
62 }
63
64 pub fn can_order(&self) -> bool {
67 self.supports
68 .order
69 .unwrap_or(matches!(self.dialect, FilterDialect::Hasura))
70 }
71
72 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 pub fn can_filter_operators(&self) -> bool {
86 self.can_filter() && matches!(self.dialect, FilterDialect::Hasura)
87 }
88
89 pub fn can_paginate(&self) -> bool {
93 self.supports.paginate.unwrap_or(false)
94 }
95
96 pub fn response_path(&self) -> &[String] {
100 &self.response_path
101 }
102
103 pub fn root_args(&self) -> Option<&Value> {
105 self.root_args.as_ref()
106 }
107}
108
109impl GraphqlApi {
110 pub fn new(endpoint: impl Into<String>) -> Self {
114 GraphqlApi::builder(endpoint).build()
115 }
116
117 pub fn builder(endpoint: impl Into<String>) -> GraphqlApiBuilder {
119 GraphqlApiBuilder::new(endpoint.into())
120 }
121
122 pub fn endpoint(&self) -> &str {
124 &self.endpoint
125 }
126
127 pub fn dialect(&self) -> FilterDialect {
130 self.dialect
131 }
132
133 pub fn breaker_state(&self) -> Option<crate::BreakerState> {
136 self.client.breaker_state()
137 }
138
139 pub fn client(&self) -> &ResilientClient {
143 &self.client
144 }
145
146 pub(crate) fn report_rows(&self, n: usize) {
148 self.client.report(TransportEvent::RowsPulled { n });
149 }
150
151 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 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#[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 pub fn max_parallel(mut self, n: usize) -> Self {
239 self.transport.max_parallel = n.max(1);
240 self
241 }
242
243 pub fn rate_limit(mut self, per_second: f64) -> Self {
245 self.transport.rate_limit = Some(per_second);
246 self
247 }
248
249 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 pub fn root_args(mut self, args: Value) -> Self {
263 self.root_args = Some(args);
264 self
265 }
266
267 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 pub fn supports(mut self, supports: Supports) -> Self {
276 self.supports = supports;
277 self
278 }
279
280 pub fn auth(mut self, auth: impl Into<String>) -> Self {
282 self.auth_header = AuthHeader::new(auth);
283 self.transport.auth_refresher = None;
284 self
285 }
286
287 pub fn auth_refresher(mut self, refresher: crate::AuthRefresher) -> Self {
292 self.auth_header = AuthHeader::default();
293 self.transport.auth_refresher = Some(refresher);
294 self
295 }
296
297 pub fn client(mut self, client: reqwest::Client) -> Self {
300 self.transport.http = Some(client);
301 self
302 }
303
304 pub fn http_client(self, client: reqwest::Client) -> Self {
306 self.client(client)
307 }
308
309 pub fn dialect(mut self, dialect: FilterDialect) -> Self {
312 self.dialect = dialect;
313 self
314 }
315
316 pub fn filter_arg_name(mut self, name: impl Into<String>) -> Self {
319 self.filter_arg_name = Some(name.into());
320 self
321 }
322
323 pub fn build(self) -> GraphqlApi {
324 GraphqlApi {
325 endpoint: self.endpoint,
326 client: crate::transport::build_client(self.transport),
327 auth_header: self.auth_header,
328 dialect: self.dialect,
329 filter_arg_name: self.filter_arg_name,
330 root_args: self.root_args,
331 response_path: self.response_path,
332 supports: self.supports,
333 }
334 }
335}
336
337pub(crate) fn split_response_path(path: &str) -> Vec<String> {
340 path.split('.')
341 .filter(|s| !s.is_empty())
342 .map(str::to_string)
343 .collect()
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 #[test]
351 fn new_keeps_endpoint() {
352 let api = GraphqlApi::new("https://api.spacex.land/graphql/");
353 assert_eq!(api.endpoint(), "https://api.spacex.land/graphql/");
354 }
355
356 #[test]
357 fn builder_sets_auth_without_panicking() {
358 let api = GraphqlApi::builder("https://example.test/graphql")
361 .auth("Bearer abc")
362 .build();
363 assert_eq!(api.endpoint(), "https://example.test/graphql");
364 }
365
366 #[test]
367 fn debug_masks_auth_header() {
368 let api = GraphqlApi::builder("https://example.test/graphql")
369 .auth("Bearer secret-token")
370 .build();
371 let text = format!("{api:?}");
372 assert!(!text.contains("secret-token"), "{text}");
373 assert!(text.contains("<set>"), "{text}");
374 }
375}