iota_graphql_rpc_client/
simple_client.rs

1// Copyright (c) Mysten Labs, Inc.
2// Modifications Copyright (c) 2024 IOTA Stiftung
3// SPDX-License-Identifier: Apache-2.0
4
5use std::collections::BTreeMap;
6
7use iota_graphql_rpc_headers::LIMITS_HEADER;
8use reqwest::{Response, header, header::HeaderValue};
9use serde_json::Value;
10
11use super::response::GraphqlResponse;
12use crate::ClientError;
13
14#[derive(Clone, Debug)]
15pub struct GraphqlQueryVariable {
16    pub name: String,
17    pub ty: String,
18    pub value: Value,
19}
20
21#[derive(Clone)]
22pub struct SimpleClient {
23    inner: reqwest::Client,
24    url: String,
25}
26
27impl SimpleClient {
28    pub fn new<S: Into<String>>(base_url: S) -> Self {
29        Self {
30            inner: reqwest::Client::new(),
31            url: base_url.into(),
32        }
33    }
34
35    pub async fn execute(
36        &self,
37        query: String,
38        headers: Vec<(header::HeaderName, header::HeaderValue)>,
39    ) -> Result<serde_json::Value, ClientError> {
40        self.execute_impl(query, vec![], headers, false)
41            .await?
42            .json()
43            .await
44            .map_err(|e| e.into())
45    }
46
47    pub async fn execute_to_graphql(
48        &self,
49        query: String,
50        get_usage: bool,
51        variables: Vec<GraphqlQueryVariable>,
52        mut headers: Vec<(header::HeaderName, header::HeaderValue)>,
53    ) -> Result<GraphqlResponse, ClientError> {
54        if get_usage {
55            headers.push((
56                LIMITS_HEADER.clone().as_str().try_into().unwrap(),
57                HeaderValue::from_static("true"),
58            ));
59        }
60        GraphqlResponse::from_resp(self.execute_impl(query, variables, headers, false).await?).await
61    }
62
63    async fn execute_impl(
64        &self,
65        query: String,
66        variables: Vec<GraphqlQueryVariable>,
67        headers: Vec<(header::HeaderName, header::HeaderValue)>,
68        is_mutation: bool,
69    ) -> Result<Response, ClientError> {
70        let (type_defs, var_vals) = resolve_variables(&variables)?;
71        let body = if type_defs.is_empty() {
72            serde_json::json!({
73                "query": query,
74            })
75        } else {
76            // Make type defs which is a csv is the form of $var_name: $var_type
77            let type_defs_csv = type_defs
78                .iter()
79                .map(|(name, ty)| format!("${}: {}", name, ty))
80                .collect::<Vec<_>>()
81                .join(", ");
82            let query = format!(
83                "{} ({}) {}",
84                if is_mutation { "mutation" } else { "query" },
85                type_defs_csv,
86                query
87            );
88            serde_json::json!({
89                "query": query,
90                "variables": var_vals,
91            })
92        };
93
94        let mut builder = self.inner.post(&self.url).json(&body);
95        for (key, value) in headers {
96            builder = builder.header(key, value);
97        }
98        builder.send().await.map_err(|e| e.into())
99    }
100
101    pub async fn execute_mutation_to_graphql(
102        &self,
103        mutation: String,
104        variables: Vec<GraphqlQueryVariable>,
105    ) -> Result<GraphqlResponse, ClientError> {
106        GraphqlResponse::from_resp(self.execute_impl(mutation, variables, vec![], true).await?)
107            .await
108    }
109
110    /// Send a request to the GraphQL server to check if it is alive.
111    pub async fn ping(&self) -> Result<(), ClientError> {
112        self.inner
113            .get(format!("{}/health", self.url))
114            .send()
115            .await?;
116        Ok(())
117    }
118}
119
120#[expect(clippy::type_complexity, clippy::result_large_err)]
121pub fn resolve_variables(
122    vars: &[GraphqlQueryVariable],
123) -> Result<(BTreeMap<String, String>, BTreeMap<String, Value>), ClientError> {
124    let mut type_defs: BTreeMap<String, String> = BTreeMap::new();
125    let mut var_vals: BTreeMap<String, Value> = BTreeMap::new();
126
127    for (idx, GraphqlQueryVariable { name, ty, value }) in vars.iter().enumerate() {
128        if !is_valid_variable_name(name) {
129            return Err(ClientError::InvalidVariableName {
130                var_name: name.to_owned(),
131            });
132        }
133        if name.trim().is_empty() {
134            return Err(ClientError::InvalidEmptyItem {
135                item_type: "Variable name".to_owned(),
136                idx,
137            });
138        }
139        if ty.trim().is_empty() {
140            return Err(ClientError::InvalidEmptyItem {
141                item_type: "Variable type".to_owned(),
142                idx,
143            });
144        }
145        if let Some(var_type_prev) = type_defs.get(name) {
146            if var_type_prev != ty {
147                return Err(ClientError::VariableDefinitionConflict {
148                    var_name: name.to_owned(),
149                    var_type_prev: var_type_prev.to_owned(),
150                    var_type_curr: ty.to_owned(),
151                });
152            }
153            if var_vals[name] != *value {
154                return Err(ClientError::VariableValueConflict {
155                    var_name: name.to_owned(),
156                    var_val_prev: var_vals[name].clone(),
157                    var_val_curr: value.clone(),
158                });
159            }
160        }
161        type_defs.insert(name.to_owned(), ty.to_owned());
162        var_vals.insert(name.to_owned(), value.to_owned());
163    }
164
165    Ok((type_defs, var_vals))
166}
167
168pub fn is_valid_variable_name(s: &str) -> bool {
169    let mut cs = s.chars();
170    let Some(fst) = cs.next() else { return false };
171
172    match fst {
173        '_' => if s.len() > 1 {},
174        'a'..='z' | 'A'..='Z' => {}
175        _ => return false,
176    }
177
178    cs.all(|c| matches!(c, '_' | 'a' ..= 'z' | 'A' ..= 'Z' | '0' ..= '9'))
179}