commercetools / commercetools/commercetools-sdk-java-v2
Product Search stats facets are deserialized as ProductSearchFacetResultImpl instead of ProductSearchFacetResultStats
- Dominant language
- Java
- Stars
- 47
- Forks
- 21
- Avg merge
- 9d 13h
- Merged PRs (30d)
- 4
Description
### Summary
Product Search stats facet results are not deserialized correctly into `ProductSearchFacetResultStats`.
The raw API response contains the expected stats fields (min, max, mean, sum, count), but when using the typed SDK response model, the stats facet is deserialized to the base `ProductSearchFacetResultImpl` -> stat values are lost
### Environment
- SDK artifact: com.commercetools.sdk:commercetools-sdk-java-api
- SDK version: 19.9.1
- Java version: 21.0.11-tem
- Endpoint: Product Search API, products().search().post(...)
- Region: GCP_EUROPE_WEST1
### RAW HTTP
Given a Product Search request with both a distinct facet and a stats facet:
```json
{
"limit": 0,
"offset": 0,
"markMatchingVariants": true,
"facets": [
{
"distinct": {
"name": "colour",
"level": "variants",
"field": "variants.attributes.merkmal_colour_shopfilter_label",
"language": "de-DE",
"fieldType": "ltext"
}
},
{
"stats": {
"name": "price",
"field": "variants.prices.centAmount"
}
}
]
}
```
The raw API response contains the stats facet correctly:
```json
{
"name": "price",
"min": 199.0,
"max": 3599900.0,
"mean": 233019.64729760564,
"sum": 2.00167605343E11,
"count": 859016
}
```
But the typed SDK response deserializes it as:
ProductSearchFacetResultImpl[name=price]
### Minimal Evidence
```java
String statsJson = """
{
"name": "price",
"min": 199.0,
"max": 3599900.0,
"mean": 233019.64729760564,
"sum": 2.00167605343E11,
"count": 859016
}
""";
ProductSearchFacetResult withJsonUtilsAsBase = JsonUtils.fromJsonString(statsJson, ProductSearchFacetResult.class);
ProductSearchFacetResult withJsonUtilsAsStats = JsonUtils.fromJsonString(statsJson, ProductSearchFacetResultStats.class);
System.out.println(withJsonUtilsAsBase.getClass());
System.out.println(withJsonUtilsAsBase);
System.out.println(withJsonUtilsAsStats.getClass());
System.out.println(withJsonUtilsAsStats);
```
Output:
```text
class com.commercetools.api.models.product_search.ProductSearchFacetResultImpl
ProductSearchFacetResultImpl[name=price]
class com.commercetools.api.models.product_search.ProductSearchFacetResultStatsImpl
ProductSearchFacetResultStatsImpl[name=price,min=199.0,max=3599900.0,mean=233019.64729760564,sum=2.00167605343E11,count=859016]
```
So it can be deserialized correctly when forced to the subtype but not through the parent type
### Workaround
Executing the request as JsonNode preserves the stats data:
```java
JsonNode jsonNode = apiRoot.products()
.search()
.post(searchRequest)
.executeBlocking(JsonNode.class)
.getBody();
```
Then stats facets can be manually mapped based on the presence of min / max:
```java
if (facetNode.has("min") || facetNode.has("max")) {
ProductSearchFacetResultStats statsFacet =
JsonUtils.fromJsonString(facetNode.toString(), ProductSearchFacetResultStats.class);
}
```
This works, but requires bypassing the typed `ProductPagedSearchResponse` model and manually mapping facet results.
### Scratch File for testing
Needs adjustment for the bucket facet field (set DISTINCT_FIELD to value that is present in your sphere).
Needs System ENVs for PROJECT_KEY, CLIENT_SECRET and CLIENT_ID
```java
import com.commercetools.api.client.ProjectApiRoot;
import com.commercetools.api.defaultconfig.ApiRootBuilder;
import com.commercetools.api.defaultconfig.ServiceRegion;
import com.commercetools.api.models.product_search.*;
import com.commercetools.api.models.search.SearchFieldType;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.vrap.rmf.base.client.http.NotFoundExceptionMiddleware;
import io.vrap.rmf.base.client.http.PolicyBuilder;
import io.vrap.rmf.base.client.http.RetryPolicyBuilder;
import io.vrap.rmf.base.client.oauth2.ClientCredentials;
import io.vrap.rmf.base.client.utils.json.JsonUtils;
import java.io.IOException;
import java.util.Collections;
import java.util.List;
class Scratch {
public static final String DISTINCT_FIELD = "variants.attributes.merkmal_colour_shopfilter_label";
public static final String PRICE_FIELD = "variants.prices.centAmount";
public static final String PROJECT_KEY = System.getenv("PROJECT_KEY");
public static final String CLIENT_SECRET = System.getenv("CLIENT_SECRET");
public static final String CLIENT_ID = System.getenv("CLIENT_ID");
public static void main(String[] args) throws JsonProcessingException {
ProjectApiRoot apiRoot = ApiRootBuilder.of()
.defaultClient(
ClientCredentials.of()
.withClientId(CLIENT_ID)
.withClientSecret(CLIENT_SECRET)
.build(),
ServiceRegion.GCP_EUROPE_WEST1.getOAuthTokenUrl(),
ServiceRegion.GCP_EUROPE_WEST1.getApiUrl()
)
.addMiddleware(PolicyBuilder.of()
.withRetry(
RetryPolicyBuilder.of()
.maxRetries(3)
.maxDelay(10_000L)
.failures(Collections.singletonList(IOException.class)))
.build())
.addMiddleware(NotFoundExceptionMiddleware.of())
.build(PROJECT_KEY);
ProductSearchRequestBuilder builder = ProductSearchRequest.builder();
ProductSearchFacetDistinctExpression distinctFacet = ProductSearchFacetDistinctExpression.builder()
.distinct(distinct -> {
distinct.name("colour")
.field(DISTINCT_FIELD)
.fieldType(SearchFieldType.LTEXT)
.level(ProductSearchFacetCountLevelEnum.VARIANTS)
.language("de-DE");
return distinct;
})
.build();
ProductSearchFacetStatsExpression price = ProductSearchFacetStatsExpression.builder()
.stats(stats -> {
stats.name("price")
.field(PRICE_FIELD);
return stats;
})
.build();
ProductSearchRequest searchRequest = builder.facets(
List.of(
distinctFacet,
price
)
)
.limit(0)
.offset(0)
.markMatchingVariants(true)
.build();
ProductPagedSearchResponse body = apiRoot.products().search().post(searchRequest).executeBlocking().getBody();
System.out.println("###");
System.out.println(body);
System.out.println("###");
body.getFacets().forEach(facet -> {
System.out.println("Facet class: " + facet.getClass().getName());
if (facet instanceof ProductSearchFacetResultStats) {
System.out.println("Stats found!");
} else if (facet instanceof ProductSearchFacetResultBucket) {
System.out.println("Bucket found!");
} else {
System.out.println("Stats lost: deserialized as base implementation.");
}
});
//WORKAROUND:
JsonNode jsonNode = apiRoot.products().search().post(searchRequest).executeBlocking(JsonNode.class).getBody();
System.out.println("###");
System.out.println(jsonNode);
jsonNode.findPath("facets").elements().forEachRemaining(facetNode -> {
if (facetNode.has("min") || facetNode.has("max") || facetNode.has("mean") || facetNode.has("sum") || facetNode.has("count")) {
ProductSearchFacetResultStats statFacet = JsonUtils.fromJsonString(facetNode.toString(), ProductSearchFacetResultStats.class);
System.out.println("Stats found!");
System.out.println(statFacet);
} else {
ProductSearchFacetResult otherFacet = JsonUtils.fromJsonString(facetNode.toString(), ProductSearchFacetResult.class);
System.out.println("Other facets");
System.out.println(otherFacet);
}
});
System.out.println("###");
// Additional deserialization tests
String statsJson = """
{
"name": "price",
"min": 199.0,
"max": 3599900.0,
"mean": 233019.64729760564,
"sum": 2.00167605343E11,
"count": 859016
}
""";
ProductSearchFacetResult withJsonUtilsAsBase = JsonUtils.fromJsonString(statsJson, ProductSearchFacetResult.class);
ProductSearchFacetResultStats withJsonUtilsAsStats = JsonUtils.fromJsonString(statsJson, ProductSearchFacetResultStats.class);
System.out.println(withJsonUtilsAsBase.getClass());
System.out.println(withJsonUtilsAsBase);
System.out.println(withJsonUtilsAsStats.getClass());
System.out.println(withJsonUtilsAsStats);
}
}
```
Contributor guide
No contributing guide indexed for this repository
Assessment
This issue has not been assessed yet.