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()); + } +}