vantage_api_client/graphql/
api.rs1use serde::Serialize;
13use serde_json::Value;
14use vantage_core::{Result, error};
15
16use crate::graphql::condition::FilterDialect;
17
18#[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#[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 pub fn can_filter(&self) -> bool {
57 self.supports.filter.unwrap_or(true)
58 }
59
60 pub fn can_order(&self) -> bool {
63 self.supports
64 .order
65 .unwrap_or(matches!(self.dialect, FilterDialect::Hasura))
66 }
67
68 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 pub fn can_filter_operators(&self) -> bool {
82 self.can_filter() && matches!(self.dialect, FilterDialect::Hasura)
83 }
84
85 pub fn can_paginate(&self) -> bool {
89 self.supports.paginate.unwrap_or(false)
90 }
91
92 pub fn response_path(&self) -> &[String] {
96 &self.response_path
97 }
98
99 pub fn root_args(&self) -> Option<&Value> {
101 self.root_args.as_ref()
102 }
103}
104
105impl GraphqlApi {
106 pub fn new(endpoint: impl Into<String>) -> Self {
110 GraphqlApi::builder(endpoint).build()
111 }
112
113 pub fn builder(endpoint: impl Into<String>) -> GraphqlApiBuilder {
115 GraphqlApiBuilder::new(endpoint.into())
116 }
117
118 pub fn endpoint(&self) -> &str {
120 &self.endpoint
121 }
122
123 pub fn dialect(&self) -> FilterDialect {
126 self.dialect
127 }
128
129 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 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#[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 pub fn root_args(mut self, args: Value) -> Self {
224 self.root_args = Some(args);
225 self
226 }
227
228 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 pub fn supports(mut self, supports: Supports) -> Self {
237 self.supports = supports;
238 self
239 }
240
241 pub fn auth(mut self, auth: impl Into<String>) -> Self {
243 self.auth_header = Some(auth.into());
244 self
245 }
246
247 pub fn client(mut self, client: reqwest::Client) -> Self {
250 self.client = Some(client);
251 self
252 }
253
254 pub fn dialect(mut self, dialect: FilterDialect) -> Self {
257 self.dialect = dialect;
258 self
259 }
260
261 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
282pub(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 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}