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:
{
"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:
{
"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
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:
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:
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:
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
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);
}
}
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 lostEnvironment
RAW HTTP
Given a Product Search request with both a distinct facet and a stats facet:
{ "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:
{ "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
Output:
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:
Then stats facets can be manually mapped based on the presence of min / max:
This works, but requires bypassing the typed
ProductPagedSearchResponsemodel 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