diff --git a/README.md b/README.md index e14d6f8..aed7dda 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | `knowledge()` | `list`, `create`, `createText`, `createUrl`, `createFile`, `update`, `delete`, `sync`, `search` | | `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`, `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` | | `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` | 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/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 e5d3b36..cc1b2c9 100644 --- a/src/main/java/com/fopost/sdk/resource/AdsResource.java +++ b/src/main/java/com/fopost/sdk/resource/AdsResource.java @@ -4,7 +4,6 @@ import com.fopost.sdk.model.Ad; import com.fopost.sdk.model.AdActivity; import com.fopost.sdk.model.AdLabel; -import com.fopost.sdk.model.AdLibraryPage; import com.fopost.sdk.model.AdStudy; import com.fopost.sdk.model.CatalogBatchResult; import com.fopost.sdk.model.CatalogProductsPage; @@ -39,12 +38,17 @@ import com.fopost.sdk.model.AdCreative; import com.fopost.sdk.model.AdIdentity; 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; @@ -56,7 +60,10 @@ import com.fopost.sdk.model.NetworkAd; import com.fopost.sdk.model.ReachEstimate; import com.fopost.sdk.model.SparkPost; +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.BoostPostParams; import com.fopost.sdk.param.BulkAdStatusParams; @@ -64,7 +71,9 @@ 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; @@ -73,6 +82,7 @@ import com.fopost.sdk.param.UpdateAdSetParams; import com.fopost.sdk.param.UploadConversionsParams; 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; @@ -160,16 +170,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) { @@ -178,7 +193,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. */ @@ -494,6 +523,126 @@ 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(); + } + + + 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")); + } +}