From 608a097648b125bf39c894556d39e217cee24523 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 4 Oct 2026 11:21:58 +0200 Subject: [PATCH] feat(ads): add the Google Ads surface This SDK was the only one without it. Task 46 shipped /v1/ads/google/* to every other client repo and missed this one, so googleAds() is new here rather than an extension of something existing. Covers all 33 Google-only routes: recommendations and the optimization score, keywords and keyword research, search terms, bid strategies, the ad schedule, negative keyword lists, assets, Performance Max asset groups, Local Services leads, conversions, and the GAQL passthrough. The shared surface is deliberately not duplicated. Campaigns, ad groups, ads, audiences, insights, labels, change history, experiments and conversion value rules already reach Google through ads(), because those routes dispatch by connection. GoogleAdsScope renders snake_case on a query and camelCase in a body, since the API takes the same three fields both ways. --- CLAUDE.md | 16 +- README.md | 39 ++ src/main/java/com/fopost/sdk/FoPost.java | 11 + .../sdk/model/GoogleAdScheduleSlot.java | 5 + .../com/fopost/sdk/model/GoogleAsset.java | 4 + .../fopost/sdk/model/GoogleAssetGroup.java | 7 + .../com/fopost/sdk/model/GoogleAssetLink.java | 5 + .../fopost/sdk/model/GoogleAssetsResult.java | 6 + .../fopost/sdk/model/GoogleBidStrategy.java | 5 + .../sdk/model/GoogleConversionAction.java | 12 + .../com/fopost/sdk/model/GoogleKeyword.java | 18 + .../fopost/sdk/model/GoogleKeywordIdea.java | 10 + .../sdk/model/GoogleLocalServicesLead.java | 13 + .../sdk/model/GoogleOptimizationScore.java | 10 + .../GoogleOptimizationScoreCampaign.java | 4 + .../fopost/sdk/model/GoogleQueryResult.java | 7 + .../sdk/model/GoogleRecommendation.java | 15 + .../sdk/model/GoogleRecommendationImpact.java | 14 + .../fopost/sdk/model/GoogleSearchTerm.java | 4 + .../com/fopost/sdk/model/GoogleSharedSet.java | 4 + .../sdk/param/GoogleAdScheduleParams.java | 44 +++ .../com/fopost/sdk/param/GoogleAdsScope.java | 54 +++ .../sdk/param/GoogleAssetGroupParams.java | 58 +++ .../fopost/sdk/param/GoogleAssetParams.java | 53 +++ .../sdk/param/GoogleBidStrategyParams.java | 28 ++ .../sdk/param/GoogleConversionParams.java | 138 +++++++ .../sdk/param/GoogleKeywordIdeasParams.java | 38 ++ .../fopost/sdk/param/GoogleKeywordParams.java | 57 +++ .../param/GoogleNegativeKeywordParams.java | 32 ++ .../sdk/resource/GoogleAdsResource.java | 355 ++++++++++++++++++ .../java/com/fopost/sdk/GoogleAdsTest.java | 150 ++++++++ 31 files changed, 1214 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/fopost/sdk/model/GoogleAdScheduleSlot.java create mode 100644 src/main/java/com/fopost/sdk/model/GoogleAsset.java create mode 100644 src/main/java/com/fopost/sdk/model/GoogleAssetGroup.java create mode 100644 src/main/java/com/fopost/sdk/model/GoogleAssetLink.java create mode 100644 src/main/java/com/fopost/sdk/model/GoogleAssetsResult.java create mode 100644 src/main/java/com/fopost/sdk/model/GoogleBidStrategy.java create mode 100644 src/main/java/com/fopost/sdk/model/GoogleConversionAction.java create mode 100644 src/main/java/com/fopost/sdk/model/GoogleKeyword.java create mode 100644 src/main/java/com/fopost/sdk/model/GoogleKeywordIdea.java create mode 100644 src/main/java/com/fopost/sdk/model/GoogleLocalServicesLead.java create mode 100644 src/main/java/com/fopost/sdk/model/GoogleOptimizationScore.java create mode 100644 src/main/java/com/fopost/sdk/model/GoogleOptimizationScoreCampaign.java create mode 100644 src/main/java/com/fopost/sdk/model/GoogleQueryResult.java create mode 100644 src/main/java/com/fopost/sdk/model/GoogleRecommendation.java create mode 100644 src/main/java/com/fopost/sdk/model/GoogleRecommendationImpact.java create mode 100644 src/main/java/com/fopost/sdk/model/GoogleSearchTerm.java create mode 100644 src/main/java/com/fopost/sdk/model/GoogleSharedSet.java create mode 100644 src/main/java/com/fopost/sdk/param/GoogleAdScheduleParams.java create mode 100644 src/main/java/com/fopost/sdk/param/GoogleAdsScope.java create mode 100644 src/main/java/com/fopost/sdk/param/GoogleAssetGroupParams.java create mode 100644 src/main/java/com/fopost/sdk/param/GoogleAssetParams.java create mode 100644 src/main/java/com/fopost/sdk/param/GoogleBidStrategyParams.java create mode 100644 src/main/java/com/fopost/sdk/param/GoogleConversionParams.java create mode 100644 src/main/java/com/fopost/sdk/param/GoogleKeywordIdeasParams.java create mode 100644 src/main/java/com/fopost/sdk/param/GoogleKeywordParams.java create mode 100644 src/main/java/com/fopost/sdk/param/GoogleNegativeKeywordParams.java create mode 100644 src/main/java/com/fopost/sdk/resource/GoogleAdsResource.java create mode 100644 src/test/java/com/fopost/sdk/GoogleAdsTest.java diff --git a/CLAUDE.md b/CLAUDE.md index 087b287..6081ace 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,7 +61,7 @@ src/main/java/com/fopost/sdk/ resource/ PostsResource, AccountsResource (+ .communities()), WorkspacesResource, LabelsResource, WebhooksResource, AnalyticsResource, AutomationsResource, MediaResource, AiResource, CommunitiesResource, - InboxResource, AdsResource, ValidateResource, + InboxResource, AdsResource, GoogleAdsResource, ValidateResource, BroadcastsResource, SequencesResource ``` @@ -81,7 +81,7 @@ calls `ApiClient.unwrap(...)` and `convert(...)`/`convertList(...)` into a recor - `FoPost` instances are immutable and safe to share across threads. **Resources wired today:** `posts`, `accounts` (with `accounts().communities()`), `workspaces`, -`labels`, `webhooks`, `analytics`, `automations`, `media`, `ai`, `inbox`, `contacts`, `broadcasts`, `sequences`, `ads`, `validate`. This is +`labels`, `webhooks`, `analytics`, `automations`, `media`, `ai`, `inbox`, `contacts`, `broadcasts`, `sequences`, `ads`, `googleAds`, `validate`. This is the most complete of the FoPost SDKs — do not narrow it. `FoPost.request(...)` is the escape hatch for anything unwrapped. @@ -107,6 +107,18 @@ anything unwrapped. - `ads` (scope `ads`) covers `/v1/ads/*`. Request bodies are camelCase; `boost`, `create`, `setStatus` and `delete` also need the `publish` scope, and a boost or ad starts paused unless `paused` is false. Say both in the javadoc of anything new that spends. +- `googleAds` (scope `ads`) covers `/v1/ads/google/*` plus the GAQL passthrough at + `POST /v1/ads/insights/query`: recommendations, the optimization score, keywords, keyword + ideas, search terms, bid strategies, the ad schedule, negative keyword lists, assets, + Performance Max asset groups, Local Services leads and conversions. A connection on another + network answers 400, so this is the one resource that is not network-agnostic. Campaigns, ad + groups, ads, audiences, insights, labels, change history, experiments and conversion value + rules are all on `ads` and work on Google through the shared routes — do not duplicate them + here. `GoogleAdsScope` carries the connection and the customer, and renders snake_case on a + query and camelCase in a body, because the API takes it both ways. An object id is + `~~`: a Google resource name has slashes and cannot ride in a path + segment. Applying a recommendation changes what the live account serves or bids, so it needs + `publish`; dismissing one only hides it. ## API Contract diff --git a/README.md b/README.md index aed7dda..30712a2 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | `ads()` | `list`, `external`, `boostable`, `connections`, `sources`, `authorizeMeta`, `deleteConnection`, `boost`, `create`, `refresh`, `setStatus`, `delete`, `accountTree`, `createCampaign`, `campaign`, `updateCampaign`, `deleteCampaign`, `duplicateCampaign`, `createAdSet`, `adSet`, `updateAdSet`, `deleteAdSet`, `duplicateAdSet`, `createNetworkAd`, `networkAd`, `updateNetworkAd`, `deleteNetworkAd`, `duplicateNetworkAd`, `bulkSetStatus`, `creatives`, `createCreative`, `creative`, `deleteCreative`, `estimateReach`, `insights`, `adInsights`, `audiences`, `createAudience`, `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`, `searchTargeting`, `leadForms`, `createLeadForm`, `leadForm`, `archiveLeadForm`, `leads`, `leadsFeed`, `leadPages`, `subscribeLeadPage`, `unsubscribeLeadPage`, `goals`, `catalogs`, `createCatalog`, `catalog`, `updateCatalog`, `deleteCatalog`, `catalogProducts`, `writeCatalogProducts`, `productFeeds`, `createProductFeed`, `deleteProductFeed`, `feedUploads`, `startFeedUpload`, `productSets`, `createProductSet`, `updateProductSet`, `deleteProductSet`, `reachFrequency`, `createReachFrequency`, `reachFrequencyPrediction`, `reserveReachFrequency`, `cancelReachFrequency`, `library`, `partnershipCreators`, `requestPartnership`, `revokePartnership`, `accountActivity`, `labels`, `createLabel`, `updateLabel`, `deleteLabel`, `applyLabel`, `studies`, `createStudy`, `study`, `deleteStudy`, `iosCampaignLimits`, `highDemandPeriods`, `createHighDemandPeriod`, `deleteHighDemandPeriod`, `valueRuleSets`, `createValueRuleSet`, `deleteValueRuleSet` | | `inbox()` | `list`, `threads`, `conversations`, `unreadCount`, `accounts`, `platforms`, `markThreadRead`, `markConversationRead`, `refresh`, `update`, `editComment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `pin`, `unpin`, `react`, `startConversation`, `setTyping`, `listApprovals`, `approveReply`, `rejectReply` | | `ads()` | `list`, `external`, `boostable`, `connections`, `sources`, `providers`, `authorize`, `deleteConnection`, `boost`, `create`, `refresh`, `setStatus`, `delete`, `accountTree`, `createCampaign`, `campaign`, `updateCampaign`, `deleteCampaign`, `duplicateCampaign`, `createAdSet`, `adSet`, `updateAdSet`, `deleteAdSet`, `duplicateAdSet`, `createNetworkAd`, `networkAd`, `updateNetworkAd`, `deleteNetworkAd`, `duplicateNetworkAd`, `bulkSetStatus`, `creatives`, `createCreative`, `creative`, `deleteCreative`, `estimateReach`, `insights`, `adInsights`, `audiences`, `createAudience`, `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`, `addAudienceCompanies`, `searchTargeting`, `leadForms`, `createLeadForm`, `leadForm`, `archiveLeadForm`, `leads`, `leadsFeed`, `leadPages`, `subscribeLeadPage`, `unsubscribeLeadPage`, `bidPricing`, `supplyForecast`, `conversionRules`, `createConversionRule`, `conversionRule`, `updateConversionRule`, `deleteConversionRule`, `attachConversionRule`, `detachConversionRule`, `conversionMetrics`, `sendConversionEvents` | +| `googleAds()` | `recommendations`, `optimizationScore`, `applyRecommendations`, `dismissRecommendations`, `keywords`, `createKeyword`, `updateKeyword`, `deleteKeyword`, `keywordIdeas`, `keywordMetrics`, `searchTerms`, `bidStrategies`, `createBidStrategy`, `adSchedule`, `setAdSchedule`, `negativeKeywordLists`, `createNegativeKeywordList`, `addNegativeKeywords`, `attachNegativeKeywordList`, `assets`, `createAsset`, `attachAsset`, `deleteAsset`, `assetGroups`, `createAssetGroup`, `updateAssetGroup`, `deleteAssetGroup`, `localServicesLeads`, `conversionActions`, `createConversionAction`, `uploadConversions`, `uploadConversionAdjustments`, `query` | | `googleBusiness()` | `getLocation`, `updateLocation`, `getAttributes`, `updateAttributes`, `getMenus`, `replaceMenus`, `getServices`, `replaceServices`, `listMedia`, `addMedia`, `deleteMedia`, `listPlaceActions`, `createPlaceAction`, `updatePlaceAction`, `deletePlaceAction`, `getVerificationOptions`, `startVerification`, `completeVerification`, `getPerformance`, `getSearchKeywords`, `assign` | | `validate()` | `post`, `length`, `media` | | `activity()` | `list` | @@ -324,6 +325,44 @@ Creating, updating, deleting and duplicating campaigns, ad sets and network ads, `bulkSetStatus`, need the `publish` scope as well as `ads`. `leadsFeed` pages with a cursor: pass `nextCursor` back through `LeadsFeedParams.cursor(...)` until it is null. +### Google Ads + +Campaigns, ad groups, ads, audiences, insights, labels, change history, experiments and +conversion value rules are all on `ads()` and dispatch by connection, so they work on Google +through the same calls as any other network. What only Google has is on `googleAds()`: + +```java +var scope = GoogleAdsScope.of(connectionId, "1234567890"); + +for (var recommendation : client.googleAds().recommendations(scope, List.of("KEYWORD"))) { + System.out.println(recommendation.type() + " -> " + recommendation.impact().potentialClicks()); +} + +client.googleAds().applyRecommendations( + GoogleAdsScope.of(connectionId, "1234567890").workspace(workspace.id()), + List.of("customers/1234567890/recommendations/ABC~1")); + +var ideas = client.googleAds().keywordIdeas( + new GoogleKeywordIdeasParams(scope).seeds(List.of("running shoes"))); +``` + +Also here: `optimizationScore`, `keywords`, `createKeyword`, `updateKeyword`, `deleteKeyword`, +`keywordMetrics`, `searchTerms`, `bidStrategies`, `createBidStrategy`, `adSchedule`, +`setAdSchedule`, `negativeKeywordLists`, `createNegativeKeywordList`, `addNegativeKeywords`, +`attachNegativeKeywordList`, `assets`, `createAsset`, `attachAsset`, `deleteAsset`, +`assetGroups`, `createAssetGroup`, `updateAssetGroup`, `deleteAssetGroup`, +`localServicesLeads`, `conversionActions`, `createConversionAction`, `uploadConversions`, +`uploadConversionAdjustments`, and `query` for a raw read-only GAQL SELECT. + +An object id is `~~` — a Google resource name has slashes and cannot ride +in a URL path segment, so every id carries the account it belongs to. The customer has to be an +account the connection's grant reaches; any other answers 404. Amounts are in the account's +currency, in minor units. + +Applying a recommendation changes what the live account serves or bids straight away, so it +needs `publish` as well as `ads`; dismissing one only hides it. A connection on another network +answers 400 on every call here. + ## Validation Check a draft, a text, or a media url against the platform rules before creating anything: diff --git a/src/main/java/com/fopost/sdk/FoPost.java b/src/main/java/com/fopost/sdk/FoPost.java index b29b11f..c217d74 100644 --- a/src/main/java/com/fopost/sdk/FoPost.java +++ b/src/main/java/com/fopost/sdk/FoPost.java @@ -17,6 +17,7 @@ import com.fopost.sdk.resource.ContactsResource; import com.fopost.sdk.resource.InboxResource; import com.fopost.sdk.resource.KnowledgeResource; +import com.fopost.sdk.resource.GoogleAdsResource; import com.fopost.sdk.resource.GoogleBusinessResource; import com.fopost.sdk.resource.LabelsResource; import com.fopost.sdk.resource.MediaResource; @@ -80,6 +81,7 @@ public final class FoPost { private final GoogleBusinessResource googleBusiness; private final InboxResource inbox; private final AdsResource ads; + private final GoogleAdsResource googleAds; private final ValidateResource validate; private FoPost(ApiClient http) { @@ -102,6 +104,7 @@ private FoPost(ApiClient http) { this.broadcasts = new BroadcastsResource(http); this.sequences = new SequencesResource(http); this.ads = new AdsResource(http); + this.googleAds = new GoogleAdsResource(http); this.validate = new ValidateResource(http); } @@ -198,6 +201,14 @@ public AdsResource ads() { return ads; } + /** + * The Google Ads surface no other network has. Campaigns, ad groups, ads, audiences and + * insights are on {@link #ads()} and dispatch by connection. + */ + public GoogleAdsResource googleAds() { + return googleAds; + } + public ValidateResource validate() { return validate; } diff --git a/src/main/java/com/fopost/sdk/model/GoogleAdScheduleSlot.java b/src/main/java/com/fopost/sdk/model/GoogleAdScheduleSlot.java new file mode 100644 index 0000000..5018089 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/GoogleAdScheduleSlot.java @@ -0,0 +1,5 @@ +package com.fopost.sdk.model; + +/** One slot of a campaign's ad schedule. */ +public record GoogleAdScheduleSlot( + String id, String dayOfWeek, int startHour, int endHour, Double bidModifier) {} diff --git a/src/main/java/com/fopost/sdk/model/GoogleAsset.java b/src/main/java/com/fopost/sdk/model/GoogleAsset.java new file mode 100644 index 0000000..5338a1f --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/GoogleAsset.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** A sitelink, callout, or structured snippet. */ +public record GoogleAsset(String id, String name, String type, String text, String finalUrl) {} diff --git a/src/main/java/com/fopost/sdk/model/GoogleAssetGroup.java b/src/main/java/com/fopost/sdk/model/GoogleAssetGroup.java new file mode 100644 index 0000000..0499695 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/GoogleAssetGroup.java @@ -0,0 +1,7 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** A Performance Max asset group. */ +public record GoogleAssetGroup( + String id, String campaignId, String name, String status, List finalUrls) {} diff --git a/src/main/java/com/fopost/sdk/model/GoogleAssetLink.java b/src/main/java/com/fopost/sdk/model/GoogleAssetLink.java new file mode 100644 index 0000000..950b963 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/GoogleAssetLink.java @@ -0,0 +1,5 @@ +package com.fopost.sdk.model; + +/** Where an asset is attached. An asset with no links serves nowhere. */ +public record GoogleAssetLink( + String id, String assetId, String level, String ownerId, String fieldType, String status) {} diff --git a/src/main/java/com/fopost/sdk/model/GoogleAssetsResult.java b/src/main/java/com/fopost/sdk/model/GoogleAssetsResult.java new file mode 100644 index 0000000..8c3e6a1 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/GoogleAssetsResult.java @@ -0,0 +1,6 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** The account's assets with the links that place each one. */ +public record GoogleAssetsResult(List assets, List links) {} diff --git a/src/main/java/com/fopost/sdk/model/GoogleBidStrategy.java b/src/main/java/com/fopost/sdk/model/GoogleBidStrategy.java new file mode 100644 index 0000000..92e5733 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/GoogleBidStrategy.java @@ -0,0 +1,5 @@ +package com.fopost.sdk.model; + +/** A portfolio bid strategy on the account. */ +public record GoogleBidStrategy( + String id, String name, String type, String status, int campaignCount) {} diff --git a/src/main/java/com/fopost/sdk/model/GoogleConversionAction.java b/src/main/java/com/fopost/sdk/model/GoogleConversionAction.java new file mode 100644 index 0000000..7599fc2 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/GoogleConversionAction.java @@ -0,0 +1,12 @@ +package com.fopost.sdk.model; + +/** A conversion action on the account. */ +public record GoogleConversionAction( + String id, + String name, + String category, + String status, + String type, + String countingType, + /** The account's currency, in minor units. */ + Long valueMinor) {} diff --git a/src/main/java/com/fopost/sdk/model/GoogleKeyword.java b/src/main/java/com/fopost/sdk/model/GoogleKeyword.java new file mode 100644 index 0000000..317b2ee --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/GoogleKeyword.java @@ -0,0 +1,18 @@ +package com.fopost.sdk.model; + +/** + * A keyword on an ad group. + * + *

{@code id} is {@code ~keyword~~}: a Google resource name + * has slashes and cannot ride in a URL path segment, so every id here carries the account it + * belongs to. + */ +public record GoogleKeyword( + String id, + String adGroupId, + String text, + String matchType, + String status, + /** The account's currency, in minor units. */ + Long cpcBidMinor, + boolean negative) {} diff --git a/src/main/java/com/fopost/sdk/model/GoogleKeywordIdea.java b/src/main/java/com/fopost/sdk/model/GoogleKeywordIdea.java new file mode 100644 index 0000000..27dcab3 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/GoogleKeywordIdea.java @@ -0,0 +1,10 @@ +package com.fopost.sdk.model; + +/** A keyword idea, or the historical metrics of a keyword you already have. */ +public record GoogleKeywordIdea( + String text, + long avgMonthlySearches, + String competition, + /** The account's currency, in minor units. */ + Long lowTopOfPageBidMinor, + Long highTopOfPageBidMinor) {} diff --git a/src/main/java/com/fopost/sdk/model/GoogleLocalServicesLead.java b/src/main/java/com/fopost/sdk/model/GoogleLocalServicesLead.java new file mode 100644 index 0000000..b40a615 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/GoogleLocalServicesLead.java @@ -0,0 +1,13 @@ +package com.fopost.sdk.model; + +/** A lead from Local Services Ads, read live and never stored. */ +public record GoogleLocalServicesLead( + String id, + String category, + String service, + String contactName, + String phone, + String email, + String status, + String type, + String createdAt) {} diff --git a/src/main/java/com/fopost/sdk/model/GoogleOptimizationScore.java b/src/main/java/com/fopost/sdk/model/GoogleOptimizationScore.java new file mode 100644 index 0000000..c74c5cf --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/GoogleOptimizationScore.java @@ -0,0 +1,10 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** Google's estimate of how well the account is set up, from 0 to 1. */ +public record GoogleOptimizationScore( + Double score, + /** How much this account's score counts against others under the same manager. */ + Double weight, + List campaigns) {} diff --git a/src/main/java/com/fopost/sdk/model/GoogleOptimizationScoreCampaign.java b/src/main/java/com/fopost/sdk/model/GoogleOptimizationScoreCampaign.java new file mode 100644 index 0000000..03155cf --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/GoogleOptimizationScoreCampaign.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** One campaign's optimization score. */ +public record GoogleOptimizationScoreCampaign(String id, String name, Double score) {} diff --git a/src/main/java/com/fopost/sdk/model/GoogleQueryResult.java b/src/main/java/com/fopost/sdk/model/GoogleQueryResult.java new file mode 100644 index 0000000..026bd12 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/GoogleQueryResult.java @@ -0,0 +1,7 @@ +package com.fopost.sdk.model; + +import com.fasterxml.jackson.databind.JsonNode; +import java.util.List; + +/** Rows exactly as Google returns them. */ +public record GoogleQueryResult(List rows) {} diff --git a/src/main/java/com/fopost/sdk/model/GoogleRecommendation.java b/src/main/java/com/fopost/sdk/model/GoogleRecommendation.java new file mode 100644 index 0000000..3581fc2 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/GoogleRecommendation.java @@ -0,0 +1,15 @@ +package com.fopost.sdk.model; + +/** + * One of Google's own recommendations for the account. + * + *

{@code id} is the Google resource name rather than the {@code ~} form other objects use, + * because a recommendation is not an object you address again: it is what apply and dismiss take. + */ +public record GoogleRecommendation( + String id, + String type, + String campaignId, + String adGroupId, + boolean dismissed, + GoogleRecommendationImpact impact) {} diff --git a/src/main/java/com/fopost/sdk/model/GoogleRecommendationImpact.java b/src/main/java/com/fopost/sdk/model/GoogleRecommendationImpact.java new file mode 100644 index 0000000..834671f --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/GoogleRecommendationImpact.java @@ -0,0 +1,14 @@ +package com.fopost.sdk.model; + +/** + * What Google projects applying a recommendation would change. A null field is one Google does + * not estimate for that recommendation. + */ +public record GoogleRecommendationImpact( + Double baseClicks, + Double potentialClicks, + /** The account's currency, in minor units. */ + Long baseCostMinor, + Long potentialCostMinor, + Double baseConversions, + Double potentialConversions) {} diff --git a/src/main/java/com/fopost/sdk/model/GoogleSearchTerm.java b/src/main/java/com/fopost/sdk/model/GoogleSearchTerm.java new file mode 100644 index 0000000..dd51624 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/GoogleSearchTerm.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** What someone actually searched, with the metrics it earned. */ +public record GoogleSearchTerm(String term, String adGroupId, String status, AdInsights metrics) {} diff --git a/src/main/java/com/fopost/sdk/model/GoogleSharedSet.java b/src/main/java/com/fopost/sdk/model/GoogleSharedSet.java new file mode 100644 index 0000000..b3e302f --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/GoogleSharedSet.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** A negative keyword list. */ +public record GoogleSharedSet(String id, String name, String type, int memberCount) {} diff --git a/src/main/java/com/fopost/sdk/param/GoogleAdScheduleParams.java b/src/main/java/com/fopost/sdk/param/GoogleAdScheduleParams.java new file mode 100644 index 0000000..2c0a2c5 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/GoogleAdScheduleParams.java @@ -0,0 +1,44 @@ +package com.fopost.sdk.param; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Replace a campaign's ad schedule. Google has no partial edit for one, so every slot the + * campaign should keep has to be here. + */ +public final class GoogleAdScheduleParams { + + private final Map body; + private final List> slots = new ArrayList<>(); + + public GoogleAdScheduleParams(GoogleAdsScope scope, String campaignId) { + body = scope.toMap(); + body.put("campaignId", campaignId); + } + + /** {@code dayOfWeek} is MONDAY through SUNDAY; the hours are 0 to 24. */ + public GoogleAdScheduleParams slot(String dayOfWeek, int startHour, int endHour) { + return slot(dayOfWeek, startHour, endHour, null); + } + + public GoogleAdScheduleParams slot( + String dayOfWeek, int startHour, int endHour, Double bidModifier) { + Map slot = new LinkedHashMap<>(); + slot.put("dayOfWeek", dayOfWeek); + slot.put("startHour", startHour); + slot.put("endHour", endHour); + if (bidModifier != null) { + slot.put("bidModifier", bidModifier); + } + slots.add(slot); + return this; + } + + public Map toMap() { + body.put("slots", slots); + return body; + } +} diff --git a/src/main/java/com/fopost/sdk/param/GoogleAdsScope.java b/src/main/java/com/fopost/sdk/param/GoogleAdsScope.java new file mode 100644 index 0000000..7ecba8b --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/GoogleAdsScope.java @@ -0,0 +1,54 @@ +package com.fopost.sdk.param; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * The connection and the Google Ads account a call runs against. + * + *

{@code customerId} is digits only and has to name an account the connection's grant reaches: + * any other answers 404. The workspace may be left out on a read, and is required on a write. + */ +public final class GoogleAdsScope { + + private final String connectionId; + private final String customerId; + private String workspaceId; + + private GoogleAdsScope(String connectionId, String customerId) { + this.connectionId = connectionId; + this.customerId = customerId; + } + + public static GoogleAdsScope of(String connectionId, String customerId) { + return new GoogleAdsScope(connectionId, customerId); + } + + /** A write names the workspace the connection lives in. */ + public GoogleAdsScope workspace(String workspaceId) { + this.workspaceId = workspaceId; + return this; + } + + /** Snake_case, the way a read takes it on the query string. */ + public Map toQuery() { + Map query = new LinkedHashMap<>(); + if (workspaceId != null) { + query.put("workspace_id", workspaceId); + } + query.put("connection_id", connectionId); + query.put("customer_id", customerId); + return query; + } + + /** CamelCase, the way a write takes it in the body. */ + public Map toMap() { + Map body = new LinkedHashMap<>(); + if (workspaceId != null) { + body.put("workspaceId", workspaceId); + } + body.put("connectionId", connectionId); + body.put("customerId", customerId); + return body; + } +} diff --git a/src/main/java/com/fopost/sdk/param/GoogleAssetGroupParams.java b/src/main/java/com/fopost/sdk/param/GoogleAssetGroupParams.java new file mode 100644 index 0000000..445503f --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/GoogleAssetGroupParams.java @@ -0,0 +1,58 @@ +package com.fopost.sdk.param; + +import java.util.List; +import java.util.Map; + +/** Create or change a Performance Max asset group. */ +public final class GoogleAssetGroupParams { + + private GoogleAssetGroupParams() {} + + /** Creates one. It starts paused unless a status says otherwise. */ + public static final class Create { + + private final Map body; + + public Create( + GoogleAdsScope scope, String campaignId, String name, List finalUrls) { + body = scope.toMap(); + body.put("campaignId", campaignId); + body.put("name", name); + body.put("finalUrls", finalUrls); + } + + /** {@code active} or {@code paused}. */ + public Create status(String status) { + body.put("status", status); + return this; + } + + public Map toMap() { + return body; + } + } + + /** Renames, pauses or resumes one. */ + public static final class Update { + + private final Map body; + + public Update(GoogleAdsScope scope) { + body = scope.toMap(); + } + + public Update name(String name) { + body.put("name", name); + return this; + } + + public Update status(String status) { + body.put("status", status); + return this; + } + + public Map toMap() { + return body; + } + } +} diff --git a/src/main/java/com/fopost/sdk/param/GoogleAssetParams.java b/src/main/java/com/fopost/sdk/param/GoogleAssetParams.java new file mode 100644 index 0000000..543958f --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/GoogleAssetParams.java @@ -0,0 +1,53 @@ +package com.fopost.sdk.param; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Add an asset to the library. The kind picks which other fields apply. */ +public final class GoogleAssetParams { + + private final Map body; + private final Map spec = new LinkedHashMap<>(); + + private GoogleAssetParams(GoogleAdsScope scope, String kind) { + body = scope.toMap(); + spec.put("kind", kind); + } + + /** A link under the ad, with its own destination. */ + public static GoogleAssetParams sitelink(GoogleAdsScope scope, String text, String finalUrl) { + GoogleAssetParams params = new GoogleAssetParams(scope, "sitelink"); + params.spec.put("text", text); + params.spec.put("finalUrl", finalUrl); + return params; + } + + /** A short phrase beside the ad. */ + public static GoogleAssetParams callout(GoogleAdsScope scope, String text) { + GoogleAssetParams params = new GoogleAssetParams(scope, "callout"); + params.spec.put("text", text); + return params; + } + + /** A header and the values listed under it. */ + public static GoogleAssetParams snippet( + GoogleAdsScope scope, String header, List values) { + GoogleAssetParams params = new GoogleAssetParams(scope, "snippet"); + params.spec.put("header", header); + params.spec.put("values", values); + return params; + } + + /** The lines under a sitelink. */ + public GoogleAssetParams descriptions(String description1, String description2) { + spec.put("description1", description1); + spec.put("description2", description2); + return this; + } + + public Map toMap() { + body.put("spec", spec); + return body; + } +} diff --git a/src/main/java/com/fopost/sdk/param/GoogleBidStrategyParams.java b/src/main/java/com/fopost/sdk/param/GoogleBidStrategyParams.java new file mode 100644 index 0000000..9917318 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/GoogleBidStrategyParams.java @@ -0,0 +1,28 @@ +package com.fopost.sdk.param; + +import java.util.Map; + +/** + * Add a portfolio bid strategy. {@code type} is one of TARGET_SPEND, MAXIMIZE_CONVERSIONS, + * MAXIMIZE_CONVERSION_VALUE, TARGET_CPA or TARGET_ROAS. + */ +public final class GoogleBidStrategyParams { + + private final Map body; + + public GoogleBidStrategyParams(GoogleAdsScope scope, String name, String type) { + body = scope.toMap(); + body.put("name", name); + body.put("type", type); + } + + /** The account's currency, where the strategy takes a target. */ + public GoogleBidStrategyParams targetMinor(long minor) { + body.put("targetMinor", minor); + return this; + } + + public Map toMap() { + return body; + } +} diff --git a/src/main/java/com/fopost/sdk/param/GoogleConversionParams.java b/src/main/java/com/fopost/sdk/param/GoogleConversionParams.java new file mode 100644 index 0000000..bfc18a1 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/GoogleConversionParams.java @@ -0,0 +1,138 @@ +package com.fopost.sdk.param; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Create a conversion action, or send conversions Google did not see itself. */ +public final class GoogleConversionParams { + + private GoogleConversionParams() {} + + /** Adds a conversion action. */ + public static final class CreateAction { + + private final Map body; + + public CreateAction(GoogleAdsScope scope, String name, String category) { + body = scope.toMap(); + body.put("name", name); + body.put("category", category); + } + + /** The account's currency, in minor units. */ + public CreateAction valueMinor(long minor) { + body.put("valueMinor", minor); + return this; + } + + /** {@code ONE_PER_CLICK} or {@code MANY_PER_CLICK}. */ + public CreateAction countingType(String countingType) { + body.put("countingType", countingType); + return this; + } + + public Map toMap() { + return body; + } + } + + /** Sends offline conversions, each matched to a click. */ + public static final class Upload { + + private final Map body; + private final List> conversions = new ArrayList<>(); + + public Upload(GoogleAdsScope scope) { + body = scope.toMap(); + } + + /** + * One conversion. {@code clickIdField} is gclid, gbraid or wbraid: one of them is required, + * because it is what matches the click. {@code conversionDateTime} is + * {@code yyyy-MM-dd HH:mm:ss+|-HH:mm}, the only shape Google accepts. + */ + public Upload conversion( + String clickIdField, + String clickId, + String conversionActionId, + String conversionDateTime) { + Map conversion = new LinkedHashMap<>(); + conversion.put(clickIdField, clickId); + conversion.put("conversionActionId", conversionActionId); + conversion.put("conversionDateTime", conversionDateTime); + conversions.add(conversion); + return this; + } + + /** Puts a value on the conversion added last. */ + public Upload valueMinor(long minor, String currencyCode) { + Map last = conversions.get(conversions.size() - 1); + last.put("valueMinor", minor); + last.put("currencyCode", currencyCode); + return this; + } + + /** Puts an order id on the conversion added last. */ + public Upload orderId(String orderId) { + conversions.get(conversions.size() - 1).put("orderId", orderId); + return this; + } + + public Map toMap() { + body.put("conversions", conversions); + return body; + } + } + + /** Restates, retracts or enhances conversions already counted. */ + public static final class Adjust { + + private final Map body; + private final List> adjustments = new ArrayList<>(); + + public Adjust(GoogleAdsScope scope) { + body = scope.toMap(); + } + + /** {@code adjustmentType} is RESTATEMENT, RETRACTION or ENHANCEMENT. */ + public Adjust adjustment( + String conversionActionId, String adjustmentType, String adjustmentDateTime) { + Map adjustment = new LinkedHashMap<>(); + adjustment.put("conversionActionId", conversionActionId); + adjustment.put("adjustmentType", adjustmentType); + adjustment.put("adjustmentDateTime", adjustmentDateTime); + adjustments.add(adjustment); + return this; + } + + /** Identifies the conversion the adjustment added last applies to. */ + public Adjust matching(String gclid, String orderId, String conversionDateTime) { + Map last = adjustments.get(adjustments.size() - 1); + if (gclid != null) { + last.put("gclid", gclid); + } + if (orderId != null) { + last.put("orderId", orderId); + } + if (conversionDateTime != null) { + last.put("conversionDateTime", conversionDateTime); + } + return this; + } + + /** The restated value of the adjustment added last. */ + public Adjust restatementValueMinor(long minor, String currencyCode) { + Map last = adjustments.get(adjustments.size() - 1); + last.put("restatementValueMinor", minor); + last.put("currencyCode", currencyCode); + return this; + } + + public Map toMap() { + body.put("adjustments", adjustments); + return body; + } + } +} diff --git a/src/main/java/com/fopost/sdk/param/GoogleKeywordIdeasParams.java b/src/main/java/com/fopost/sdk/param/GoogleKeywordIdeasParams.java new file mode 100644 index 0000000..11f9bd6 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/GoogleKeywordIdeasParams.java @@ -0,0 +1,38 @@ +package com.fopost.sdk.param; + +import java.util.List; +import java.util.Map; + +/** Ask for keyword ideas from seed terms, a landing page, or both. */ +public final class GoogleKeywordIdeasParams { + + private final Map body; + + public GoogleKeywordIdeasParams(GoogleAdsScope scope) { + body = scope.toMap(); + } + + public GoogleKeywordIdeasParams seeds(List seeds) { + body.put("seeds", seeds); + return this; + } + + public GoogleKeywordIdeasParams url(String url) { + body.put("url", url); + return this; + } + + public GoogleKeywordIdeasParams languageId(String languageId) { + body.put("languageId", languageId); + return this; + } + + public GoogleKeywordIdeasParams geoTargetIds(List geoTargetIds) { + body.put("geoTargetIds", geoTargetIds); + return this; + } + + public Map toMap() { + return body; + } +} diff --git a/src/main/java/com/fopost/sdk/param/GoogleKeywordParams.java b/src/main/java/com/fopost/sdk/param/GoogleKeywordParams.java new file mode 100644 index 0000000..b0eae89 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/GoogleKeywordParams.java @@ -0,0 +1,57 @@ +package com.fopost.sdk.param; + +import java.util.Map; + +/** Add a keyword to an ad group, or change one that is already there. */ +public final class GoogleKeywordParams { + + private GoogleKeywordParams() {} + + /** Adds a keyword. {@code matchType} is EXACT, PHRASE or BROAD. */ + public static final class Create { + + private final Map body; + + public Create(GoogleAdsScope scope, String adGroupId, String text, String matchType) { + body = scope.toMap(); + body.put("adGroupId", adGroupId); + body.put("text", text); + body.put("matchType", matchType); + } + + /** The account's currency, in minor units. */ + public Create cpcBidMinor(long minor) { + body.put("cpcBidMinor", minor); + return this; + } + + public Map toMap() { + return body; + } + } + + /** Pauses, resumes or rebids a keyword. */ + public static final class Update { + + private final Map body; + + public Update(GoogleAdsScope scope) { + body = scope.toMap(); + } + + /** {@code active} or {@code paused}. */ + public Update status(String status) { + body.put("status", status); + return this; + } + + public Update cpcBidMinor(long minor) { + body.put("cpcBidMinor", minor); + return this; + } + + public Map toMap() { + return body; + } + } +} diff --git a/src/main/java/com/fopost/sdk/param/GoogleNegativeKeywordParams.java b/src/main/java/com/fopost/sdk/param/GoogleNegativeKeywordParams.java new file mode 100644 index 0000000..c74cbd9 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/GoogleNegativeKeywordParams.java @@ -0,0 +1,32 @@ +package com.fopost.sdk.param; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** Add keywords a campaign should never match. */ +public final class GoogleNegativeKeywordParams { + + private final Map body; + private final List> keywords = new ArrayList<>(); + + public GoogleNegativeKeywordParams(GoogleAdsScope scope, String sharedSetId) { + body = scope.toMap(); + body.put("sharedSetId", sharedSetId); + } + + /** {@code matchType} is EXACT, PHRASE or BROAD. */ + public GoogleNegativeKeywordParams keyword(String text, String matchType) { + Map keyword = new LinkedHashMap<>(); + keyword.put("text", text); + keyword.put("matchType", matchType); + keywords.add(keyword); + return this; + } + + public Map toMap() { + body.put("keywords", keywords); + return body; + } +} diff --git a/src/main/java/com/fopost/sdk/resource/GoogleAdsResource.java b/src/main/java/com/fopost/sdk/resource/GoogleAdsResource.java new file mode 100644 index 0000000..ce030ce --- /dev/null +++ b/src/main/java/com/fopost/sdk/resource/GoogleAdsResource.java @@ -0,0 +1,355 @@ +package com.fopost.sdk.resource; + +import com.fopost.sdk.internal.ApiClient; +import com.fopost.sdk.model.GoogleAdScheduleSlot; +import com.fopost.sdk.model.GoogleAssetGroup; +import com.fopost.sdk.model.GoogleAssetsResult; +import com.fopost.sdk.model.GoogleBidStrategy; +import com.fopost.sdk.model.GoogleConversionAction; +import com.fopost.sdk.model.GoogleKeyword; +import com.fopost.sdk.model.GoogleKeywordIdea; +import com.fopost.sdk.model.GoogleLocalServicesLead; +import com.fopost.sdk.model.GoogleOptimizationScore; +import com.fopost.sdk.model.GoogleQueryResult; +import com.fopost.sdk.model.GoogleRecommendation; +import com.fopost.sdk.model.GoogleSearchTerm; +import com.fopost.sdk.model.GoogleSharedSet; +import com.fopost.sdk.param.GoogleAdScheduleParams; +import com.fopost.sdk.param.GoogleAdsScope; +import com.fopost.sdk.param.GoogleAssetGroupParams; +import com.fopost.sdk.param.GoogleAssetParams; +import com.fopost.sdk.param.GoogleBidStrategyParams; +import com.fopost.sdk.param.GoogleConversionParams; +import com.fopost.sdk.param.GoogleKeywordIdeasParams; +import com.fopost.sdk.param.GoogleKeywordParams; +import com.fopost.sdk.param.GoogleNegativeKeywordParams; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; + +/** + * The Google Ads surface no other network has: recommendations, the optimization score, keywords, + * assets, Performance Max asset groups, Local Services leads, conversions, and raw GAQL. + * + *

Campaigns, ad groups, ads, audiences and insights are on {@link AdsResource} and dispatch by + * connection; a connection on another network answers 400 here. Every call needs the {@code ads} + * scope, and anything that changes what a live account serves or bids also needs {@code publish}. + * Amounts are in the account's currency, in minor units. + * + *

{@code
+ * var scope = GoogleAdsScope.of(connectionId, "1234567890");
+ * for (var recommendation : fopost.googleAds().recommendations(scope)) {
+ *     System.out.println(recommendation.type());
+ * }
+ * }
+ */ +public final class GoogleAdsResource { + + private final ApiClient http; + + public GoogleAdsResource(ApiClient http) { + this.http = http; + } + + // ── Recommendations ── + + /** Google's own read on what the account should change next. */ + public List recommendations(GoogleAdsScope scope) { + return recommendations(scope, List.of()); + } + + /** {@code types} narrows to those recommendation types, such as {@code KEYWORD}. */ + public List recommendations(GoogleAdsScope scope, List types) { + Map query = scope.toQuery(); + if (!types.isEmpty()) { + query.put("types", String.join(",", types)); + } + return http.convertList( + ApiClient.unwrap(http.get("/v1/ads/google/recommendations", query)), + GoogleRecommendation.class); + } + + /** The account's score and weight, and the score of each live campaign. */ + public GoogleOptimizationScore optimizationScore(GoogleAdsScope scope) { + return http.convert( + ApiClient.unwrap(http.get("/v1/ads/google/optimization-score", scope.toQuery())), + GoogleOptimizationScore.class); + } + + /** + * Applies each one, which changes what the live account serves or bids, and answers how many + * landed. Needs {@code publish} as well as {@code ads}. Each id has to name a recommendation + * on this customer; any other answers 404. + */ + public int applyRecommendations(GoogleAdsScope scope, List ids) { + return counted("/v1/ads/google/recommendations/apply", scope, ids, "applied"); + } + + /** Hides each one so Google stops surfacing it. Needs {@code publish}. */ + public int dismissRecommendations(GoogleAdsScope scope, List ids) { + return counted("/v1/ads/google/recommendations/dismiss", scope, ids, "dismissed"); + } + + // ── Keywords ── + + /** The keywords on the account. */ + public List keywords(GoogleAdsScope scope) { + return keywords(scope, null); + } + + /** The keywords on one ad group. */ + public List keywords(GoogleAdsScope scope, String adGroupId) { + Map query = scope.toQuery(); + if (adGroupId != null) { + query.put("ad_group_id", adGroupId); + } + return http.convertList( + ApiClient.unwrap(http.get("/v1/ads/google/keywords", query)), GoogleKeyword.class); + } + + /** Adds a keyword, which goes live. Needs {@code publish} as well as {@code ads}. */ + public String createKeyword(GoogleKeywordParams.Create params) { + return id(http.post("/v1/ads/google/keywords", params.toMap())); + } + + /** Pauses, resumes or rebids a keyword. Needs {@code publish}. */ + public String updateKeyword(String id, GoogleKeywordParams.Update params) { + return id(http.patch("/v1/ads/google/keywords/" + encode(id), params.toMap())); + } + + /** Removes a keyword. Needs {@code publish} as well as {@code ads}. */ + public void deleteKeyword(String id, GoogleAdsScope scope) { + http.request("DELETE", "/v1/ads/google/keywords/" + encode(id), scope.toMap(), Map.of()); + } + + /** Ideas from seed keywords, a landing page, or both. */ + public List keywordIdeas(GoogleKeywordIdeasParams params) { + return http.convertList( + ApiClient.unwrap(http.post("/v1/ads/google/keyword-ideas", params.toMap())), + GoogleKeywordIdea.class); + } + + /** Historical metrics for keywords you already have. */ + public List keywordMetrics(GoogleAdsScope scope, List keywords) { + Map body = scope.toMap(); + body.put("keywords", keywords); + return http.convertList( + ApiClient.unwrap(http.post("/v1/ads/google/keyword-metrics", body)), + GoogleKeywordIdea.class); + } + + /** What people actually searched, with the metrics each term earned. */ + public List searchTerms(GoogleAdsScope scope, String since, String until) { + Map query = scope.toQuery(); + query.put("since", since); + query.put("until", until); + return http.convertList( + ApiClient.unwrap(http.get("/v1/ads/google/search-terms", query)), + GoogleSearchTerm.class); + } + + // ── Bid strategies and ad schedule ── + + /** The account's portfolio bid strategies. */ + public List bidStrategies(GoogleAdsScope scope) { + return http.convertList( + ApiClient.unwrap(http.get("/v1/ads/google/bid-strategies", scope.toQuery())), + GoogleBidStrategy.class); + } + + /** Adds a bid strategy, which changes how campaigns bid. Needs {@code publish}. */ + public String createBidStrategy(GoogleBidStrategyParams params) { + return id(http.post("/v1/ads/google/bid-strategies", params.toMap())); + } + + /** A campaign's ad schedule. */ + public List adSchedule(GoogleAdsScope scope, String campaignId) { + Map query = scope.toQuery(); + query.put("campaign_id", campaignId); + return http.convertList( + ApiClient.unwrap(http.get("/v1/ads/google/ad-schedule", query)), + GoogleAdScheduleSlot.class); + } + + /** + * Replaces every slot on the campaign: Google has no partial edit for a schedule, so a slot + * left out stops serving. Answers how many landed. Needs {@code publish}. + */ + public int setAdSchedule(GoogleAdScheduleParams params) { + return ApiClient.unwrap(http.put("/v1/ads/google/ad-schedule", params.toMap())) + .path("slots") + .asInt(); + } + + // ── Negative keyword lists ── + + /** The account's negative keyword lists. */ + public List negativeKeywordLists(GoogleAdsScope scope) { + return http.convertList( + ApiClient.unwrap(http.get("/v1/ads/google/negative-keywords", scope.toQuery())), + GoogleSharedSet.class); + } + + /** Creates a negative keyword list. Needs {@code publish} as well as {@code ads}. */ + public String createNegativeKeywordList(GoogleAdsScope scope, String name) { + Map body = scope.toMap(); + body.put("name", name); + return id(http.post("/v1/ads/google/negative-keywords", body)); + } + + /** Adds keywords to a list; answers how many landed. Needs {@code publish}. */ + public int addNegativeKeywords(GoogleNegativeKeywordParams params) { + return ApiClient.unwrap(http.post("/v1/ads/google/negative-keywords/keywords", params.toMap())) + .path("added") + .asInt(); + } + + /** Puts a list on a campaign, which stops it matching those terms. Needs {@code publish}. */ + public void attachNegativeKeywordList( + GoogleAdsScope scope, String sharedSetId, String campaignId) { + Map body = scope.toMap(); + body.put("sharedSetId", sharedSetId); + body.put("campaignId", campaignId); + http.post("/v1/ads/google/negative-keywords/attach", body); + } + + // ── Assets ── + + /** Sitelinks, callouts and snippets, with the links that place each one. */ + public GoogleAssetsResult assets(GoogleAdsScope scope) { + return http.convert( + ApiClient.unwrap(http.get("/v1/ads/google/assets", scope.toQuery())), + GoogleAssetsResult.class); + } + + /** Adds an asset to the library. Needs {@code publish} as well as {@code ads}. */ + public String createAsset(GoogleAssetParams params) { + return id(http.post("/v1/ads/google/assets", params.toMap())); + } + + /** + * Puts an asset under the ads it belongs to, which changes what they render. Attaches to the + * account when {@code campaignId} is null. Needs {@code publish}. + */ + public void attachAsset( + GoogleAdsScope scope, String assetId, String fieldType, String campaignId) { + Map body = scope.toMap(); + body.put("assetId", assetId); + body.put("fieldType", fieldType); + if (campaignId != null) { + body.put("campaignId", campaignId); + } + http.post("/v1/ads/google/assets/attach", body); + } + + /** + * Removes the links that put an asset under an ad; on Google the asset itself is permanent. + * Needs {@code publish} as well as {@code ads}. + */ + public void deleteAsset(String id, GoogleAdsScope scope) { + http.request("DELETE", "/v1/ads/google/assets/" + encode(id), scope.toMap(), Map.of()); + } + + // ── Performance Max asset groups ── + + /** The account's Performance Max asset groups. */ + public List assetGroups(GoogleAdsScope scope) { + return assetGroups(scope, null); + } + + /** One campaign's Performance Max asset groups. */ + public List assetGroups(GoogleAdsScope scope, String campaignId) { + Map query = scope.toQuery(); + if (campaignId != null) { + query.put("campaign_id", campaignId); + } + return http.convertList( + ApiClient.unwrap(http.get("/v1/ads/google/asset-groups", query)), + GoogleAssetGroup.class); + } + + /** Creates an asset group. It starts paused unless the status says otherwise. Needs {@code publish}. */ + public String createAssetGroup(GoogleAssetGroupParams.Create params) { + return id(http.post("/v1/ads/google/asset-groups", params.toMap())); + } + + /** Renames, pauses or resumes an asset group. Needs {@code publish}. */ + public String updateAssetGroup(String id, GoogleAssetGroupParams.Update params) { + return id(http.patch("/v1/ads/google/asset-groups/" + encode(id), params.toMap())); + } + + /** Removes an asset group. Needs {@code publish} as well as {@code ads}. */ + public void deleteAssetGroup(String id, GoogleAdsScope scope) { + http.request("DELETE", "/v1/ads/google/asset-groups/" + encode(id), scope.toMap(), Map.of()); + } + + // ── Local Services leads ── + + /** Leads from Local Services Ads, read live on every call and never stored by FoPost. */ + public List localServicesLeads( + GoogleAdsScope scope, String since, String until) { + Map query = scope.toQuery(); + query.put("since", since); + query.put("until", until); + return http.convertList( + ApiClient.unwrap(http.get("/v1/ads/google/local-services", query)), + GoogleLocalServicesLead.class); + } + + // ── Conversions ── + + /** The account's conversion actions. */ + public List conversionActions(GoogleAdsScope scope) { + return http.convertList( + ApiClient.unwrap(http.get("/v1/ads/google/conversions", scope.toQuery())), + GoogleConversionAction.class); + } + + /** Adds a conversion action. Needs {@code publish} as well as {@code ads}. */ + public String createConversionAction(GoogleConversionParams.CreateAction params) { + return id(http.post("/v1/ads/google/conversions", params.toMap())); + } + + /** Sends offline conversions; answers how many landed. Needs {@code publish}. */ + public int uploadConversions(GoogleConversionParams.Upload params) { + return ApiClient.unwrap(http.post("/v1/ads/google/conversions/upload", params.toMap())) + .path("uploaded") + .asInt(); + } + + /** Sends conversion adjustments; answers how many landed. Needs {@code publish}. */ + public int uploadConversionAdjustments(GoogleConversionParams.Adjust params) { + return ApiClient.unwrap(http.post("/v1/ads/google/conversions/adjustments", params.toMap())) + .path("uploaded") + .asInt(); + } + + // ── GAQL ── + + /** + * Runs a read-only GAQL SELECT; rows come back exactly as Google sends them. The account read + * is the scope's customer, never anything named inside the query text, and anything that is + * not a SELECT is refused before the connection is touched. + */ + public GoogleQueryResult query(GoogleAdsScope scope, String query) { + Map body = scope.toMap(); + body.put("query", query); + return http.convert( + ApiClient.unwrap(http.post("/v1/ads/insights/query", body)), GoogleQueryResult.class); + } + + private int counted(String path, GoogleAdsScope scope, List ids, String key) { + Map body = scope.toMap(); + body.put("ids", ids); + return ApiClient.unwrap(http.post(path, body)).path(key).asInt(); + } + + private String id(com.fasterxml.jackson.databind.JsonNode response) { + return ApiClient.unwrap(response).path("id").asText(); + } + + private static String encode(String id) { + return URLEncoder.encode(id, StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/com/fopost/sdk/GoogleAdsTest.java b/src/test/java/com/fopost/sdk/GoogleAdsTest.java new file mode 100644 index 0000000..b81c98e --- /dev/null +++ b/src/test/java/com/fopost/sdk/GoogleAdsTest.java @@ -0,0 +1,150 @@ +package com.fopost.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fopost.sdk.model.GoogleKeyword; +import com.fopost.sdk.model.GoogleOptimizationScore; +import com.fopost.sdk.model.GoogleQueryResult; +import com.fopost.sdk.model.GoogleRecommendation; +import com.fopost.sdk.param.GoogleAdsScope; +import com.fopost.sdk.param.GoogleKeywordParams; +import java.util.List; +import org.junit.jupiter.api.Test; + +class GoogleAdsTest { + + private static final GoogleAdsScope SCOPE = GoogleAdsScope.of("c1", "1234567890"); + + @Test + void keywordsNameTheConnectionAndTheCustomer() { + FakeTransport transport = new FakeTransport() + .enqueue(200, """ + {"data":[{"id":"1234567890~keyword~77~99","adGroupId":"1234567890~adGroup~77", + "text":"running shoes","matchType":"EXACT","status":"ENABLED", + "cpcBidMinor":180,"negative":false}]}"""); + + List keywords = TestSupport.client(transport) + .googleAds() + .keywords(SCOPE, "1234567890~adGroup~77"); + + assertEquals("running shoes", keywords.get(0).text()); + assertEquals(180L, keywords.get(0).cpcBidMinor()); + String url = transport.last().url(); + assertTrue(url.contains("connection_id=c1"), url); + assertTrue(url.contains("customer_id=1234567890"), url); + assertTrue(url.contains("ad_group_id=1234567890%7EadGroup%7E77"), url); + } + + @Test + void createKeywordSendsTheScopeInACamelCaseBody() { + FakeTransport transport = new FakeTransport().enqueue(201, """ + {"data":{"id":"1234567890~keyword~77~99"}}"""); + + String id = TestSupport.client(transport) + .googleAds() + .createKeyword(new GoogleKeywordParams.Create( + GoogleAdsScope.of("c1", "1234567890").workspace("w1"), + "1234567890~adGroup~77", + "running shoes", + "EXACT") + .cpcBidMinor(180)); + + assertEquals("1234567890~keyword~77~99", id); + assertEquals("https://api.fopost.test/v1/ads/google/keywords", transport.last().url()); + assertEquals( + "{\"workspaceId\":\"w1\",\"connectionId\":\"c1\",\"customerId\":\"1234567890\"," + + "\"adGroupId\":\"1234567890~adGroup~77\",\"text\":\"running shoes\"," + + "\"matchType\":\"EXACT\",\"cpcBidMinor\":180}", + transport.lastBody()); + } + + @Test + void recommendationsJoinTheTypesFilter() { + FakeTransport transport = new FakeTransport() + .enqueue(200, """ + {"data":[{"id":"customers/1234567890/recommendations/ABC~1","type":"KEYWORD", + "campaignId":"1234567890~campaign~55","adGroupId":null, + "dismissed":false, + "impact":{"baseClicks":10,"potentialClicks":25, + "baseCostMinor":100,"potentialCostMinor":250, + "baseConversions":1,"potentialConversions":3}}]}"""); + + List rows = TestSupport.client(transport) + .googleAds() + .recommendations(SCOPE, List.of("KEYWORD", "TARGET_CPA_OPT_IN")); + + assertEquals("KEYWORD", rows.get(0).type()); + assertEquals(25.0, rows.get(0).impact().potentialClicks()); + assertFalse(rows.get(0).dismissed()); + assertTrue(transport.last().url().contains("types=KEYWORD%2CTARGET_CPA_OPT_IN")); + } + + @Test + void recommendationsOmitTheTypesFilterWhenNoneAreGiven() { + FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":[]}"); + + TestSupport.client(transport).googleAds().recommendations(SCOPE); + + assertFalse(transport.last().url().contains("types="), transport.last().url()); + } + + @Test + void applyRecommendationsSendsTheIdsAndCountsWhatLanded() { + FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":{\"applied\":1}}"); + + int applied = TestSupport.client(transport) + .googleAds() + .applyRecommendations( + GoogleAdsScope.of("c1", "1234567890").workspace("w1"), + List.of("customers/1234567890/recommendations/ABC~1")); + + assertEquals(1, applied); + assertEquals( + "https://api.fopost.test/v1/ads/google/recommendations/apply", + transport.last().url()); + assertTrue(transport.lastBody().contains("customers/1234567890/recommendations/ABC~1")); + } + + @Test + void optimizationScoreReadsTheAccountAndItsCampaigns() { + FakeTransport transport = new FakeTransport() + .enqueue(200, """ + {"data":{"score":0.82,"weight":1.0, + "campaigns":[{"id":"1234567890~campaign~55","name":"Search","score":0.75}]}}"""); + + GoogleOptimizationScore score = + TestSupport.client(transport).googleAds().optimizationScore(SCOPE); + + assertEquals(0.82, score.score()); + assertEquals("Search", score.campaigns().get(0).name()); + } + + @Test + void queryReturnsRowsAsGoogleSendsThem() { + FakeTransport transport = new FakeTransport() + .enqueue(200, """ + {"data":{"rows":[{"campaign":{"id":"55"}}]}}"""); + + GoogleQueryResult result = TestSupport.client(transport) + .googleAds() + .query(SCOPE, "SELECT campaign.id FROM campaign"); + + assertEquals(1, result.rows().size()); + assertEquals("https://api.fopost.test/v1/ads/insights/query", transport.last().url()); + } + + @Test + void authorizeGoogleGoesThroughTheGenericProviderRoute() { + FakeTransport transport = new FakeTransport() + .enqueue(200, "{\"data\":{\"url\":\"https://accounts.google.com/o/x\"}}"); + + String url = TestSupport.client(transport).ads().authorize("google", "w1"); + + assertEquals("https://accounts.google.com/o/x", url); + assertEquals( + "https://api.fopost.test/v1/ads/connections/google/authorize", + transport.last().url()); + } +}