From 6698f6eb604952bea3c53a625931637e016b5994 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 01:55:47 +0200 Subject: [PATCH 1/4] feat: blogs resource for articles and products on a connected site --- README.md | 1 + src/main/java/com/fopost/sdk/FoPost.java | 8 ++ .../com/fopost/sdk/model/RemoteArticle.java | 25 ++++ .../java/com/fopost/sdk/model/RemoteBlog.java | 10 ++ .../com/fopost/sdk/model/RemoteProduct.java | 24 ++++ .../fopost/sdk/param/ArticleListParams.java | 36 ++++++ .../com/fopost/sdk/param/ArticleParams.java | 63 ++++++++++ .../fopost/sdk/param/ProductListParams.java | 36 ++++++ .../com/fopost/sdk/param/ProductParams.java | 54 +++++++++ .../fopost/sdk/resource/BlogsResource.java | 110 +++++++++++++++++ src/test/java/com/fopost/sdk/BlogsTest.java | 112 ++++++++++++++++++ 11 files changed, 479 insertions(+) create mode 100644 src/main/java/com/fopost/sdk/model/RemoteArticle.java create mode 100644 src/main/java/com/fopost/sdk/model/RemoteBlog.java create mode 100644 src/main/java/com/fopost/sdk/model/RemoteProduct.java create mode 100644 src/main/java/com/fopost/sdk/param/ArticleListParams.java create mode 100644 src/main/java/com/fopost/sdk/param/ArticleParams.java create mode 100644 src/main/java/com/fopost/sdk/param/ProductListParams.java create mode 100644 src/main/java/com/fopost/sdk/param/ProductParams.java create mode 100644 src/main/java/com/fopost/sdk/resource/BlogsResource.java create mode 100644 src/test/java/com/fopost/sdk/BlogsTest.java diff --git a/README.md b/README.md index 4aab69e..eac1224 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,7 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | `ads()` | `list`, `external`, `boostable`, `connections`, `sources`, `providers`, `authorize`, `deleteConnection`, `boost`, `create`, `refresh`, `setStatus`, `delete`, `accountTree`, `createCampaign`, `campaign`, `updateCampaign`, `deleteCampaign`, `duplicateCampaign`, `createAdSet`, `adSet`, `updateAdSet`, `deleteAdSet`, `duplicateAdSet`, `createNetworkAd`, `networkAd`, `updateNetworkAd`, `deleteNetworkAd`, `duplicateNetworkAd`, `bulkSetStatus`, `creatives`, `createCreative`, `creative`, `deleteCreative`, `estimateReach`, `insights`, `adInsights`, `audiences`, `createAudience`, `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`, `addAudienceCompanies`, `searchTargeting`, `leadForms`, `createLeadForm`, `leadForm`, `archiveLeadForm`, `leads`, `leadsFeed`, `leadPages`, `subscribeLeadPage`, `unsubscribeLeadPage`, `bidPricing`, `supplyForecast`, `conversionRules`, `createConversionRule`, `conversionRule`, `updateConversionRule`, `deleteConversionRule`, `attachConversionRule`, `detachConversionRule`, `conversionMetrics`, `sendConversionEvents` | | `googleAds()` | `recommendations`, `optimizationScore`, `applyRecommendations`, `dismissRecommendations`, `keywords`, `createKeyword`, `updateKeyword`, `deleteKeyword`, `keywordIdeas`, `keywordMetrics`, `searchTerms`, `bidStrategies`, `createBidStrategy`, `adSchedule`, `setAdSchedule`, `negativeKeywordLists`, `createNegativeKeywordList`, `addNegativeKeywords`, `attachNegativeKeywordList`, `assets`, `createAsset`, `attachAsset`, `deleteAsset`, `assetGroups`, `createAssetGroup`, `updateAssetGroup`, `deleteAssetGroup`, `localServicesLeads`, `conversionActions`, `createConversionAction`, `uploadConversions`, `uploadConversionAdjustments`, `query` | | `googleBusiness()` | `getLocation`, `updateLocation`, `getAttributes`, `updateAttributes`, `getMenus`, `replaceMenus`, `getServices`, `replaceServices`, `listMedia`, `addMedia`, `deleteMedia`, `listPlaceActions`, `createPlaceAction`, `updatePlaceAction`, `deletePlaceAction`, `getVerificationOptions`, `startVerification`, `completeVerification`, `getPerformance`, `getSearchKeywords`, `assign` | +| `blogs()` | `listBlogs`, `listArticles`, `getArticle`, `createArticle`, `updateArticle`, `deleteArticle`, `listProducts`, `updateProduct` | | `validate()` | `post`, `length`, `media`, `subreddit` | | `activity()` | `list` | diff --git a/src/main/java/com/fopost/sdk/FoPost.java b/src/main/java/com/fopost/sdk/FoPost.java index b99d595..afea03f 100644 --- a/src/main/java/com/fopost/sdk/FoPost.java +++ b/src/main/java/com/fopost/sdk/FoPost.java @@ -16,6 +16,7 @@ import com.fopost.sdk.resource.BlogResource; import com.fopost.sdk.resource.BroadcastsResource; import com.fopost.sdk.resource.ContactsResource; +import com.fopost.sdk.resource.BlogsResource; import com.fopost.sdk.resource.InboxResource; import com.fopost.sdk.resource.KnowledgeResource; import com.fopost.sdk.resource.GoogleAdsResource; @@ -86,6 +87,7 @@ public final class FoPost { private final AdsResource ads; private final GoogleAdsResource googleAds; private final BlogResource blog; + private final BlogsResource blogs; private final ValidateResource validate; private FoPost(ApiClient http) { @@ -111,6 +113,7 @@ private FoPost(ApiClient http) { this.ads = new AdsResource(http); this.googleAds = new GoogleAdsResource(http); this.blog = new BlogResource(http); + this.blogs = new BlogsResource(http); this.validate = new ValidateResource(http); } @@ -186,6 +189,11 @@ public GoogleBusinessResource googleBusiness() { return googleBusiness; } + /** Articles and products that already live on a connected site. */ + public BlogsResource blogs() { + return blogs; + } + public InboxResource inbox() { return inbox; } diff --git a/src/main/java/com/fopost/sdk/model/RemoteArticle.java b/src/main/java/com/fopost/sdk/model/RemoteArticle.java new file mode 100644 index 0000000..bf0395d --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/RemoteArticle.java @@ -0,0 +1,25 @@ +package com.fopost.sdk.model; + +import java.time.Instant; +import java.util.List; + +/** + * An article that already lives on a connected site, addressed by the + * platform's own id. + * + *

{@code status} is one of {@code published}, {@code draft}, {@code pending} + * or {@code scheduled}. + */ +public record RemoteArticle( + String id, + String blogId, + String title, + String bodyHtml, + String excerpt, + String status, + String authorName, + List tags, + String imageUrl, + String url, + Instant publishedAt, + Instant updatedAt) {} diff --git a/src/main/java/com/fopost/sdk/model/RemoteBlog.java b/src/main/java/com/fopost/sdk/model/RemoteBlog.java new file mode 100644 index 0000000..898d561 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/RemoteBlog.java @@ -0,0 +1,10 @@ +package com.fopost.sdk.model; + +/** + * A blog on a connected site. {@code id} is the platform's own id, never a + * FoPost id. + * + *

A Shopify store reports every blog it has; WordPress has one implicit blog + * and reports it under the id {@code default}, so both answer the same shape. + */ +public record RemoteBlog(String id, String title, String handle, String url) {} diff --git a/src/main/java/com/fopost/sdk/model/RemoteProduct.java b/src/main/java/com/fopost/sdk/model/RemoteProduct.java new file mode 100644 index 0000000..34d35a1 --- /dev/null +++ b/src/main/java/com/fopost/sdk/model/RemoteProduct.java @@ -0,0 +1,24 @@ +package com.fopost.sdk.model; + +import java.time.Instant; +import java.util.List; + +/** + * A product on a connected store. {@code price} is the lowest variant price, as + * a decimal string; {@code status} is {@code active}, {@code draft} or + * {@code archived}. + */ +public record RemoteProduct( + String id, + String title, + String handle, + String status, + String description, + String vendor, + String productType, + List tags, + String imageUrl, + String url, + String price, + String currency, + Instant updatedAt) {} diff --git a/src/main/java/com/fopost/sdk/param/ArticleListParams.java b/src/main/java/com/fopost/sdk/param/ArticleListParams.java new file mode 100644 index 0000000..5a75896 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/ArticleListParams.java @@ -0,0 +1,36 @@ +package com.fopost.sdk.param; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Filters for listing a blog's articles. Every field is optional. */ +public final class ArticleListParams { + + private final Map query = new LinkedHashMap<>(); + + public static ArticleListParams create() { + return new ArticleListParams(); + } + + /** How many to return, 1 to 50. The API defaults to 20. */ + public ArticleListParams limit(int limit) { + query.put("limit", limit); + return this; + } + + /** {@code published}, {@code draft}, {@code pending} or {@code scheduled}. */ + public ArticleListParams status(String status) { + query.put("status", status); + return this; + } + + /** Matches the article title. */ + public ArticleListParams q(String q) { + query.put("q", q); + return this; + } + + public Map toQuery() { + return new LinkedHashMap<>(query); + } +} diff --git a/src/main/java/com/fopost/sdk/param/ArticleParams.java b/src/main/java/com/fopost/sdk/param/ArticleParams.java new file mode 100644 index 0000000..9f05090 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/ArticleParams.java @@ -0,0 +1,63 @@ +package com.fopost.sdk.param; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The body of a create or update on an article. + * + *

Only what is set here travels, so on an update an omitted field keeps + * whatever the site already had. A create needs at least {@code title} and + * {@code body}; an update needs at least one field. + */ +public final class ArticleParams { + + private final Map body = new LinkedHashMap<>(); + + public static ArticleParams create() { + return new ArticleParams(); + } + + public ArticleParams title(String title) { + body.put("title", title); + return this; + } + + /** FoPost body markup; the site's own format is rendered from it. */ + public ArticleParams body(String value) { + body.put("body", value); + return this; + } + + public ArticleParams excerpt(String excerpt) { + body.put("excerpt", excerpt); + return this; + } + + /** {@code published}, {@code draft}, {@code pending} or {@code scheduled}. */ + public ArticleParams status(String status) { + body.put("status", status); + return this; + } + + public ArticleParams tags(List tags) { + body.put("tags", tags); + return this; + } + + public ArticleParams authorName(String authorName) { + body.put("author_name", authorName); + return this; + } + + /** Public http(s) URL of the featured image. */ + public ArticleParams imageUrl(String imageUrl) { + body.put("image_url", imageUrl); + return this; + } + + public Map toBody() { + return new LinkedHashMap<>(body); + } +} diff --git a/src/main/java/com/fopost/sdk/param/ProductListParams.java b/src/main/java/com/fopost/sdk/param/ProductListParams.java new file mode 100644 index 0000000..3479040 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/ProductListParams.java @@ -0,0 +1,36 @@ +package com.fopost.sdk.param; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** Filters for listing a store's products. Every field is optional. */ +public final class ProductListParams { + + private final Map query = new LinkedHashMap<>(); + + public static ProductListParams create() { + return new ProductListParams(); + } + + /** How many to return, 1 to 50. The API defaults to 20. */ + public ProductListParams limit(int limit) { + query.put("limit", limit); + return this; + } + + /** {@code active}, {@code draft} or {@code archived}. */ + public ProductListParams status(String status) { + query.put("status", status); + return this; + } + + /** Matches the product title. */ + public ProductListParams q(String q) { + query.put("q", q); + return this; + } + + public Map toQuery() { + return new LinkedHashMap<>(query); + } +} diff --git a/src/main/java/com/fopost/sdk/param/ProductParams.java b/src/main/java/com/fopost/sdk/param/ProductParams.java new file mode 100644 index 0000000..127a9e6 --- /dev/null +++ b/src/main/java/com/fopost/sdk/param/ProductParams.java @@ -0,0 +1,54 @@ +package com.fopost.sdk.param; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * The body of an update on a store product. Only what is set travels, so an + * omitted field keeps whatever the store already had. Set at least one. + */ +public final class ProductParams { + + private final Map body = new LinkedHashMap<>(); + + public static ProductParams create() { + return new ProductParams(); + } + + public ProductParams title(String title) { + body.put("title", title); + return this; + } + + /** Body markup, rendered to HTML on the store. */ + public ProductParams description(String description) { + body.put("description", description); + return this; + } + + /** {@code active}, {@code draft} or {@code archived}. */ + public ProductParams status(String status) { + body.put("status", status); + return this; + } + + public ProductParams tags(List tags) { + body.put("tags", tags); + return this; + } + + public ProductParams productType(String productType) { + body.put("product_type", productType); + return this; + } + + public ProductParams vendor(String vendor) { + body.put("vendor", vendor); + return this; + } + + public Map toBody() { + return new LinkedHashMap<>(body); + } +} diff --git a/src/main/java/com/fopost/sdk/resource/BlogsResource.java b/src/main/java/com/fopost/sdk/resource/BlogsResource.java new file mode 100644 index 0000000..db6deef --- /dev/null +++ b/src/main/java/com/fopost/sdk/resource/BlogsResource.java @@ -0,0 +1,110 @@ +package com.fopost.sdk.resource; + +import com.fopost.sdk.internal.ApiClient; +import com.fopost.sdk.model.RemoteArticle; +import com.fopost.sdk.model.RemoteBlog; +import com.fopost.sdk.model.RemoteProduct; +import com.fopost.sdk.param.ArticleListParams; +import com.fopost.sdk.param.ArticleParams; +import com.fopost.sdk.param.ProductListParams; +import com.fopost.sdk.param.ProductParams; +import java.util.List; +import java.util.Map; + +/** + * Articles and products that already live on a connected site, addressed by the + * platform's own ids rather than FoPost ids. + * + *

Reads need the {@code posts} scope; anything that changes the site needs + * {@code posts} and {@code publish}. An account on a platform that cannot manage + * articles answers 400 {@code unsupported_platform}. + * + *

An update changes the live article in place and never creates a second + * post, so a link already shared keeps working. + */ +public final class BlogsResource { + + private final ApiClient http; + + public BlogsResource(ApiClient http) { + this.http = http; + } + + private static String base(String accountId) { + return "/v1/accounts/" + accountId + "/blogs"; + } + + /** The blogs the account can write to. WordPress reports one, under {@code default}. */ + public List listBlogs(String accountId) { + return http.convertList( + ApiClient.unwrap(http.get(base(accountId), null)), RemoteBlog.class); + } + + public List listArticles(String accountId, String blogId) { + return listArticles(accountId, blogId, null); + } + + public List listArticles( + String accountId, String blogId, ArticleListParams params) { + Map query = params == null ? null : params.toQuery(); + return http.convertList( + ApiClient.unwrap(http.get(base(accountId) + "/" + blogId + "/articles", query)), + RemoteArticle.class); + } + + public RemoteArticle getArticle(String accountId, String blogId, String articleId) { + return http.convert( + ApiClient.unwrap( + http.get(base(accountId) + "/" + blogId + "/articles/" + articleId, null)), + RemoteArticle.class); + } + + /** Writes a new article. Needs the {@code publish} scope. */ + public RemoteArticle createArticle(String accountId, String blogId, ArticleParams params) { + return http.convert( + ApiClient.unwrap( + http.post(base(accountId) + "/" + blogId + "/articles", params.toBody())), + RemoteArticle.class); + } + + /** Changes the live article in place; never creates a duplicate. */ + public RemoteArticle updateArticle( + String accountId, String blogId, String articleId, ArticleParams params) { + return http.convert( + ApiClient.unwrap( + http.request( + "PATCH", + base(accountId) + "/" + blogId + "/articles/" + articleId, + params.toBody(), + null)), + RemoteArticle.class); + } + + /** Removes the article from the site. This cannot be undone. */ + public void deleteArticle(String accountId, String blogId, String articleId) { + http.delete(base(accountId) + "/" + blogId + "/articles/" + articleId); + } + + public List listProducts(String accountId) { + return listProducts(accountId, null); + } + + public List listProducts(String accountId, ProductListParams params) { + Map query = params == null ? null : params.toQuery(); + return http.convertList( + ApiClient.unwrap(http.get("/v1/accounts/" + accountId + "/products", query)), + RemoteProduct.class); + } + + /** Changes the product on the store. Only what is set travels. */ + public RemoteProduct updateProduct(String accountId, String productId, ProductParams params) { + return http.convert( + ApiClient.unwrap( + http.request( + "PATCH", + "/v1/accounts/" + accountId + "/products/" + productId, + params.toBody(), + null)), + RemoteProduct.class); + } +} diff --git a/src/test/java/com/fopost/sdk/BlogsTest.java b/src/test/java/com/fopost/sdk/BlogsTest.java new file mode 100644 index 0000000..e111917 --- /dev/null +++ b/src/test/java/com/fopost/sdk/BlogsTest.java @@ -0,0 +1,112 @@ +package com.fopost.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.fopost.sdk.param.ArticleListParams; +import com.fopost.sdk.param.ArticleParams; +import com.fopost.sdk.param.ProductParams; +import java.util.List; +import org.junit.jupiter.api.Test; + +class BlogsTest { + + private static final String ARTICLE = + "{\"id\":\"99\",\"blog_id\":\"11\",\"title\":\"Spring drop\"," + + "\"body_html\":\"

Hello

\",\"excerpt\":\"A short summary\"," + + "\"status\":\"published\",\"author_name\":\"Store Owner\",\"tags\":[\"news\"]," + + "\"image_url\":null,\"url\":\"https://demo.myshopify.com/blogs/article/spring-drop\"," + + "\"published_at\":\"2026-09-01T10:00:00.000Z\",\"updated_at\":null}"; + + @Test + void listBlogsReadsEveryBlogOnTheSite() { + FakeTransport transport = new FakeTransport() + .enqueue(200, "{\"data\":[{\"id\":\"11\",\"title\":\"News\",\"handle\":\"news\",\"url\":null}]}"); + + var blogs = TestSupport.client(transport).blogs().listBlogs("a1"); + + assertEquals("https://api.fopost.test/v1/accounts/a1/blogs", transport.last().url()); + assertEquals(1, blogs.size()); + assertEquals("11", blogs.get(0).id()); + assertEquals("News", blogs.get(0).title()); + } + + @Test + void listArticlesSendsTheFilters() { + FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":[" + ARTICLE + "]}"); + + var articles = TestSupport.client(transport) + .blogs() + .listArticles("a1", "11", ArticleListParams.create().limit(5).status("draft").q("spring")); + + assertEquals( + "https://api.fopost.test/v1/accounts/a1/blogs/11/articles?limit=5&status=draft&q=spring", + transport.last().url()); + assertEquals("99", articles.get(0).id()); + assertEquals(List.of("news"), articles.get(0).tags()); + assertEquals("Store Owner", articles.get(0).authorName()); + } + + /** + * The article id is in the path and only what the caller set is sent, which + * is what stops an edit from creating a second post on the site. + */ + @Test + void updateArticleChangesItInPlace() { + FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":" + ARTICLE + "}"); + + TestSupport.client(transport) + .blogs() + .updateArticle("a1", "11", "99", ArticleParams.create().title("Spring drop, restocked")); + + assertEquals("PATCH", transport.last().method()); + assertEquals( + "https://api.fopost.test/v1/accounts/a1/blogs/11/articles/99", transport.last().url()); + assertEquals("{\"title\":\"Spring drop, restocked\"}", transport.last().bodyAsString()); + } + + @Test + void createArticleSendsOnlyWhatItWasGiven() { + FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":" + ARTICLE + "}"); + + TestSupport.client(transport) + .blogs() + .createArticle( + "a1", "11", ArticleParams.create().title("Spring drop").body("Hello").status("draft")); + + assertEquals("POST", transport.last().method()); + assertEquals( + "{\"title\":\"Spring drop\",\"body\":\"Hello\",\"status\":\"draft\"}", + transport.last().bodyAsString()); + } + + @Test + void deleteArticleHitsTheArticleRoute() { + FakeTransport transport = new FakeTransport().enqueue(204, ""); + + TestSupport.client(transport).blogs().deleteArticle("a1", "11", "99"); + + assertEquals("DELETE", transport.last().method()); + assertEquals( + "https://api.fopost.test/v1/accounts/a1/blogs/11/articles/99", transport.last().url()); + } + + @Test + void updateProductSendsOnlyWhatChanged() { + FakeTransport transport = new FakeTransport() + .enqueue( + 200, + "{\"data\":{\"id\":\"7\",\"title\":\"Mug XL\",\"handle\":\"mug\",\"status\":\"draft\"," + + "\"description\":null,\"vendor\":null,\"product_type\":\"Drinkware\"," + + "\"tags\":[],\"image_url\":null,\"url\":null,\"price\":\"12.00\"," + + "\"currency\":\"USD\",\"updated_at\":null}}"); + + var product = TestSupport.client(transport) + .blogs() + .updateProduct("a1", "7", ProductParams.create().title("Mug XL").productType("Drinkware")); + + assertEquals("PATCH", transport.last().method()); + assertEquals( + "{\"title\":\"Mug XL\",\"product_type\":\"Drinkware\"}", transport.last().bodyAsString()); + assertEquals("12.00", product.price()); + } +} From 63d0f372fbfd8dbaa7a64d57cdf4f22deaf4b40d Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 01:56:32 +0200 Subject: [PATCH 2/4] feat: blogs resource for articles and products on a connected site Reaches content the connected site already owns, by the platform's own ids: the blogs on a WordPress site or a Shopify store, the articles on a blog, and a Shopify store's products. updateArticle addresses the article by its own id and sends only what was set on ArticleParams, so editing a live post changes it in place instead of forking it into a duplicate. Reads need the posts scope; the writes need publish too. --- README.md | 1 + src/test/java/com/fopost/sdk/BlogsTest.java | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index eac1224..38d4777 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | `googleAds()` | `recommendations`, `optimizationScore`, `applyRecommendations`, `dismissRecommendations`, `keywords`, `createKeyword`, `updateKeyword`, `deleteKeyword`, `keywordIdeas`, `keywordMetrics`, `searchTerms`, `bidStrategies`, `createBidStrategy`, `adSchedule`, `setAdSchedule`, `negativeKeywordLists`, `createNegativeKeywordList`, `addNegativeKeywords`, `attachNegativeKeywordList`, `assets`, `createAsset`, `attachAsset`, `deleteAsset`, `assetGroups`, `createAssetGroup`, `updateAssetGroup`, `deleteAssetGroup`, `localServicesLeads`, `conversionActions`, `createConversionAction`, `uploadConversions`, `uploadConversionAdjustments`, `query` | | `googleBusiness()` | `getLocation`, `updateLocation`, `getAttributes`, `updateAttributes`, `getMenus`, `replaceMenus`, `getServices`, `replaceServices`, `listMedia`, `addMedia`, `deleteMedia`, `listPlaceActions`, `createPlaceAction`, `updatePlaceAction`, `deletePlaceAction`, `getVerificationOptions`, `startVerification`, `completeVerification`, `getPerformance`, `getSearchKeywords`, `assign` | | `blogs()` | `listBlogs`, `listArticles`, `getArticle`, `createArticle`, `updateArticle`, `deleteArticle`, `listProducts`, `updateProduct` | +| `blogs()` | `listBlogs`, `listArticles`, `getArticle`, `createArticle`, `updateArticle`, `deleteArticle`, `listProducts`, `updateProduct` | | `validate()` | `post`, `length`, `media`, `subreddit` | | `activity()` | `list` | diff --git a/src/test/java/com/fopost/sdk/BlogsTest.java b/src/test/java/com/fopost/sdk/BlogsTest.java index e111917..e6c3a2d 100644 --- a/src/test/java/com/fopost/sdk/BlogsTest.java +++ b/src/test/java/com/fopost/sdk/BlogsTest.java @@ -61,7 +61,7 @@ void updateArticleChangesItInPlace() { assertEquals("PATCH", transport.last().method()); assertEquals( "https://api.fopost.test/v1/accounts/a1/blogs/11/articles/99", transport.last().url()); - assertEquals("{\"title\":\"Spring drop, restocked\"}", transport.last().bodyAsString()); + assertEquals("{\"title\":\"Spring drop, restocked\"}", transport.lastBody()); } @Test @@ -76,7 +76,7 @@ void createArticleSendsOnlyWhatItWasGiven() { assertEquals("POST", transport.last().method()); assertEquals( "{\"title\":\"Spring drop\",\"body\":\"Hello\",\"status\":\"draft\"}", - transport.last().bodyAsString()); + transport.lastBody()); } @Test @@ -106,7 +106,7 @@ void updateProductSendsOnlyWhatChanged() { assertEquals("PATCH", transport.last().method()); assertEquals( - "{\"title\":\"Mug XL\",\"product_type\":\"Drinkware\"}", transport.last().bodyAsString()); + "{\"title\":\"Mug XL\",\"product_type\":\"Drinkware\"}", transport.lastBody()); assertEquals("12.00", product.price()); } } From 54f37bf779173e3881f43531a5f3d86349a94997 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 01:57:00 +0200 Subject: [PATCH 3/4] docs: drop the duplicated blogs row from the resource table --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 38d4777..1869ada 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,6 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | `ads()` | `list`, `external`, `boostable`, `connections`, `sources`, `providers`, `authorize`, `deleteConnection`, `boost`, `create`, `refresh`, `setStatus`, `delete`, `accountTree`, `createCampaign`, `campaign`, `updateCampaign`, `deleteCampaign`, `duplicateCampaign`, `createAdSet`, `adSet`, `updateAdSet`, `deleteAdSet`, `duplicateAdSet`, `createNetworkAd`, `networkAd`, `updateNetworkAd`, `deleteNetworkAd`, `duplicateNetworkAd`, `bulkSetStatus`, `creatives`, `createCreative`, `creative`, `deleteCreative`, `estimateReach`, `insights`, `adInsights`, `audiences`, `createAudience`, `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`, `addAudienceCompanies`, `searchTargeting`, `leadForms`, `createLeadForm`, `leadForm`, `archiveLeadForm`, `leads`, `leadsFeed`, `leadPages`, `subscribeLeadPage`, `unsubscribeLeadPage`, `bidPricing`, `supplyForecast`, `conversionRules`, `createConversionRule`, `conversionRule`, `updateConversionRule`, `deleteConversionRule`, `attachConversionRule`, `detachConversionRule`, `conversionMetrics`, `sendConversionEvents` | | `googleAds()` | `recommendations`, `optimizationScore`, `applyRecommendations`, `dismissRecommendations`, `keywords`, `createKeyword`, `updateKeyword`, `deleteKeyword`, `keywordIdeas`, `keywordMetrics`, `searchTerms`, `bidStrategies`, `createBidStrategy`, `adSchedule`, `setAdSchedule`, `negativeKeywordLists`, `createNegativeKeywordList`, `addNegativeKeywords`, `attachNegativeKeywordList`, `assets`, `createAsset`, `attachAsset`, `deleteAsset`, `assetGroups`, `createAssetGroup`, `updateAssetGroup`, `deleteAssetGroup`, `localServicesLeads`, `conversionActions`, `createConversionAction`, `uploadConversions`, `uploadConversionAdjustments`, `query` | | `googleBusiness()` | `getLocation`, `updateLocation`, `getAttributes`, `updateAttributes`, `getMenus`, `replaceMenus`, `getServices`, `replaceServices`, `listMedia`, `addMedia`, `deleteMedia`, `listPlaceActions`, `createPlaceAction`, `updatePlaceAction`, `deletePlaceAction`, `getVerificationOptions`, `startVerification`, `completeVerification`, `getPerformance`, `getSearchKeywords`, `assign` | -| `blogs()` | `listBlogs`, `listArticles`, `getArticle`, `createArticle`, `updateArticle`, `deleteArticle`, `listProducts`, `updateProduct` | | `blogs()` | `listBlogs`, `listArticles`, `getArticle`, `createArticle`, `updateArticle`, `deleteArticle`, `listProducts`, `updateProduct` | | `validate()` | `post`, `length`, `media`, `subreddit` | | `activity()` | `list` | From 1f4e0dc5edadd5d54bcdc612079a2cd9dcf35009 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 18 Oct 2026 15:13:07 +0200 Subject: [PATCH 4/4] chore(release): 0.3.0 --- CHANGELOG.md | 55 ++++++++++++++++++++++++++++------------------------ 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05b24d8..5d08096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to this project are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses [Semantic Versioning](https://semver.org/). -## [Unreleased] +## [0.3.0] - 2026-10-18 ### Added @@ -94,6 +94,35 @@ 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`. +- `ads()`: the campaign tree and its objects, read live from Meta: `accountTree`, and + create/get/update/delete/duplicate for campaigns, ad sets and network ads, plus `bulkSetStatus`. + The writes need the `publish` scope as well as `ads`. +- `ads()`: `creatives`, `createCreative` (image, video or carousel), `creative`, `deleteCreative`; + `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`; `estimateReach`; `insights` + and `adInsights` with an optional breakdown and daily timeline. +- `ads()`: `leadForm`, `archiveLeadForm`, the cursor-paged `leadsFeed`, and `leadPages`, + `subscribeLeadPage`, `unsubscribeLeadPage`. +- `CreateAdParams.urlTags` and `Ad.Creative.urlTags`. + +- `inbox()`: `like`, `unlike`, `pin`, `unpin`, `react`, `editComment`, `startConversation` + (`StartConversationParams`), `setTyping`, and `reply` with `InboxReplyParams` for media and quick + replies. All of them also need the `publish` scope, as does deleting our own reply. +- `InboxItem` gains `liked`, `pinned`, `reaction`, `editedAt` and the `canLike`, `canPin`, + `canEdit`, `canReact`, `canSendMedia`, `canQuickReply` and `canPrivateReply` flags; + `InboxAccount` gains `canStartConversation`. +- `validate` resource (`client.validate()`): `post`, `length` and `media` check a draft, a text + length, or a media url against the platform rules without creating a post. Needs the `posts` + scope. +- `accounts()`: `redditSubreddits`, `redditSubredditRules`, `redditFlairs` and + `setRedditDefaultSubreddit` for a Reddit account, plus `validate().subreddit` to check a + subreddit before publishing and `inbox().vote(itemId, direction)` with `up`, `down` or `none` + to take an earlier vote back. A flair id is valid only in the subreddit it came from. +- `blogs` resource (`client.blogs()`): `listBlogs`, `listArticles`, `getArticle`, `createArticle`, + `updateArticle`, `deleteArticle`, `listProducts` and `updateProduct` reach the blogs, articles + and products a connected site already owns, addressed by the platform's own ids. + `updateArticle` edits the live article in place. Reads need the `posts` scope; writes also + need `publish`. + ### Changed - `blog()` matches the live API contract: the workspace travels as `workspace_id` on the query @@ -117,30 +146,6 @@ All notable changes to this project are documented here. The format follows - **Breaking:** `BlogSettings.autoPublish` and `BlogParams.Settings.autoPublish(...)`. Every article now goes through `submitArticle` before it publishes. -## [0.3.0] - 2026-09-19 - -### Added - -- `ads()`: the campaign tree and its objects, read live from Meta: `accountTree`, and - create/get/update/delete/duplicate for campaigns, ad sets and network ads, plus `bulkSetStatus`. - The writes need the `publish` scope as well as `ads`. -- `ads()`: `creatives`, `createCreative` (image, video or carousel), `creative`, `deleteCreative`; - `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`; `estimateReach`; `insights` - and `adInsights` with an optional breakdown and daily timeline. -- `ads()`: `leadForm`, `archiveLeadForm`, the cursor-paged `leadsFeed`, and `leadPages`, - `subscribeLeadPage`, `unsubscribeLeadPage`. -- `CreateAdParams.urlTags` and `Ad.Creative.urlTags`. - -- `inbox()`: `like`, `unlike`, `pin`, `unpin`, `react`, `editComment`, `startConversation` - (`StartConversationParams`), `setTyping`, and `reply` with `InboxReplyParams` for media and quick - replies. All of them also need the `publish` scope, as does deleting our own reply. -- `InboxItem` gains `liked`, `pinned`, `reaction`, `editedAt` and the `canLike`, `canPin`, - `canEdit`, `canReact`, `canSendMedia`, `canQuickReply` and `canPrivateReply` flags; - `InboxAccount` gains `canStartConversation`. -- `validate` resource (`client.validate()`): `post`, `length` and `media` check a draft, a text - length, or a media url against the platform rules without creating a post. Needs the `posts` - scope. - ## [0.2.0] - 2026-09-19 ### Added