Skip to main content

shopify_client/common/
http.rs

1use 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
39/// Shared HTTP client. Constructed once per process so connection pooling and
40/// keep-alive work — `reqwest::Client::new()` per-call would defeat both.
41/// Exposed `pub(crate)` so REST handlers (e.g. admin `order`) can reuse the
42/// same pool instead of allocating their own client.
43pub(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
48/// Generic GraphQL executor.
49pub 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
109/// Admin Shopify GraphQL endpoint executor.
110pub 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/// Storefront Shopify GraphQL endpoint executor.
132#[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}