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 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/// api for graphql queries.
31#[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    /// run a read-only graphql query.
42    ///
43    /// returns the `data` field when present, otherwise the raw response json.
44    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}