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()); + } +}