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
284 }
285
286 pub fn client(mut self, client: reqwest::Client) -> Self {
289 self.transport.http = Some(client);
290 self
291 }
292
293 pub fn http_client(self, client: reqwest::Client) -> Self {
295 self.client(client)
296 }
297
298 pub fn dialect(mut self, dialect: FilterDialect) -> Self {
301 self.dialect = dialect;
302 self
303 }
304
305 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
326pub(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 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}