diff --git a/CHANGELOG.md b/CHANGELOG.md index e334c50..3851a85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,19 @@ All notable changes to this project are documented here. The format follows ### Added +- `blog()`: articles for a website. `getSettings`, `updateSettings`, `rotateWebsiteSecret` + and `testWebsite`; `listKeywords`, `planKeywords`, `addKeyword`, `updateKeyword` and + `deleteKeyword`; `listArticles`, `requestArticle`, `getArticle`, `updateArticle`, + `regenerateArticle`, `publishArticle`, `rejectArticle` and `deleteArticle`, with the + `BlogSettings`, `BlogKeyword`, `BlogArticle`, `BlogArticleFull` and `BlogArticleImage` + records and the `BlogArticleStatus`, `BlogKeywordStatus` and `BlogTargetKind` constants. + Reads need `posts`; planning, requesting and regenerating spend AI credits and need `ai`; + publishing needs `publish`. +- `WebhookVerifier.verify` checks a delivery's signature over the raw bytes in constant time, + preferring the timestamped `X-FoPost-Signature-256` within 300 seconds and falling back to + `X-FoPost-Signature` when no timestamp is sent, then returns the parsed `WebhookEnvelope`. + Also reachable as `webhooks().verify`. `WebhookEvents.ARTICLE_PUBLISHED` and the + `BlogArticlePublished` record type its payload. - `media().limits(workspaceId)` reads `GET /v1/media/limits` into `UploadLimits`: the account's largest file by any route (100 MB up to 2 connected accounts, 400 MB up to 7, 2 GB up to 15, 5 GB beyond or with the Large Uploads add-on), the per-file multipart ceiling, the next tier, @@ -23,14 +36,6 @@ All notable changes to this project are documented here. The format follows - `PresignUploadParams` takes `folderId`, `origin`, `brandRole`, `brandVoiceId` and `personConsent`; `PresignedUpload.maxFileBytes` reports the ceiling at reservation time. -### Changed - -- Presign and direct upload accept sizes up to 5 GB; there is no 50 MB ceiling in the SDK. -- An oversize file is a `413` with code `file_too_large`. It surfaces as a `FoPostException` - with `status()` 413 and `max_file_bytes`, `file_bytes` and `upgrade_url` on `body()`. -- Multipart bodies and direct uploads stream from their source. `HttpRequestData` gained - `bodyStream` and `bodyLength` beside `body`; a custom `Transport` must send whichever is set. - - `InboxItem.moderationStatus` carries the platform's own state for a comment (`published`, `held`, `spam`, `rejected`), and `InboxAccount.reconnectRequired` flags an account connected before the inbox asked for a permission it needs. @@ -71,6 +76,14 @@ All notable changes to this project are documented here. The format follows `updateSlackIdentity` (`UpdateSlackIdentityParams`) for a Slack account. A webhook-connected account answers 409 `webhook_connection`. +### Changed + +- Presign and direct upload accept sizes up to 5 GB; there is no 50 MB ceiling in the SDK. +- An oversize file is a `413` with code `file_too_large`. It surfaces as a `FoPostException` + with `status()` 413 and `max_file_bytes`, `file_bytes` and `upgrade_url` on `body()`. +- Multipart bodies and direct uploads stream from their source. `HttpRequestData` gained + `bodyStream` and `bodyLength` beside `body`; a custom `Transport` must send whichever is set. + ## [0.3.0] - 2026-09-19 ### Added diff --git a/CLAUDE.md b/CLAUDE.md index 6081ace..5e14c65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -62,7 +62,8 @@ src/main/java/com/fopost/sdk/ LabelsResource, WebhooksResource, AnalyticsResource, AutomationsResource, MediaResource, AiResource, CommunitiesResource, InboxResource, AdsResource, GoogleAdsResource, ValidateResource, - BroadcastsResource, SequencesResource + BroadcastsResource, SequencesResource, BlogResource + WebhookVerifier.java signature check over the raw bytes; WebhookEnvelope is what it returns ``` **Request flow.** `client.posts().create(params)` → `PostsResource` builds the body and calls @@ -81,7 +82,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`, `googleAds`, `validate`. This is +`labels`, `webhooks`, `analytics`, `automations`, `media`, `ai`, `inbox`, `contacts`, `broadcasts`, `sequences`, `ads`, `googleAds`, `blog`, `validate`. This is the most complete of the FoPost SDKs — do not narrow it. `FoPost.request(...)` is the escape hatch for anything unwrapped. diff --git a/README.md b/README.md index 7740df0..3dde80b 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,8 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | `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` | +| `webhooks()` | `list`, `create`, `update`, `delete`, `test`, `verify` | +| `blog()` | `getSettings`, `updateSettings`, `rotateWebsiteSecret`, `testWebsite`, `listKeywords`, `planKeywords`, `addKeyword`, `updateKeyword`, `deleteKeyword`, `listArticles`, `requestArticle`, `getArticle`, `updateArticle`, `regenerateArticle`, `publishArticle`, `rejectArticle`, `deleteArticle` | | `analytics()` | `overview`, `timeSeries`, `topPosts`, `labels`, `postsTable`, `postingStreak`, `demographics`, `collect`, `decay`, `frequency`, `timeline`, `changes`, `collectPost`, `nativePosts` | | `automations()` | `list`, `get`, `create`, `update`, `delete`, `toggle`, `runs`, `run`, `trigger`, `stats` | | `media()` | `list`, `limits`, `upload`, `presign`, `complete`, `uploadDirect`, `delete` | @@ -256,6 +257,50 @@ System.out.println(hook.secret()); client.webhooks().test(hook.id()); ``` +## Blog + +A post goes to a social network; an article goes to a website. `blog()` holds the keyword plan, +the articles the writer produces, and the settings that say where they are published: +`BlogTargetKind.PRODUCT_BLOG`, a connected `ACCOUNT`, or your own `WEBSITE`, which receives each +published article as a signed webhook. + +```java +BlogArticle article = client.blog().requestArticle( + workspace.id(), + BlogParams.Article.create() + .keyword("how to schedule posts") + .instructions("Lead with the calendar view.")); + +// Poll until the status leaves queued and writing, then read the body. +BlogArticleFull draft = client.blog().getArticle(workspace.id(), article.id()); +client.blog().publishArticle(workspace.id(), article.id()); +``` + +Reads, settings and keyword edits need `posts`; `planKeywords`, `requestArticle` and +`regenerateArticle` spend AI credits and need `ai` (an empty balance is a +`PaymentRequiredException`); `publishArticle` needs `publish` and answers 409 `no_target` until +a target is set. Setting `websiteUrl` issues the site's signing secret once, on +`BlogSettingsUpdate.websiteSecret()`; `rotateWebsiteSecret` issues a new one. + +To verify a delivery, hand `WebhookVerifier` the raw request bytes, the headers and the secret. +It prefers the timestamped signature, refuses one more than 300 seconds old, and falls back to +the body-only signature when no timestamp header is present. The same call is on +`client.webhooks().verify(...)`. + +```java +byte[] rawBody = request.getInputStream().readAllBytes(); +WebhookEnvelope envelope = WebhookVerifier.verify(rawBody, headers, System.getenv("FOPOST_WEBHOOK_SECRET")); + +if (WebhookEvents.ARTICLE_PUBLISHED.equals(envelope.event())) { + BlogArticlePublished published = envelope.data(BlogArticlePublished.class); + store(published.slug(), published.bodyHtml()); +} +``` + +A failed check is a `FoPostException` with code `missing_signature`, `invalid_signature`, +`stale_timestamp` or `invalid_payload`. Image URLs in the payload are fetchable for six hours; +download and store your own copy. + ## AI features ```java diff --git a/src/main/java/com/fopost/sdk/FoPost.java b/src/main/java/com/fopost/sdk/FoPost.java index c217d74..de3ccd4 100644 --- a/src/main/java/com/fopost/sdk/FoPost.java +++ b/src/main/java/com/fopost/sdk/FoPost.java @@ -13,6 +13,7 @@ import com.fopost.sdk.resource.AiResource; import com.fopost.sdk.resource.AnalyticsResource; import com.fopost.sdk.resource.AutomationsResource; +import com.fopost.sdk.resource.BlogResource; import com.fopost.sdk.resource.BroadcastsResource; import com.fopost.sdk.resource.ContactsResource; import com.fopost.sdk.resource.InboxResource; @@ -82,6 +83,7 @@ public final class FoPost { private final InboxResource inbox; private final AdsResource ads; private final GoogleAdsResource googleAds; + private final BlogResource blog; private final ValidateResource validate; private FoPost(ApiClient http) { @@ -105,6 +107,7 @@ private FoPost(ApiClient http) { this.sequences = new SequencesResource(http); this.ads = new AdsResource(http); this.googleAds = new GoogleAdsResource(http); + this.blog = new BlogResource(http); this.validate = new ValidateResource(http); } @@ -209,6 +212,14 @@ public GoogleAdsResource googleAds() { return googleAds; } + /** + * Articles for a website: the keyword plan, the drafts the writer produces, and where they + * are published. A post goes to a social network; an article goes to a website. + */ + public BlogResource blog() { + return blog; + } + public ValidateResource validate() { return validate; } diff --git a/src/main/java/com/fopost/sdk/WebhookEnvelope.java b/src/main/java/com/fopost/sdk/WebhookEnvelope.java new file mode 100644 index 0000000..e3976ad --- /dev/null +++ b/src/main/java/com/fopost/sdk/WebhookEnvelope.java @@ -0,0 +1,25 @@ +package com.fopost.sdk; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fopost.sdk.internal.Json; + +/** + * A verified webhook delivery: the event name, its body and when it was sent. + * + *
{@code
+ * WebhookEnvelope envelope = WebhookVerifier.verify(rawBody, headers, secret);
+ * if (WebhookEvents.ARTICLE_PUBLISHED.equals(envelope.event())) {
+ *     BlogArticlePublished article = envelope.data(BlogArticlePublished.class);
+ * }
+ * }
+ */ +public record WebhookEnvelope(String event, JsonNode data, String timestamp) { + + /** The body as a typed record, such as {@link com.fopost.sdk.model.BlogArticlePublished}. */ + public T data(Class type) { + if (data == null || data.isNull() || data.isMissingNode()) { + return null; + } + return Json.MAPPER.convertValue(data, type); + } +} diff --git a/src/main/java/com/fopost/sdk/WebhookVerifier.java b/src/main/java/com/fopost/sdk/WebhookVerifier.java new file mode 100644 index 0000000..f732c8f --- /dev/null +++ b/src/main/java/com/fopost/sdk/WebhookVerifier.java @@ -0,0 +1,164 @@ +package com.fopost.sdk; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fopost.sdk.internal.Json; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.Clock; +import java.time.Duration; +import java.util.Locale; +import java.util.Map; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** + * Verifies the signature on a webhook delivery and hands back the parsed envelope. + * + *

Every delivery carries {@code X-FoPost-Timestamp} and {@code X-FoPost-Signature-256}, an + * HMAC-SHA256 over {@code .}; that pair is checked first and refused when the + * timestamp is more than {@link #TOLERANCE} from now. The original {@code X-FoPost-Signature}, over + * the body alone, is accepted only when no timestamp header is present. + * + *

Verify the exact bytes that arrived. Re-serialising parsed JSON changes key order, whitespace + * and number formatting, and the signature no longer matches. + * + *

{@code
+ * byte[] rawBody = request.getInputStream().readAllBytes();
+ * WebhookEnvelope envelope = WebhookVerifier.verify(rawBody, headers, secret);
+ * }
+ * + * A failed check is a {@link FoPostException} with code {@code missing_signature}, + * {@code invalid_signature}, {@code stale_timestamp} or {@code invalid_payload}. + */ +public final class WebhookVerifier { + + public static final String SIGNATURE_HEADER = "X-FoPost-Signature"; + public static final String TIMESTAMPED_SIGNATURE_HEADER = "X-FoPost-Signature-256"; + public static final String TIMESTAMP_HEADER = "X-FoPost-Timestamp"; + public static final String EVENT_HEADER = "X-FoPost-Event"; + public static final String DELIVERY_HEADER = "X-FoPost-Delivery"; + + /** How far a delivery's timestamp may sit from the current time. */ + public static final Duration TOLERANCE = Duration.ofSeconds(300); + + private static final String PREFIX = "sha256="; + + private WebhookVerifier() {} + + public static WebhookEnvelope verify(byte[] rawBody, Map headers, String secret) { + return verify(rawBody, headers, secret, Clock.systemUTC()); + } + + /** {@code rawBody} is the request body as received, before any parsing. */ + public static WebhookEnvelope verify(String rawBody, Map headers, String secret) { + return verify(rawBody.getBytes(StandardCharsets.UTF_8), headers, secret); + } + + /** As {@link #verify(byte[], Map, String)}, reading the current time from {@code clock}. */ + public static WebhookEnvelope verify(byte[] rawBody, Map headers, String secret, Clock clock) { + if (rawBody == null || secret == null || secret.isEmpty()) { + throw failure("missing_signature", "fopost: a body and the webhook secret are required"); + } + String timestamp = header(headers, TIMESTAMP_HEADER); + if (timestamp != null) { + String signature = header(headers, TIMESTAMPED_SIGNATURE_HEADER); + if (signature == null) { + throw failure("missing_signature", "fopost: " + TIMESTAMPED_SIGNATURE_HEADER + " header is missing"); + } + long sent; + try { + sent = Long.parseLong(timestamp.trim()); + } catch (NumberFormatException e) { + throw failure("invalid_signature", "fopost: " + TIMESTAMP_HEADER + " is not a unix time"); + } + long now = clock.instant().getEpochSecond(); + if (Math.abs(now - sent) > TOLERANCE.getSeconds()) { + throw failure("stale_timestamp", "fopost: the delivery is outside the " + TOLERANCE.getSeconds() + + " second tolerance"); + } + if (!matches(signature, sign(rawBody, secret, sent))) { + throw failure("invalid_signature", "fopost: the timestamped signature does not match the body"); + } + } else { + String signature = header(headers, SIGNATURE_HEADER); + if (signature == null) { + throw failure("missing_signature", "fopost: " + SIGNATURE_HEADER + " header is missing"); + } + if (!matches(signature, sign(rawBody, secret))) { + throw failure("invalid_signature", "fopost: the signature does not match the body"); + } + } + return parse(rawBody); + } + + /** The {@code X-FoPost-Signature} value the API sends for these bytes. */ + public static String sign(byte[] rawBody, String secret) { + return PREFIX + hex(hmac(secret, rawBody)); + } + + /** The {@code X-FoPost-Signature-256} value the API sends for these bytes at {@code timestamp}. */ + public static String sign(byte[] rawBody, String secret, long timestamp) { + byte[] prefix = (timestamp + ".").getBytes(StandardCharsets.UTF_8); + byte[] signed = new byte[prefix.length + rawBody.length]; + System.arraycopy(prefix, 0, signed, 0, prefix.length); + System.arraycopy(rawBody, 0, signed, prefix.length, rawBody.length); + return PREFIX + hex(hmac(secret, signed)); + } + + private static boolean matches(String provided, String expected) { + byte[] left = provided.trim().toLowerCase(Locale.ROOT).getBytes(StandardCharsets.UTF_8); + byte[] right = expected.getBytes(StandardCharsets.UTF_8); + return MessageDigest.isEqual(left, right); + } + + private static WebhookEnvelope parse(byte[] rawBody) { + JsonNode body; + try { + body = Json.MAPPER.readTree(rawBody); + } catch (IOException e) { + throw failure("invalid_payload", "fopost: the webhook body is not JSON"); + } + if (body == null || !body.isObject() || !body.path("event").isTextual()) { + throw failure("invalid_payload", "fopost: the webhook body has no event"); + } + return new WebhookEnvelope( + body.get("event").asText(), body.path("data"), body.path("timestamp").asText(null)); + } + + private static byte[] hmac(String secret, byte[] message) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return mac.doFinal(message); + } catch (NoSuchAlgorithmException | InvalidKeyException e) { + throw new FoPostException("fopost: HmacSHA256 is not available", e); + } + } + + private static String hex(byte[] bytes) { + StringBuilder out = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + out.append(Character.forDigit((b >> 4) & 0xF, 16)).append(Character.forDigit(b & 0xF, 16)); + } + return out.toString(); + } + + private static String header(Map headers, String name) { + if (headers == null) { + return null; + } + for (Map.Entry entry : headers.entrySet()) { + if (entry.getKey() != null && entry.getKey().equalsIgnoreCase(name) && entry.getValue() != null) { + return entry.getValue(); + } + } + return null; + } + + private static FoPostException failure(String code, String message) { + return new FoPostException(message, 0, code, null); + } +} diff --git a/src/main/java/com/fopost/sdk/model/BlogArticle.java b/src/main/java/com/fopost/sdk/model/BlogArticle.java new file mode 100644 index 0000000..b6ee92c --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BlogArticle.java @@ -0,0 +1,32 @@ +package com.fopost.sdk.model; + +import java.time.Instant; +import java.util.List; + +/** + * An article as a listing shows it. {@code status} is one of {@link BlogArticleStatus}; + * {@code origin} is {@code plan}, {@code user} or {@code agent}. {@code reviewUntil} is when a + * draft may auto-publish, {@code publishAt} where it sits on the calendar, and {@code error} the + * reason the last write or publish failed. {@link com.fopost.sdk.resource.BlogResource#getArticle} + * returns the body as well, as a {@link BlogArticleFull}. + */ +public record BlogArticle( + String id, + String title, + String slug, + String keyword, + String excerpt, + String metaTitle, + String metaDescription, + BlogArticleImage featuredImage, + List tags, + int wordCount, + String status, + String origin, + Instant reviewUntil, + Instant publishAt, + Instant publishedAt, + String publishedUrl, + String error, + Instant createdAt, + Instant updatedAt) {} diff --git a/src/main/java/com/fopost/sdk/model/BlogArticleFull.java b/src/main/java/com/fopost/sdk/model/BlogArticleFull.java new file mode 100644 index 0000000..61c7462 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BlogArticleFull.java @@ -0,0 +1,37 @@ +package com.fopost.sdk.model; + +import com.fasterxml.jackson.databind.JsonNode; +import java.time.Instant; +import java.util.List; + +/** + * An article with its body: every field of {@link BlogArticle} plus the instructions it was + * written from, the outline, the research brief, both renderings of the body and the in-body + * images. + */ +public record BlogArticleFull( + String id, + String title, + String slug, + String keyword, + String excerpt, + String metaTitle, + String metaDescription, + BlogArticleImage featuredImage, + List tags, + int wordCount, + String status, + String origin, + Instant reviewUntil, + Instant publishAt, + Instant publishedAt, + String publishedUrl, + String error, + Instant createdAt, + Instant updatedAt, + String instructions, + List outline, + JsonNode brief, + String bodyMarkdown, + String bodyHtml, + List images) {} diff --git a/src/main/java/com/fopost/sdk/model/BlogArticleImage.java b/src/main/java/com/fopost/sdk/model/BlogArticleImage.java new file mode 100644 index 0000000..f38c810 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BlogArticleImage.java @@ -0,0 +1,8 @@ +package com.fopost.sdk.model; + +/** + * An image on an article. {@code position} is the index of the outline heading the image sits + * under, and is absent on the featured image. A URL in a webhook payload is fetchable for six + * hours; download and store your own copy. + */ +public record BlogArticleImage(String mediaId, String url, String alt, Integer position) {} diff --git a/src/main/java/com/fopost/sdk/model/BlogArticlePublished.java b/src/main/java/com/fopost/sdk/model/BlogArticlePublished.java new file mode 100644 index 0000000..01fb267 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BlogArticlePublished.java @@ -0,0 +1,25 @@ +package com.fopost.sdk.model; + +import java.time.Instant; +import java.util.List; + +/** + * The {@code data} of an {@code article.published} webhook, and of the delivery a website target + * receives. Image URLs are fetchable for six hours; download and store your own copy. + */ +public record BlogArticlePublished( + String id, + String title, + String slug, + String excerpt, + String metaTitle, + String metaDescription, + String bodyMarkdown, + String bodyHtml, + BlogArticleImage featuredImage, + List images, + List tags, + String keyword, + int wordCount, + Instant publishedAt, + String publishedUrl) {} diff --git a/src/main/java/com/fopost/sdk/model/BlogArticleStatus.java b/src/main/java/com/fopost/sdk/model/BlogArticleStatus.java new file mode 100644 index 0000000..c9fc2d9 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BlogArticleStatus.java @@ -0,0 +1,21 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** The statuses an article moves through, from the request to the live page. */ +public final class BlogArticleStatus { + + private BlogArticleStatus() {} + + public static final String QUEUED = "queued"; + public static final String WRITING = "writing"; + public static final String DRAFT = "draft"; + public static final String SCHEDULED = "scheduled"; + public static final String PUBLISHING = "publishing"; + public static final String PUBLISHED = "published"; + public static final String FAILED = "failed"; + public static final String REJECTED = "rejected"; + + public static final List ALL = + List.of(QUEUED, WRITING, DRAFT, SCHEDULED, PUBLISHING, PUBLISHED, FAILED, REJECTED); +} diff --git a/src/main/java/com/fopost/sdk/model/BlogKeyword.java b/src/main/java/com/fopost/sdk/model/BlogKeyword.java new file mode 100644 index 0000000..9c3feaf --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BlogKeyword.java @@ -0,0 +1,20 @@ +package com.fopost.sdk.model; + +import java.time.Instant; +import java.time.LocalDate; + +/** + * A keyword in the plan. {@code status} is one of {@link BlogKeywordStatus}; {@code source} is + * {@code provider}, {@code user} or {@code agent}. {@code difficulty} runs 0 to 100. + */ +public record BlogKeyword( + String id, + String keyword, + Integer searchVolume, + Integer difficulty, + Double cpc, + String intent, + String source, + String status, + LocalDate plannedFor, + Instant createdAt) {} diff --git a/src/main/java/com/fopost/sdk/model/BlogKeywordStatus.java b/src/main/java/com/fopost/sdk/model/BlogKeywordStatus.java new file mode 100644 index 0000000..5a558cc --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BlogKeywordStatus.java @@ -0,0 +1,16 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** Where a keyword sits in the plan. */ +public final class BlogKeywordStatus { + + private BlogKeywordStatus() {} + + public static final String SUGGESTED = "suggested"; + public static final String PLANNED = "planned"; + public static final String WRITTEN = "written"; + public static final String DISMISSED = "dismissed"; + + public static final List ALL = List.of(SUGGESTED, PLANNED, WRITTEN, DISMISSED); +} diff --git a/src/main/java/com/fopost/sdk/model/BlogSettings.java b/src/main/java/com/fopost/sdk/model/BlogSettings.java new file mode 100644 index 0000000..6dedc11 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BlogSettings.java @@ -0,0 +1,35 @@ +package com.fopost.sdk.model; + +import java.time.Instant; + +/** + * A workspace's blog configuration. + * + *

{@code cadencePerWeek} is how many articles the planner queues each week; 0 turns planning + * off. {@code reviewHoldDays} is how long a draft waits for review before it may auto-publish. + * {@code targetKind} is one of {@link BlogTargetKind}. {@code hasWebsiteSecret} says whether a + * website target has a signing secret; the secret itself is only ever returned when issued. + * {@code keywordDataAvailable} says whether search volume and difficulty can be fetched on this + * deployment. + */ +public record BlogSettings( + String siteUrl, + String topic, + String audience, + String language, + Integer locationCode, + int cadencePerWeek, + int reviewHoldDays, + boolean autoPublish, + String imageTier, + Integer inBodyImages, + Integer minWords, + Integer maxWords, + String brandVoiceId, + String targetKind, + String targetAccountId, + String targetPlatform, + String websiteUrl, + boolean hasWebsiteSecret, + Boolean keywordDataAvailable, + Instant updatedAt) {} diff --git a/src/main/java/com/fopost/sdk/model/BlogSettingsUpdate.java b/src/main/java/com/fopost/sdk/model/BlogSettingsUpdate.java new file mode 100644 index 0000000..9987e8a --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BlogSettingsUpdate.java @@ -0,0 +1,7 @@ +package com.fopost.sdk.model; + +/** + * The answer to a settings update. {@code websiteSecret} is set only when the update set + * {@code websiteUrl} for the first time, and is never shown again: store it then. + */ +public record BlogSettingsUpdate(BlogSettings settings, String websiteSecret) {} diff --git a/src/main/java/com/fopost/sdk/model/BlogTargetKind.java b/src/main/java/com/fopost/sdk/model/BlogTargetKind.java new file mode 100644 index 0000000..02c1435 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BlogTargetKind.java @@ -0,0 +1,15 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** Where published articles go: the hosted product blog, a connected account, or your own site. */ +public final class BlogTargetKind { + + private BlogTargetKind() {} + + public static final String PRODUCT_BLOG = "product_blog"; + public static final String ACCOUNT = "account"; + public static final String WEBSITE = "website"; + + public static final List ALL = List.of(PRODUCT_BLOG, ACCOUNT, WEBSITE); +} diff --git a/src/main/java/com/fopost/sdk/model/BlogWebsiteTest.java b/src/main/java/com/fopost/sdk/model/BlogWebsiteTest.java new file mode 100644 index 0000000..f745c86 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BlogWebsiteTest.java @@ -0,0 +1,4 @@ +package com.fopost.sdk.model; + +/** The outcome of a test delivery to the website target. {@code status} is the site's HTTP answer. */ +public record BlogWebsiteTest(boolean ok, Integer status, String error) {} diff --git a/src/main/java/com/fopost/sdk/model/WebhookEvents.java b/src/main/java/com/fopost/sdk/model/WebhookEvents.java index 86552d4..959bbbd 100644 --- a/src/main/java/com/fopost/sdk/model/WebhookEvents.java +++ b/src/main/java/com/fopost/sdk/model/WebhookEvents.java @@ -14,8 +14,9 @@ private WebhookEvents() {} public static final String DELIVERY_FAILED = "delivery.failed"; public static final String DELIVERY_DELAYED = "delivery.delayed"; public static final String ACCOUNT_HEALTH_CHANGED = "account.health_changed"; + public static final String ARTICLE_PUBLISHED = "article.published"; public static final List ALL = List.of( POST_PUBLISHED, POST_FAILED, POST_PARTIALLY_FAILED, DELIVERY_PUBLISHED, - DELIVERY_FAILED, DELIVERY_DELAYED, ACCOUNT_HEALTH_CHANGED); + DELIVERY_FAILED, DELIVERY_DELAYED, ACCOUNT_HEALTH_CHANGED, ARTICLE_PUBLISHED); } diff --git a/src/main/java/com/fopost/sdk/param/BlogParams.java b/src/main/java/com/fopost/sdk/param/BlogParams.java new file mode 100644 index 0000000..b779bc7 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/BlogParams.java @@ -0,0 +1,302 @@ +package com.fopost.sdk.param; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fopost.sdk.internal.Json; +import java.time.Instant; +import java.time.LocalDate; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Request builders for {@link com.fopost.sdk.resource.BlogResource}. Bodies are camelCase on the + * wire, and a field set to null is sent as null so the API clears it. + */ +public final class BlogParams { + + private BlogParams() {} + + // A plain Map would lose its nulls: the mapper is NON_NULL, and a cleared field must reach + // the API as null. + static ObjectNode node(Map values) { + ObjectNode node = Json.MAPPER.createObjectNode(); + values.forEach((key, value) -> { + if (value == null) { + node.putNull(key); + } else { + node.set(key, Json.MAPPER.valueToTree(value)); + } + }); + return node; + } + + /** A partial update of the workspace's blog settings; only what is set is sent. */ + public static final class Settings { + + private final Map body = new LinkedHashMap<>(); + + public static Settings create() { + return new Settings(); + } + + /** The site the articles are written for; null clears it. */ + public Settings siteUrl(String siteUrl) { + body.put("siteUrl", siteUrl); + return this; + } + + public Settings topic(String topic) { + body.put("topic", topic); + return this; + } + + public Settings audience(String audience) { + body.put("audience", audience); + return this; + } + + public Settings language(String language) { + body.put("language", language); + return this; + } + + public Settings locationCode(int locationCode) { + body.put("locationCode", locationCode); + return this; + } + + /** Articles planned per week, 0 to 7; 0 turns planning off. */ + public Settings cadencePerWeek(int cadencePerWeek) { + body.put("cadencePerWeek", cadencePerWeek); + return this; + } + + /** Days a draft waits for review before it may auto-publish, 0 to 14. */ + public Settings reviewHoldDays(int reviewHoldDays) { + body.put("reviewHoldDays", reviewHoldDays); + return this; + } + + public Settings autoPublish(boolean autoPublish) { + body.put("autoPublish", autoPublish); + return this; + } + + /** {@code draft}, {@code standard} or {@code best}. */ + public Settings imageTier(String imageTier) { + body.put("imageTier", imageTier); + return this; + } + + /** Images placed in the body, 0 to 3. */ + public Settings inBodyImages(int inBodyImages) { + body.put("inBodyImages", inBodyImages); + return this; + } + + public Settings minWords(int minWords) { + body.put("minWords", minWords); + return this; + } + + public Settings maxWords(int maxWords) { + body.put("maxWords", maxWords); + return this; + } + + /** The brand the writer takes its voice from; null returns to the workspace default. */ + public Settings brandVoiceId(String brandVoiceId) { + body.put("brandVoiceId", brandVoiceId); + return this; + } + + /** One of {@link com.fopost.sdk.model.BlogTargetKind}; null leaves the blog with no target. */ + public Settings targetKind(String targetKind) { + body.put("targetKind", targetKind); + return this; + } + + public Settings targetAccountId(String targetAccountId) { + body.put("targetAccountId", targetAccountId); + return this; + } + + /** + * Where published articles are POSTed as a signed webhook. Setting it for the first time + * issues a secret, returned once on {@link com.fopost.sdk.model.BlogSettingsUpdate}. + */ + public Settings websiteUrl(String websiteUrl) { + body.put("websiteUrl", websiteUrl); + return this; + } + + public ObjectNode toNode() { + return node(body); + } + } + + /** Filters for a keyword or article listing. */ + public static final class Filter { + + private final Map query = new LinkedHashMap<>(); + + public static Filter create() { + return new Filter(); + } + + /** One or more statuses; several are joined with a comma. */ + public Filter status(String... statuses) { + query.put("status", String.join(",", statuses)); + return this; + } + + public Filter limit(int limit) { + query.put("limit", limit); + return this; + } + + public Map toQuery() { + return new LinkedHashMap<>(query); + } + } + + /** A keyword added to the plan by hand. */ + public static final class Keyword { + + private final Map body = new LinkedHashMap<>(); + + public static Keyword of(String keyword) { + Keyword params = new Keyword(); + params.body.put("keyword", keyword); + return params; + } + + /** The day the article for it should be written. */ + public Keyword plannedFor(LocalDate plannedFor) { + Params.put(body, "plannedFor", plannedFor == null ? null : plannedFor.toString()); + return this; + } + + public Map toMap() { + return new LinkedHashMap<>(body); + } + } + + /** Moves a keyword in the plan or re-dates it. */ + public static final class KeywordUpdate { + + private final Map body = new LinkedHashMap<>(); + + public static KeywordUpdate create() { + return new KeywordUpdate(); + } + + /** One of {@link com.fopost.sdk.model.BlogKeywordStatus}. */ + public KeywordUpdate status(String status) { + body.put("status", status); + return this; + } + + /** The day the article for it should be written; null takes it off the calendar. */ + public KeywordUpdate plannedFor(LocalDate plannedFor) { + body.put("plannedFor", plannedFor == null ? null : plannedFor.toString()); + return this; + } + + public ObjectNode toNode() { + return node(body); + } + } + + /** Asks the writer for an article. One of {@code title}, {@code keyword} or {@code keywordId} is required. */ + public static final class Article { + + private final Map body = new LinkedHashMap<>(); + + public static Article create() { + return new Article(); + } + + public Article title(String title) { + body.put("title", title); + return this; + } + + public Article keyword(String keyword) { + body.put("keyword", keyword); + return this; + } + + /** A keyword from the plan; its term becomes the target. */ + public Article keywordId(String keywordId) { + body.put("keywordId", keywordId); + return this; + } + + /** Up to 4000 characters of direction for the writer. */ + public Article instructions(String instructions) { + body.put("instructions", instructions); + return this; + } + + public Map toMap() { + return new LinkedHashMap<>(body); + } + } + + /** Edits a draft. Only a draft or scheduled article is editable; anything else answers 409. */ + public static final class ArticleUpdate { + + private final Map body = new LinkedHashMap<>(); + + public static ArticleUpdate create() { + return new ArticleUpdate(); + } + + public ArticleUpdate title(String title) { + body.put("title", title); + return this; + } + + /** Lowercase letters, digits and hyphens. */ + public ArticleUpdate slug(String slug) { + body.put("slug", slug); + return this; + } + + public ArticleUpdate excerpt(String excerpt) { + body.put("excerpt", excerpt); + return this; + } + + public ArticleUpdate metaTitle(String metaTitle) { + body.put("metaTitle", metaTitle); + return this; + } + + public ArticleUpdate metaDescription(String metaDescription) { + body.put("metaDescription", metaDescription); + return this; + } + + public ArticleUpdate bodyMarkdown(String bodyMarkdown) { + body.put("bodyMarkdown", bodyMarkdown); + return this; + } + + public ArticleUpdate tags(List tags) { + body.put("tags", tags); + return this; + } + + /** Puts the draft on the calendar; null returns it to review. */ + public ArticleUpdate publishAt(Instant publishAt) { + body.put("publishAt", Params.iso(publishAt)); + return this; + } + + public ObjectNode toNode() { + return node(body); + } + } +} diff --git a/src/main/java/com/fopost/sdk/resource/BlogResource.java b/src/main/java/com/fopost/sdk/resource/BlogResource.java new file mode 100644 index 0000000..f49d25e --- /dev/null +++ b/src/main/java/com/fopost/sdk/resource/BlogResource.java @@ -0,0 +1,207 @@ +package com.fopost.sdk.resource; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fopost.sdk.FoPostException; +import com.fopost.sdk.internal.ApiClient; +import com.fopost.sdk.internal.Json; +import com.fopost.sdk.model.BlogArticle; +import com.fopost.sdk.model.BlogArticleFull; +import com.fopost.sdk.model.BlogKeyword; +import com.fopost.sdk.model.BlogSettings; +import com.fopost.sdk.model.BlogSettingsUpdate; +import com.fopost.sdk.model.BlogWebsiteTest; +import com.fopost.sdk.param.BlogParams; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Articles for a website: the keyword plan, the drafts the writer produces, and where they are + * published. A post goes to a social network; an article goes to a website. + * + *

Reads, settings and keyword edits need the {@code posts} scope. {@code planKeywords}, + * {@code requestArticle} and {@code regenerateArticle} spend AI credits and need {@code ai}; an + * empty balance answers 402. {@code publishArticle} needs {@code publish} and answers 409 + * {@code no_target} until the settings name a target. Every call names the workspace: on the query + * string for a read, an update or a delete, in the body for a create. + * + *

{@code
+ * BlogArticle article = fopost.blog().requestArticle(
+ *         workspace.id(), BlogParams.Article.create().keyword("how to schedule posts"));
+ *
+ * BlogArticleFull full = fopost.blog().getArticle(workspace.id(), article.id());
+ * fopost.blog().publishArticle(workspace.id(), article.id());
+ * }
+ */ +public final class BlogResource { + + private final ApiClient http; + + public BlogResource(ApiClient http) { + this.http = http; + } + + // ── Settings ── + + public BlogSettings getSettings(String workspaceId) { + return http.convert( + ApiClient.unwrap(http.get("/v1/blog/settings", workspace(workspaceId))), BlogSettings.class); + } + + /** + * Change the settings. Setting {@code websiteUrl} for the first time issues the signing secret + * for that site; it comes back once on {@link BlogSettingsUpdate#websiteSecret()} and is never + * shown again. Rotate it with {@link #rotateWebsiteSecret}. + */ + public BlogSettingsUpdate updateSettings(String workspaceId, BlogParams.Settings params) { + JsonNode body = http.patch("/v1/blog/settings", withWorkspace(workspaceId, params.toNode())); + BlogSettings settings = http.convert(ApiClient.unwrap(body), BlogSettings.class); + JsonNode secret = body.path("websiteSecret"); + return new BlogSettingsUpdate(settings, secret.isTextual() ? secret.asText() : null); + } + + /** Issue a new website signing secret. The old one stops verifying at once. */ + public String rotateWebsiteSecret(String workspaceId) { + JsonNode body = ApiClient.unwrap(http.post("/v1/blog/settings/website-secret", workspace(workspaceId))); + return body.path("websiteSecret").asText(null); + } + + /** + * Send a signed {@code article.test} delivery to the website target. A site that does not + * answer 2xx comes back with {@code ok} false and its status, rather than as an exception. + */ + public BlogWebsiteTest testWebsite(String workspaceId) { + try { + return http.convert( + ApiClient.unwrap(http.post("/v1/blog/settings/website-test", workspace(workspaceId))), + BlogWebsiteTest.class); + } catch (FoPostException e) { + // The API answers 502 with the same {data: {ok, status, error}} body when the site fails. + if (e.status() == 502 && e.body() instanceof JsonNode body && body.has("data")) { + return http.convert(body.get("data"), BlogWebsiteTest.class); + } + throw e; + } + } + + // ── Keywords ── + + public List listKeywords(String workspaceId) { + return listKeywords(workspaceId, BlogParams.Filter.create()); + } + + public List listKeywords(String workspaceId, BlogParams.Filter filter) { + Map query = workspace(workspaceId); + query.putAll(filter.toQuery()); + return http.convertList(ApiClient.unwrap(http.get("/v1/blog/keywords", query)), BlogKeyword.class); + } + + /** Queue a planning run from the settings' topic and audience. Spends AI credits. */ + public void planKeywords(String workspaceId) { + planKeywords(workspaceId, List.of()); + } + + /** Queue a planning run around these seed terms. Spends AI credits. */ + public void planKeywords(String workspaceId, List seeds) { + Map body = workspace(workspaceId); + if (!seeds.isEmpty()) { + body.put("seeds", seeds); + } + http.post("/v1/blog/keywords/plan", body); + } + + public BlogKeyword addKeyword(String workspaceId, BlogParams.Keyword params) { + Map body = workspace(workspaceId); + body.putAll(params.toMap()); + return http.convert(ApiClient.unwrap(http.post("/v1/blog/keywords", body)), BlogKeyword.class); + } + + public BlogKeyword updateKeyword(String workspaceId, String keywordId, BlogParams.KeywordUpdate params) { + return http.convert( + ApiClient.unwrap(http.request( + "PATCH", "/v1/blog/keywords/" + keywordId, params.toNode(), workspace(workspaceId))), + BlogKeyword.class); + } + + public void deleteKeyword(String workspaceId, String keywordId) { + http.request("DELETE", "/v1/blog/keywords/" + keywordId, null, workspace(workspaceId)); + } + + // ── Articles ── + + public List listArticles(String workspaceId) { + return listArticles(workspaceId, BlogParams.Filter.create()); + } + + public List listArticles(String workspaceId, BlogParams.Filter filter) { + Map query = workspace(workspaceId); + query.putAll(filter.toQuery()); + return http.convertList(ApiClient.unwrap(http.get("/v1/blog/articles", query)), BlogArticle.class); + } + + /** + * Ask the writer for an article. The answer is the queued row; poll {@link #getArticle} until + * its status leaves {@code queued} and {@code writing}. Spends AI credits. + */ + public BlogArticle requestArticle(String workspaceId, BlogParams.Article params) { + Map body = workspace(workspaceId); + body.putAll(params.toMap()); + return http.convert(ApiClient.unwrap(http.post("/v1/blog/articles", body)), BlogArticle.class); + } + + public BlogArticleFull getArticle(String workspaceId, String articleId) { + return http.convert( + ApiClient.unwrap(http.get("/v1/blog/articles/" + articleId, workspace(workspaceId))), + BlogArticleFull.class); + } + + /** Edit a draft. An article that is not editable answers 409 {@code invalid_state}. */ + public BlogArticleFull updateArticle(String workspaceId, String articleId, BlogParams.ArticleUpdate params) { + return http.convert( + ApiClient.unwrap(http.request( + "PATCH", "/v1/blog/articles/" + articleId, params.toNode(), workspace(workspaceId))), + BlogArticleFull.class); + } + + /** Write the article again from its instructions. Spends AI credits. */ + public BlogArticle regenerateArticle(String workspaceId, String articleId) { + return action(workspaceId, articleId, "regenerate"); + } + + /** + * Publish a draft to the configured target. Needs {@code publish}; answers 409 + * {@code no_target} when the settings name none. + */ + public BlogArticle publishArticle(String workspaceId, String articleId) { + return action(workspaceId, articleId, "publish"); + } + + /** Take a draft out of review without publishing it. */ + public BlogArticle rejectArticle(String workspaceId, String articleId) { + return action(workspaceId, articleId, "reject"); + } + + public void deleteArticle(String workspaceId, String articleId) { + http.request("DELETE", "/v1/blog/articles/" + articleId, null, workspace(workspaceId)); + } + + private BlogArticle action(String workspaceId, String articleId, String action) { + return http.convert( + ApiClient.unwrap(http.post("/v1/blog/articles/" + articleId + "/" + action, null, workspace(workspaceId))), + BlogArticle.class); + } + + private static Map workspace(String workspaceId) { + Map map = new LinkedHashMap<>(); + map.put("workspaceId", workspaceId); + return map; + } + + private static ObjectNode withWorkspace(String workspaceId, ObjectNode fields) { + ObjectNode body = Json.MAPPER.createObjectNode(); + body.put("workspaceId", workspaceId); + body.setAll(fields); + return body; + } +} diff --git a/src/main/java/com/fopost/sdk/resource/WebhooksResource.java b/src/main/java/com/fopost/sdk/resource/WebhooksResource.java index 062ceae..eb94e78 100644 --- a/src/main/java/com/fopost/sdk/resource/WebhooksResource.java +++ b/src/main/java/com/fopost/sdk/resource/WebhooksResource.java @@ -1,5 +1,7 @@ package com.fopost.sdk.resource; +import com.fopost.sdk.WebhookEnvelope; +import com.fopost.sdk.WebhookVerifier; import com.fopost.sdk.internal.ApiClient; import com.fopost.sdk.model.Webhook; import com.fopost.sdk.param.UpdateWebhookParams; @@ -46,4 +48,12 @@ public void delete(String webhookId) { public void test(String webhookId) { http.post("/v1/webhooks/" + webhookId + "/test", null); } + + /** + * Check a delivery's signature and parse it. {@code rawBody} is the request body exactly as + * received. See {@link WebhookVerifier} for the rule and the failure codes. + */ + public WebhookEnvelope verify(byte[] rawBody, Map headers, String secret) { + return WebhookVerifier.verify(rawBody, headers, secret); + } } diff --git a/src/test/java/com/fopost/sdk/BlogTest.java b/src/test/java/com/fopost/sdk/BlogTest.java new file mode 100644 index 0000000..30e2b44 --- /dev/null +++ b/src/test/java/com/fopost/sdk/BlogTest.java @@ -0,0 +1,205 @@ +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fopost.sdk.model.BlogArticle; +import com.fopost.sdk.model.BlogArticleFull; +import com.fopost.sdk.model.BlogArticleStatus; +import com.fopost.sdk.model.BlogKeyword; +import com.fopost.sdk.model.BlogKeywordStatus; +import com.fopost.sdk.model.BlogSettings; +import com.fopost.sdk.model.BlogSettingsUpdate; +import com.fopost.sdk.model.BlogTargetKind; +import com.fopost.sdk.model.BlogWebsiteTest; +import com.fopost.sdk.param.BlogParams; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import org.junit.jupiter.api.Test; + +class BlogTest { + + private static final String SETTINGS = + """ + {"siteUrl":"https://yourbrand.com","topic":"Scheduling","audience":"Marketers", + "language":"en","locationCode":2840,"cadencePerWeek":2,"reviewHoldDays":3, + "autoPublish":false,"imageTier":"standard","inBodyImages":2,"minWords":900, + "maxWords":1500,"brandVoiceId":null,"targetKind":"website","targetAccountId":null, + "targetPlatform":null,"websiteUrl":"https://yourbrand.com/hooks/fopost", + "hasWebsiteSecret":true,"keywordDataAvailable":true, + "updatedAt":"2026-10-11T09:00:00.000Z"}"""; + + private static final String KEYWORD = + """ + {"id":"kw_1","keyword":"how to schedule posts","searchVolume":1200,"difficulty":34, + "cpc":1.25,"intent":"informational","source":"provider","status":"planned", + "plannedFor":"2026-10-14","createdAt":"2026-10-11T09:00:00.000Z"}"""; + + private static final String ARTICLE = + """ + {"id":"art_1","title":"How to schedule posts","slug":"how-to-schedule-posts", + "keyword":"how to schedule posts","excerpt":null,"metaTitle":null,"metaDescription":null, + "featuredImage":{"mediaId":"m_1","url":"https://cdn.example/f.png","alt":"Calendar"}, + "tags":["scheduling"],"wordCount":0,"status":"queued","origin":"user", + "reviewUntil":null,"publishAt":null,"publishedAt":null,"publishedUrl":null,"error":null, + "createdAt":"2026-10-11T09:00:00.000Z","updatedAt":"2026-10-11T09:00:00.000Z"}"""; + + @Test + void settingsTravelOnTheQueryForAReadAndInTheBodyForAnUpdate() { + FakeTransport transport = new FakeTransport() + .enqueue(200, "{\"data\":" + SETTINGS + "}") + .enqueue(200, "{\"data\":" + SETTINGS + ",\"websiteSecret\":\"whsec_abc\"}") + .enqueue(200, "{\"data\":{\"websiteSecret\":\"whsec_def\"}}") + .enqueue(502, "{\"data\":{\"ok\":false,\"status\":500,\"error\":\"HTTP 500\"}}"); + FoPost client = TestSupport.client(transport); + + BlogSettings settings = client.blog().getSettings("w1"); + assertEquals("https://api.fopost.test/v1/blog/settings?workspaceId=w1", transport.last().url()); + assertEquals(BlogTargetKind.WEBSITE, settings.targetKind()); + assertEquals(2, settings.cadencePerWeek()); + assertTrue(settings.hasWebsiteSecret()); + assertEquals(Instant.parse("2026-10-11T09:00:00Z"), settings.updatedAt()); + + BlogSettingsUpdate updated = client.blog().updateSettings( + "w1", + BlogParams.Settings.create() + .websiteUrl("https://yourbrand.com/hooks/fopost") + .targetKind(BlogTargetKind.WEBSITE) + .brandVoiceId(null) + .cadencePerWeek(2)); + assertEquals("PATCH", transport.last().method()); + assertEquals("https://api.fopost.test/v1/blog/settings", transport.last().url()); + assertEquals( + "{\"workspaceId\":\"w1\",\"websiteUrl\":\"https://yourbrand.com/hooks/fopost\"," + + "\"targetKind\":\"website\",\"brandVoiceId\":null,\"cadencePerWeek\":2}", + transport.lastBody()); + assertEquals("whsec_abc", updated.websiteSecret()); + assertEquals("en", updated.settings().language()); + + assertEquals("whsec_def", client.blog().rotateWebsiteSecret("w1")); + assertEquals("https://api.fopost.test/v1/blog/settings/website-secret", transport.last().url()); + assertEquals("{\"workspaceId\":\"w1\"}", transport.lastBody()); + + BlogWebsiteTest test = client.blog().testWebsite("w1"); + assertEquals("https://api.fopost.test/v1/blog/settings/website-test", transport.last().url()); + assertFalse(test.ok()); + assertEquals(500, test.status()); + assertEquals("HTTP 500", test.error()); + } + + @Test + void keywordsFilterOnTheQueryAndClearAPlannedDateWithNull() { + FakeTransport transport = new FakeTransport() + .enqueue(200, "{\"data\":[" + KEYWORD + "]}") + .enqueue(202, "{\"data\":{\"status\":\"queued\"}}") + .enqueue(201, "{\"data\":" + KEYWORD + "}") + .enqueue(200, "{\"data\":" + KEYWORD + "}") + .enqueue(204, ""); + FoPost client = TestSupport.client(transport); + + List keywords = client.blog().listKeywords( + "w1", BlogParams.Filter.create().status(BlogKeywordStatus.SUGGESTED, BlogKeywordStatus.PLANNED).limit(20)); + assertEquals( + "https://api.fopost.test/v1/blog/keywords?workspaceId=w1&status=suggested%2Cplanned&limit=20", + transport.last().url()); + assertEquals(1200, keywords.get(0).searchVolume()); + assertEquals(LocalDate.of(2026, 10, 14), keywords.get(0).plannedFor()); + assertEquals(1.25, keywords.get(0).cpc()); + + client.blog().planKeywords("w1", List.of("scheduling", "analytics")); + assertEquals("https://api.fopost.test/v1/blog/keywords/plan", transport.last().url()); + assertEquals("{\"workspaceId\":\"w1\",\"seeds\":[\"scheduling\",\"analytics\"]}", transport.lastBody()); + + BlogKeyword added = client.blog().addKeyword( + "w1", BlogParams.Keyword.of("how to schedule posts").plannedFor(LocalDate.of(2026, 10, 14))); + assertEquals("https://api.fopost.test/v1/blog/keywords", transport.last().url()); + assertEquals( + "{\"workspaceId\":\"w1\",\"keyword\":\"how to schedule posts\",\"plannedFor\":\"2026-10-14\"}", + transport.lastBody()); + assertEquals("kw_1", added.id()); + + client.blog().updateKeyword( + "w1", "kw_1", BlogParams.KeywordUpdate.create().status(BlogKeywordStatus.DISMISSED).plannedFor(null)); + assertEquals("PATCH", transport.last().method()); + assertEquals("https://api.fopost.test/v1/blog/keywords/kw_1?workspaceId=w1", transport.last().url()); + assertEquals("{\"status\":\"dismissed\",\"plannedFor\":null}", transport.lastBody()); + + client.blog().deleteKeyword("w1", "kw_1"); + assertEquals("DELETE", transport.last().method()); + assertEquals("https://api.fopost.test/v1/blog/keywords/kw_1?workspaceId=w1", transport.last().url()); + assertNull(transport.last().body()); + } + + @Test + void articlesAreRequestedInTheBodyAndActedOnByPath() { + String full = ARTICLE.substring(0, ARTICLE.length() - 1) + + ",\"instructions\":\"Keep it short\",\"outline\":[\"Why\",\"How\"]," + + "\"brief\":{\"sources\":2},\"bodyMarkdown\":\"# Hi\",\"bodyHtml\":\"

Hi

\"," + + "\"images\":[{\"mediaId\":\"m_2\",\"url\":\"https://cdn.example/i.png\",\"alt\":\"Step\",\"position\":1}]}"; + FakeTransport transport = new FakeTransport() + .enqueue(200, "{\"data\":[" + ARTICLE + "]}") + .enqueue(202, "{\"data\":" + ARTICLE + "}") + .enqueue(200, "{\"data\":" + full + "}") + .enqueue(200, "{\"data\":" + full + "}") + .enqueue(202, "{\"data\":" + ARTICLE + "}") + .enqueue(202, "{\"data\":" + ARTICLE + "}") + .enqueue(200, "{\"data\":" + ARTICLE + "}") + .enqueue(204, "") + .enqueue(402, "{\"error\":\"insufficient_credits\",\"message\":\"Balance\",\"upgrade_url\":\"https://fopost.com/billing\"}") + .enqueue(409, "{\"error\":\"no_target\",\"message\":\"Pick a target first\"}"); + FoPost client = TestSupport.client(transport); + + List articles = client.blog().listArticles( + "w1", BlogParams.Filter.create().status(BlogArticleStatus.DRAFT)); + assertEquals("https://api.fopost.test/v1/blog/articles?workspaceId=w1&status=draft", transport.last().url()); + assertEquals("Calendar", articles.get(0).featuredImage().alt()); + assertEquals(List.of("scheduling"), articles.get(0).tags()); + + BlogArticle requested = client.blog().requestArticle( + "w1", BlogParams.Article.create().keyword("how to schedule posts").instructions("Keep it short")); + assertEquals("https://api.fopost.test/v1/blog/articles", transport.last().url()); + assertEquals( + "{\"workspaceId\":\"w1\",\"keyword\":\"how to schedule posts\",\"instructions\":\"Keep it short\"}", + transport.lastBody()); + assertEquals(BlogArticleStatus.QUEUED, requested.status()); + + BlogArticleFull fetched = client.blog().getArticle("w1", "art_1"); + assertEquals("https://api.fopost.test/v1/blog/articles/art_1?workspaceId=w1", transport.last().url()); + assertEquals("# Hi", fetched.bodyMarkdown()); + assertEquals(List.of("Why", "How"), fetched.outline()); + assertEquals(2, fetched.brief().path("sources").asInt()); + assertEquals(1, fetched.images().get(0).position()); + + client.blog().updateArticle( + "w1", "art_1", BlogParams.ArticleUpdate.create().title("New title").tags(List.of("a", "b")).publishAt(null)); + assertEquals("PATCH", transport.last().method()); + assertEquals("https://api.fopost.test/v1/blog/articles/art_1?workspaceId=w1", transport.last().url()); + assertEquals("{\"title\":\"New title\",\"tags\":[\"a\",\"b\"],\"publishAt\":null}", transport.lastBody()); + + client.blog().regenerateArticle("w1", "art_1"); + assertEquals("https://api.fopost.test/v1/blog/articles/art_1/regenerate?workspaceId=w1", transport.last().url()); + client.blog().publishArticle("w1", "art_1"); + assertEquals("https://api.fopost.test/v1/blog/articles/art_1/publish?workspaceId=w1", transport.last().url()); + assertEquals("POST", transport.last().method()); + assertNull(transport.last().body()); + client.blog().rejectArticle("w1", "art_1"); + assertEquals("https://api.fopost.test/v1/blog/articles/art_1/reject?workspaceId=w1", transport.last().url()); + client.blog().deleteArticle("w1", "art_1"); + assertEquals("DELETE", transport.last().method()); + assertEquals("https://api.fopost.test/v1/blog/articles/art_1?workspaceId=w1", transport.last().url()); + + PaymentRequiredException noCredits = assertThrows( + PaymentRequiredException.class, + () -> client.blog().requestArticle("w1", BlogParams.Article.create().title("Again"))); + assertEquals("insufficient_credits", noCredits.code()); + assertEquals("https://fopost.com/billing", noCredits.upgradeUrl()); + + FoPostException noTarget = assertThrows(FoPostException.class, () -> client.blog().publishArticle("w1", "art_1")); + assertEquals(409, noTarget.status()); + assertEquals("no_target", noTarget.code()); + } +} diff --git a/src/test/java/com/fopost/sdk/WebhookVerifierTest.java b/src/test/java/com/fopost/sdk/WebhookVerifierTest.java new file mode 100644 index 0000000..1e5b41e --- /dev/null +++ b/src/test/java/com/fopost/sdk/WebhookVerifierTest.java @@ -0,0 +1,88 @@ +package com.fopost.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.fopost.sdk.model.BlogArticlePublished; +import com.fopost.sdk.model.WebhookEvents; +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class WebhookVerifierTest { + + private static final String SECRET = "whsec_test"; + private static final long NOW = 1_760_000_000L; + private static final Clock CLOCK = Clock.fixed(Instant.ofEpochSecond(NOW), ZoneOffset.UTC); + + private static final String BODY = + """ + {"event":"article.published","timestamp":"2026-10-11T09:00:00.000Z", + "data":{"id":"art_1","title":"How to schedule posts","slug":"how-to-schedule-posts", + "excerpt":null,"metaTitle":null,"metaDescription":null, + "bodyMarkdown":"# Hi","bodyHtml":"

Hi

", + "featuredImage":{"mediaId":"m_1","url":"https://cdn.example/f.png","alt":"Calendar"}, + "images":[],"tags":["scheduling"],"keyword":"how to schedule posts", + "wordCount":2,"publishedAt":"2026-10-11T09:00:00.000Z", + "publishedUrl":"https://yourbrand.com/blog/how-to-schedule-posts"}}"""; + + private static byte[] bytes(String body) { + return body.getBytes(StandardCharsets.UTF_8); + } + + private static Map timestamped(String body, long timestamp) { + return Map.of( + "x-fopost-timestamp", Long.toString(timestamp), + "x-fopost-signature-256", WebhookVerifier.sign(bytes(body), SECRET, timestamp), + "x-fopost-signature", WebhookVerifier.sign(bytes(body), SECRET)); + } + + @Test + void aFreshTimestampedSignaturePassesAndParsesTheArticle() { + WebhookEnvelope envelope = WebhookVerifier.verify(bytes(BODY), timestamped(BODY, NOW - 10), SECRET, CLOCK); + + assertEquals(WebhookEvents.ARTICLE_PUBLISHED, envelope.event()); + assertEquals("2026-10-11T09:00:00.000Z", envelope.timestamp()); + BlogArticlePublished article = envelope.data(BlogArticlePublished.class); + assertEquals("how-to-schedule-posts", article.slug()); + assertEquals("Calendar", article.featuredImage().alt()); + assertEquals("https://yourbrand.com/blog/how-to-schedule-posts", article.publishedUrl()); + assertEquals(Instant.parse("2026-10-11T09:00:00Z"), article.publishedAt()); + } + + @Test + void aTamperedBodyFails() { + String tampered = BODY.replace("How to schedule posts", "Something else"); + + FoPostException error = assertThrows( + FoPostException.class, + () -> WebhookVerifier.verify(bytes(tampered), timestamped(BODY, NOW), SECRET, CLOCK)); + assertEquals("invalid_signature", error.code()); + } + + @Test + void aStaleTimestampFailsEvenWhenTheSignatureMatches() { + FoPostException error = assertThrows( + FoPostException.class, + () -> WebhookVerifier.verify(bytes(BODY), timestamped(BODY, NOW - 301), SECRET, CLOCK)); + assertEquals("stale_timestamp", error.code()); + + WebhookVerifier.verify(bytes(BODY), timestamped(BODY, NOW - 300), SECRET, CLOCK); + } + + @Test + void theBodyOnlySignatureIsAcceptedWhenNoTimestampIsSent() { + Map legacy = Map.of("X-FoPost-Signature", WebhookVerifier.sign(bytes(BODY), SECRET)); + + WebhookEnvelope envelope = WebhookVerifier.verify(BODY, legacy, SECRET); + assertEquals(WebhookEvents.ARTICLE_PUBLISHED, envelope.event()); + + FoPostException error = assertThrows( + FoPostException.class, + () -> WebhookVerifier.verify(bytes(BODY), Map.of("X-FoPost-Signature", "sha256=00"), SECRET)); + assertEquals("invalid_signature", error.code()); + } +}