Skip to main content

netbox/
graphql.rs

1//! graphql endpoint helper.
2//!
3//! basic usage:
4//! ```no_run
5//! # use netbox::{Client, ClientConfig};
6//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
7//! # let client = Client::new(ClientConfig::new("https://netbox.example.com", "token"))?;
8//! let data = client
9//!     .graphql()
10//!     .query("{ devices { name } }", None)
11//!     .await?;
12//! println!("{}", data);
13//! # Ok(())
14//! # }
15//! ```
16
17use 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/// api for graphql queries.
32#[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    /// run a read-only graphql query.
43    ///
44    /// returns the `data` field when present, otherwise the raw response json.
45    /// requests go through the shared retry loop and execute_request path, so
46    /// they get retry-on-429, exponential backoff, hook callbacks, and tracing.
47    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}