Skip to main content

mkt_google/
provider.rs

1//! [`MarketingProvider`] implementation for Google Ads.
2
3use mkt_core::error::{MktError, Result};
4use mkt_core::models::{
5    Campaign, CampaignFilters, CampaignId, CreateCampaignInput, InsightsQuery, InsightsReport,
6    Paginated, ProviderHealth, UpdateCampaignInput,
7};
8use mkt_core::provider::{MarketingProvider, ProviderCapabilities};
9use tracing::instrument;
10
11use crate::client::GoogleClient;
12use crate::mapping;
13
14/// Google Ads marketing provider.
15///
16/// Wraps a [`GoogleClient`] and implements [`MarketingProvider`] using the
17/// Google Ads REST API: GAQL for reads, `:mutate` endpoints for writes.
18#[derive(Debug)]
19pub struct GoogleProvider {
20    client: GoogleClient,
21}
22
23impl GoogleProvider {
24    /// Create a new Google Ads provider.
25    pub const fn new(client: GoogleClient) -> Self {
26        Self { client }
27    }
28
29    /// Fetch one campaign by numeric ID via GAQL.
30    async fn fetch_campaign(&self, id: &str) -> Result<Campaign> {
31        let query = format!(
32            "SELECT {} FROM campaign WHERE campaign.id = {id}",
33            mapping::CAMPAIGN_GAQL_FIELDS
34        );
35        let resp = self.client.search(&query).await?;
36        let row = resp["results"]
37            .as_array()
38            .and_then(|rows| rows.first())
39            .ok_or_else(|| MktError::ApiError {
40                provider: "google".into(),
41                status: 404,
42                message: format!("campaign {id} not found"),
43                retry_after: None,
44            })?;
45        mapping::google_row_to_campaign(row)
46    }
47}
48
49impl MarketingProvider for GoogleProvider {
50    fn name(&self) -> &'static str {
51        "google"
52    }
53
54    fn display_name(&self) -> &'static str {
55        "Google Ads"
56    }
57
58    fn capabilities(&self) -> ProviderCapabilities {
59        ProviderCapabilities {
60            campaigns: true,
61            adsets: false,
62            ads: false,
63            creatives: false,
64            audiences: false,
65            insights: true,
66            organic_posts: false,
67            dark_posts: false,
68            video_upload: false,
69            image_upload: false,
70            workflow_templates: false,
71        }
72    }
73
74    // ── Campaigns ──────────────────────────────────────
75
76    #[instrument(skip(self))]
77    fn list_campaigns(
78        &self,
79        filters: &CampaignFilters,
80    ) -> impl std::future::Future<Output = Result<Paginated<Campaign>>> + Send {
81        async move {
82            let mut query = format!("SELECT {} FROM campaign", mapping::CAMPAIGN_GAQL_FIELDS);
83
84            let mut conditions: Vec<String> = Vec::new();
85            if let Some(status) = &filters.status {
86                conditions.push(format!(
87                    "campaign.status = '{}'",
88                    mapping::domain_status_to_google(status)
89                ));
90            }
91            if let Some(name) = &filters.name_contains {
92                // GAQL LIKE with % wildcards; single quotes escaped.
93                let escaped = name.replace('\'', "\\'");
94                conditions.push(format!("campaign.name LIKE '%{escaped}%'"));
95            }
96            if !conditions.is_empty() {
97                query.push_str(" WHERE ");
98                query.push_str(&conditions.join(" AND "));
99            }
100            if let Some(limit) = filters.limit {
101                query = format!("{query} LIMIT {limit}");
102            }
103
104            let resp = self.client.search(&query).await?;
105
106            let items = resp["results"]
107                .as_array()
108                .unwrap_or(&Vec::new())
109                .iter()
110                .map(mapping::google_row_to_campaign)
111                .collect::<Result<Vec<_>>>()?;
112
113            let next_cursor = resp["nextPageToken"].as_str().map(String::from);
114
115            Ok(Paginated {
116                data: items,
117                next_cursor,
118                total: None,
119            })
120        }
121    }
122
123    #[instrument(skip(self))]
124    fn get_campaign(
125        &self,
126        id: &CampaignId,
127    ) -> impl std::future::Future<Output = Result<Campaign>> + Send {
128        async move { self.fetch_campaign(&id.0).await }
129    }
130
131    #[instrument(skip(self, input))]
132    fn create_campaign(
133        &self,
134        input: &CreateCampaignInput,
135    ) -> impl std::future::Future<Output = Result<Campaign>> + Send {
136        async move {
137            // Google requires a budget resource before the campaign.
138            let budget_ops = mapping::budget_create_operation(input).ok_or_else(|| {
139                MktError::ValidationError {
140                    field: "budget".into(),
141                    message: "a budget is required to create a Google Ads campaign".into(),
142                }
143            })?;
144            let budget_resp = self.client.mutate("campaignBudgets", &budget_ops).await?;
145            let budget_resource = budget_resp["results"][0]["resourceName"]
146                .as_str()
147                .ok_or_else(|| MktError::ApiError {
148                    provider: "google".into(),
149                    status: 0,
150                    message: "budget mutate response missing resourceName".into(),
151                    retry_after: None,
152                })?;
153
154            let campaign_ops = mapping::campaign_create_operation(input, budget_resource);
155            let campaign_resp = self.client.mutate("campaigns", &campaign_ops).await?;
156            let new_id = mapping::campaign_id_from_mutate(&campaign_resp)?;
157
158            self.fetch_campaign(&new_id).await
159        }
160    }
161
162    #[instrument(skip(self, input))]
163    fn update_campaign(
164        &self,
165        id: &CampaignId,
166        input: &UpdateCampaignInput,
167    ) -> impl std::future::Future<Output = Result<Campaign>> + Send {
168        async move {
169            let ops = mapping::campaign_update_operation(self.client.customer_id(), &id.0, input);
170            let _resp = self.client.mutate("campaigns", &ops).await?;
171            self.fetch_campaign(&id.0).await
172        }
173    }
174
175    #[instrument(skip(self))]
176    fn delete_campaign(
177        &self,
178        id: &CampaignId,
179    ) -> impl std::future::Future<Output = Result<()>> + Send {
180        async move {
181            let resource = mapping::campaign_resource_name(self.client.customer_id(), &id.0);
182            let ops = serde_json::json!([{ "remove": resource }]);
183            let _resp = self.client.mutate("campaigns", &ops).await?;
184            Ok(())
185        }
186    }
187
188    // ── Insights ─────────────────────────────────────────
189
190    #[instrument(skip(self, query))]
191    fn get_insights(
192        &self,
193        query: &InsightsQuery,
194    ) -> impl std::future::Future<Output = Result<InsightsReport>> + Send {
195        async move {
196            let metrics = if query.metrics.is_empty() {
197                "metrics.impressions, metrics.clicks, metrics.cost_micros, metrics.ctr".to_string()
198            } else {
199                query
200                    .metrics
201                    .iter()
202                    .map(|m| format!("metrics.{m}"))
203                    .collect::<Vec<_>>()
204                    .join(", ")
205            };
206
207            let gaql = format!(
208                "SELECT campaign.id, campaign.name, segments.date, {metrics} FROM campaign"
209            );
210
211            // GAQL requires a finite date range whenever a core date
212            // segment is selected, so an unbounded query must not be sent.
213            let gaql = query.date_range.as_ref().map_or_else(
214                || format!("{gaql} WHERE segments.date DURING LAST_30_DAYS"),
215                |range| {
216                    format!(
217                        "{gaql} WHERE segments.date BETWEEN '{}' AND '{}'",
218                        range.start.format("%Y-%m-%d"),
219                        range.end.format("%Y-%m-%d")
220                    )
221                },
222            );
223
224            let resp = self.client.search(&gaql).await?;
225            mapping::google_insights_to_domain(&resp)
226        }
227    }
228
229    // ── Health check ───────────────────────────────────
230
231    async fn health_check(&self) -> Result<ProviderHealth> {
232        let start = std::time::Instant::now();
233        let result = self
234            .client
235            .search("SELECT customer.id FROM customer LIMIT 1")
236            .await;
237        #[allow(clippy::cast_possible_truncation)] // health-check latency fits in u64
238        let latency = start.elapsed().as_millis() as u64;
239
240        match result {
241            Ok(_) => Ok(ProviderHealth {
242                provider: "google".into(),
243                healthy: true,
244                latency_ms: latency,
245                message: None,
246            }),
247            Err(e) => Ok(ProviderHealth {
248                provider: "google".into(),
249                healthy: false,
250                latency_ms: latency,
251                message: Some(e.to_string()),
252            }),
253        }
254    }
255}