From ea061c5142f9457ad146b85f5cd578b223d48a5c Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 03:36:13 +0200 Subject: [PATCH] feat(ads): support a second ad network providers() and authorize(provider, ...) reach whichever networks a deployment knows; authorizeMeta stays as a deprecated alias. Company-list audiences, LinkedIn's forecasts, conversion rules and the public ad library are wrapped. --- README.md | 2 +- .../com/fopost/sdk/model/AdLibraryAd.java | 23 +++ .../com/fopost/sdk/model/AdLibraryPage.java | 6 + .../java/com/fopost/sdk/model/AdProvider.java | 21 +++ .../com/fopost/sdk/model/AdTrackingMacro.java | 4 + .../java/com/fopost/sdk/model/BidPricing.java | 9 + .../fopost/sdk/model/ConversionMetrics.java | 9 + .../com/fopost/sdk/model/ConversionRule.java | 21 +++ .../com/fopost/sdk/model/SupplyForecast.java | 13 ++ .../com/fopost/sdk/param/AdCompanyParams.java | 59 ++++++ .../fopost/sdk/param/AdForecastParams.java | 53 ++++++ .../com/fopost/sdk/param/AdLibraryParams.java | 60 +++++++ .../sdk/param/ConversionEventParams.java | 49 +++++ .../sdk/param/CreateConversionRuleParams.java | 54 ++++++ .../sdk/param/UpdateConversionRuleParams.java | 54 ++++++ .../com/fopost/sdk/resource/AdsResource.java | 170 +++++++++++++++++- .../java/com/fopost/sdk/AdsNetworksTest.java | 81 +++++++++ 17 files changed, 680 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/fopost/sdk/model/AdLibraryAd.java create mode 100644 src/main/java/com/fopost/sdk/model/AdLibraryPage.java create mode 100644 src/main/java/com/fopost/sdk/model/AdProvider.java create mode 100644 src/main/java/com/fopost/sdk/model/AdTrackingMacro.java create mode 100644 src/main/java/com/fopost/sdk/model/BidPricing.java create mode 100644 src/main/java/com/fopost/sdk/model/ConversionMetrics.java create mode 100644 src/main/java/com/fopost/sdk/model/ConversionRule.java create mode 100644 src/main/java/com/fopost/sdk/model/SupplyForecast.java create mode 100644 src/main/java/com/fopost/sdk/param/AdCompanyParams.java create mode 100644 src/main/java/com/fopost/sdk/param/AdForecastParams.java create mode 100644 src/main/java/com/fopost/sdk/param/AdLibraryParams.java create mode 100644 src/main/java/com/fopost/sdk/param/ConversionEventParams.java create mode 100644 src/main/java/com/fopost/sdk/param/CreateConversionRuleParams.java create mode 100644 src/main/java/com/fopost/sdk/param/UpdateConversionRuleParams.java create mode 100644 src/test/java/com/fopost/sdk/AdsNetworksTest.java diff --git a/README.md b/README.md index 343931c..c3e30e5 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | `media()` | `list`, `upload`, `presign`, `complete`, `uploadDirect`, `delete` | | `ai()` | `credits`, `generateCaption`, `rewrite`, `repurposeUrl` | | `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`, `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` | +| `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`, `adLibrary` | | `validate()` | `post`, `length`, `media` | `accounts().communities()` covers the X communities an account can post into: `list`, `sync`, diff --git a/src/main/java/com/fopost/sdk/model/AdLibraryAd.java b/src/main/java/com/fopost/sdk/model/AdLibraryAd.java new file mode 100644 index 0000000..0675429 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/AdLibraryAd.java @@ -0,0 +1,23 @@ +package com.fopost.sdk.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * A public ad from the network's own library, never a connection's own data. {@code payer} is the + * paying entity, where the network discloses one. + */ +public record AdLibraryAd( + String id, + String advertiserName, + String advertiserUrl, + String headline, + String body, + @JsonProperty("type") String adType, + String thumbnailUrl, + String firstImpressionAt, + String lastImpressionAt, + List countries, + String detailsUrl, + String payer, + String impressionsRange) {} diff --git a/src/main/java/com/fopost/sdk/model/AdLibraryPage.java b/src/main/java/com/fopost/sdk/model/AdLibraryPage.java new file mode 100644 index 0000000..c698235 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/AdLibraryPage.java @@ -0,0 +1,6 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** One page of ad-library results; pass {@code nextCursor} back as the cursor. */ +public record AdLibraryPage(List ads, String nextCursor) {} diff --git a/src/main/java/com/fopost/sdk/model/AdProvider.java b/src/main/java/com/fopost/sdk/model/AdProvider.java new file mode 100644 index 0000000..0e41ec7 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/AdProvider.java @@ -0,0 +1,21 @@ +package com.fopost.sdk.model; + +import java.util.List; +import java.util.Map; + +/** + * An ad network from the API's registry. {@code configured} false cannot be connected yet. + * + *

{@code capabilities} says what the network supports — campaigns, audiences, conversions, + * forecasts, adLibrary and so on. {@code targetingFacets} is what {@code searchTargeting} accepts + * here, and {@code trackingMacros} what the network expands in a creative's tracking parameters. + */ +public record AdProvider( + String id, + String name, + String logo, + Boolean configured, + List connectMethods, + Map capabilities, + List targetingFacets, + List trackingMacros) {} diff --git a/src/main/java/com/fopost/sdk/model/AdTrackingMacro.java b/src/main/java/com/fopost/sdk/model/AdTrackingMacro.java new file mode 100644 index 0000000..f22e873 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/AdTrackingMacro.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** A token a network expands in a link's tracking parameters at delivery time. */ +public record AdTrackingMacro(String token, String description) {} diff --git a/src/main/java/com/fopost/sdk/model/BidPricing.java b/src/main/java/com/fopost/sdk/model/BidPricing.java new file mode 100644 index 0000000..053d4c5 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BidPricing.java @@ -0,0 +1,9 @@ +package com.fopost.sdk.model; + +/** What the auction costs, in minor units of the ad account currency. */ +public record BidPricing( + String currency, + Long suggestedBidMinor, + Long minBidMinor, + Long maxBidMinor, + Long dailyBudgetFloorMinor) {} diff --git a/src/main/java/com/fopost/sdk/model/ConversionMetrics.java b/src/main/java/com/fopost/sdk/model/ConversionMetrics.java new file mode 100644 index 0000000..d49061c --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/ConversionMetrics.java @@ -0,0 +1,9 @@ +package com.fopost.sdk.model; + +/** What a conversion rule recorded over a date range. */ +public record ConversionMetrics( + Integer conversions, + Integer postClickConversions, + Integer viewThroughConversions, + Long valueMinor, + Long costPerConversionMinor) {} diff --git a/src/main/java/com/fopost/sdk/model/ConversionRule.java b/src/main/java/com/fopost/sdk/model/ConversionRule.java new file mode 100644 index 0000000..0f9ae6d --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/ConversionRule.java @@ -0,0 +1,21 @@ +package com.fopost.sdk.model; + +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * How the network attributes a sale or a sign-up back to an ad set. {@code campaignIds} are the ad + * sets this rule is attached to. + */ +public record ConversionRule( + String id, + String name, + @JsonProperty("type") String conversionType, + String attribution, + Integer postClickWindowDays, + Integer viewThroughWindowDays, + Long valueMinor, + String currency, + Boolean enabled, + String createdAt, + List campaignIds) {} diff --git a/src/main/java/com/fopost/sdk/model/SupplyForecast.java b/src/main/java/com/fopost/sdk/model/SupplyForecast.java new file mode 100644 index 0000000..39e24e3 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/SupplyForecast.java @@ -0,0 +1,13 @@ +package com.fopost.sdk.model; + +/** + * What an audience would deliver at a budget, over the network's own window. {@code ready} is + * false while the network has no answer for that audience. + */ +public record SupplyForecast( + String currency, + Long impressions, + Long clicks, + Long spendMinor, + Long windowDays, + Boolean ready) {} diff --git a/src/main/java/com/fopost/sdk/param/AdCompanyParams.java b/src/main/java/com/fopost/sdk/param/AdCompanyParams.java new file mode 100644 index 0000000..8347bce --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/AdCompanyParams.java @@ -0,0 +1,59 @@ +package com.fopost.sdk.param; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * One row of a company-list upload. At least one of name, domain, page url or ticker is required; + * the rows travel with the request and are never stored. + */ +public final class AdCompanyParams { + + private final Map body = new LinkedHashMap<>(); + + private AdCompanyParams() {} + + public static AdCompanyParams named(String name) { + AdCompanyParams params = new AdCompanyParams(); + params.body.put("name", name); + return params; + } + + public static AdCompanyParams domain(String domain) { + AdCompanyParams params = new AdCompanyParams(); + params.body.put("domain", domain); + return params; + } + + /** The company's page on the network. */ + public static AdCompanyParams pageUrl(String pageUrl) { + AdCompanyParams params = new AdCompanyParams(); + params.body.put("pageUrl", pageUrl); + return params; + } + + public AdCompanyParams withName(String name) { + body.put("name", name); + return this; + } + + public AdCompanyParams withDomain(String domain) { + body.put("domain", domain); + return this; + } + + /** Stock ticker, where the network matches on one. */ + public AdCompanyParams withTicker(String ticker) { + body.put("ticker", ticker); + return this; + } + + public AdCompanyParams withCountry(String country) { + body.put("country", country); + return this; + } + + public Map toMap() { + return new LinkedHashMap<>(body); + } +} diff --git a/src/main/java/com/fopost/sdk/param/AdForecastParams.java b/src/main/java/com/fopost/sdk/param/AdForecastParams.java new file mode 100644 index 0000000..fc7783a --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/AdForecastParams.java @@ -0,0 +1,53 @@ +package com.fopost.sdk.param; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The shared body of a bid-pricing or supply-forecast request. {@code bidType} applies to bid + * pricing only, {@code budgetMinor} to the supply forecast only. + */ +public final class AdForecastParams { + + private final Map body = new LinkedHashMap<>(); + + private AdForecastParams() {} + + /** {@code goal} is engagement, traffic, awareness or video_views. */ + public static AdForecastParams of( + String workspaceId, + String connectionId, + String adAccountId, + String goal, + AdTargetingParams targeting) { + AdForecastParams params = new AdForecastParams(); + params.body.put("workspaceId", workspaceId); + params.body.put("connectionId", connectionId); + params.body.put("adAccountId", adAccountId); + params.body.put("goal", goal); + params.body.put("targeting", targeting.toMap()); + return params; + } + + public AdForecastParams placements(List placements) { + body.put("placements", List.copyOf(placements)); + return this; + } + + /** CPC, CPM or CPV. Bid pricing only. */ + public AdForecastParams bidType(String bidType) { + body.put("bidType", bidType); + return this; + } + + /** The budget for the forecast window, minor units. Supply forecast only. */ + public AdForecastParams budgetMinor(long budgetMinor) { + body.put("budgetMinor", budgetMinor); + return this; + } + + public Map toMap() { + return new LinkedHashMap<>(body); + } +} diff --git a/src/main/java/com/fopost/sdk/param/AdLibraryParams.java b/src/main/java/com/fopost/sdk/param/AdLibraryParams.java new file mode 100644 index 0000000..ee34c2f --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/AdLibraryParams.java @@ -0,0 +1,60 @@ +package com.fopost.sdk.param; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** What an ad-library search narrows on. Dates are YYYY-MM-DD. */ +public final class AdLibraryParams { + + private final Map query = new LinkedHashMap<>(); + + private AdLibraryParams() {} + + public static AdLibraryParams on(String connectionId) { + AdLibraryParams params = new AdLibraryParams(); + params.query.put("connection_id", connectionId); + return params; + } + + public AdLibraryParams workspaceId(String workspaceId) { + query.put("workspace_id", workspaceId); + return this; + } + + public AdLibraryParams keyword(String keyword) { + query.put("keyword", keyword); + return this; + } + + public AdLibraryParams advertiser(String advertiser) { + query.put("advertiser", advertiser); + return this; + } + + /** ISO 3166-1 alpha-2 codes. */ + public AdLibraryParams countries(List countries) { + query.put("countries", String.join(",", countries)); + return this; + } + + public AdLibraryParams since(String since) { + query.put("since", since); + return this; + } + + public AdLibraryParams until(String until) { + query.put("until", until); + return this; + } + + /** The {@code nextCursor} from the previous page. */ + public AdLibraryParams cursor(String cursor) { + query.put("cursor", cursor); + return this; + } + + public Map toQuery() { + return new LinkedHashMap<>(query); + } +} diff --git a/src/main/java/com/fopost/sdk/param/ConversionEventParams.java b/src/main/java/com/fopost/sdk/param/ConversionEventParams.java new file mode 100644 index 0000000..745bcba --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/ConversionEventParams.java @@ -0,0 +1,49 @@ +package com.fopost.sdk.param; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * One conversion sent back to the network. It needs an email or a click id; the address is hashed + * inside the API, so the network never receives it and nothing about an event is stored. + */ +public final class ConversionEventParams { + + private final Map body = new LinkedHashMap<>(); + + private ConversionEventParams() {} + + /** {@code happenedAt} is epoch milliseconds. */ + public static ConversionEventParams at(long happenedAt) { + ConversionEventParams params = new ConversionEventParams(); + params.body.put("happenedAt", happenedAt); + return params; + } + + public ConversionEventParams email(String email) { + body.put("email", email); + return this; + } + + /** The network's click id, as the landing page received it. */ + public ConversionEventParams clickId(String clickId) { + body.put("clickId", clickId); + return this; + } + + public ConversionEventParams value(long valueMinor, String currency) { + body.put("valueMinor", valueMinor); + body.put("currency", currency); + return this; + } + + /** Your own id for the event, so a replay is counted once. */ + public ConversionEventParams eventId(String eventId) { + body.put("eventId", eventId); + return this; + } + + public Map toMap() { + return new LinkedHashMap<>(body); + } +} diff --git a/src/main/java/com/fopost/sdk/param/CreateConversionRuleParams.java b/src/main/java/com/fopost/sdk/param/CreateConversionRuleParams.java new file mode 100644 index 0000000..b967e59 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/CreateConversionRuleParams.java @@ -0,0 +1,54 @@ +package com.fopost.sdk.param; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** A new conversion rule on one ad account. */ +public final class CreateConversionRuleParams { + + private final Map body = new LinkedHashMap<>(); + + private CreateConversionRuleParams() {} + + /** + * {@code type} is purchase, lead, sign_up, add_to_cart, download, install, key_page_view or + * other; {@code attribution} is last_touch or each_campaign. + */ + public static CreateConversionRuleParams of( + String workspaceId, + String connectionId, + String adAccountId, + String name, + String type, + String attribution) { + CreateConversionRuleParams params = new CreateConversionRuleParams(); + params.body.put("workspaceId", workspaceId); + params.body.put("connectionId", connectionId); + params.body.put("adAccountId", adAccountId); + params.body.put("name", name); + params.body.put("type", type); + params.body.put("attribution", attribution); + return params; + } + + public CreateConversionRuleParams postClickWindowDays(int days) { + body.put("postClickWindowDays", days); + return this; + } + + public CreateConversionRuleParams viewThroughWindowDays(int days) { + body.put("viewThroughWindowDays", days); + return this; + } + + /** What one conversion is worth, minor units. */ + public CreateConversionRuleParams value(long valueMinor, String currency) { + body.put("valueMinor", valueMinor); + body.put("currency", currency); + return this; + } + + public Map toMap() { + return new LinkedHashMap<>(body); + } +} diff --git a/src/main/java/com/fopost/sdk/param/UpdateConversionRuleParams.java b/src/main/java/com/fopost/sdk/param/UpdateConversionRuleParams.java new file mode 100644 index 0000000..8a63eb3 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/UpdateConversionRuleParams.java @@ -0,0 +1,54 @@ +package com.fopost.sdk.param; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Changes to a conversion rule. Only the fields you set move. */ +public final class UpdateConversionRuleParams { + + private final Map body = new LinkedHashMap<>(); + + public static UpdateConversionRuleParams create() { + return new UpdateConversionRuleParams(); + } + + public UpdateConversionRuleParams name(String name) { + body.put("name", name); + return this; + } + + public UpdateConversionRuleParams type(String type) { + body.put("type", type); + return this; + } + + public UpdateConversionRuleParams attribution(String attribution) { + body.put("attribution", attribution); + return this; + } + + public UpdateConversionRuleParams postClickWindowDays(int days) { + body.put("postClickWindowDays", days); + return this; + } + + public UpdateConversionRuleParams viewThroughWindowDays(int days) { + body.put("viewThroughWindowDays", days); + return this; + } + + public UpdateConversionRuleParams value(long valueMinor, String currency) { + body.put("valueMinor", valueMinor); + body.put("currency", currency); + return this; + } + + public UpdateConversionRuleParams enabled(boolean enabled) { + body.put("enabled", enabled); + return this; + } + + public Map toMap() { + return new LinkedHashMap<>(body); + } +} diff --git a/src/main/java/com/fopost/sdk/resource/AdsResource.java b/src/main/java/com/fopost/sdk/resource/AdsResource.java index 96d54c4..bcfa068 100644 --- a/src/main/java/com/fopost/sdk/resource/AdsResource.java +++ b/src/main/java/com/fopost/sdk/resource/AdsResource.java @@ -7,12 +7,17 @@ import com.fopost.sdk.model.AdConnection; import com.fopost.sdk.model.AdCreative; import com.fopost.sdk.model.AdInsightsReport; +import com.fopost.sdk.model.AdLibraryPage; +import com.fopost.sdk.model.AdProvider; import com.fopost.sdk.model.AdSet; import com.fopost.sdk.model.AdSource; import com.fopost.sdk.model.Audience; import com.fopost.sdk.model.AudiencesResult; import com.fopost.sdk.model.BoostablePost; +import com.fopost.sdk.model.BidPricing; import com.fopost.sdk.model.BulkAdStatusResult; +import com.fopost.sdk.model.ConversionMetrics; +import com.fopost.sdk.model.ConversionRule; import com.fopost.sdk.model.CreatedAudience; import com.fopost.sdk.model.ExternalAd; import com.fopost.sdk.model.LeadFormDetail; @@ -23,15 +28,21 @@ import com.fopost.sdk.model.LeadsPage; import com.fopost.sdk.model.NetworkAd; import com.fopost.sdk.model.ReachEstimate; +import com.fopost.sdk.model.SupplyForecast; import com.fopost.sdk.model.TargetingOption; +import com.fopost.sdk.param.AdCompanyParams; +import com.fopost.sdk.param.AdForecastParams; import com.fopost.sdk.param.AdInsightsParams; +import com.fopost.sdk.param.AdLibraryParams; import com.fopost.sdk.param.BoostPostParams; import com.fopost.sdk.param.BulkAdStatusParams; import com.fopost.sdk.param.CreateAdCampaignParams; import com.fopost.sdk.param.CreateAdCreativeParams; import com.fopost.sdk.param.CreateAdParams; import com.fopost.sdk.param.CreateAdSetParams; +import com.fopost.sdk.param.ConversionEventParams; import com.fopost.sdk.param.CreateAudienceParams; +import com.fopost.sdk.param.CreateConversionRuleParams; import com.fopost.sdk.param.CreateLeadFormParams; import com.fopost.sdk.param.CreateNetworkAdParams; import com.fopost.sdk.param.LeadsFeedParams; @@ -39,6 +50,7 @@ import com.fopost.sdk.param.UpdateAdCampaignParams; import com.fopost.sdk.param.UpdateAdSetParams; import com.fopost.sdk.param.UpdateAudienceParams; +import com.fopost.sdk.param.UpdateConversionRuleParams; import com.fopost.sdk.param.UpdateNetworkAdParams; import java.util.LinkedHashMap; import java.util.List; @@ -125,16 +137,21 @@ public List sources(String workspaceId) { // ─── Connections ────────────────────────────────────────────────────────── - public String authorizeMeta(String workspaceId) { - return authorizeMeta(workspaceId, null, null); + /** The ad networks this deployment knows, with what each one supports. */ + public List providers() { + return http.convertList(ApiClient.unwrap(http.get("/v1/ads/providers", Map.of())), AdProvider.class); + } + + public String authorize(String provider, String workspaceId) { + return authorize(provider, workspaceId, null, null); } /** - * The login url for connecting a Meta Ads account. The user who calls this must finish the - * login in their own browser session. {@code method} is business or user; {@code returnTo} is - * the dashboard path to land on afterwards. + * The login url for connecting an ad network. The user who calls this must finish the login in + * their own browser session. {@code method} is one of the network's own connect methods; + * {@code returnTo} is the dashboard path to land on afterwards. */ - public String authorizeMeta(String workspaceId, String method, String returnTo) { + public String authorize(String provider, String workspaceId, String method, String returnTo) { Map body = new LinkedHashMap<>(); body.put("workspaceId", workspaceId); if (method != null) { @@ -143,7 +160,21 @@ public String authorizeMeta(String workspaceId, String method, String returnTo) if (returnTo != null) { body.put("returnTo", returnTo); } - return ApiClient.unwrap(http.post("/v1/ads/connections/meta/authorize", body)).path("url").asText(); + return ApiClient.unwrap(http.post("/v1/ads/connections/" + provider + "/authorize", body)) + .path("url") + .asText(); + } + + /** @deprecated use {@link #authorize(String, String)} with the provider id meta. */ + @Deprecated + public String authorizeMeta(String workspaceId) { + return authorize("meta", workspaceId, null, null); + } + + /** @deprecated use {@link #authorize(String, String, String, String)}. */ + @Deprecated + public String authorizeMeta(String workspaceId, String method, String returnTo) { + return authorize("meta", workspaceId, method, returnTo); } /** Also deletes every ad record FoPost created through the connection. */ @@ -459,6 +490,131 @@ public int addAudienceUsers(String audienceId, String workspaceId, String connec .asInt(); } + /** + * Add companies to a company-list audience. Returns how many the network took. The rows travel + * with the request and are never stored. + */ + public int addAudienceCompanies( + String audienceId, String workspaceId, String connectionId, List companies) { + Map body = new LinkedHashMap<>(); + body.put("companies", companies.stream().map(AdCompanyParams::toMap).toList()); + Map query = connectionQuery(workspaceId, connectionId); + return ApiClient.unwrap(http.post("/v1/ads/audiences/" + audienceId + "/companies", body, query)) + .path("added") + .asInt(); + } + + // ─── Forecasts, conversions and the public ad library ───────────────────── + + /** What the auction currently costs for that audience. */ + public BidPricing bidPricing(AdForecastParams params) { + return http.convert( + ApiClient.unwrap(http.post("/v1/ads/linkedin/bid-pricing", params.toMap())), BidPricing.class); + } + + /** What that audience would deliver at that budget. */ + public SupplyForecast supplyForecast(AdForecastParams params) { + return http.convert( + ApiClient.unwrap(http.post("/v1/ads/linkedin/supply-forecast", params.toMap())), + SupplyForecast.class); + } + + public List conversionRules(String workspaceId, String connectionId, String adAccountId) { + Map query = connectionQuery(workspaceId, connectionId); + query.put("ad_account_id", adAccountId); + return http.convertList( + ApiClient.unwrap(http.get("/v1/ads/linkedin/conversion-rules", query)), ConversionRule.class); + } + + /** Returns the new rule's id. */ + public String createConversionRule(CreateConversionRuleParams params) { + return ApiClient.unwrap(http.post("/v1/ads/linkedin/conversion-rules", params.toMap())) + .path("id") + .asText(); + } + + public ConversionRule conversionRule(String ruleId, String workspaceId, String connectionId) { + return http.convert( + ApiClient.unwrap(http.get(conversionRulePath(ruleId, ""), connectionQuery(workspaceId, connectionId))), + ConversionRule.class); + } + + public ConversionRule updateConversionRule( + String ruleId, String workspaceId, String connectionId, UpdateConversionRuleParams params) { + return http.convert( + ApiClient.unwrap(http.request( + "PATCH", + conversionRulePath(ruleId, ""), + params.toMap(), + connectionQuery(workspaceId, connectionId))), + ConversionRule.class); + } + + /** Turns the rule off; the network keeps the history. */ + public void deleteConversionRule(String ruleId, String workspaceId, String connectionId) { + http.request("DELETE", conversionRulePath(ruleId, ""), null, connectionQuery(workspaceId, connectionId)); + } + + public ConversionRule attachConversionRule( + String ruleId, String workspaceId, String connectionId, String campaignId) { + return association("POST", ruleId, workspaceId, connectionId, campaignId); + } + + public ConversionRule detachConversionRule( + String ruleId, String workspaceId, String connectionId, String campaignId) { + return association("DELETE", ruleId, workspaceId, connectionId, campaignId); + } + + /** What the rule recorded between two YYYY-MM-DD days, inclusive. */ + public ConversionMetrics conversionMetrics( + String ruleId, String workspaceId, String connectionId, String since, String until) { + Map query = connectionQuery(workspaceId, connectionId); + query.put("since", since); + query.put("until", until); + return http.convert( + ApiClient.unwrap(http.get(conversionRulePath(ruleId, "/metrics"), query)), ConversionMetrics.class); + } + + /** + * Send conversions back to the network. Returns how many it took. Each event needs an email or + * a click id; the address is hashed inside the API and nothing about an event is stored. + */ + public int sendConversionEvents( + String ruleId, String workspaceId, String connectionId, List events) { + Map body = new LinkedHashMap<>(); + body.put("events", events.stream().map(ConversionEventParams::toMap).toList()); + return ApiClient.unwrap( + http.post( + conversionRulePath(ruleId, "/events"), + body, + connectionQuery(workspaceId, connectionId))) + .path("accepted") + .asInt(); + } + + /** The network's own public ad library, not the connection's ads. */ + public AdLibraryPage adLibrary(AdLibraryParams params) { + return http.convert( + ApiClient.unwrap(http.get("/v1/ads/ad-library", params.toQuery())), AdLibraryPage.class); + } + + private ConversionRule association( + String method, String ruleId, String workspaceId, String connectionId, String campaignId) { + Map body = new LinkedHashMap<>(); + body.put("campaignId", campaignId); + return http.convert( + ApiClient.unwrap(http.request( + method, + conversionRulePath(ruleId, "/associations"), + body, + connectionQuery(workspaceId, connectionId))), + ConversionRule.class); + } + + private static String conversionRulePath(String ruleId, String suffix) { + return "/v1/ads/linkedin/conversion-rules/" + ruleId + suffix; + } + public List searchTargeting(String connectionId, String type, String q) { return searchTargeting(connectionId, type, q, null); } diff --git a/src/test/java/com/fopost/sdk/AdsNetworksTest.java b/src/test/java/com/fopost/sdk/AdsNetworksTest.java new file mode 100644 index 0000000..c279951 --- /dev/null +++ b/src/test/java/com/fopost/sdk/AdsNetworksTest.java @@ -0,0 +1,81 @@ +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.AdProvider; +import com.fopost.sdk.param.AdCompanyParams; +import com.fopost.sdk.param.ConversionEventParams; +import java.util.List; +import org.junit.jupiter.api.Test; + +class AdsNetworksTest { + + @Test + void authorizeReachesWhicheverNetworkTheRegistryNamed() { + FakeTransport transport = + new FakeTransport().enqueue(200, "{\"data\":{\"url\":\"https://www.linkedin.com/oauth\"}}"); + + String url = TestSupport.client(transport).ads().authorize("linkedin", "w1", null, "/ads"); + + assertEquals("https://api.fopost.test/v1/ads/connections/linkedin/authorize", transport.last().url()); + assertEquals("{\"workspaceId\":\"w1\",\"returnTo\":\"/ads\"}", transport.lastBody()); + assertEquals("https://www.linkedin.com/oauth", url); + } + + @Test + void providersCarryWhatEachNetworkSupports() { + FakeTransport transport = new FakeTransport() + .enqueue(200, """ + {"data":[{"id":"linkedin","name":"LinkedIn Ads","logo":"linkedin","configured":false, + "connectMethods":[],"capabilities":{"conversions":true}, + "targetingFacets":["country","job_title"], + "trackingMacros":[{"token":"{{LINKEDIN_CAMPAIGN_ID}}","description":"Campaign"}]}]}"""); + + List providers = TestSupport.client(transport).ads().providers(); + + assertEquals(1, providers.size()); + assertFalse(providers.get(0).configured()); + assertTrue(providers.get(0).capabilities().get("conversions")); + assertEquals(List.of("country", "job_title"), providers.get(0).targetingFacets()); + assertEquals("{{LINKEDIN_CAMPAIGN_ID}}", providers.get(0).trackingMacros().get(0).token()); + } + + @Test + void companyRowsTravelWithTheRequest() { + FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":{\"added\":2}}"); + + int added = TestSupport.client(transport) + .ads() + .addAudienceCompanies( + "urn:li:adSegment:44", + "w1", + "c1", + List.of(AdCompanyParams.domain("northwind.example"), AdCompanyParams.named("Contoso"))); + + assertEquals(2, added); + assertEquals( + "{\"companies\":[{\"domain\":\"northwind.example\"},{\"name\":\"Contoso\"}]}", + transport.lastBody()); + } + + @Test + void conversionEventsSendTheIdentityTheApiHashes() { + FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":{\"accepted\":1}}"); + + int accepted = TestSupport.client(transport) + .ads() + .sendConversionEvents( + "urn:li:conversion:9", + "w1", + "c1", + List.of(ConversionEventParams.at(1758326400000L).email("buyer@example.test"))); + + assertEquals(1, accepted); + assertTrue(transport + .last() + .url() + .startsWith("https://api.fopost.test/v1/ads/linkedin/conversion-rules/urn:li:conversion:9/events")); + } +}