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::RateLimiter;
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/// Maximum concurrent requests (semaphore permits).
20const MAX_CONCURRENT: usize = 100;
21
22/// Low-level client for the Google Ads REST API.
23#[derive(Debug)]
24pub struct GoogleClient {
25    http: Client,
26    base_url: String,
27    access_token: SecretString,
28    developer_token: String,
29    customer_id: String,
30    login_customer_id: Option<String>,
31    rate_limiter: RateLimiter,
32}
33
34impl GoogleClient {
35    /// Create a new client for the given customer account.
36    ///
37    /// `customer_id` and `login_customer_id` accept dashed form
38    /// (`123-456-7890`); dashes are stripped.
39    ///
40    /// # Errors
41    ///
42    /// Returns an error if the underlying HTTP client cannot be built.
43    pub fn new(
44        access_token: SecretString,
45        developer_token: String,
46        customer_id: &str,
47        login_customer_id: Option<&str>,
48    ) -> Result<Self> {
49        let base_url = format!("https://googleads.googleapis.com/{API_VERSION}/");
50        Self::new_with_base_url(
51            access_token,
52            developer_token,
53            customer_id,
54            login_customer_id,
55            base_url,
56        )
57    }
58
59    /// Create a new client with a custom base URL (e.g. for wiremock tests).
60    ///
61    /// # Errors
62    ///
63    /// Returns an error if the underlying HTTP client cannot be built.
64    pub fn new_with_base_url(
65        access_token: SecretString,
66        developer_token: String,
67        customer_id: &str,
68        login_customer_id: Option<&str>,
69        base_url: String,
70    ) -> Result<Self> {
71        let http = mkt_core::http::build_http_client(None)?;
72        Ok(Self {
73            http,
74            base_url,
75            access_token,
76            developer_token,
77            customer_id: customer_id.replace('-', ""),
78            login_customer_id: login_customer_id.map(|id| id.replace('-', "")),
79            rate_limiter: RateLimiter::new(MAX_CONCURRENT),
80        })
81    }
82
83    /// The customer ID (dashes stripped).
84    pub fn customer_id(&self) -> &str {
85        &self.customer_id
86    }
87
88    /// Run a GAQL query through `googleAds:search`.
89    ///
90    /// # Errors
91    ///
92    /// Returns [`MktError::ApiError`] for non-2xx responses and
93    /// [`MktError::Http`] for transport failures.
94    #[instrument(skip(self, query), fields(provider = "google"))]
95    pub async fn search(&self, query: &str) -> Result<serde_json::Value> {
96        let path = format!("customers/{}/googleAds:search", self.customer_id);
97        let body = serde_json::json!({ "query": query });
98        self.post(&path, &body).await
99    }
100
101    /// Send mutate operations to a resource endpoint, e.g.
102    /// `mutate("campaigns", ops)` posts to `customers/{cid}/campaigns:mutate`.
103    ///
104    /// # Errors
105    ///
106    /// Returns [`MktError::ApiError`] for non-2xx responses and
107    /// [`MktError::Http`] for transport failures.
108    #[instrument(skip(self, operations), fields(provider = "google"))]
109    pub async fn mutate(
110        &self,
111        resource: &str,
112        operations: &serde_json::Value,
113    ) -> Result<serde_json::Value> {
114        let path = format!("customers/{}/{resource}:mutate", self.customer_id);
115        let body = serde_json::json!({ "operations": operations });
116        self.post(&path, &body).await
117    }
118
119    /// Perform an authenticated POST request with a JSON body.
120    async fn post(&self, path: &str, body: &serde_json::Value) -> Result<serde_json::Value> {
121        self.rate_limiter.acquire(1).await?;
122        let url = format!("{}{path}", self.base_url);
123
124        let mut request = self
125            .http
126            .post(&url)
127            .bearer_auth(self.access_token.expose_secret())
128            .header("developer-token", &self.developer_token)
129            .json(body);
130
131        if let Some(login_id) = &self.login_customer_id {
132            request = request.header("login-customer-id", login_id);
133        }
134
135        let response = request.send().await?;
136        Self::parse_response(response).await
137    }
138
139    /// Parse a response, returning either the JSON body or a mapped error.
140    async fn parse_response(response: reqwest::Response) -> Result<serde_json::Value> {
141        let status = response.status().as_u16();
142        let body = response.text().await?;
143
144        if (200..300).contains(&status) {
145            let value: serde_json::Value = serde_json::from_str(&body)?;
146            return Ok(value);
147        }
148
149        if let Ok(api_err) = serde_json::from_str::<GoogleApiErrorResponse>(&body) {
150            return Err(api_err.into_mkt_error(status));
151        }
152
153        Err(MktError::ApiError {
154            provider: "google".into(),
155            status,
156            message: body,
157            retry_after: None,
158        })
159    }
160}