Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions RELEASE_INFO-6.7.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,36 @@ The Store API OpenAPI schema was corrected where it contradicted the real respon
- `Country.addressFormat` and `currentFilters.navigationId` are no longer required, and `redirectUrl` can be `null`.
- `POST /product/{productId}/review` and `GET /breadcrumb/{id}` document their `204` responses.

### Store API reads every field of the compressed `_criteria` parameter

On every Store API `GET` route, `_criteria` is now a compressed form of the query string: its fields are read like query parameters. So a `GET` request with `_criteria` reads the same fields as the `POST` request with that body. Before, `_criteria` was read only for the criteria and, on the product listing routes, for listing parameters such as `order` or `p`. Routes ignored the other fields inside it, for example:

| Route | Ignored fields |
|---|---|
| `/category/{navigationId}`, `/cms/{id}`, `/landing-page/{landingPageId}` | `limit`, `includes`, `excludes`, `slots` |
| `/search` | `limit` |
| `/search-suggest` | `search` |
| `/product/{productId}` | `slots` |
| `/navigation/{activeId}/{rootId}` | `depth`, `buildTree` |
| `/media` | `ids`, `includes`, `excludes` |
| `/product/{productId}/find-variant` | `options`, `switchedGroup`, `includes`, `excludes` |
| `/payment-method`, `/shipping-method` | `onlyAvailable` |
| `/checkout/cart` | `includes`, `excludes` |

A field that a route reads from the `POST` body only is not read from `_criteria`, the same as from a plain query parameter. This concerns, for example, the filter flags of the listing routes, such as `manufacturer-filter` or `property-whitelist`. The values of the fields are read as strings, as in a query string, and a field set to `null` counts as not sent. App scripts of `/store-api/script/{hook}` find them in the query as well.

Sending these fields as plain query parameters keeps working. If you send `_criteria`, check these changes:

- A field in `_criteria` takes precedence over a query parameter of the same name. Before, a plain `limit` took precedence on the listing routes.
- On the product listing, search and suggest routes, listing filters of extensions got the fields of `_criteria` with their JSON types before, such as `true` or `5`. They now get strings, the same as from plain query parameters.
- Query parameters that are not part of `_criteria` are applied next to it. Before, the criteria were built from `_criteria` alone, so a plain `filter` next to it was ignored. This also applies to the `GET` list and detail routes of the Admin API.
- The `sw-include-search-info` header is respected together with `_criteria`, on the Admin API as well.
- An invalid `_criteria` value is answered with `400` on every Store API `GET` route. Before, routes without criteria, such as `/media` or `/checkout/cart`, ignored it.

`includes` and `excludes` that are not an array are answered with `400` instead of `500`. On the Admin API this applies to the fields of a single entity in JSON:API responses, such as `includes[product]=name`.

The OpenAPI schema now also declares `_criteria` for `readCategoryGet`, `readCmsGet`, `readLandingPageGet`, `searchPageGet`, `searchSuggestGet`, `readMediaGet`, `searchProductVariantIdsGet` and `readProductCrossSellingsGet`. `GET` operations without criteria parameters, such as `readCart`, accept it without declaring it.

## Administration

### [Internal] Native `<sw-block>` names are isolated per component
Expand Down
4 changes: 4 additions & 0 deletions adr/2025-09-15-store-api-cache-strategy.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,7 @@ Important details:
- More complex OpenAPI schema, problem with array format differences between clients persists.
- Transparent request - easier to debug and log.
Rejected in favor of more compact representation and simpler OpenAPI schema.

## Updates

- 2026-09-29: `_criteria` is a compressed form of the query string on every Store API `GET` route. Besides the criteria, it can contain any other query parameter of the route, and a field in it takes precedence over a plain query parameter of the same name. So a `GET` request reads the same fields as the `POST` request with that body, which the automatic request method selection of the SDKs needs. Fields that a route reads from the `POST` body only, such as the filter flags of the listing routes, are the exception. It does not change which routes should be called with `GET`.
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,15 @@
use Shopware\Core\Framework\DataAbstractionLayer\Search\RequestCriteriaBuilder;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Plugin\Exception\DecorationPatternException;
use Shopware\Core\Framework\Routing\StoreApiRouteScope;
use Shopware\Core\PlatformRequest;
use Shopware\Core\System\SalesChannel\SalesChannelContext;
use Symfony\Component\HttpFoundation\Request;

/**
* This processor adds support of ProductListingCriteria fields passed in the compressed criteria payload.
* It should run before any other filter/processor that relies on request parameters.
* Store API requests are not handled here, the CompressedCriteriaRequestListener already copied all fields of the payload.
*
* @internal
*/
Expand Down Expand Up @@ -43,6 +46,11 @@ public function prepare(Request $request, Criteria $criteria, SalesChannelContex
return;
}

// the listener copied the fields as query-string values, copying them again would overwrite them with their JSON types
if (\in_array(StoreApiRouteScope::ID, (array) $request->attributes->get(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, []), true)) {
return;
}

$payload = $this->compressedCriteriaDecoder->decode((string) $request->query->get('_criteria'));
foreach ($payload as $param => $value) {
if (!\in_array($param, RequestCriteriaBuilder::KNOWN_FIELDS, true)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ public function __invoke(RefreshHttpCacheMessage $msg): void
*/
$request->setSession(new Session(new MockArraySessionStorage()));

$response = $this->kernel->handle($request, HttpKernelInterface::MAIN_REQUEST, false);
// the kernel gets a copy, like in the HttpCache, because listeners can change the query parameters the cache key is built from
$response = $this->kernel->handle(clone $request, HttpKernelInterface::MAIN_REQUEST, false);
$this->store->write($request, $response);

$this->cache->delete($msg->lockKey);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,7 @@
"CompressedCriteria": {
"name": "_criteria",
"in": "query",
"description": "Compressed and encoded criteria object. Format: base64url(gzip(json_encode(criteria))). This parameter allows passing complex criteria as a single encoded string instead of multiple query parameters. The criteria object should be JSON-encoded, then gzipped, and finally base64url-encoded. The criteria object structure is defined in the Criteria schema (see #/components/schemas/Criteria).",
"description": "Compressed and encoded criteria object. Format: base64url(gzip(json_encode(criteria))). This parameter allows passing complex criteria as a single encoded string instead of multiple query parameters. The criteria object should be JSON-encoded, then gzipped, and finally base64url-encoded. The criteria object structure is defined in the Criteria schema (see #/components/schemas/Criteria). Besides the criteria, the object can contain the other query parameters of the route, so it works as a compressed query string on every Store API GET route. A field in `_criteria` takes precedence over a query parameter of the same name, other query parameters are applied next to it.",
"required": false,
"schema": {
"type": "string",
Expand All @@ -190,7 +190,7 @@
"CompressedNoneFieldsCriteria": {
"name": "_criteria",
"in": "query",
"description": "Compressed and encoded criteria object. Format: base64url(gzip(json_encode(criteria))). This parameter allows passing complex criteria as a single encoded string instead of multiple query parameters. The criteria object should be JSON-encoded, then gzipped, and finally base64url-encoded. The criteria object structure is defined in the NoneFieldsCriteria schema (see #/components/schemas/NoneFieldsCriteria).",
"description": "Compressed and encoded criteria object. Format: base64url(gzip(json_encode(criteria))). This parameter allows passing complex criteria as a single encoded string instead of multiple query parameters. The criteria object should be JSON-encoded, then gzipped, and finally base64url-encoded. The criteria object structure is defined in the NoneFieldsCriteria schema (see #/components/schemas/NoneFieldsCriteria). Besides the criteria, the object can contain the other query parameters of the route, so it works as a compressed query string on every Store API GET route. A field in `_criteria` takes precedence over a query parameter of the same name, other query parameters are applied next to it.",
"required": false,
"schema": {
"type": "string",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,9 @@
},
{
"x-parameter-group": "productListingCriteria"
},
{
"$ref": "#/components/parameters/CompressedCriteria"
}
],
"responses": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,9 @@
},
{
"x-parameter-group": "productListingCriteria"
},
{
"$ref": "#/components/parameters/CompressedCriteria"
}
],
"responses": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@
},
{
"x-parameter-group": "productListingCriteria"
},
{
"$ref": "#/components/parameters/CompressedCriteria"
}
],
"responses": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@
}
}
},
{
"$ref": "#/components/parameters/CompressedCriteria"
},
{
"$ref": "#/components/parameters/swLanguageId"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@
"type": "boolean"
}
},
{
"$ref": "#/components/parameters/CompressedCriteria"
},
{
"$ref": "#/components/parameters/swLanguageId"
}
Expand Down Expand Up @@ -841,6 +844,9 @@
"type": "string"
}
},
{
"$ref": "#/components/parameters/CompressedCriteria"
},
{
"$ref": "#/components/parameters/swLanguageId"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,9 @@
},
{
"x-parameter-group": "productListingFlags"
},
{
"$ref": "#/components/parameters/CompressedCriteria"
}
],
"responses": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,9 @@
},
{
"x-parameter-group": "productListingFlags"
},
{
"$ref": "#/components/parameters/CompressedCriteria"
}
],
"responses": {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php declare(strict_types=1);

namespace Shopware\Core\Framework\Api\EventListener;

use Shopware\Core\Framework\DataAbstractionLayer\Search\CompressedCriteriaDecoder;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Routing\KernelListenerPriorities;
use Shopware\Core\Framework\Routing\StoreApiRouteScope;
use Shopware\Core\PlatformRequest;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\ControllerEvent;
use Symfony\Component\HttpKernel\KernelEvents;

/**
* Copies the fields of the compressed `_criteria` parameter into the query parameters of a Store API GET request.
* So `_criteria` is a compressed form of the query string, and a GET request reads the same fields as a POST request with that body,
* except fields that a route reads from the body only.
*
* @internal
*/
#[Package('framework')]
class CompressedCriteriaRequestListener implements EventSubscriberInterface
{
private const PARAMETER = '_criteria';

public function __construct(private readonly CompressedCriteriaDecoder $decoder)
{
}

public static function getSubscribedEvents(): array
{
return [
// runs after the access key check and before the context, the argument resolvers and the controller read the request
KernelEvents::CONTROLLER => [
'expandCompressedCriteria',
KernelListenerPriorities::KERNEL_CONTROLLER_EVENT_PRIORITY_AUTH_VALIDATE_POST,
],
];
}

public function expandCompressedCriteria(ControllerEvent $event): void
{
$request = $event->getRequest();

if (!$request->isMethod(Request::METHOD_GET) || !$request->query->has(self::PARAMETER)) {
return;
}

$scopes = (array) $request->attributes->get(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, []);
if (!\in_array(StoreApiRouteScope::ID, $scopes, true)) {
return;
}

$payload = $this->decoder->decode((string) $request->query->get(self::PARAMETER));

foreach ($payload as $field => $value) {
$field = (string) $field;

if ($field === self::PARAMETER) {
continue;
}

// a field without a value is not sent, as in a POST body, and also hides a plain query parameter of the same name
if ($value === null) {
$request->query->remove($field);

continue;
}

// the compressed criteria wins over a plain query parameter of the same name
$request->query->set($field, self::toQueryValue($value));
}
}

/**
* Values become strings, as in a query string, so readers of the query parameters get the types they expect.
* The criteria keep their JSON types, because the criteria builder decodes the parameter itself.
*/
private static function toQueryValue(mixed $value): mixed
{
return match (true) {
\is_array($value) => array_map(self::toQueryValue(...), $value),
\is_bool($value) => $value ? '1' : '0',
\is_scalar($value) => (string) $value,
default => '',
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,17 +75,7 @@ public function __construct(

public function handleRequest(Request $request, Criteria $criteria, EntityDefinition $definition, Context $context): Criteria
{
if ($request->isMethod(Request::METHOD_GET)) {
// Check for _criteria parameter first
if ($request->query->has('_criteria')) {
$payload = $this->compressedCriteriaDecoder->decode((string) $request->query->get('_criteria'));

return $this->fromArray($payload, $criteria, $definition, $context);
}
$criteria = $this->fromArray($request->query->all(), $criteria, $definition, $context);
} else {
$criteria = $this->fromArray($request->request->all(), $criteria, $definition, $context);
}
$criteria = $this->fromArray($this->getPayload($request), $criteria, $definition, $context);

// @deprecated tag:v6.8.0 - switch the default to 0
if ($request->headers->get(PlatformRequest::HEADER_INCLUDE_SEARCH_INFO, '1') === '0') {
Expand Down Expand Up @@ -125,6 +115,32 @@ public function addTotalCountMode(string $totalCountMode, Criteria $criteria): v
}
}

/**
* @return array<string, mixed>
*/
private function getPayload(Request $request): array
{
if (!$request->isMethod(Request::METHOD_GET)) {
return $request->request->all();
}

$payload = $request->query->all();

if (!$request->query->has('_criteria')) {
return $payload;
}

// a field of the _criteria parameter replaces the individual query parameter of the same name as a whole,
// individual query parameters that are not part of it are kept
$payload = array_replace(
$payload,
$this->compressedCriteriaDecoder->decode((string) $request->query->get('_criteria'))
);
unset($payload['_criteria']);

return $payload;
}

/**
* @param array<string, mixed> $payload
*/
Expand Down
8 changes: 8 additions & 0 deletions src/Core/Framework/DependencyInjection/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
use Shopware\Core\Framework\Api\EventListener\Authentication\ApiAuthenticationListener;
use Shopware\Core\Framework\Api\EventListener\Authentication\SalesChannelAuthenticationListener;
use Shopware\Core\Framework\Api\EventListener\Authentication\UserCredentialsChangedSubscriber;
use Shopware\Core\Framework\Api\EventListener\CompressedCriteriaRequestListener;
use Shopware\Core\Framework\Api\EventListener\CorsListener;
use Shopware\Core\Framework\Api\EventListener\ExpectationSubscriber;
use Shopware\Core\Framework\Api\EventListener\JsonRequestTransformerListener;
Expand Down Expand Up @@ -85,6 +86,7 @@
use Shopware\Core\Framework\DataAbstractionLayer\DefinitionInstanceRegistry;
use Shopware\Core\Framework\DataAbstractionLayer\EntityProtection\EntityProtectionValidator;
use Shopware\Core\Framework\DataAbstractionLayer\Indexing\EntityIndexerRegistry;
use Shopware\Core\Framework\DataAbstractionLayer\Search\CompressedCriteriaDecoder;
use Shopware\Core\Framework\Event\BusinessEventCollector;
use Shopware\Core\Framework\Feature\FeatureFlagRegistry;
use Shopware\Core\Framework\MessageQueue\Stats\StatsService;
Expand Down Expand Up @@ -442,6 +444,12 @@
$services->set(JsonRequestTransformerListener::class)
->tag('kernel.event_subscriber');

$services->set(CompressedCriteriaRequestListener::class)
->args([
service(CompressedCriteriaDecoder::class),
])
->tag('kernel.event_subscriber');

$services->set(ExpectationSubscriber::class)
->args([
param('kernel.shopware_version'),
Expand Down
6 changes: 1 addition & 5 deletions src/Core/Framework/Script/Api/ScriptStoreApiRoute.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
use Psr\Log\LoggerInterface;
use Shopware\Core\Framework\Adapter\Cache\CacheCompressor;
use Shopware\Core\Framework\Adapter\Cache\Http\HttpCacheKeyGenerator;
use Shopware\Core\Framework\Adapter\Request\RequestParamHelper;
use Shopware\Core\Framework\Feature;
use Shopware\Core\Framework\Log\Package;
use Shopware\Core\Framework\Routing\StoreApiRouteScope;
Expand Down Expand Up @@ -62,10 +61,7 @@ public function execute(string $hook, Request $request, SalesChannelContext $con
// hook: store-api-{hook}
$this->executor->execute($responseHook);

$fields = new ResponseFields(
RequestParamHelper::get($request, 'includes', []),
RequestParamHelper::get($request, 'excludes', []),
);
$fields = ResponseFields::fromRequest($request);

$symfonyResponse = $this->scriptResponseEncoder->encodeToSymfonyResponse(
$responseHook->getScriptResponse(),
Expand Down
Loading