1use crate::Client;
18use crate::error::{Error, Result};
19use reqwest::Method;
20use serde::Serialize;
21use serde_json::Value;
22use url::Url;
23
24#[derive(Serialize, Clone)]
25struct GraphqlRequest<'a> {
26 query: &'a str,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 variables: Option<Value>,
29}
30
31#[derive(Clone)]
33pub struct GraphqlApi {
34 client: Client,
35}
36
37impl GraphqlApi {
38 pub(crate) fn new(client: Client) -> Self {
39 Self { client }
40 }
41
42 pub async fn query(&self, query: &str, variables: Option<Value>) -> Result<Value> {
48 let graphql_path = "graphql/";
49 let body = GraphqlRequest { query, variables };
50
51 self.client
52 .retry_loop(Method::POST, graphql_path, true, |_attempt| {
53 let body = body.clone();
54 async move {
55 let url = self.graphql_url()?;
56 let request = self.client.http_client().post(url).json(&body);
57 let response = self
58 .client
59 .execute_request(&Method::POST, graphql_path, request)
60 .await?;
61
62 let status = response.status();
63 let body_text = response.text().await.map_err(Error::from)?;
64
65 if !status.is_success() {
66 return Err(Error::from_response(status, body_text));
67 }
68
69 if body_text.trim().is_empty() {
70 return Ok(Value::Null);
71 }
72
73 let value: Value = serde_json::from_str(&body_text)?;
74 if let Some(message) = graphql_error_message(&value) {
75 return Err(Error::ApiError {
76 status: status.as_u16(),
77 message,
78 body: body_text,
79 });
80 }
81
82 Ok(value.get("data").cloned().unwrap_or(value))
83 }
84 })
85 .await
86 }
87
88 fn graphql_url(&self) -> Result<Url> {
89 let base = self.client.config().base_url.as_str().trim_end_matches('/');
90 let url = format!("{}/graphql/", base);
91 Url::parse(&url).map_err(Error::from)
92 }
93}
94
95fn graphql_error_message(value: &Value) -> Option<String> {
96 let errors = value.get("errors")?;
97 let messages = match errors {
98 Value::Array(items) if !items.is_empty() => items
99 .iter()
100 .filter_map(|item| item.get("message").and_then(Value::as_str))
101 .map(|message| message.to_string())
102 .collect::<Vec<_>>(),
103 Value::Array(_) => Vec::new(),
104 _ => vec![errors.to_string()],
105 };
106
107 if messages.is_empty() {
108 None
109 } else {
110 Some(messages.join("; "))
111 }
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117 use crate::{ClientConfig, HttpHooks};
118 use httpmock::prelude::HttpMockRequest;
119 use httpmock::{Method::POST, MockServer};
120 use reqwest::StatusCode;
121 use serde_json::json;
122 use std::sync::atomic::{AtomicUsize, Ordering};
123 use std::sync::{Arc, Mutex};
124 use std::time::Duration;
125
126 #[cfg_attr(miri, ignore)]
127 #[tokio::test]
128 async fn graphql_hits_expected_path() {
129 let server = MockServer::start();
130 let config = ClientConfig::new(server.base_url(), "token").with_max_retries(0);
131 let client = Client::new(config).unwrap();
132 let api = GraphqlApi::new(client);
133
134 server.mock(|when, then| {
135 when.method(POST).path("/graphql/");
136 then.status(200)
137 .json_body(json!({ "data": { "devices": [] } }));
138 });
139
140 let data = api.query("{ devices { name } }", None).await.unwrap();
141 assert_eq!(data["devices"], json!([]));
142 }
143
144 #[cfg_attr(miri, ignore)]
145 #[tokio::test]
146 async fn graphql_surfaces_errors() {
147 let server = MockServer::start();
148 let config = ClientConfig::new(server.base_url(), "token").with_max_retries(0);
149 let client = Client::new(config).unwrap();
150 let api = GraphqlApi::new(client);
151
152 server.mock(|when, then| {
153 when.method(POST).path("/graphql/");
154 then.status(200).json_body(json!({
155 "errors": [{ "message": "bad query" }]
156 }));
157 });
158
159 let err = api.query("{ bad }", None).await.unwrap_err();
160 assert!(matches!(err, Error::ApiError { .. }));
161 assert!(err.to_string().contains("bad query"));
162 }
163
164 #[cfg_attr(miri, ignore)]
165 #[tokio::test]
166 async fn graphql_retries_on_429() {
167 let server = MockServer::start();
168 let config = ClientConfig::new(server.base_url(), "token").with_max_retries(2);
169 let client = Client::new(config).unwrap();
170 let api = GraphqlApi::new(client);
171
172 let call_count = Arc::new(AtomicUsize::new(0));
173 let counter = call_count.clone();
174 let fail = server.mock(|when, then| {
175 when.method(POST)
176 .path("/graphql/")
177 .is_true(move |_: &HttpMockRequest| counter.fetch_add(1, Ordering::SeqCst) == 0);
178 then.status(429).body("rate limited");
179 });
180 let succeed = server.mock(|when, then| {
181 when.method(POST).path("/graphql/");
182 then.status(200)
183 .json_body(json!({ "data": { "devices": [] } }));
184 });
185
186 let data = api.query("{ devices { name } }", None).await.unwrap();
187 assert_eq!(data["devices"], json!([]));
188 fail.assert_calls(1);
189 succeed.assert_calls(1);
190 }
191
192 #[cfg_attr(miri, ignore)]
193 #[tokio::test]
194 async fn graphql_retries_on_500() {
195 let server = MockServer::start();
196 let config = ClientConfig::new(server.base_url(), "token").with_max_retries(2);
197 let client = Client::new(config).unwrap();
198 let api = GraphqlApi::new(client);
199
200 let call_count = Arc::new(AtomicUsize::new(0));
201 let counter = call_count.clone();
202 let fail = server.mock(|when, then| {
203 when.method(POST)
204 .path("/graphql/")
205 .is_true(move |_: &HttpMockRequest| counter.fetch_add(1, Ordering::SeqCst) == 0);
206 then.status(500).body("internal error");
207 });
208 let succeed = server.mock(|when, then| {
209 when.method(POST).path("/graphql/");
210 then.status(200)
211 .json_body(json!({ "data": { "sites": [] } }));
212 });
213
214 let data = api.query("{ sites { name } }", None).await.unwrap();
215 assert_eq!(data["sites"], json!([]));
216 fail.assert_calls(1);
217 succeed.assert_calls(1);
218 }
219
220 #[cfg_attr(miri, ignore)]
221 #[tokio::test]
222 async fn graphql_does_not_retry_when_max_retries_is_zero() {
223 let server = MockServer::start();
224 let config = ClientConfig::new(server.base_url(), "token").with_max_retries(0);
225 let client = Client::new(config).unwrap();
226 let api = GraphqlApi::new(client);
227
228 let mock = server.mock(|when, then| {
229 when.method(POST).path("/graphql/");
230 then.status(429).body("rate limited");
231 });
232
233 let err = api.query("{ devices { name } }", None).await.unwrap_err();
234 assert!(matches!(err, Error::ApiError { status: 429, .. }));
235 mock.assert_calls(1);
236 }
237
238 struct StatusCapture {
239 statuses: Arc<Mutex<Vec<u16>>>,
240 }
241
242 impl HttpHooks for StatusCapture {
243 fn on_response(
244 &self,
245 _method: &reqwest::Method,
246 _path: &str,
247 status: StatusCode,
248 _duration: Duration,
249 ) {
250 self.statuses.lock().unwrap().push(status.as_u16());
251 }
252 }
253
254 #[cfg_attr(miri, ignore)]
255 #[tokio::test]
256 async fn graphql_invokes_hooks() {
257 let server = MockServer::start();
258 let statuses = Arc::new(Mutex::new(Vec::new()));
259 let hook = StatusCapture {
260 statuses: statuses.clone(),
261 };
262 let config = ClientConfig::new(server.base_url(), "token")
263 .with_max_retries(0)
264 .with_http_hooks(hook);
265 let client = Client::new(config).unwrap();
266 let api = GraphqlApi::new(client);
267
268 server.mock(|when, then| {
269 when.method(POST).path("/graphql/");
270 then.status(200)
271 .json_body(json!({ "data": { "devices": [] } }));
272 });
273
274 api.query("{ devices { name } }", None).await.unwrap();
275 let captured = statuses.lock().unwrap().clone();
276 assert_eq!(captured, vec![200]);
277 }
278}