[SABRA-2456] Add V2 Facets and Searchabilities API Support#179
Conversation
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment was marked as outdated.
This comment was marked as outdated.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (2)
constructorio-client/src/main/java/io/constructor/client/ConstructorIO.java:3949
- Same pagination precedence issue here:
offsetis only included whenrequest.getPage() == null, butpageis only added whenpage > 0. Ifpageis set to 0/negative, neitherpagenoroffsetwill be sent. Recommend gating offset on a valid page value (or validating page values up-front).
if (request.getPage() != null && request.getPage() > 0) {
urlBuilder.addQueryParameter("page", request.getPage().toString());
}
if (request.getNumResultsPerPage() != null && request.getNumResultsPerPage() > 0) {
urlBuilder.addQueryParameter(
"num_results_per_page", request.getNumResultsPerPage().toString());
}
if (request.getOffset() != null
&& request.getOffset() >= 0
&& request.getPage() == null) {
urlBuilder.addQueryParameter("offset", request.getOffset().toString());
}
constructorio-client/src/test/java/io/constructor/client/FacetConfigurationV2Test.java:177
- This assertion also hard-codes the default section string ("Products"). Consider asserting
ConstructorIO.DEFAULT_SECTIONinstead for consistency and future-proofing.
FacetConfigurationsV2Request request =
new FacetConfigurationsV2Request(Arrays.asList(config));
assertEquals(1, request.getFacetConfigurations().size());
assertEquals("Products", request.getSection());
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.
Comments suppressed due to low confidence (1)
constructorio-client/src/main/java/io/constructor/client/ConstructorIO.java:3853
- Bulk facet v2 replace (
replaceFacetConfigurationsV2) similarly skips validation of each facet'stype(and potentially other required fields likepath_in_metadata). Since this is a full replace (PUT), failing fast client-side with clear validation errors will be more consistent withcreateFacetConfigurationV2/replaceFacetConfigurationV2and avoid avoidable API rejections.
.addQueryParameter("section", facetConfigurationsV2Request.getSection())
.build();
Map<String, Object> bodyMap = new HashMap<>();
bodyMap.put("facets", facetConfigurationsV2Request.getFacetConfigurations());
String params = new Gson().toJson(bodyMap);
| * @throws IllegalArgumentException if request is null | ||
| * @throws ConstructorException if the request fails | ||
| */ | ||
| public String replaceFacetConfigurationsV2( |
There was a problem hiding this comment.
Should we also run validateFacetConfigurationV2Type here on every facet configuration?
There was a problem hiding this comment.
Good catch, added it. replaceFacetConfigurationsV2 now runs validateFacetConfigurationV2Type on each facet configuration, matching the single replace/create endpoints (bulk replace is a full replace, so type is required). Left the PATCH update methods as-is since type is optional for partial updates. Added a test covering the invalid-type case. (commit 9d3eb34e)
Run validateFacetConfigurationV2Type on each facet configuration in replaceFacetConfigurationsV2 for parity with the single-replace and create endpoints, since bulk replace is a full replace where type is required.
There was a problem hiding this comment.
Code Review
This PR adds v2 Facets and Searchabilities API support with new model/request classes and 14 new methods in ConstructorIO. The implementation is generally well-structured with good test coverage, but there are several important issues around HTTP client consistency, Gson instantiation, and API design that should be addressed.
Inline comments: 9 discussions added
Overall Assessment:
| RequestBody.create(params, MediaType.parse("application/json; charset=utf-8")); | ||
| Request request = this.makeAuthorizedRequestBuilder().url(url).post(body).build(); | ||
|
|
||
| Response response = client.newCall(request).execute(); |
There was a problem hiding this comment.
Important Issue: All mutating facet v2 methods (createFacetConfigurationV2, replaceFacetConfigurationV2, updateFacetConfigurationV2, updateFacetConfigurationsV2, replaceFacetConfigurationsV2, deleteFacetConfigurationV2) use client.newCall(...) instead of clientWithRetry.newCall(...). The read methods (retrieveFacetConfigurationsV2, retrieveFacetConfigurationV2) correctly use clientWithRetry. This is inconsistent with the existing v1 patterns (e.g., createFacetConfiguration, replaceFacetConfiguration) which also use client, but it's worth verifying whether mutating v2 endpoints benefit from retry-on-connection-failure or not. The v2 searchability write/delete methods also use client. If the intent is that only GETs use clientWithRetry, this should be documented; otherwise all methods should consistently use clientWithRetry.
| .addQueryParameter("section", facetConfigurationV2Request.getSection()) | ||
| .build(); | ||
|
|
||
| String params = new Gson().toJson(facetConfigurationV2Request.getFacetConfiguration()); |
There was a problem hiding this comment.
Important Issue: new Gson() is instantiated inline on every single request call — there are 7 such instantiations across the new methods (createFacetConfigurationV2, replaceFacetConfigurationV2, updateFacetConfigurationV2, updateFacetConfigurationsV2, replaceFacetConfigurationsV2, createOrUpdateSearchabilitiesV2, deleteSearchabilitiesV2). Gson instances are thread-safe and expensive to construct. A shared static private static final Gson GSON = new Gson() already exists implicitly via the GSON_WITHOUT_SEARCHABILITY_NAME builder approach; just add a plain private static final Gson GSON = new Gson() constant and reuse it for all serialization, similar to the existing pre-existing GSON_WITHOUT_SEARCHABILITY_NAME pattern.
| client.newBuilder().retryOnConnectionFailure(true).build(); | ||
|
|
||
| /** Default section used when no section is specified */ | ||
| public static final String DEFAULT_SECTION = "Products"; |
There was a problem hiding this comment.
Suggestion: DEFAULT_SECTION is declared public static final, making it part of the public API surface. If it needs to be public (it is referenced in the request classes and tests), that's fine, but consider whether this hardcoded string belongs as a constant in ConstructorIO or in a shared constants class. More importantly, the existing v1 code uses "Products" as a hardcoded string literal in multiple places without this constant — to maintain consistency either all callers should be updated to use DEFAULT_SECTION or this constant should stay private/package-private.
|
|
||
| try { | ||
| HttpUrl url = this.makeUrl(Arrays.asList("v2", "facets")); | ||
| url = |
There was a problem hiding this comment.
Suggestion: The URL construction in createFacetConfigurationV2 uses a two-step pattern (assign url, then reassign url = url.newBuilder()...build()) while every other v2 method uses the single-chain pattern directly. This is inconsistent within the PR itself. The replaceFacetConfigurationV2, updateFacetConfigurationV2 etc. all use the single-chain form which is cleaner. Use the single-chain pattern here too:
HttpUrl url = this.makeUrl(Arrays.asList("v2", "facets"))
.newBuilder()
.addQueryParameter("section", facetConfigurationV2Request.getSection())
.build();|
|
||
| List<FacetConfigurationV2> facetConfigurations = | ||
| facetConfigurationsV2Request.getFacetConfigurations(); | ||
| if (facetConfigurations != null) { |
There was a problem hiding this comment.
Important Issue: In replaceFacetConfigurationsV2, the type validation loop is guarded by if (facetConfigurations != null) — meaning a null list silently passes validation and proceeds to build a request with a null body for the "facets" key. Compare with FacetConfigurationsV2Request's constructor, which already rejects a null list at construction time. If the list can never be null at this point, the null check is redundant noise. If it can be null (via setFacetConfigurations(null)), then calling replaceFacetConfigurationsV2 with a null list should throw IllegalArgumentException just like createFacetConfigurationV2 does for a missing type. Either remove the null guard and rely on the constructor invariant, or add an explicit null check and throw.
| .addQueryParameter("section", facetConfigurationsV2Request.getSection()) | ||
| .build(); | ||
|
|
||
| Map<String, Object> bodyMap = new HashMap<>(); |
There was a problem hiding this comment.
Suggestion: The deleteSearchabilitiesV2 method builds its request body by iterating over request.getSearchabilityNames() and constructing Map<String, String> objects with a single "name" key per entry. While correct, this is unnecessarily verbose. A simpler and more readable alternative is to pass the list directly with a wrapper, or use an existing model. More pragmatically: the body creation pattern {"searchabilities": [{"name": "x"}, ...]} differs from how the PATCH bulk operation sends full objects. If the DELETE API truly only needs names (and not full objects), this is fine — but it's worth adding a comment explaining the body format.
| }) | ||
| .create(); | ||
|
|
||
| private static final Set<String> VALID_FACET_V2_TYPES = |
There was a problem hiding this comment.
Suggestion: VALID_FACET_V2_TYPES uses LinkedHashSet (ordered iteration), but a plain HashSet is sufficient here since the only operation performed is contains(). The existing v1 facet type validation uses "multiple".equals(type) || "hierarchical".equals(type) || "range".equals(type). Consider using Set.of("multiple", "hierarchical", "range") (Java 9+) or Collections.unmodifiableSet(new HashSet<>(...)) for a clearly immutable, unordered set with O(1) lookup. Also, updateFacetConfigurationsV2 (PATCH) deliberately skips type validation, while replaceFacetConfigurationsV2 (PUT) validates it — this asymmetry is correct for a partial-update vs full-replace distinction, but a brief comment explaining why PATCH skips type validation would improve readability.
| @SerializedName("range_inclusive") | ||
| private String rangeInclusive; | ||
|
|
||
| @SerializedName("range_limits") |
There was a problem hiding this comment.
Important Issue: rangeLimits is typed as List<Number> in FacetConfigurationV2, whereas the existing FacetConfiguration model uses List<String>. This inconsistency is surprising — if both models represent range limits from the same underlying API concept, they should be typed consistently. If the v2 API genuinely returns numeric values (integers/floats) while v1 returns strings, this needs a comment explaining the API contract difference. Also note that Number is an abstract type; the actual runtime type from Gson deserialization will be Double (for all JSON numbers), which could cause surprising behavior if callers cast to Integer.
| public class ConstructorIOSearchabilityV2Test { | ||
|
|
||
| private static String token = System.getenv("TEST_API_TOKEN"); | ||
| private static String apiKey = System.getenv("TEST_CATALOG_FACETS_V2_API_KEY"); |
There was a problem hiding this comment.
Suggestion: Both ConstructorIOFacetConfigurationV2Test and ConstructorIOSearchabilityV2Test use the same env variable TEST_CATALOG_FACETS_V2_API_KEY for what is a Searchability test. This reuse is fine if a single test index supports both Facets v2 and Searchabilities v2, but the variable name is misleading for the Searchability test class. Consider renaming the env variable to something neutral like TEST_CATALOG_V2_API_KEY, or introducing a dedicated TEST_CATALOG_SEARCHABILITIES_V2_API_KEY secret for clarity. Also, the workflow file at .github/workflows/run-tests.yml only adds TEST_CATALOG_FACETS_V2_API_KEY — if a separate key is ever needed for searchability tests, it will need to be added there too.
Summary
This PR adds support for the v2 versions of the facets and searchabilities APIs, which include additional fields and capabilities not available in v1.
Key Changes
New Model Classes:
FacetConfigurationV2- v2 facet configuration withpath_in_metadatafield supportSearchabilityV2- Searchability configuration modelNew Request Classes:
FacetConfigurationV2Request- Single facet v2 operationsFacetConfigurationsV2Request- Bulk facet v2 operationsSearchabilityV2Request- Single searchability operationsSearchabilitiesV2Request- Bulk searchability PATCH operationsSearchabilitiesV2GetRequest- Searchability list operations with filtering/paginationSearchabilitiesV2DeleteRequest- Bulk searchability deletionNew API Methods in
ConstructorIO:GET /v2/facetsretrieveFacetConfigurationsV2()GET /v2/facets/{name}retrieveFacetConfigurationV2()POST /v2/facetscreateFacetConfigurationV2()PUT /v2/facets/{name}replaceFacetConfigurationV2()PATCH /v2/facets/{name}updateFacetConfigurationV2()PATCH /v2/facetsupdateFacetConfigurationsV2()PUT /v2/facetsreplaceFacetConfigurationsV2()DELETE /v2/facets/{name}deleteFacetConfigurationV2()GET /v2/searchabilitiesretrieveSearchabilitiesV2()GET /v2/searchabilities/{name}retrieveSearchabilityV2()PATCH /v2/searchabilities/{name}createOrUpdateSearchabilityV2()PATCH /v2/searchabilitiescreateOrUpdateSearchabilitiesV2()DELETE /v2/searchabilities/{name}deleteSearchabilityV2()DELETE /v2/searchabilitiesdeleteSearchabilitiesV2()v2 API Differences from v1
path_in_metadatafield which specifies where in item metadata the facet data is locatedskip_rebuildparameterTest Plan