1use crate::Client;
18use crate::error::{Error, Result};
19use serde::Serialize;
20use serde_json::Value;
21use url::Url;
22
23#[derive(Serialize)]
24struct GraphqlRequest<'a> {
25 query: &'a str,
26 #[serde(skip_serializing_if = "Option::is_none")]
27 variables: Option<Value>,
28}
29
30#[derive(Clone)]
32pub struct GraphqlApi {
33 client: Client,
34}
35
36impl GraphqlApi {
37 pub(crate) fn new(client: Client) -> Self {
38 Self { client }
39 }
40
41 pub async fn query(&self, query: &str, variables: Option<Value>) -> Result<Value> {
45 let url = self.graphql_url()?;
46 let body = GraphqlRequest { query, variables };
47
48 let response = self
49 .client
50 .http_client()
51 .post(url)
52 .json(&body)
53 .send()
54 .await
55 .map_err(Error::from)?;
56
57 let status = response.status();
58 let body_text = response.text().await.map_err(Error::from)?;
59
60 if !status.is_success() {
61 return Err(Error::from_response(status, body_text));
62 }
63
64 if body_text.trim().is_empty() {
65 return Ok(Value::Null);
66 }
67
68 let value: Value = serde_json::from_str(&body_text)?;
69 if let Some(message) = graphql_error_message(&value) {
70 return Err(Error::ApiError {
71 status: status.as_u16(),
72 message,
73 body: body_text,
74 });
75 }
76
77 Ok(value.get("data").cloned().unwrap_or(value))
78 }
79
80 fn graphql_url(&self) -> Result<Url> {
81 let base = self.client.config().base_url.as_str().trim_end_matches('/');
82 let url = format!("{}/graphql/", base);
83 Url::parse(&url).map_err(Error::from)
84 }
85}
86
87fn graphql_error_message(value: &Value) -> Option<String> {
88 let errors = value.get("errors")?;
89 let messages = match errors {
90 Value::Array(items) if !items.is_empty() => items
91 .iter()
92 .filter_map(|item| item.get("message").and_then(Value::as_str))
93 .map(|message| message.to_string())
94 .collect::<Vec<_>>(),
95 Value::Array(_) => Vec::new(),
96 _ => vec![errors.to_string()],
97 };
98
99 if messages.is_empty() {
100 None
101 } else {
102 Some(messages.join("; "))
103 }
104}
105
106#[cfg(test)]
107mod tests {
108 use super::*;
109 use crate::ClientConfig;
110 use httpmock::{Method::POST, MockServer};
111 use serde_json::json;
112
113 #[cfg_attr(miri, ignore)]
114 #[tokio::test]
115 async fn graphql_hits_expected_path() {
116 let server = MockServer::start();
117 let config = ClientConfig::new(server.base_url(), "token").with_max_retries(0);
118 let client = Client::new(config).unwrap();
119 let api = GraphqlApi::new(client);
120
121 server.mock(|when, then| {
122 when.method(POST).path("/graphql/");
123 then.status(200)
124 .json_body(json!({ "data": { "devices": [] } }));
125 });
126
127 let data = api.query("{ devices { name } }", None).await.unwrap();
128 assert_eq!(data["devices"], json!([]));
129 }
130
131 #[cfg_attr(miri, ignore)]
132 #[tokio::test]
133 async fn graphql_surfaces_errors() {
134 let server = MockServer::start();
135 let config = ClientConfig::new(server.base_url(), "token").with_max_retries(0);
136 let client = Client::new(config).unwrap();
137 let api = GraphqlApi::new(client);
138
139 server.mock(|when, then| {
140 when.method(POST).path("/graphql/");
141 then.status(200).json_body(json!({
142 "errors": [{ "message": "bad query" }]
143 }));
144 });
145
146 let err = api.query("{ bad }", None).await.unwrap_err();
147 assert!(matches!(err, Error::ApiError { .. }));
148 assert!(err.to_string().contains("bad query"));
149 }
150}