From 37b626d722f8fad73909919261191f5f42318a40 Mon Sep 17 00:00:00 2001 From: Ali Hesari Date: Sun, 20 Sep 2026 03:13:11 +0200 Subject: [PATCH] feat(google-business): manage a connected Business Profile location A googleBusiness() resource covering the profile, attributes, food menus, services, photos, place action links, verification, performance and search keywords, plus assign, which hands the location to another workspace. Responses come back as JsonNode: Google's own shape relayed field for field, rather than models we would have to keep chasing. --- README.md | 3 + src/main/java/com/fopost/sdk/FoPost.java | 8 + .../sdk/resource/GoogleBusinessResource.java | 238 ++++++++++++++++++ .../com/fopost/sdk/GoogleBusinessTest.java | 117 +++++++++ 4 files changed, 366 insertions(+) create mode 100644 src/main/java/com/fopost/sdk/resource/GoogleBusinessResource.java create mode 100644 src/test/java/com/fopost/sdk/GoogleBusinessTest.java diff --git a/README.md b/README.md index 5b0de09..137753d 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,9 @@ long failed = client.posts().stream(PostListParams.create().workspaceId(workspac | `sequences()` | `list`, `get`, `create`, `update`, `delete`, `enroll`, `unenroll`, `enrollments` | | `knowledge()` | `list`, `create`, `createText`, `createUrl`, `createFile`, `update`, `delete`, `sync`, `search` | | `ads()` | `list`, `external`, `boostable`, `connections`, `sources`, `authorizeMeta`, `deleteConnection`, `boost`, `create`, `refresh`, `setStatus`, `delete`, `accountTree`, `createCampaign`, `campaign`, `updateCampaign`, `deleteCampaign`, `duplicateCampaign`, `createAdSet`, `adSet`, `updateAdSet`, `deleteAdSet`, `duplicateAdSet`, `createNetworkAd`, `networkAd`, `updateNetworkAd`, `deleteNetworkAd`, `duplicateNetworkAd`, `bulkSetStatus`, `creatives`, `createCreative`, `creative`, `deleteCreative`, `estimateReach`, `insights`, `adInsights`, `audiences`, `createAudience`, `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`, `searchTargeting`, `leadForms`, `createLeadForm`, `leadForm`, `archiveLeadForm`, `leads`, `leadsFeed`, `leadPages`, `subscribeLeadPage`, `unsubscribeLeadPage`, `goals`, `catalogs`, `createCatalog`, `catalog`, `updateCatalog`, `deleteCatalog`, `catalogProducts`, `writeCatalogProducts`, `productFeeds`, `createProductFeed`, `deleteProductFeed`, `feedUploads`, `startFeedUpload`, `productSets`, `createProductSet`, `updateProductSet`, `deleteProductSet`, `reachFrequency`, `createReachFrequency`, `reachFrequencyPrediction`, `reserveReachFrequency`, `cancelReachFrequency`, `library`, `partnershipCreators`, `requestPartnership`, `revokePartnership`, `accountActivity`, `labels`, `createLabel`, `updateLabel`, `deleteLabel`, `applyLabel`, `studies`, `createStudy`, `study`, `deleteStudy`, `iosCampaignLimits`, `highDemandPeriods`, `createHighDemandPeriod`, `deleteHighDemandPeriod`, `valueRuleSets`, `createValueRuleSet`, `deleteValueRuleSet` | +| `inbox()` | `list`, `threads`, `conversations`, `unreadCount`, `accounts`, `platforms`, `markThreadRead`, `markConversationRead`, `refresh`, `update`, `editComment`, `reply`, `hide`, `unhide`, `delete`, `like`, `unlike`, `pin`, `unpin`, `react`, `startConversation`, `setTyping`, `listApprovals`, `approveReply`, `rejectReply` | +| `ads()` | `list`, `external`, `boostable`, `connections`, `sources`, `authorizeMeta`, `deleteConnection`, `boost`, `create`, `refresh`, `setStatus`, `delete`, `accountTree`, `createCampaign`, `campaign`, `updateCampaign`, `deleteCampaign`, `duplicateCampaign`, `createAdSet`, `adSet`, `updateAdSet`, `deleteAdSet`, `duplicateAdSet`, `createNetworkAd`, `networkAd`, `updateNetworkAd`, `deleteNetworkAd`, `duplicateNetworkAd`, `bulkSetStatus`, `creatives`, `createCreative`, `creative`, `deleteCreative`, `estimateReach`, `insights`, `adInsights`, `audiences`, `createAudience`, `audience`, `updateAudience`, `deleteAudience`, `addAudienceUsers`, `searchTargeting`, `leadForms`, `createLeadForm`, `leadForm`, `archiveLeadForm`, `leads`, `leadsFeed`, `leadPages`, `subscribeLeadPage`, `unsubscribeLeadPage` | +| `googleBusiness()` | `getLocation`, `updateLocation`, `getAttributes`, `updateAttributes`, `getMenus`, `replaceMenus`, `getServices`, `replaceServices`, `listMedia`, `addMedia`, `deleteMedia`, `listPlaceActions`, `createPlaceAction`, `updatePlaceAction`, `deletePlaceAction`, `getVerificationOptions`, `startVerification`, `completeVerification`, `getPerformance`, `getSearchKeywords`, `assign` | | `validate()` | `post`, `length`, `media` | | `activity()` | `list` | diff --git a/src/main/java/com/fopost/sdk/FoPost.java b/src/main/java/com/fopost/sdk/FoPost.java index 3e877f7..b29b11f 100644 --- a/src/main/java/com/fopost/sdk/FoPost.java +++ b/src/main/java/com/fopost/sdk/FoPost.java @@ -17,6 +17,7 @@ import com.fopost.sdk.resource.ContactsResource; import com.fopost.sdk.resource.InboxResource; import com.fopost.sdk.resource.KnowledgeResource; +import com.fopost.sdk.resource.GoogleBusinessResource; import com.fopost.sdk.resource.LabelsResource; import com.fopost.sdk.resource.MediaResource; import com.fopost.sdk.resource.PostsResource; @@ -76,6 +77,7 @@ public final class FoPost { private final BroadcastsResource broadcasts; private final SequencesResource sequences; private final ActivityResource activity; + private final GoogleBusinessResource googleBusiness; private final InboxResource inbox; private final AdsResource ads; private final ValidateResource validate; @@ -83,6 +85,7 @@ public final class FoPost { private FoPost(ApiClient http) { this.http = http; this.posts = new PostsResource(http); + this.googleBusiness = new GoogleBusinessResource(http); this.accounts = new AccountsResource(http); this.accountGroups = new AccountGroupsResource(http); this.workspaces = new WorkspacesResource(http); @@ -164,6 +167,11 @@ public AiResource ai() { return ai; } + /** Manage a connected Google Business Profile location. */ + public GoogleBusinessResource googleBusiness() { + return googleBusiness; + } + public InboxResource inbox() { return inbox; } diff --git a/src/main/java/com/fopost/sdk/resource/GoogleBusinessResource.java b/src/main/java/com/fopost/sdk/resource/GoogleBusinessResource.java new file mode 100644 index 0000000..40f10b8 --- /dev/null +++ b/src/main/java/com/fopost/sdk/resource/GoogleBusinessResource.java @@ -0,0 +1,238 @@ +package com.fopost.sdk.resource; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fopost.sdk.internal.ApiClient; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Manage a connected Google Business Profile location: the profile itself, + * attributes, food menus, services, photos, place action links, verification + * and performance. + * + *

Google grants Business Profile API access per project. Until that grant + * lands on a deployment every call here fails with a 503 {@code + * configuration_error}. + * + *

Responses relay Google's own shape, field for field, so they come back as + * {@link JsonNode} rather than models we would have to keep chasing. + */ +public final class GoogleBusinessResource { + + /** The daily metrics fetched when a caller names none. */ + public static final List DEFAULT_DAILY_METRICS = List.of( + "BUSINESS_IMPRESSIONS_DESKTOP_MAPS", + "BUSINESS_IMPRESSIONS_DESKTOP_SEARCH", + "BUSINESS_IMPRESSIONS_MOBILE_MAPS", + "BUSINESS_IMPRESSIONS_MOBILE_SEARCH", + "CALL_CLICKS", + "WEBSITE_CLICKS", + "BUSINESS_DIRECTION_REQUESTS"); + + private final ApiClient http; + + public GoogleBusinessResource(ApiClient http) { + this.http = http; + } + + /** The connected location, in the Business Information shape. */ + public JsonNode getLocation(String accountId) { + return ApiClient.unwrap(http.get(path(accountId, "/location"), null)); + } + + /** + * Patch the profile. Only the keys the map carries change; a null value + * clears that field. Keys are the API's own snake_case names, such as + * {@code title}, {@code description}, {@code website_uri} and + * {@code primary_phone}. + */ + public JsonNode updateLocation(String accountId, Map fields) { + return ApiClient.unwrap(http.request("PATCH", path(accountId, "/location"), body(fields), null)); + } + + /** The attribute values set on the location. */ + public JsonNode getAttributes(String accountId) { + return getAttributes(accountId, false, null, null, null); + } + + /** + * The attribute values set on the location, or, with {@code available}, + * the attributes Google offers for its category and region. + */ + public JsonNode getAttributes( + String accountId, boolean available, String categoryName, String regionCode, String languageCode) { + Map query = new LinkedHashMap<>(); + if (available) { + query.put("available", "true"); + } + putIfPresent(query, "category_name", categoryName); + putIfPresent(query, "region_code", regionCode); + putIfPresent(query, "language_code", languageCode); + return ApiClient.unwrap(http.get(path(accountId, "/attributes"), query.isEmpty() ? null : query)); + } + + /** Only the named attributes change; every other one is left alone. */ + public JsonNode updateAttributes(String accountId, List> attributes) { + return ApiClient.unwrap(http.request( + "PATCH", path(accountId, "/attributes"), Map.of("attributes", attributes), null)); + } + + /** The location's food menus. */ + public JsonNode getMenus(String accountId) { + return ApiClient.unwrap(http.get(path(accountId, "/menus"), null)); + } + + /** Google has no per-section patch, so the whole menu set is replaced. */ + public JsonNode replaceMenus(String accountId, List> menus) { + return ApiClient.unwrap(http.put(path(accountId, "/menus"), Map.of("menus", menus))); + } + + /** The location's service list. */ + public JsonNode getServices(String accountId) { + return ApiClient.unwrap(http.get(path(accountId, "/services"), null)); + } + + /** Replace the whole service list. */ + public JsonNode replaceServices(String accountId, List> serviceItems) { + return ApiClient.unwrap(http.put(path(accountId, "/services"), Map.of("service_items", serviceItems))); + } + + /** The photos on the location. */ + public JsonNode listMedia(String accountId) { + return listMedia(accountId, 0, null); + } + + public JsonNode listMedia(String accountId, int pageSize, String pageToken) { + Map query = new LinkedHashMap<>(); + if (pageSize > 0) { + query.put("page_size", pageSize); + } + putIfPresent(query, "page_token", pageToken); + return ApiClient.unwrap(http.get(path(accountId, "/media"), query.isEmpty() ? null : query)); + } + + /** + * Add a photo from the media library. The asset has to be in a workspace + * the caller can reach, and JPEG or PNG. + */ + public JsonNode addMedia(String accountId, String mediaId, String category, String description) { + Map body = new LinkedHashMap<>(); + body.put("media_id", mediaId); + body.put("category", category == null ? "ADDITIONAL" : category); + putIfPresent(body, "description", description); + return ApiClient.unwrap(http.post(path(accountId, "/media"), body)); + } + + /** Remove a photo by the media key Google returned. */ + public JsonNode deleteMedia(String accountId, String mediaKey) { + return ApiClient.unwrap(http.delete(path(accountId, "/media/" + mediaKey))); + } + + /** The Book, Order and Reserve links on the listing. */ + public JsonNode listPlaceActions(String accountId) { + return ApiClient.unwrap(http.get(path(accountId, "/place-actions"), null)); + } + + /** Add an action link to the listing. */ + public JsonNode createPlaceAction(String accountId, String uri, String placeActionType, Boolean isPreferred) { + Map body = new LinkedHashMap<>(); + body.put("uri", uri); + body.put("place_action_type", placeActionType); + putIfPresent(body, "is_preferred", isPreferred); + return ApiClient.unwrap(http.post(path(accountId, "/place-actions"), body)); + } + + /** Patch one action link; a null argument is left alone. */ + public JsonNode updatePlaceAction(String accountId, String linkId, String uri, Boolean isPreferred) { + Map body = new LinkedHashMap<>(); + putIfPresent(body, "uri", uri); + putIfPresent(body, "is_preferred", isPreferred); + return ApiClient.unwrap( + http.request("PATCH", path(accountId, "/place-actions/" + linkId), body, null)); + } + + /** Remove one action link. */ + public JsonNode deletePlaceAction(String accountId, String linkId) { + return ApiClient.unwrap(http.delete(path(accountId, "/place-actions/" + linkId))); + } + + /** The ways Google will let this location be verified. */ + public JsonNode getVerificationOptions(String accountId) { + return getVerificationOptions(accountId, null); + } + + public JsonNode getVerificationOptions(String accountId, String languageCode) { + Map query = new LinkedHashMap<>(); + putIfPresent(query, "language_code", languageCode); + return ApiClient.unwrap(http.get(path(accountId, "/verification"), query.isEmpty() ? null : query)); + } + + /** + * Start a verification. {@code method} is {@code ADDRESS}, {@code EMAIL}, + * {@code PHONE_CALL}, {@code SMS}, {@code AUTO} or {@code VETTED_PARTNER}; + * the response names the pending verification to complete with the PIN. + */ + public JsonNode startVerification(String accountId, String method, Map options) { + Map body = new LinkedHashMap<>(); + body.put("method", method); + if (options != null) { + options.forEach((key, value) -> putIfPresent(body, key, value)); + } + return ApiClient.unwrap(http.post(path(accountId, "/verification/start"), body)); + } + + /** Complete a pending verification with the PIN Google sent. */ + public JsonNode completeVerification(String accountId, String verificationName, String pin) { + return ApiClient.unwrap(http.post( + path(accountId, "/verification/complete"), + Map.of("verification_name", verificationName, "pin", pin))); + } + + /** + * Daily impressions, calls, direction requests and clicks for the range. + * A null or empty {@code dailyMetrics} leaves the API's own default set. + */ + public JsonNode getPerformance(String accountId, String startDate, String endDate, List dailyMetrics) { + Map query = new LinkedHashMap<>(); + query.put("start_date", startDate); + query.put("end_date", endDate); + if (dailyMetrics != null && !dailyMetrics.isEmpty()) { + query.put("daily_metrics", dailyMetrics); + } + return ApiClient.unwrap(http.get(path(accountId, "/performance"), query)); + } + + /** The search terms people used to find the listing, by month. */ + public JsonNode getSearchKeywords(String accountId, String startDate, String endDate, String pageToken) { + Map query = new LinkedHashMap<>(); + query.put("keywords", "true"); + query.put("start_date", startDate); + query.put("end_date", endDate); + putIfPresent(query, "page_token", pageToken); + return ApiClient.unwrap(http.get(path(accountId, "/performance"), query)); + } + + /** + * Hand the location to another workspace the caller owns. The connection + * and every row keyed to it move in one transaction. + */ + public JsonNode assign(String accountId, String workspaceId) { + return ApiClient.unwrap(http.post(path(accountId, "/assign"), Map.of("workspace_id", workspaceId))); + } + + private static String path(String accountId, String suffix) { + return "/v1/accounts/" + accountId + "/gbp" + suffix; + } + + /** An empty patch still has to be an object, not a missing body. */ + private static Object body(Map fields) { + return fields == null ? Map.of() : fields; + } + + private static void putIfPresent(Map target, String key, Object value) { + if (value != null) { + target.put(key, value); + } + } +} diff --git a/src/test/java/com/fopost/sdk/GoogleBusinessTest.java b/src/test/java/com/fopost/sdk/GoogleBusinessTest.java new file mode 100644 index 0000000..5a767f7 --- /dev/null +++ b/src/test/java/com/fopost/sdk/GoogleBusinessTest.java @@ -0,0 +1,117 @@ +package com.fopost.sdk; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fopost.sdk.resource.GoogleBusinessResource; +import java.util.List; +import java.util.Map; +import java.util.function.Consumer; +import org.junit.jupiter.api.Test; + +/** + * Business Profile management: one call per route, pinning the URL, the verb + * and the body each endpoint actually receives. + */ +class GoogleBusinessTest { + + private static final String BASE = "https://api.fopost.test/v1/accounts/a1/gbp"; + + @Test + void everyMethodMapsOntoItsRoute() { + record Call(String method, String url, Consumer run) {} + + List calls = List.of( + new Call("GET", BASE + "/location", gb -> gb.getLocation("a1")), + new Call("PATCH", BASE + "/location", gb -> gb.updateLocation("a1", Map.of("title", "Bakery"))), + new Call("GET", BASE + "/attributes", gb -> gb.getAttributes("a1")), + new Call("PATCH", BASE + "/attributes", gb -> gb.updateAttributes("a1", List.of())), + new Call("GET", BASE + "/menus", gb -> gb.getMenus("a1")), + new Call("PUT", BASE + "/menus", gb -> gb.replaceMenus("a1", List.of())), + new Call("GET", BASE + "/services", gb -> gb.getServices("a1")), + new Call("PUT", BASE + "/services", gb -> gb.replaceServices("a1", List.of())), + new Call("GET", BASE + "/media", gb -> gb.listMedia("a1")), + new Call("POST", BASE + "/media", gb -> gb.addMedia("a1", "m1", null, null)), + new Call("DELETE", BASE + "/media/CAoSL", gb -> gb.deleteMedia("a1", "CAoSL")), + new Call("GET", BASE + "/place-actions", gb -> gb.listPlaceActions("a1")), + new Call("POST", BASE + "/place-actions", + gb -> gb.createPlaceAction("a1", "https://example.test/book", "APPOINTMENT", null)), + new Call("PATCH", BASE + "/place-actions/links-1", + gb -> gb.updatePlaceAction("a1", "links-1", null, true)), + new Call("DELETE", BASE + "/place-actions/links-1", + gb -> gb.deletePlaceAction("a1", "links-1")), + new Call("GET", BASE + "/verification", gb -> gb.getVerificationOptions("a1")), + new Call("POST", BASE + "/verification/start", + gb -> gb.startVerification("a1", "SMS", null)), + new Call("POST", BASE + "/verification/complete", + gb -> gb.completeVerification("a1", "v1", "123456")), + new Call("POST", BASE + "/assign", gb -> gb.assign("a1", "w2"))); + + for (Call call : calls) { + FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":{\"ok\":true}}"); + call.run().accept(TestSupport.client(transport).googleBusiness()); + + assertEquals(call.method(), transport.last().method(), call.url()); + assertEquals(call.url(), transport.last().url()); + } + } + + @Test + void aPatchCarriesOnlyTheFieldsTheCallerSet() { + FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":{}}"); + + TestSupport.client(transport) + .googleBusiness() + .updateLocation("a1", Map.of("store_code", "S-12")); + + assertEquals("{\"store_code\":\"S-12\"}", transport.lastBody()); + } + + @Test + void aPhotoIsNamedByItsLibraryId() { + FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":{}}"); + + TestSupport.client(transport).googleBusiness().addMedia("a1", "m1", "INTERIOR", null); + + assertEquals("{\"media_id\":\"m1\",\"category\":\"INTERIOR\"}", transport.lastBody()); + } + + @Test + void performanceRepeatsTheMetricParameter() { + FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":{}}"); + + TestSupport.client(transport) + .googleBusiness() + .getPerformance("a1", "2026-09-01", "2026-09-07", List.of("CALL_CLICKS", "WEBSITE_CLICKS")); + + assertEquals( + BASE + "/performance?start_date=2026-09-01&end_date=2026-09-07" + + "&daily_metrics=CALL_CLICKS&daily_metrics=WEBSITE_CLICKS", + transport.last().url()); + } + + @Test + void searchKeywordsAsksTheSameRouteForTheMonthlyTerms() { + FakeTransport transport = new FakeTransport().enqueue(200, "{\"data\":{}}"); + + TestSupport.client(transport) + .googleBusiness() + .getSearchKeywords("a1", "2026-08-01", "2026-09-01", null); + + assertTrue(transport.last().url().contains("keywords=true"), transport.last().url()); + } + + @Test + void aPendingApiGrantSurfacesAsAnError() { + FakeTransport transport = new FakeTransport() + .enqueue(503, "{\"error\":\"configuration_error\",\"message\":\"Not available yet\"}"); + + FoPostException error = assertThrows( + FoPostException.class, + () -> TestSupport.client(transport).googleBusiness().getLocation("a1")); + + assertEquals(503, error.status()); + assertEquals("configuration_error", error.code()); + } +}