From b66886d97228ff473ee58b902266206b98938fd8 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 02:01:29 +0200 Subject: [PATCH] feat: read an account's per-network metric set accounts().platformMetrics() answers in the platform's own vocabulary rather than the cross-network one: ad-break earnings, story taps, a retention curve, the search terms behind a listing. A metric value is Object because a series is a list where every other kind is a number; Row.number() decodes the common case. Needs the analytics scope, and a network whose access is still pending answers 503 platform_metrics_unavailable. --- README.md | 2 +- .../sdk/model/AccountPlatformMetrics.java | 32 ++++++++ .../fopost/sdk/resource/AccountsResource.java | 16 ++++ .../com/fopost/sdk/PlatformMetricsTest.java | 74 +++++++++++++++++++ 4 files changed, 123 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/fopost/sdk/model/AccountPlatformMetrics.java create mode 100644 src/test/java/com/fopost/sdk/PlatformMetricsTest.java diff --git a/README.md b/README.md index 98c2592..e14d6f8 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `posts()` | `list`, `autoPaginate`, `stream`, `get`, `create`, `update`, `delete`, `duplicate`, `publish`, `retry`, `cancel`, `preflight`, `deliveries`, `publishRuns`, `analytics`, `bulkShift`, `bulkLabel`, `bulkDelete`, `validateImport`, `commitImport`, `rollbackImport` | | `workspaces()` | `list`, `get`, `create`, `update`, `delete`, `analytics` | -| `accounts()` | `list`, `get`, `create`, `rename`, `move`, `delete`, `healthSummary`, `health`, `togglePrimary`, `validate`, `refreshToken`, `analytics`, `createTelegramConnectCode`, `getTelegramConnectStatus`, `getTelegramBotCommands`, `setTelegramBotCommands`, `deleteTelegramBotCommands`, `listSlackChannels`, `listSlackMembers`, `getSlackIdentity`, `updateSlackIdentity`, `getIceBreakers`, `setIceBreakers`, `deleteIceBreakers`, `getPersistentMenu`, `setPersistentMenu`, `deletePersistentMenu`, `getGreeting`, `setGreeting`, `deleteGreeting`, `getWebhookSubscription`, `resubscribeWebhook`, `communities()`, `listDiscordChannels`, `switchDiscordChannel`, `getDiscordIdentity`, `updateDiscordIdentity`, `listDiscordPins`, `deleteDiscordMessage`, `pinDiscordMessage`, `unpinDiscordMessage`, `crosspostDiscordMessage`, `createDiscordThread`, `sendDiscordDm`, `listDiscordEvents`, `getDiscordEvent`, `createDiscordEvent`, `updateDiscordEvent`, `deleteDiscordEvent`, `listDiscordMembers`, `getDiscordMember`, `listDiscordRoles`, `createDiscordRole`, `updateDiscordRole`, `deleteDiscordRole`, `addDiscordMemberRole`, `removeDiscordMemberRole` | +| `accounts()` | `list`, `get`, `create`, `rename`, `move`, `delete`, `healthSummary`, `health`, `togglePrimary`, `validate`, `refreshToken`, `analytics`, `createTelegramConnectCode`, `getTelegramConnectStatus`, `getTelegramBotCommands`, `setTelegramBotCommands`, `deleteTelegramBotCommands`, `listSlackChannels`, `listSlackMembers`, `getSlackIdentity`, `updateSlackIdentity`, `getIceBreakers`, `setIceBreakers`, `deleteIceBreakers`, `getPersistentMenu`, `setPersistentMenu`, `deletePersistentMenu`, `getGreeting`, `setGreeting`, `deleteGreeting`, `getWebhookSubscription`, `resubscribeWebhook`, `communities()`, `listDiscordChannels`, `switchDiscordChannel`, `getDiscordIdentity`, `updateDiscordIdentity`, `listDiscordPins`, `deleteDiscordMessage`, `pinDiscordMessage`, `unpinDiscordMessage`, `crosspostDiscordMessage`, `createDiscordThread`, `sendDiscordDm`, `listDiscordEvents`, `getDiscordEvent`, `createDiscordEvent`, `updateDiscordEvent`, `deleteDiscordEvent`, `listDiscordMembers`, `getDiscordMember`, `listDiscordRoles`, `createDiscordRole`, `updateDiscordRole`, `deleteDiscordRole`, `addDiscordMemberRole`, `removeDiscordMemberRole`, `platformMetrics` | | `accountGroups()` | `list`, `get`, `create`, `update`, `delete`, `setMembers` | | `labels()` | `list`, `get`, `create`, `update`, `delete` | | `webhooks()` | `list`, `create`, `update`, `delete`, `test` | diff --git a/src/main/java/com/fopost/sdk/model/AccountPlatformMetrics.java b/src/main/java/com/fopost/sdk/model/AccountPlatformMetrics.java new file mode 100644 index 0000000..a2a9a18 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/AccountPlatformMetrics.java @@ -0,0 +1,32 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** + * What only this network reports, in its own vocabulary: ad-break earnings, story taps, a + * retention curve, the search terms behind a listing. + */ +public record AccountPlatformMetrics(String platform, Block account, Block post) { + + /** + * One side of the set: the account itself, or its newest measured post. + * {@code externalPostId} is null on the account side. + */ + public record Block(String fetchedAt, String externalPostId, List metrics) {} + + /** + * One metric a network reports under its own name. {@code key} is the platform's own name + * and is stable; {@code label} is ours and may be reworded, so match on {@code key}. + * {@code kind} is one of count, duration_ms, currency_usd, ratio, series. + * + *

{@code value} is a {@link Number} for every kind but series, which is a list of points, + * so it is declared as {@link Object} and {@link #number()} decodes the common case. + */ + public record Row(String key, String label, String kind, Object value) { + + /** The value as a number, or null for a series or a non-numeric answer. */ + public Double number() { + return value instanceof Number n ? n.doubleValue() : null; + } + } +} diff --git a/src/main/java/com/fopost/sdk/resource/AccountsResource.java b/src/main/java/com/fopost/sdk/resource/AccountsResource.java index f3741d9..05fa483 100644 --- a/src/main/java/com/fopost/sdk/resource/AccountsResource.java +++ b/src/main/java/com/fopost/sdk/resource/AccountsResource.java @@ -7,6 +7,7 @@ import com.fopost.sdk.model.Account; import com.fopost.sdk.model.AccountAnalyticsHistory; import com.fopost.sdk.model.AccountHealth; +import com.fopost.sdk.model.AccountPlatformMetrics; import com.fopost.sdk.model.AccountValidation; import com.fopost.sdk.model.AccountsHealthSummary; import com.fopost.sdk.model.DiscordAck; @@ -159,6 +160,21 @@ public AccountHealth health(String accountId, boolean refresh) { ApiClient.unwrap(http.get("/v1/accounts/" + accountId + "/health", params)), AccountHealth.class); } + /** + * The numbers only this account's network reports, in its own vocabulary: ad-break + * earnings, story taps, a retention curve, the search terms behind a listing. Keyed by + * the platform's own metric names, read from the newest collected snapshot rather than + * fetched live. Needs the {@code analytics} scope. + * + *

A network whose metric access has not been granted yet answers 503 + * ({@code platform_metrics_unavailable}) rather than an empty set. + */ + public AccountPlatformMetrics platformMetrics(String accountId) { + return http.convert( + ApiClient.unwrap(http.get("/v1/accounts/" + accountId + "/insights", query("raw", "true"))), + AccountPlatformMetrics.class); + } + /** Make this the account a post targets by default, or clear the flag. Returns the new state. */ public boolean togglePrimary(String accountId) { JsonNode data = ApiClient.unwrap(http.post("/v1/accounts/" + accountId + "/primary", null)); diff --git a/src/test/java/com/fopost/sdk/PlatformMetricsTest.java b/src/test/java/com/fopost/sdk/PlatformMetricsTest.java new file mode 100644 index 0000000..c95345b --- /dev/null +++ b/src/test/java/com/fopost/sdk/PlatformMetricsTest.java @@ -0,0 +1,74 @@ +package com.fopost.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fopost.sdk.model.AccountPlatformMetrics; +import java.util.List; +import org.junit.jupiter.api.Test; + +class PlatformMetricsTest { + + @Test + void platformMetricsAsksForRawAndDecodesTheSet() { + FakeTransport transport = new FakeTransport() + .enqueue(200, """ + {"data":{"platform":"facebook", + "account":{"fetched_at":"2026-09-20T02:00:00.000Z","metrics":[ + {"key":"page_daily_video_ad_break_earnings","label":"Ad Break Earnings", + "kind":"currency_usd","value":42.15}, + {"key":"page_impressions_paid","label":"Paid Impressions", + "kind":"count","value":1500}]}, + "post":{"external_post_id":"123_456","fetched_at":"2026-09-20T02:00:00.000Z", + "metrics":[]}}}"""); + + AccountPlatformMetrics metrics = TestSupport.client(transport).accounts().platformMetrics("a1"); + + assertEquals("GET", transport.last().method()); + assertEquals("https://api.fopost.test/v1/accounts/a1/insights?raw=true", transport.last().url()); + assertEquals("facebook", metrics.platform()); + assertEquals("2026-09-20T02:00:00.000Z", metrics.account().fetchedAt()); + assertEquals( + List.of("page_daily_video_ad_break_earnings", "page_impressions_paid"), + metrics.account().metrics().stream().map(AccountPlatformMetrics.Row::key).toList()); + assertEquals(42.15, metrics.account().metrics().get(0).number()); + assertEquals("123_456", metrics.post().externalPostId()); + assertTrue(metrics.post().metrics().isEmpty()); + } + + @Test + void aSeriesValueSurvivesAsAList() { + FakeTransport transport = new FakeTransport() + .enqueue(200, """ + {"data":{"platform":"youtube", + "account":{"fetched_at":null,"metrics":[ + {"key":"daily_views","label":"Views by Day","kind":"series", + "value":[{"day":"2026-09-19","views":600}]}]}, + "post":{"external_post_id":null,"fetched_at":null,"metrics":[]}}}"""); + + AccountPlatformMetrics metrics = TestSupport.client(transport).accounts().platformMetrics("a1"); + AccountPlatformMetrics.Row row = metrics.account().metrics().get(0); + + assertNull(row.number()); + assertTrue(row.value() instanceof List); + assertNull(metrics.account().fetchedAt()); + } + + @Test + void aPendingMetricGrantThrows() { + FakeTransport transport = new FakeTransport() + .enqueue(503, """ + {"error":"platform_metrics_unavailable", + "message":"google-business metrics are not available on this deployment yet."}"""); + + FoPostException error = assertThrows(FoPostException.class, + () -> TestSupport.client(transport, 1, new java.util.ArrayList<>()) + .accounts() + .platformMetrics("a1")); + + assertEquals(503, error.status()); + assertEquals("platform_metrics_unavailable", error.code()); + } +}