Skip to main content

mkt_google/
client.rs

1//! Low-level HTTP wrapper for the Google Ads API REST interface.
2//!
3//! [`GoogleClient`] handles authentication headers (`Bearer` token +
4//! `developer-token` + optional `login-customer-id`), JSON parsing, error
5//! mapping, and rate limiting. Higher-level logic lives in
6//! [`crate::provider`].
7
8use mkt_core::error::{MktError, Result};
9use mkt_core::http::{OpKind, RateLimiter, RetryPolicy, retry, retry_after_secs};
10use reqwest::Client;
11use secrecy::{ExposeSecret, SecretString};
12use tracing::instrument;
13
14use crate::error::GoogleApiErrorResponse;
15
16/// Google Ads API version used in the REST base URL.
17const API_VERSION: &str = "v24";
18
19/// Client-side request budget in cells per second (reads cost 1,
20/// writes 3). Google meters dynamically per CID (no published QPS); 5 rps is the
21/// community-safe default.
22const REQUESTS_PER_SECOND: u32 = 5;
23
24/// Low-level client for the Google Ads REST API.
25#[derive(Debug)]
26pub struct GoogleClient {
27    http: Client,
28    base_url: String,
29    access_token: SecretString,
30    developer_token: String,
31    customer_id: String,
32    login_customer_id: Option<String>,
33    rate_limiter: RateLimiter,
34    retry: RetryPolicy,
35}
36
37impl GoogleClient {
38    /// Create a new client for the given customer account.
39    ///
40    /// `customer_id` and `login_customer_id` accept dashed form
41    /// (`123-456-7890`); dashes are stripped.
42    ///
43    /// # Errors
44    ///
45    /// Returns an error if the underlying HTTP client cannot be built.
46    pub fn new(
47        access_token: SecretString,
48        developer_token: String,
49        customer_id: &str,
50        login_customer_id: Option<&str>,
51    ) -> Result<Self> {
52        let base_url = format!("https://googleads.googleapis.com/{API_VERSION}/");
53        Ok(Self::new_with_base_url(
54            access_token,
55            developer_token,
56            customer_id,
57            login_customer_id,
58            base_url,
59        )?
60        .with_retry_policy(RetryPolicy::standard()))
61    }
62
63    /// Create a new client with a custom base URL (e.g. for wiremock tests).
64    /// Starts with no retries so tests stay deterministic; production
65    /// constructors opt in via [`Self::with_retry_policy`].
66    ///
67    /// # Errors
68    ///
69    /// Returns an error if the underlying HTTP client cannot be built.
70    pub fn new_with_base_url(
71        access_token: SecretString,
72        developer_token: String,
73        customer_id: &str,
74        login_customer_id: Option<&str>,
75        base_url: String,
76    ) -> Result<Self> {
77        let http = mkt_core::http::build_http_client(None)?;
78        Ok(Self {
79            http,
80            base_url,
81            access_token,
82            developer_token,
83            customer_id: customer_id.replace('-', ""),
84            login_customer_id: login_customer_id.map(|id| id.replace('-', "")),
85            rate_limiter: RateLimiter::new(REQUESTS_PER_SECOND),
86            retry: RetryPolicy::none(),
87        })
88    }
89
90    /// Replace the retry policy (reads retry transient failures, writes
91    /// only rate limits and connection failures).
92    #[must_use]
93    pub const fn with_retry_policy(mut self, policy: RetryPolicy) -> Self {
94        self.retry = policy;
95        self
96    }
97
98    /// The customer ID (dashes stripped).
99    pub fn customer_id(&self) -> &str {
100        &self.customer_id
101    }
102
103    /// Run a GAQL query through `googleAds:search`.
104    ///
105    /// # Errors
106    ///
107    /// Returns [`MktError::ApiError`] for non-2xx responses and
108    /// [`MktError::Http`] for transport failures.
109    #[instrument(skip(self, query), fields(provider = "google"))]
110    pub async fn search(&self, query: &str) -> Result<serde_json::Value> {
111        self.search_page(query, None).await
112    }
113
114    /// Run a GAQL query requesting a specific results page.
115    ///
116    /// # Errors
117    ///
118    /// Returns [`MktError::ApiError`] for non-2xx responses and
119    /// [`MktError::Http`] for transport failures.
120    #[instrument(skip(self, query, page_token), fields(provider = "google"))]
121    pub async fn search_page(
122        &self,
123        query: &str,
124        page_token: Option<&str>,
125    ) -> Result<serde_json::Value> {
126        let path = format!("customers/{}/googleAds:search", self.customer_id);
127        let mut body = serde_json::json!({ "query": query });
128        if let Some(token) = page_token {
129            body["pageToken"] = serde_json::Value::String(token.to_string());
130        }
131        self.post(&path, &body, OpKind::Read).await
132    }
133
134    /// Send mutate operations to a resource endpoint, e.g.
135    /// `mutate("campaigns", ops)` posts to `customers/{cid}/campaigns:mutate`.
136    ///
137    /// # Errors
138    ///
139    /// Returns [`MktError::ApiError`] for non-2xx responses and
140    /// [`MktError::Http`] for transport failures.
141    #[instrument(skip(self, operations), fields(provider = "google"))]
142    pub async fn mutate(
143        &self,
144        resource: &str,
145        operations: &serde_json::Value,
146    ) -> Result<serde_json::Value> {
147        let path = format!("customers/{}/{resource}:mutate", self.customer_id);
148        let body = serde_json::json!({ "operations": operations });
149        self.post(&path, &body, OpKind::Write).await
150    }
151
152    /// Send cross-resource operations to the atomic `googleAds:mutate`
153    /// endpoint as one transactional request.
154    ///
155    /// `operations` is the `mutateOperations` array, where each entry
156    /// wraps a per-resource operation (e.g. `campaignBudgetOperation`,
157    /// `campaignOperation`). Operations may reference resources created
158    /// earlier in the same array via temporary negative IDs.
159    /// `partialFailure` is disabled so either every operation succeeds or
160    /// none is applied, and `responseContentType` requests the mutated
161    /// resource names back.
162    ///
163    /// # Errors
164    ///
165    /// Returns [`MktError::ApiError`] for non-2xx responses and
166    /// [`MktError::Http`] for transport failures.
167    #[instrument(skip(self, operations), fields(provider = "google"))]
168    pub async fn mutate_atomic(&self, operations: &serde_json::Value) -> Result<serde_json::Value> {
169        let path = format!("customers/{}/googleAds:mutate", self.customer_id);
170        let body = serde_json::json!({
171            "mutateOperations": operations,
172            "partialFailure": false,
173            "responseContentType": "MUTABLE_RESOURCE",
174        });
175        self.post(&path, &body, OpKind::Write).await
176    }
177
178    /// Perform an authenticated POST request with a JSON body.
179    async fn post(
180        &self,
181        path: &str,
182        body: &serde_json::Value,
183        kind: OpKind,
184    ) -> Result<serde_json::Value> {
185        self.rate_limiter.acquire(1).await?;
186        let url = format!("{}{path}", self.base_url);
187
188        retry(&self.retry, kind, || async {
189            let mut request = self
190                .http
191                .post(&url)
192                .bearer_auth(self.access_token.expose_secret())
193                .header("developer-token", &self.developer_token)
194                .json(body);
195
196            if let Some(login_id) = &self.login_customer_id {
197                request = request.header("login-customer-id", login_id);
198            }
199
200            let response = request.send().await?;
201            Self::parse_response(response).await
202        })
203        .await
204    }
205
206    /// Parse a response, returning either the JSON body or a mapped error.
207    async fn parse_response(response: reqwest::Response) -> Result<serde_json::Value> {
208        let status = response.status().as_u16();
209        let retry_after = retry_after_secs(response.headers());
210        let body = response.text().await?;
211
212        if (200..300).contains(&status) {
213            let value: serde_json::Value = serde_json::from_str(&body)?;
214            return Ok(value);
215        }
216
217        if status == 429 {
218            return Err(MktError::RateLimited {
219                provider: "google".into(),
220                retry_after_secs: retry_after.unwrap_or(60),
221            });
222        }
223
224        if let Ok(api_err) = serde_json::from_str::<GoogleApiErrorResponse>(&body) {
225            return Err(api_err.into_mkt_error(status));
226        }
227
228        Err(MktError::ApiError {
229            provider: "google".into(),
230            status,
231            message: body,
232            retry_after,
233        })
234    }
235}