From 35e104b9f89c0b40e9bfaf5c15276c9aca0f0ff5 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 01:11:13 +0200 Subject: [PATCH 1/4] feat(blog): suggest seeds, submit articles for approval and read the blog goal --- .../fopost/sdk/model/BlogArticleStatus.java | 3 +- .../java/com/fopost/sdk/model/BlogGoal.java | 17 +++++ .../com/fopost/sdk/model/BlogKeyword.java | 2 + .../com/fopost/sdk/model/BlogPlanRun.java | 9 +++ .../com/fopost/sdk/model/BlogSettings.java | 13 ++-- .../java/com/fopost/sdk/param/BlogParams.java | 37 ++++++++--- .../com/fopost/sdk/resource/BlogResource.java | 61 ++++++++++++++--- src/test/java/com/fopost/sdk/BlogTest.java | 65 +++++++++++++++++-- 8 files changed, 179 insertions(+), 28 deletions(-) create mode 100644 src/main/java/com/fopost/sdk/model/BlogGoal.java create mode 100644 src/main/java/com/fopost/sdk/model/BlogPlanRun.java diff --git a/src/main/java/com/fopost/sdk/model/BlogArticleStatus.java b/src/main/java/com/fopost/sdk/model/BlogArticleStatus.java index c9fc2d9..02fe820 100644 --- a/src/main/java/com/fopost/sdk/model/BlogArticleStatus.java +++ b/src/main/java/com/fopost/sdk/model/BlogArticleStatus.java @@ -10,6 +10,7 @@ private BlogArticleStatus() {} public static final String QUEUED = "queued"; public static final String WRITING = "writing"; public static final String DRAFT = "draft"; + public static final String IN_REVIEW = "in_review"; public static final String SCHEDULED = "scheduled"; public static final String PUBLISHING = "publishing"; public static final String PUBLISHED = "published"; @@ -17,5 +18,5 @@ private BlogArticleStatus() {} public static final String REJECTED = "rejected"; public static final List ALL = - List.of(QUEUED, WRITING, DRAFT, SCHEDULED, PUBLISHING, PUBLISHED, FAILED, REJECTED); + List.of(QUEUED, WRITING, DRAFT, IN_REVIEW, SCHEDULED, PUBLISHING, PUBLISHED, FAILED, REJECTED); } diff --git a/src/main/java/com/fopost/sdk/model/BlogGoal.java b/src/main/java/com/fopost/sdk/model/BlogGoal.java new file mode 100644 index 0000000..db58dd0 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BlogGoal.java @@ -0,0 +1,17 @@ +package com.fopost.sdk.model; + +import java.util.List; + +/** What the blog is for; steers suggested search terms and keyword ranking. */ +public final class BlogGoal { + + private BlogGoal() {} + + public static final String TRAFFIC = "traffic"; + public static final String LEADS = "leads"; + public static final String SALES = "sales"; + public static final String SIGNUPS = "signups"; + public static final String AUTHORITY = "authority"; + + public static final List ALL = List.of(TRAFFIC, LEADS, SALES, SIGNUPS, AUTHORITY); +} diff --git a/src/main/java/com/fopost/sdk/model/BlogKeyword.java b/src/main/java/com/fopost/sdk/model/BlogKeyword.java index 9c3feaf..245a31d 100644 --- a/src/main/java/com/fopost/sdk/model/BlogKeyword.java +++ b/src/main/java/com/fopost/sdk/model/BlogKeyword.java @@ -6,6 +6,7 @@ /** * 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. + * {@code fitReason} says why the keyword serves the blog goal. */ public record BlogKeyword( String id, @@ -17,4 +18,5 @@ public record BlogKeyword( String source, String status, LocalDate plannedFor, + String fitReason, Instant createdAt) {} diff --git a/src/main/java/com/fopost/sdk/model/BlogPlanRun.java b/src/main/java/com/fopost/sdk/model/BlogPlanRun.java new file mode 100644 index 0000000..9c42f39 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/BlogPlanRun.java @@ -0,0 +1,9 @@ +package com.fopost.sdk.model; + +import java.time.Instant; + +/** + * The last keyword research run. {@code status} is {@code queued}, {@code running}, {@code done} or + * {@code failed}; {@code added} is how many new keywords it found. + */ +public record BlogPlanRun(String status, String error, Integer added, Instant finishedAt) {} diff --git a/src/main/java/com/fopost/sdk/model/BlogSettings.java b/src/main/java/com/fopost/sdk/model/BlogSettings.java index 6dedc11..c2e064b 100644 --- a/src/main/java/com/fopost/sdk/model/BlogSettings.java +++ b/src/main/java/com/fopost/sdk/model/BlogSettings.java @@ -5,10 +5,12 @@ /** * 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 + *

{@code goal} is one of {@link BlogGoal}, or null. {@code cadencePerWeek} is how many articles + * the planner queues each week; 0 turns planning off. {@code reviewHoldDays} is how many days before + * its planned date a picked keyword starts being written. {@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 planRun} is the last keyword research run, null before the first. * {@code keywordDataAvailable} says whether search volume and difficulty can be fetched on this * deployment. */ @@ -16,11 +18,13 @@ public record BlogSettings( String siteUrl, String topic, String audience, + String goal, + String focusTopics, + String avoidTopics, String language, Integer locationCode, int cadencePerWeek, int reviewHoldDays, - boolean autoPublish, String imageTier, Integer inBodyImages, Integer minWords, @@ -31,5 +35,6 @@ public record BlogSettings( String targetPlatform, String websiteUrl, boolean hasWebsiteSecret, + BlogPlanRun planRun, Boolean keywordDataAvailable, Instant updatedAt) {} diff --git a/src/main/java/com/fopost/sdk/param/BlogParams.java b/src/main/java/com/fopost/sdk/param/BlogParams.java index b779bc7..4d47a62 100644 --- a/src/main/java/com/fopost/sdk/param/BlogParams.java +++ b/src/main/java/com/fopost/sdk/param/BlogParams.java @@ -55,6 +55,24 @@ public Settings audience(String audience) { return this; } + /** One of {@link com.fopost.sdk.model.BlogGoal}; null clears it. */ + public Settings goal(String goal) { + body.put("goal", goal); + return this; + } + + /** What the blog wants to write about; null clears it. */ + public Settings focusTopics(String focusTopics) { + body.put("focusTopics", focusTopics); + return this; + } + + /** What the blog never writes about; null clears it. */ + public Settings avoidTopics(String avoidTopics) { + body.put("avoidTopics", avoidTopics); + return this; + } + public Settings language(String language) { body.put("language", language); return this; @@ -71,17 +89,12 @@ public Settings cadencePerWeek(int cadencePerWeek) { return this; } - /** Days a draft waits for review before it may auto-publish, 0 to 14. */ + /** Days before its planned date that a picked keyword starts being written, 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); @@ -191,7 +204,10 @@ public static KeywordUpdate create() { return new KeywordUpdate(); } - /** One of {@link com.fopost.sdk.model.BlogKeywordStatus}. */ + /** + * One of {@link com.fopost.sdk.model.BlogKeywordStatus}. {@code planned} picks the keyword and + * gives it the next free date. + */ public KeywordUpdate status(String status) { body.put("status", status); return this; @@ -244,7 +260,7 @@ public Map toMap() { } } - /** Edits a draft. Only a draft or scheduled article is editable; anything else answers 409. */ + /** Edits an article. One still being written or already publishing answers 409. */ public static final class ArticleUpdate { private final Map body = new LinkedHashMap<>(); @@ -289,7 +305,10 @@ public ArticleUpdate tags(List tags) { return this; } - /** Puts the draft on the calendar; null returns it to review. */ + /** + * Puts an article in review on the calendar; a draft answers 409 until it is submitted. Null + * returns it to draft. + */ public ArticleUpdate publishAt(Instant publishAt) { body.put("publishAt", Params.iso(publishAt)); return this; diff --git a/src/main/java/com/fopost/sdk/resource/BlogResource.java b/src/main/java/com/fopost/sdk/resource/BlogResource.java index f49d25e..5a193b7 100644 --- a/src/main/java/com/fopost/sdk/resource/BlogResource.java +++ b/src/main/java/com/fopost/sdk/resource/BlogResource.java @@ -20,10 +20,11 @@ * 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 + *

Reads, settings, keyword edits and {@code submitArticle} need the {@code posts} scope. + * {@code suggestSeeds}, {@code planKeywords}, {@code requestArticle} and {@code regenerateArticle} + * spend AI credits and need {@code ai}; an empty balance answers 402. {@code publishArticle} needs + * {@code publish}, answers 409 for a draft that was never submitted, and 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
@@ -31,6 +32,7 @@
  *         workspace.id(), BlogParams.Article.create().keyword("how to schedule posts"));
  *
  * BlogArticleFull full = fopost.blog().getArticle(workspace.id(), article.id());
+ * fopost.blog().submitArticle(workspace.id(), article.id());
  * fopost.blog().publishArticle(workspace.id(), article.id());
  * }
*/ @@ -97,12 +99,42 @@ public List listKeywords(String workspaceId, BlogParams.Filter filt 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. */ + /** About 10 search terms from the blog goal and topics. Spends AI credits. */ + public List suggestSeeds(String workspaceId) { + return suggestSeeds(workspaceId, List.of(), null); + } + + /** + * About 10 search terms from the blog goal and topics, to edit before {@link #planKeywords}. + * Pass the current {@code seeds} (at most 20) to refine them and a {@code prompt} (up to 500 + * characters, or null) to steer. Spends AI credits. + */ + public List suggestSeeds(String workspaceId, List seeds, String prompt) { + Map body = workspace(workspaceId); + if (!seeds.isEmpty()) { + body.put("seeds", seeds); + } + if (prompt != null) { + body.put("prompt", prompt); + } + JsonNode data = ApiClient.unwrap(http.post("/v1/blog/keywords/suggest", body)); + return http.convertList(data.path("seeds"), String.class); + } + + /** + * Queue a research run with terms derived from the blog settings. Results land as + * {@code suggested} keywords; follow the run in {@link BlogSettings#planRun()}. Spends AI + * credits. + */ public void planKeywords(String workspaceId) { planKeywords(workspaceId, List.of()); } - /** Queue a planning run around these seed terms. Spends AI credits. */ + /** + * Queue a research run around these seed terms. Results land as {@code suggested} keywords and + * never fill the calendar; set one to {@code planned} with {@link #updateKeyword} to pick it. + * Spends AI credits. + */ public void planKeywords(String workspaceId, List seeds) { Map body = workspace(workspaceId); if (!seeds.isEmpty()) { @@ -156,7 +188,10 @@ public BlogArticleFull getArticle(String workspaceId, String articleId) { BlogArticleFull.class); } - /** Edit a draft. An article that is not editable answers 409 {@code invalid_state}. */ + /** + * Edit an article. One that is not editable answers 409 {@code invalid_state}, as does a + * {@code publishAt} on a draft that was never submitted with {@link #submitArticle}. + */ public BlogArticleFull updateArticle(String workspaceId, String articleId, BlogParams.ArticleUpdate params) { return http.convert( ApiClient.unwrap(http.request( @@ -170,14 +205,20 @@ public BlogArticle regenerateArticle(String workspaceId, String articleId) { } /** - * Publish a draft to the configured target. Needs {@code publish}; answers 409 - * {@code no_target} when the settings name none. + * Publish an article in review to the configured target. Needs {@code publish}. A draft answers + * 409 until it is submitted with {@link #submitArticle}; 409 {@code no_target} means 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. */ + /** Move a draft, failed or rejected article to {@code in_review}. Any other state answers 409. */ + public BlogArticle submitArticle(String workspaceId, String articleId) { + return action(workspaceId, articleId, "submit"); + } + + /** Take an article out of review without publishing it. */ public BlogArticle rejectArticle(String workspaceId, String articleId) { return action(workspaceId, articleId, "reject"); } diff --git a/src/test/java/com/fopost/sdk/BlogTest.java b/src/test/java/com/fopost/sdk/BlogTest.java index 30e2b44..a574678 100644 --- a/src/test/java/com/fopost/sdk/BlogTest.java +++ b/src/test/java/com/fopost/sdk/BlogTest.java @@ -9,6 +9,7 @@ import com.fopost.sdk.model.BlogArticle; import com.fopost.sdk.model.BlogArticleFull; import com.fopost.sdk.model.BlogArticleStatus; +import com.fopost.sdk.model.BlogGoal; import com.fopost.sdk.model.BlogKeyword; import com.fopost.sdk.model.BlogKeywordStatus; import com.fopost.sdk.model.BlogSettings; @@ -26,18 +27,21 @@ class BlogTest { private static final String SETTINGS = """ {"siteUrl":"https://yourbrand.com","topic":"Scheduling","audience":"Marketers", + "goal":"leads","focusTopics":"Scheduling tips","avoidTopics":"Competitor brands", "language":"en","locationCode":2840,"cadencePerWeek":2,"reviewHoldDays":3, - "autoPublish":false,"imageTier":"standard","inBodyImages":2,"minWords":900, + "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, + "planRun":{"status":"done","error":null,"added":12,"finishedAt":"2026-10-11T09:05:00.000Z"}, "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"}"""; + "plannedFor":"2026-10-14","fitReason":"Readers who schedule posts need a tool", + "createdAt":"2026-10-11T09:00:00.000Z"}"""; private static final String ARTICLE = """ @@ -63,6 +67,12 @@ void settingsTravelOnTheQueryForAReadAndInTheBodyForAnUpdate() { assertEquals(2, settings.cadencePerWeek()); assertTrue(settings.hasWebsiteSecret()); assertEquals(Instant.parse("2026-10-11T09:00:00Z"), settings.updatedAt()); + assertEquals(BlogGoal.LEADS, settings.goal()); + assertEquals("Scheduling tips", settings.focusTopics()); + assertEquals("Competitor brands", settings.avoidTopics()); + assertEquals("done", settings.planRun().status()); + assertEquals(12, settings.planRun().added()); + assertEquals(Instant.parse("2026-10-11T09:05:00Z"), settings.planRun().finishedAt()); BlogSettingsUpdate updated = client.blog().updateSettings( "w1", @@ -70,12 +80,16 @@ void settingsTravelOnTheQueryForAReadAndInTheBodyForAnUpdate() { .websiteUrl("https://yourbrand.com/hooks/fopost") .targetKind(BlogTargetKind.WEBSITE) .brandVoiceId(null) - .cadencePerWeek(2)); + .cadencePerWeek(2) + .goal(BlogGoal.LEADS) + .focusTopics("Scheduling tips") + .avoidTopics(null)); 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}", + + "\"targetKind\":\"website\",\"brandVoiceId\":null,\"cadencePerWeek\":2," + + "\"goal\":\"leads\",\"focusTopics\":\"Scheduling tips\",\"avoidTopics\":null}", transport.lastBody()); assertEquals("whsec_abc", updated.websiteSecret()); assertEquals("en", updated.settings().language()); @@ -109,6 +123,7 @@ void keywordsFilterOnTheQueryAndClearAPlannedDateWithNull() { assertEquals(1200, keywords.get(0).searchVolume()); assertEquals(LocalDate.of(2026, 10, 14), keywords.get(0).plannedFor()); assertEquals(1.25, keywords.get(0).cpc()); + assertEquals("Readers who schedule posts need a tool", keywords.get(0).fitReason()); client.blog().planKeywords("w1", List.of("scheduling", "analytics")); assertEquals("https://api.fopost.test/v1/blog/keywords/plan", transport.last().url()); @@ -202,4 +217,46 @@ void articlesAreRequestedInTheBodyAndActedOnByPath() { assertEquals(409, noTarget.status()); assertEquals("no_target", noTarget.code()); } + + @Test + void suggestSeedsSendsTheCurrentSeedsAndPromptAndReturnsTheTerms() { + FakeTransport transport = new FakeTransport() + .enqueue(200, "{\"data\":{\"seeds\":[\"schedule posts\",\"best time to post\"]}}") + .enqueue(200, "{\"data\":{\"seeds\":[]}}") + .enqueue(402, "{\"error\":\"insufficient_credits\",\"message\":\"Balance\"}"); + FoPost client = TestSupport.client(transport); + + List seeds = client.blog().suggestSeeds("w1", List.of("scheduling"), "Small teams"); + assertEquals("POST", transport.last().method()); + assertEquals("https://api.fopost.test/v1/blog/keywords/suggest", transport.last().url()); + assertEquals( + "{\"workspaceId\":\"w1\",\"seeds\":[\"scheduling\"],\"prompt\":\"Small teams\"}", + transport.lastBody()); + assertEquals(List.of("schedule posts", "best time to post"), seeds); + + assertEquals(List.of(), client.blog().suggestSeeds("w1")); + assertEquals("{\"workspaceId\":\"w1\"}", transport.lastBody()); + + assertThrows(PaymentRequiredException.class, () -> client.blog().suggestSeeds("w1")); + } + + @Test + void submitArticleMovesItToReviewByPath() { + String inReview = ARTICLE.replace("\"status\":\"queued\"", "\"status\":\"in_review\""); + FakeTransport transport = new FakeTransport() + .enqueue(200, "{\"data\":" + inReview + "}") + .enqueue(409, "{\"error\":\"invalid_state\",\"message\":\"Only a draft can be submitted\"}"); + FoPost client = TestSupport.client(transport); + + BlogArticle submitted = client.blog().submitArticle("w1", "art_1"); + assertEquals("POST", transport.last().method()); + assertEquals("https://api.fopost.test/v1/blog/articles/art_1/submit?workspaceId=w1", transport.last().url()); + assertNull(transport.last().body()); + assertEquals(BlogArticleStatus.IN_REVIEW, submitted.status()); + assertTrue(BlogArticleStatus.ALL.contains(submitted.status())); + + FoPostException conflict = assertThrows(FoPostException.class, () -> client.blog().submitArticle("w1", "art_1")); + assertEquals(409, conflict.status()); + assertEquals("invalid_state", conflict.code()); + } } From 4fe561df044756252ca5939bb7af59dc103238f3 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 01:11:13 +0200 Subject: [PATCH 2/4] docs(blog): goal-driven planning and the approval step --- CHANGELOG.md | 19 +++++++++++++++++++ README.md | 32 +++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 561f924..5194312 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ All notable changes to this project are documented here. The format follows ### Added +- `blog().suggestSeeds(workspaceId[, seeds, prompt])` suggests about 10 search terms from the + blog goal and topics (needs `ai`, 402 on an empty balance), and `blog().submitArticle` moves a + draft, failed or rejected article to `in_review` (needs `posts`, 409 otherwise). +- `BlogSettings` gains `goal`, `focusTopics`, `avoidTopics` and `planRun` (`BlogPlanRun`: + `status`, `error`, `added`, `finishedAt`); `BlogParams.Settings` gains `goal`, `focusTopics` + and `avoidTopics`, with the `BlogGoal` constants. `BlogKeyword.fitReason` says why a keyword + serves the goal. +- `BlogArticleStatus.IN_REVIEW`. - `whatsapp()` for a WhatsApp Business connection: the business profile (`getProfile`, `updateProfile`, `requestDisplayName`, `setUsername`), message templates including the platform's own library (`listTemplates`, @@ -88,12 +96,23 @@ All notable changes to this project are documented here. The format follows ### Changed +- `blog().planKeywords` no longer fills the calendar: results land as `suggested` keywords, and + setting one to `planned` with `updateKeyword` picks it and gives it the next free date. +- `blog().publishArticle` and `publishAt` on `updateArticle` need an article in review (or + already scheduled or failed); a draft answers 409 until it is submitted. +- `BlogSettings.reviewHoldDays` is now the number of days before its planned date that a picked + keyword starts being written. - 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. +### Removed + +- **Breaking:** `BlogSettings.autoPublish` and `BlogParams.Settings.autoPublish(...)`. Every + article now goes through `submitArticle` before it publishes. + ## [0.3.0] - 2026-09-19 ### Added diff --git a/README.md b/README.md index b7715f0..50b5bf6 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | `accountGroups()` | `list`, `get`, `create`, `update`, `delete`, `setMembers` | | `labels()` | `list`, `get`, `create`, `update`, `delete` | | `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` | +| `blog()` | `getSettings`, `updateSettings`, `rotateWebsiteSecret`, `testWebsite`, `listKeywords`, `suggestSeeds`, `planKeywords`, `addKeyword`, `updateKeyword`, `deleteKeyword`, `listArticles`, `requestArticle`, `getArticle`, `updateArticle`, `regenerateArticle`, `submitArticle`, `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` | @@ -266,6 +266,27 @@ the articles the writer produces, and the settings that say where they are publi `BlogTargetKind.PRODUCT_BLOG`, a connected `ACCOUNT`, or your own `WEBSITE`, which receives each published article as a signed webhook. +Planning starts from the blog's goal. Suggest search terms, research them, then pick the keywords +worth writing; a picked keyword takes the next free date on the calendar. + +```java +client.blog().updateSettings( + workspace.id(), + BlogParams.Settings.create().goal(BlogGoal.LEADS).focusTopics("Scheduling for small teams")); + +List seeds = client.blog().suggestSeeds(workspace.id(), List.of(), "Beginners, not agencies"); +client.blog().planKeywords(workspace.id(), seeds); + +for (BlogKeyword keyword : client.blog().listKeywords( + workspace.id(), BlogParams.Filter.create().status(BlogKeywordStatus.SUGGESTED).limit(3))) { + client.blog().updateKeyword( + workspace.id(), keyword.id(), BlogParams.KeywordUpdate.create().status(BlogKeywordStatus.PLANNED)); +} +``` + +Every article is approved before it publishes: submit a draft for review, then publish it or give +it a `publishAt`. + ```java BlogArticle article = client.blog().requestArticle( workspace.id(), @@ -275,13 +296,14 @@ BlogArticle article = client.blog().requestArticle( // Poll until the status leaves queued and writing, then read the body. BlogArticleFull draft = client.blog().getArticle(workspace.id(), article.id()); +client.blog().submitArticle(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 +Reads, settings, keyword edits and `submitArticle` need `posts`; `suggestSeeds`, `planKeywords`, +`requestArticle` and `regenerateArticle` spend AI credits and need `ai` (an empty balance is a +`PaymentRequiredException`); `publishArticle` needs `publish`, answers 409 for a draft that was +never submitted, and 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. From afcde62a597284597abaf805c18aac6ab94746bc Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 14:39:06 +0200 Subject: [PATCH 3/4] fix(blog): match the live contract with workspace_id and snake_case responses --- .../com/fopost/sdk/model/BlogWebsiteTest.java | 7 +- .../com/fopost/sdk/resource/BlogResource.java | 63 +++++----- src/test/java/com/fopost/sdk/BlogTest.java | 110 +++++++++--------- 3 files changed, 100 insertions(+), 80 deletions(-) diff --git a/src/main/java/com/fopost/sdk/model/BlogWebsiteTest.java b/src/main/java/com/fopost/sdk/model/BlogWebsiteTest.java index f745c86..72e7658 100644 --- a/src/main/java/com/fopost/sdk/model/BlogWebsiteTest.java +++ b/src/main/java/com/fopost/sdk/model/BlogWebsiteTest.java @@ -1,4 +1,7 @@ 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) {} +/** + * The outcome of a test delivery to the website target. {@code httpStatus} is the site's HTTP + * answer; {@code error} says why a failed test failed. + */ +public record BlogWebsiteTest(boolean ok, Integer httpStatus, String error) {} diff --git a/src/main/java/com/fopost/sdk/resource/BlogResource.java b/src/main/java/com/fopost/sdk/resource/BlogResource.java index 5a193b7..c6583b4 100644 --- a/src/main/java/com/fopost/sdk/resource/BlogResource.java +++ b/src/main/java/com/fopost/sdk/resource/BlogResource.java @@ -24,8 +24,8 @@ * {@code suggestSeeds}, {@code planKeywords}, {@code requestArticle} and {@code regenerateArticle} * spend AI credits and need {@code ai}; an empty balance answers 402. {@code publishArticle} needs * {@code publish}, answers 409 for a draft that was never submitted, and 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. + * until the settings name a target. Every call names the workspace: as {@code workspace_id} on the + * query string for a read, an update or a delete, as {@code workspaceId} in the body for a create. * *
{@code
  * BlogArticle article = fopost.blog().requestArticle(
@@ -48,7 +48,7 @@ public BlogResource(ApiClient http) {
 
     public BlogSettings getSettings(String workspaceId) {
         return http.convert(
-                ApiClient.unwrap(http.get("/v1/blog/settings", workspace(workspaceId))), BlogSettings.class);
+                ApiClient.unwrap(http.get("/v1/blog/settings", query(workspaceId))), BlogSettings.class);
     }
 
     /**
@@ -59,34 +59,39 @@ public BlogSettings getSettings(String workspaceId) {
     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");
+        JsonNode secret = ApiClient.unwrap(body).path("website_secret");
         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);
+        JsonNode body = ApiClient.unwrap(http.post("/v1/blog/settings/website-secret", body(workspaceId)));
+        return body.path("website_secret").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.
+     * answer 2xx comes back with {@code ok} false, its {@code httpStatus} and the reason, 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);
+            JsonNode body = ApiClient.unwrap(http.post("/v1/blog/settings/website-test", body(workspaceId)));
+            return new BlogWebsiteTest(true, httpStatus(body), null);
         } 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);
+            // The API answers 502 website_test_failed, with the site's http_status, when the site fails.
+            if (e.status() == 502 && e.body() instanceof JsonNode body && body.has("http_status")) {
+                return new BlogWebsiteTest(false, httpStatus(body), e.getMessage());
             }
             throw e;
         }
     }
 
+    private static Integer httpStatus(JsonNode body) {
+        JsonNode status = body.path("http_status");
+        return status.isNumber() ? status.asInt() : null;
+    }
+
     // ── Keywords ──
 
     public List listKeywords(String workspaceId) {
@@ -94,7 +99,7 @@ public List listKeywords(String workspaceId) {
     }
 
     public List listKeywords(String workspaceId, BlogParams.Filter filter) {
-        Map query = workspace(workspaceId);
+        Map query = query(workspaceId);
         query.putAll(filter.toQuery());
         return http.convertList(ApiClient.unwrap(http.get("/v1/blog/keywords", query)), BlogKeyword.class);
     }
@@ -110,7 +115,7 @@ public List suggestSeeds(String workspaceId) {
      * characters, or null) to steer. Spends AI credits.
      */
     public List suggestSeeds(String workspaceId, List seeds, String prompt) {
-        Map body = workspace(workspaceId);
+        Map body = body(workspaceId);
         if (!seeds.isEmpty()) {
             body.put("seeds", seeds);
         }
@@ -136,7 +141,7 @@ public void planKeywords(String workspaceId) {
      * Spends AI credits.
      */
     public void planKeywords(String workspaceId, List seeds) {
-        Map body = workspace(workspaceId);
+        Map body = body(workspaceId);
         if (!seeds.isEmpty()) {
             body.put("seeds", seeds);
         }
@@ -144,7 +149,7 @@ public void planKeywords(String workspaceId, List seeds) {
     }
 
     public BlogKeyword addKeyword(String workspaceId, BlogParams.Keyword params) {
-        Map body = workspace(workspaceId);
+        Map body = body(workspaceId);
         body.putAll(params.toMap());
         return http.convert(ApiClient.unwrap(http.post("/v1/blog/keywords", body)), BlogKeyword.class);
     }
@@ -152,12 +157,12 @@ public BlogKeyword addKeyword(String workspaceId, BlogParams.Keyword params) {
     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))),
+                        "PATCH", "/v1/blog/keywords/" + keywordId, params.toNode(), query(workspaceId))),
                 BlogKeyword.class);
     }
 
     public void deleteKeyword(String workspaceId, String keywordId) {
-        http.request("DELETE", "/v1/blog/keywords/" + keywordId, null, workspace(workspaceId));
+        http.request("DELETE", "/v1/blog/keywords/" + keywordId, null, query(workspaceId));
     }
 
     // ── Articles ──
@@ -167,7 +172,7 @@ public List listArticles(String workspaceId) {
     }
 
     public List listArticles(String workspaceId, BlogParams.Filter filter) {
-        Map query = workspace(workspaceId);
+        Map query = query(workspaceId);
         query.putAll(filter.toQuery());
         return http.convertList(ApiClient.unwrap(http.get("/v1/blog/articles", query)), BlogArticle.class);
     }
@@ -177,14 +182,14 @@ public List listArticles(String workspaceId, BlogParams.Filter filt
      * its status leaves {@code queued} and {@code writing}. Spends AI credits.
      */
     public BlogArticle requestArticle(String workspaceId, BlogParams.Article params) {
-        Map body = workspace(workspaceId);
+        Map body = body(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))),
+                ApiClient.unwrap(http.get("/v1/blog/articles/" + articleId, query(workspaceId))),
                 BlogArticleFull.class);
     }
 
@@ -195,7 +200,7 @@ public BlogArticleFull getArticle(String workspaceId, String articleId) {
     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))),
+                        "PATCH", "/v1/blog/articles/" + articleId, params.toNode(), query(workspaceId))),
                 BlogArticleFull.class);
     }
 
@@ -224,16 +229,22 @@ public BlogArticle rejectArticle(String workspaceId, String articleId) {
     }
 
     public void deleteArticle(String workspaceId, String articleId) {
-        http.request("DELETE", "/v1/blog/articles/" + articleId, null, workspace(workspaceId));
+        http.request("DELETE", "/v1/blog/articles/" + articleId, null, query(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))),
+                ApiClient.unwrap(http.post("/v1/blog/articles/" + articleId + "/" + action, null, query(workspaceId))),
                 BlogArticle.class);
     }
 
-    private static Map workspace(String workspaceId) {
+    private static Map query(String workspaceId) {
+        Map map = new LinkedHashMap<>();
+        map.put("workspace_id", workspaceId);
+        return map;
+    }
+
+    private static Map body(String workspaceId) {
         Map map = new LinkedHashMap<>();
         map.put("workspaceId", workspaceId);
         return map;
diff --git a/src/test/java/com/fopost/sdk/BlogTest.java b/src/test/java/com/fopost/sdk/BlogTest.java
index a574678..fc7274f 100644
--- a/src/test/java/com/fopost/sdk/BlogTest.java
+++ b/src/test/java/com/fopost/sdk/BlogTest.java
@@ -26,43 +26,44 @@ class BlogTest {
 
     private static final String SETTINGS =
             """
-            {"siteUrl":"https://yourbrand.com","topic":"Scheduling","audience":"Marketers",
-             "goal":"leads","focusTopics":"Scheduling tips","avoidTopics":"Competitor brands",
-             "language":"en","locationCode":2840,"cadencePerWeek":2,"reviewHoldDays":3,
-             "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,
-             "planRun":{"status":"done","error":null,"added":12,"finishedAt":"2026-10-11T09:05:00.000Z"},
-             "updatedAt":"2026-10-11T09:00:00.000Z"}""";
+            {"site_url":"https://yourbrand.com","topic":"Scheduling","audience":"Marketers",
+             "goal":"leads","focus_topics":"Scheduling tips","avoid_topics":"Competitor brands",
+             "language":"en","location_code":2840,"cadence_per_week":2,"review_hold_days":3,
+             "image_tier":"standard","in_body_images":2,"min_words":900,
+             "max_words":1500,"brand_voice_id":null,"target_kind":"website","target_account_id":null,
+             "target_platform":null,"website_url":"https://yourbrand.com/hooks/fopost",
+             "has_website_secret":true,"keyword_data_available":true,
+             "plan_run":{"status":"done","error":null,"added":12,"finished_at":"2026-10-11T09:05:00.000Z"},
+             "updated_at":"2026-10-11T09:00:00.000Z"}""";
 
     private static final String KEYWORD =
             """
-            {"id":"kw_1","keyword":"how to schedule posts","searchVolume":1200,"difficulty":34,
+            {"id":"kw_1","keyword":"how to schedule posts","search_volume":1200,"difficulty":34,
              "cpc":1.25,"intent":"informational","source":"provider","status":"planned",
-             "plannedFor":"2026-10-14","fitReason":"Readers who schedule posts need a tool",
-             "createdAt":"2026-10-11T09:00:00.000Z"}""";
+             "planned_for":"2026-10-14","fit_reason":"Readers who schedule posts need a tool",
+             "created_at":"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"}""";
+             "keyword":"how to schedule posts","excerpt":null,"meta_title":null,"meta_description":null,
+             "featured_image":{"media_id":"m_1","url":"https://cdn.example/f.png","alt":"Calendar"},
+             "tags":["scheduling"],"word_count":0,"status":"queued","origin":"user",
+             "review_until":null,"publish_at":null,"published_at":null,"published_url":null,"error":null,
+             "created_at":"2026-10-11T09:00:00.000Z","updated_at":"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\"}}");
+                .enqueue(200, SETTINGS)
+                .enqueue(200, SETTINGS.substring(0, SETTINGS.length() - 1) + ",\"website_secret\":\"whsec_abc\"}")
+                .enqueue(200, "{\"website_secret\":\"whsec_def\"}")
+                .enqueue(200, "{\"http_status\":204}")
+                .enqueue(502, "{\"error\":\"website_test_failed\",\"message\":\"HTTP 500\",\"http_status\":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("https://api.fopost.test/v1/blog/settings?workspace_id=w1", transport.last().url());
         assertEquals(BlogTargetKind.WEBSITE, settings.targetKind());
         assertEquals(2, settings.cadencePerWeek());
         assertTrue(settings.hasWebsiteSecret());
@@ -98,27 +99,32 @@ void settingsTravelOnTheQueryForAReadAndInTheBodyForAnUpdate() {
         assertEquals("https://api.fopost.test/v1/blog/settings/website-secret", transport.last().url());
         assertEquals("{\"workspaceId\":\"w1\"}", transport.lastBody());
 
-        BlogWebsiteTest test = client.blog().testWebsite("w1");
+        BlogWebsiteTest passed = 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());
+        assertEquals("{\"workspaceId\":\"w1\"}", transport.lastBody());
+        assertTrue(passed.ok());
+        assertEquals(204, passed.httpStatus());
+
+        BlogWebsiteTest failed = client.blog().testWebsite("w1");
+        assertFalse(failed.ok());
+        assertEquals(500, failed.httpStatus());
+        assertEquals("HTTP 500", failed.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(202, "{\"status\":\"queued\"}")
+                .enqueue(201, KEYWORD)
+                .enqueue(200, 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",
+                "https://api.fopost.test/v1/blog/keywords?workspace_id=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());
@@ -140,12 +146,12 @@ void keywordsFilterOnTheQueryAndClearAPlannedDateWithNull() {
         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("https://api.fopost.test/v1/blog/keywords/kw_1?workspace_id=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());
+        assertEquals("https://api.fopost.test/v1/blog/keywords/kw_1?workspace_id=w1", transport.last().url());
         assertNull(transport.last().body());
     }
 
@@ -153,16 +159,16 @@ void keywordsFilterOnTheQueryAndClearAPlannedDateWithNull() {
     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}]}"; + + "\"brief\":{\"questions\":[\"Why\"],\"search_volume\":2},\"body_markdown\":\"# Hi\",\"body_html\":\"

Hi

\"," + + "\"images\":[{\"media_id\":\"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(202, ARTICLE) + .enqueue(200, full) + .enqueue(200, full) + .enqueue(202, ARTICLE) + .enqueue(202, ARTICLE) + .enqueue(200, 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\"}"); @@ -170,7 +176,7 @@ void articlesAreRequestedInTheBodyAndActedOnByPath() { 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("https://api.fopost.test/v1/blog/articles?workspace_id=w1&status=draft", transport.last().url()); assertEquals("Calendar", articles.get(0).featuredImage().alt()); assertEquals(List.of("scheduling"), articles.get(0).tags()); @@ -183,29 +189,29 @@ void articlesAreRequestedInTheBodyAndActedOnByPath() { 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("https://api.fopost.test/v1/blog/articles/art_1?workspace_id=w1", transport.last().url()); assertEquals("# Hi", fetched.bodyMarkdown()); assertEquals(List.of("Why", "How"), fetched.outline()); - assertEquals(2, fetched.brief().path("sources").asInt()); + assertEquals(2, fetched.brief().path("search_volume").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("https://api.fopost.test/v1/blog/articles/art_1?workspace_id=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()); + assertEquals("https://api.fopost.test/v1/blog/articles/art_1/regenerate?workspace_id=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("https://api.fopost.test/v1/blog/articles/art_1/publish?workspace_id=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()); + assertEquals("https://api.fopost.test/v1/blog/articles/art_1/reject?workspace_id=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()); + assertEquals("https://api.fopost.test/v1/blog/articles/art_1?workspace_id=w1", transport.last().url()); PaymentRequiredException noCredits = assertThrows( PaymentRequiredException.class, @@ -221,8 +227,8 @@ void articlesAreRequestedInTheBodyAndActedOnByPath() { @Test void suggestSeedsSendsTheCurrentSeedsAndPromptAndReturnsTheTerms() { FakeTransport transport = new FakeTransport() - .enqueue(200, "{\"data\":{\"seeds\":[\"schedule posts\",\"best time to post\"]}}") - .enqueue(200, "{\"data\":{\"seeds\":[]}}") + .enqueue(200, "{\"seeds\":[\"schedule posts\",\"best time to post\"]}") + .enqueue(200, "{\"seeds\":[]}") .enqueue(402, "{\"error\":\"insufficient_credits\",\"message\":\"Balance\"}"); FoPost client = TestSupport.client(transport); @@ -244,13 +250,13 @@ void suggestSeedsSendsTheCurrentSeedsAndPromptAndReturnsTheTerms() { void submitArticleMovesItToReviewByPath() { String inReview = ARTICLE.replace("\"status\":\"queued\"", "\"status\":\"in_review\""); FakeTransport transport = new FakeTransport() - .enqueue(200, "{\"data\":" + inReview + "}") + .enqueue(200, inReview) .enqueue(409, "{\"error\":\"invalid_state\",\"message\":\"Only a draft can be submitted\"}"); FoPost client = TestSupport.client(transport); BlogArticle submitted = client.blog().submitArticle("w1", "art_1"); assertEquals("POST", transport.last().method()); - assertEquals("https://api.fopost.test/v1/blog/articles/art_1/submit?workspaceId=w1", transport.last().url()); + assertEquals("https://api.fopost.test/v1/blog/articles/art_1/submit?workspace_id=w1", transport.last().url()); assertNull(transport.last().body()); assertEquals(BlogArticleStatus.IN_REVIEW, submitted.status()); assertTrue(BlogArticleStatus.ALL.contains(submitted.status())); From 8dfd07a1d1c14b6612263904e0f615c3e9676415 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 14:39:06 +0200 Subject: [PATCH 4/4] docs(changelog): note the blog contract fix --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5194312..05b24d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,10 @@ All notable changes to this project are documented here. The format follows ### Changed +- `blog()` matches the live API contract: the workspace travels as `workspace_id` on the query + string, single objects are read bare and lists from `{data}`, and responses are read in + snake_case. `BlogWebsiteTest.status` is now `httpStatus`, read from the API's `http_status`; + a failed test still comes back with `ok` false rather than as an exception. - `blog().planKeywords` no longer fills the calendar: results land as `suggested` keywords, and setting one to `planned` with `updateKeyword` picks it and gives it the next free date. - `blog().publishArticle` and `publishAt` on `updateArticle` need an article in review (or