shopify_client/common/
http.rs1use crate::common::types::{APIError, RequestCallbacks};
2use crate::common::ServiceContext;
3use reqwest::header::{HeaderMap, HeaderValue, CONTENT_TYPE};
4use std::sync::OnceLock;
5
6#[derive(serde::Serialize)]
7struct GraphQLRequest<'a> {
8 query: &'a str,
9 variables: serde_json::Value,
10}
11
12#[derive(serde::Deserialize)]
13struct GraphQLResponse<T> {
14 data: Option<T>,
15 errors: Option<Vec<GraphQLError>>,
16}
17
18#[derive(serde::Deserialize, Debug)]
19struct GraphQLError {
20 message: String,
21 #[serde(default)]
22 extensions: Option<serde_json::Value>,
23}
24
25impl GraphQLError {
26 fn format_with_code(&self) -> String {
27 let code = self
28 .extensions
29 .as_ref()
30 .and_then(|ext| ext.get("code"))
31 .and_then(|v| v.as_str());
32 match code {
33 Some(c) => format!("[{}] {}", c, self.message),
34 None => self.message.clone(),
35 }
36 }
37}
38
39pub(crate) fn http_client() -> &'static reqwest::Client {
44 static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
45 CLIENT.get_or_init(reqwest::Client::new)
46}
47
48pub async fn execute_graphql_raw<T: serde::de::DeserializeOwned>(
50 endpoint: &str,
51 auth_header_name: &'static str,
52 auth_token: &str,
53 callbacks: &RequestCallbacks,
54 query: &str,
55 variables: serde_json::Value,
56) -> Result<T, APIError> {
57 let request_body = GraphQLRequest { query, variables };
58
59 let body_str = serde_json::to_string(&request_body).unwrap_or_default();
60
61 let mut callback_headers = HeaderMap::new();
62 callback_headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
63
64 callbacks.call_before(endpoint, Some(&body_str), &callback_headers);
65
66 let response = http_client()
67 .post(endpoint)
68 .header(auth_header_name, auth_token)
69 .header("Content-Type", "application/json")
70 .json(&request_body)
71 .send()
72 .await;
73
74 match response {
75 Ok(resp) => {
76 let response_headers = resp.headers().clone();
77 let response_text = match resp.text().await {
78 Ok(text) => text,
79 Err(e) => {
80 let error_msg = format!("<failed to read response body: {}>", e);
81 callbacks.call_after(endpoint, &error_msg, &response_headers);
82 return Err(APIError::FailedToParse);
83 }
84 };
85
86 callbacks.call_after(endpoint, &response_text, &response_headers);
87
88 let graphql_response = serde_json::from_str::<GraphQLResponse<T>>(&response_text)
89 .map_err(|_| APIError::FailedToParse)?;
90
91 if let Some(errors) = graphql_response.errors {
92 let error_messages: Vec<String> =
93 errors.iter().map(GraphQLError::format_with_code).collect();
94 return Err(APIError::ServerError {
95 errors: error_messages.join("; "),
96 });
97 }
98
99 graphql_response.data.ok_or(APIError::FailedToParse)
100 }
101 Err(e) => {
102 let error_msg = format!("<network error: {}>", e);
103 callbacks.call_after(endpoint, &error_msg, &HeaderMap::new());
104 Err(APIError::NetworkError)
105 }
106 }
107}
108
109pub async fn execute_graphql<T: serde::de::DeserializeOwned>(
111 ctx: &ServiceContext,
112 query: &str,
113 variables: serde_json::Value,
114) -> Result<T, APIError> {
115 let endpoint = format!(
116 "{}/admin/api/{}/graphql.json",
117 ctx.shop_url.trim_end_matches('/'),
118 ctx.version
119 );
120 execute_graphql_raw(
121 &endpoint,
122 "X-Shopify-Access-Token",
123 &ctx.access_token,
124 &ctx.callbacks,
125 query,
126 variables,
127 )
128 .await
129}
130
131#[cfg(feature = "storefront")]
133pub async fn execute_storefront_graphql<T: serde::de::DeserializeOwned>(
134 ctx: &ServiceContext,
135 query: &str,
136 variables: serde_json::Value,
137) -> Result<T, APIError> {
138 let endpoint = format!(
139 "{}/api/{}/graphql.json",
140 ctx.shop_url.trim_end_matches('/'),
141 ctx.version
142 );
143 execute_graphql_raw(
144 &endpoint,
145 "X-Shopify-Storefront-Access-Token",
146 &ctx.access_token,
147 &ctx.callbacks,
148 query,
149 variables,
150 )
151 .await
152}