From 9eae21b3d136860967ae1465899d997ac448f540 Mon Sep 17 00:00:00 2001 From: mkucmus Date: Mon, 28 Sep 2026 13:58:02 +0200 Subject: [PATCH 1/8] fix(store-api): read all fields of the compressed _criteria parameter --- RELEASE_INFO-6.7.md | 22 ++ .../components/parameters/criteria.json | 4 +- .../Schema/StoreApi/paths/category.json | 3 + .../Generator/Schema/StoreApi/paths/cms.json | 3 + .../Schema/StoreApi/paths/landing-page.json | 3 + .../Schema/StoreApi/paths/search-suggest.json | 3 + .../Schema/StoreApi/paths/search.json | 3 + .../CompressedCriteriaRequestListener.php | 67 ++++++ .../Search/RequestCriteriaBuilder.php | 17 +- .../Framework/DependencyInjection/api.php | 8 + .../Script/Api/ScriptStoreApiRoute.php | 6 +- .../SalesChannel/Api/ResponseFields.php | 29 +++ .../Api/StoreApiResponseListener.php | 6 +- .../SalesChannel/SalesChannelException.php | 2 +- .../SalesChannel/CategoryRouteTest.php | 82 ++++++++ .../SalesChannel/LandingPageRouteTest.php | 50 +++++ .../Script/Api/ScriptStoreApiRouteTest.php | 9 + .../CompressedCriteriaRequestListenerTest.php | 194 ++++++++++++++++++ .../Search/RequestCriteriaBuilderTest.php | 19 ++ .../SalesChannel/Api/ResponseFieldsTest.php | 49 +++++ .../Api/StoreApiResponseListenerTest.php | 45 ++++ .../SalesChannelExceptionTest.php | 7 + 22 files changed, 609 insertions(+), 22 deletions(-) create mode 100644 src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php create mode 100644 tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php diff --git a/RELEASE_INFO-6.7.md b/RELEASE_INFO-6.7.md index 082e1d8c3c9..9c8b14fe91a 100644 --- a/RELEASE_INFO-6.7.md +++ b/RELEASE_INFO-6.7.md @@ -56,6 +56,28 @@ 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 + +A Store API `GET` request with `_criteria` now returns the same as the `POST` request with that body. Before, several routes ignored fields inside `_criteria`: + +| Route | Ignored fields | +|---|---| +| `/category/{navigationId}`, `/cms/{id}`, `/landing-page/{landingPageId}` | `limit`, `includes`, `excludes`, `slots` | +| `/search` | `limit` | +| `/product/{productId}` | `slots` | +| `/navigation/{activeId}/{rootId}` | `depth`, `buildTree` | +| `/media` | `ids`, `includes`, `excludes` | +| `/product/{productId}/find-variant` | `options`, `switchedGroup` | + +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. +- An invalid `_criteria` value is answered with `400` on every Store API route. Routes without criteria ignored it before. +- The `sw-include-search-info` header is respected together with `_criteria`. +- `includes` and `excludes` that are not an array are answered with `400` instead of `500`. + +The OpenAPI schema declares `_criteria` for `readCategoryGet`, `readCmsGet`, `readLandingPageGet`, `searchPageGet` and `searchSuggestGet`. + ## Administration ### New extension points for the Shopping Experiences layout list diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json index 6b7cb946901..262a87da2dd 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json @@ -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). The object can also contain the other fields that the POST request of the same route accepts in its body. A field in `_criteria` takes precedence over a query parameter of the same name.", "required": false, "schema": { "type": "string", @@ -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). The object can also contain the other fields that the POST request of the same route accepts in its body. A field in `_criteria` takes precedence over a query parameter of the same name.", "required": false, "schema": { "type": "string", diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/category.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/category.json index 5a419f251b6..6a529e6cd7d 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/category.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/category.json @@ -221,6 +221,9 @@ }, { "x-parameter-group": "productListingCriteria" + }, + { + "$ref": "#/components/parameters/CompressedCriteria" } ], "responses": { diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/cms.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/cms.json index 1ced0eb3495..541ceadb004 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/cms.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/cms.json @@ -114,6 +114,9 @@ }, { "x-parameter-group": "productListingCriteria" + }, + { + "$ref": "#/components/parameters/CompressedCriteria" } ], "responses": { diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/landing-page.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/landing-page.json index 8c695fc9a6a..6d0ab1f2ad0 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/landing-page.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/landing-page.json @@ -127,6 +127,9 @@ }, { "x-parameter-group": "productListingCriteria" + }, + { + "$ref": "#/components/parameters/CompressedCriteria" } ], "responses": { diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/search-suggest.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/search-suggest.json index 5af7613b80c..243133ec84b 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/search-suggest.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/search-suggest.json @@ -115,6 +115,9 @@ }, { "x-parameter-group": "productListingFlags" + }, + { + "$ref": "#/components/parameters/CompressedCriteria" } ], "responses": { diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/search.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/search.json index 92f8282e7e3..9665dd5cc5f 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/search.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/search.json @@ -129,6 +129,9 @@ }, { "x-parameter-group": "productListingFlags" + }, + { + "$ref": "#/components/parameters/CompressedCriteria" } ], "responses": { diff --git a/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php b/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php new file mode 100644 index 00000000000..c0651ba044b --- /dev/null +++ b/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php @@ -0,0 +1,67 @@ + [ + '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; + } + + // the compressed criteria wins over a plain query parameter of the same name + $request->query->set($field, $value); + } + } +} diff --git a/src/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilder.php b/src/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilder.php index afeab6bd588..63a7b916a97 100644 --- a/src/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilder.php +++ b/src/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilder.php @@ -75,18 +75,17 @@ 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); + if (!$request->isMethod(Request::METHOD_GET)) { + $payload = $request->request->all(); + } elseif ($request->query->has('_criteria')) { + // the _criteria parameter takes precedence over the individual query parameters + $payload = $this->compressedCriteriaDecoder->decode((string) $request->query->get('_criteria')); } else { - $criteria = $this->fromArray($request->request->all(), $criteria, $definition, $context); + $payload = $request->query->all(); } + $criteria = $this->fromArray($payload, $criteria, $definition, $context); + // @deprecated tag:v6.8.0 - switch the default to 0 if ($request->headers->get(PlatformRequest::HEADER_INCLUDE_SEARCH_INFO, '1') === '0') { $criteria->addState(Criteria::STATE_DISABLE_SEARCH_INFO); diff --git a/src/Core/Framework/DependencyInjection/api.php b/src/Core/Framework/DependencyInjection/api.php index af32828ef19..75b682d7658 100644 --- a/src/Core/Framework/DependencyInjection/api.php +++ b/src/Core/Framework/DependencyInjection/api.php @@ -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; @@ -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; @@ -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'), diff --git a/src/Core/Framework/Script/Api/ScriptStoreApiRoute.php b/src/Core/Framework/Script/Api/ScriptStoreApiRoute.php index 04f43cad494..2416db65040 100644 --- a/src/Core/Framework/Script/Api/ScriptStoreApiRoute.php +++ b/src/Core/Framework/Script/Api/ScriptStoreApiRoute.php @@ -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; @@ -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(), diff --git a/src/Core/System/SalesChannel/Api/ResponseFields.php b/src/Core/System/SalesChannel/Api/ResponseFields.php index 7cd4bebe134..7c7f1941625 100644 --- a/src/Core/System/SalesChannel/Api/ResponseFields.php +++ b/src/Core/System/SalesChannel/Api/ResponseFields.php @@ -2,8 +2,10 @@ namespace Shopware\Core\System\SalesChannel\Api; +use Shopware\Core\Framework\Adapter\Request\RequestParamHelper; use Shopware\Core\Framework\Log\Package; use Shopware\Core\System\SalesChannel\SalesChannelException; +use Symfony\Component\HttpFoundation\Request; #[Package('framework')] class ResponseFields @@ -19,6 +21,17 @@ public function __construct( $this->validateFields(); } + /** + * Reads the `includes` and `excludes` parameters of the request. + */ + public static function fromRequest(Request $request): self + { + return new self( + self::getFieldsParameter($request, 'includes'), + self::getFieldsParameter($request, 'excludes'), + ); + } + public function isAllowed(string $type, string $property): bool { if (isset($this->excludes[$type]) && \in_array($property, $this->excludes[$type], true)) { @@ -87,4 +100,20 @@ protected function validateFields(): void } } } + + /** + * @return array>|null + */ + private static function getFieldsParameter(Request $request, string $name): ?array + { + $fields = RequestParamHelper::get($request, $name, []); + + if ($fields !== null && !\is_array($fields)) { + throw SalesChannelException::invalidType( + \sprintf('The %s must be of the type array, %s given', $name, \gettype($fields)) + ); + } + + return $fields; + } } diff --git a/src/Core/System/SalesChannel/Api/StoreApiResponseListener.php b/src/Core/System/SalesChannel/Api/StoreApiResponseListener.php index 6c8beaf3bcd..911716faa79 100644 --- a/src/Core/System/SalesChannel/Api/StoreApiResponseListener.php +++ b/src/Core/System/SalesChannel/Api/StoreApiResponseListener.php @@ -4,7 +4,6 @@ use Shopware\Core\Content\Media\MediaUrlPlaceholderHandlerInterface; use Shopware\Core\Content\Seo\SeoUrlPlaceholderHandlerInterface; -use Shopware\Core\Framework\Adapter\Request\RequestParamHelper; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; use Shopware\Core\Framework\Log\Package; use Shopware\Core\PlatformRequest; @@ -56,10 +55,7 @@ public function encodeResponse(ResponseEvent $event): void if ($criteria instanceof Criteria) { $fields = new ResponseFields($criteria->getIncludes(), $criteria->getExcludes()); } else { - $fields = new ResponseFields( - RequestParamHelper::get($request, 'includes', []), - RequestParamHelper::get($request, 'excludes', []), - ); + $fields = ResponseFields::fromRequest($request); } $encoded = $this->encoder->encode($response->getObject(), $fields); diff --git a/src/Core/System/SalesChannel/SalesChannelException.php b/src/Core/System/SalesChannel/SalesChannelException.php index 575d4e4bc58..3fc8d600f2f 100644 --- a/src/Core/System/SalesChannel/SalesChannelException.php +++ b/src/Core/System/SalesChannel/SalesChannelException.php @@ -193,7 +193,7 @@ public static function salesChannelDomainInUse(?\Throwable $previous = null): Sh public static function invalidType(string $message): self { return new self( - Response::HTTP_INTERNAL_SERVER_ERROR, + Response::HTTP_BAD_REQUEST, self::INVALID_TYPE, $message ); diff --git a/tests/integration/Core/Content/Category/SalesChannel/CategoryRouteTest.php b/tests/integration/Core/Content/Category/SalesChannel/CategoryRouteTest.php index da6aa66f2f2..7e5fc49b006 100644 --- a/tests/integration/Core/Content/Category/SalesChannel/CategoryRouteTest.php +++ b/tests/integration/Core/Content/Category/SalesChannel/CategoryRouteTest.php @@ -14,10 +14,13 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour; +use Shopware\Core\Framework\Util\Base64; use Shopware\Core\System\SalesChannel\SalesChannelCollection; +use Shopware\Core\System\SalesChannel\SalesChannelException; use Shopware\Core\Test\Stub\Framework\IdsCollection; use Shopware\Tests\Integration\Core\Content\Category\SalesChannel\fixtures\CategoryRouteInheritanceFixtures; use Symfony\Bundle\FrameworkBundle\KernelBrowser; +use Symfony\Component\HttpFoundation\Response; /** * @internal @@ -105,6 +108,63 @@ public function testIncludesConsidered(): void } } + /** + * @param array $parameters + */ + #[DataProvider('requestParameterProvider')] + public function testCompressedCriteriaIsReadLikeTheRequestBody(array $parameters): void + { + $this->createListingData(); + $url = '/store-api/category/' . $this->ids->get('category'); + + $this->browser->request('POST', $url); + $withoutParameters = $this->decodeResponse(); + + $this->browser->request('POST', $url, $parameters); + $expected = $this->decodeResponse(); + static::assertNotSame($withoutParameters, $expected, 'The parameters have to change the response'); + + $this->browser->request('GET', $url, ['_criteria' => self::compress($parameters)]); + + static::assertSame($expected, $this->decodeResponse()); + } + + /** + * @return iterable}> + */ + public static function requestParameterProvider(): iterable + { + yield 'limit of the product listing' => [ + 'parameters' => ['limit' => 1], + ]; + + yield 'included fields of the response' => [ + 'parameters' => ['includes' => ['category' => ['id', 'name']]], + ]; + + yield 'excluded fields of the response' => [ + 'parameters' => ['excludes' => ['category' => ['cmsPage']]], + ]; + + yield 'slots of the CMS page, no slot has the given id' => [ + 'parameters' => ['slots' => '0198c5a1f2d97c3e9b4a6d5e7f801234'], + ]; + } + + public function testIncludesThatAreNotAnArrayAreRejected(): void + { + $this->createListingData(); + + $this->browser->request( + 'GET', + '/store-api/category/' . $this->ids->get('category'), + ['includes' => '{"category":["id"]}'] + ); + + static::assertSame(Response::HTTP_BAD_REQUEST, $this->browser->getResponse()->getStatusCode()); + static::assertSame(SalesChannelException::INVALID_TYPE, $this->decodeResponse()['errors'][0]['code']); + } + public function testHome(): void { $this->createListingData(); @@ -360,6 +420,28 @@ private function createDomainsWithSystemDefaultParent(Context $context): void ]], $context); } + /** + * @return array + */ + private function decodeResponse(): array + { + $content = $this->browser->getResponse()->getContent(); + static::assertIsString($content); + + return json_decode($content, true, 512, \JSON_THROW_ON_ERROR); + } + + /** + * @param array $parameters + */ + private static function compress(array $parameters): string + { + $compressed = gzencode(json_encode($parameters, \JSON_THROW_ON_ERROR)); + static::assertNotFalse($compressed, 'Gzip compressing failed'); + + return Base64::urlEncode($compressed); + } + private function assertError(string $categoryId): void { static::assertIsString($this->browser->getResponse()->getContent()); diff --git a/tests/integration/Core/Content/LandingPage/SalesChannel/LandingPageRouteTest.php b/tests/integration/Core/Content/LandingPage/SalesChannel/LandingPageRouteTest.php index f569f77db3d..0f80553e094 100644 --- a/tests/integration/Core/Content/LandingPage/SalesChannel/LandingPageRouteTest.php +++ b/tests/integration/Core/Content/LandingPage/SalesChannel/LandingPageRouteTest.php @@ -3,6 +3,7 @@ namespace Shopware\Tests\Integration\Core\Content\LandingPage\SalesChannel; use Doctrine\DBAL\Connection; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Shopware\Core\Content\Cms\CmsPageCollection; @@ -14,6 +15,7 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour; +use Shopware\Core\Framework\Util\Base64; use Shopware\Core\Framework\Uuid\Uuid; use Shopware\Core\System\SalesChannel\Context\SalesChannelContextFactory; use Shopware\Core\System\SalesChannel\Context\SalesChannelContextService; @@ -81,6 +83,43 @@ public function testPostLandingPage(): void static::assertArrayHasKey('cmsPage', $response); } + /** + * @param array $parameters + */ + #[DataProvider('requestParameterProvider')] + public function testCompressedCriteriaIsReadLikeTheRequestBody(array $parameters): void + { + $url = '/store-api/landing-page/' . $this->ids->get('landing-page'); + + $this->browser->request('POST', $url); + $withoutParameters = $this->decodeResponse(); + + $this->browser->request('POST', $url, $parameters); + $expected = $this->decodeResponse(); + static::assertNotSame($withoutParameters, $expected, 'The parameters have to change the response'); + + $compressed = gzencode(json_encode($parameters, \JSON_THROW_ON_ERROR)); + static::assertNotFalse($compressed, 'Gzip compressing failed'); + + $this->browser->request('GET', $url, ['_criteria' => Base64::urlEncode($compressed)]); + + static::assertSame($expected, $this->decodeResponse()); + } + + /** + * @return iterable}> + */ + public static function requestParameterProvider(): iterable + { + yield 'included fields of the response' => [ + 'parameters' => ['includes' => ['landing_page' => ['id', 'name']]], + ]; + + yield 'slots of the CMS page, no slot has the given id' => [ + 'parameters' => ['slots' => '0198c5a1f2d97c3e9b4a6d5e7f801234'], + ]; + } + public function testLoadLandingPageCmsSlotConfigFromParentLanguageOverride(): void { $this->createLanguages(); @@ -230,6 +269,17 @@ private function createData(): void ], $context); } + /** + * @return array + */ + private function decodeResponse(): array + { + $content = $this->browser->getResponse()->getContent(); + static::assertIsString($content); + + return json_decode($content, true, 512, \JSON_THROW_ON_ERROR); + } + private function createLanguages(): void { static::getContainer()->get('language.repository')->upsert([ diff --git a/tests/integration/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php b/tests/integration/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php index c773fa08624..640aa2fe8e8 100644 --- a/tests/integration/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php +++ b/tests/integration/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php @@ -77,6 +77,15 @@ public function testApiEndpointWithSlashInHookName(): void static::assertSame('store_api_simple_script_response', $response['apiAlias']); } + public function testIncludesThatAreNotAnArrayAreRejected(): void + { + $this->loadAppsFromDir(__DIR__ . '/_fixtures'); + + $this->browser->request('POST', '/store-api/script/simple-script', ['includes' => 'foo']); + + static::assertSame(Response::HTTP_BAD_REQUEST, $this->browser->getResponse()->getStatusCode(), (string) $this->browser->getResponse()->getContent()); + } + public function testRepositoryCall(): void { $this->loadAppsFromDir(__DIR__ . '/_fixtures'); diff --git a/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php b/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php new file mode 100644 index 00000000000..afa1cf6bc99 --- /dev/null +++ b/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php @@ -0,0 +1,194 @@ +listener = new CompressedCriteriaRequestListener(new CompressedCriteriaDecoder()); + } + + public function testRunsAfterTheAuthenticationAndBeforeTheContextIsResolved(): void + { + static::assertSame( + [ + KernelEvents::CONTROLLER => [ + 'expandCompressedCriteria', + KernelListenerPriorities::KERNEL_CONTROLLER_EVENT_PRIORITY_AUTH_VALIDATE_POST, + ], + ], + CompressedCriteriaRequestListener::getSubscribedEvents() + ); + } + + public function testFieldsOfTheCompressedCriteriaAreReadableAsQueryParameters(): void + { + $request = self::createStoreApiRequest([ + '_criteria' => self::compress([ + 'limit' => 2, + 'slots' => 'slot-a|slot-b', + 'buildTree' => false, + 'includes' => ['category' => ['id', 'name']], + ]), + ]); + + $this->listener->expandCompressedCriteria(self::createEvent($request)); + + static::assertSame(2, $request->query->getInt('limit')); + static::assertSame('slot-a|slot-b', $request->query->get('slots')); + static::assertFalse($request->query->getBoolean('buildTree', true)); + static::assertSame(['category' => ['id', 'name']], $request->query->all('includes')); + } + + public function testCompressedCriteriaWinsOverPlainQueryParameter(): void + { + $request = self::createStoreApiRequest([ + 'limit' => '10', + 'p' => '3', + '_criteria' => self::compress(['limit' => 2]), + ]); + + $this->listener->expandCompressedCriteria(self::createEvent($request)); + + static::assertSame(2, $request->query->getInt('limit')); + static::assertSame(3, $request->query->getInt('p'), 'A plain parameter that is not part of the compressed criteria is kept'); + } + + public function testCompressedParameterIsKeptForTheCriteriaBuilder(): void + { + $compressed = self::compress(['limit' => 2, '_criteria' => 'nested']); + + $request = self::createStoreApiRequest(['_criteria' => $compressed]); + + $this->listener->expandCompressedCriteria(self::createEvent($request)); + + static::assertSame($compressed, $request->query->get('_criteria')); + } + + public function testListPayloadDoesNotFail(): void + { + $request = self::createStoreApiRequest(['_criteria' => self::compress(['first', 'second'])]); + + $this->listener->expandCompressedCriteria(self::createEvent($request)); + + static::assertSame('first', $request->query->get('0')); + static::assertSame('second', $request->query->get('1')); + } + + /** + * @param array $query + * @param list $scopes + */ + #[DataProvider('untouchedRequestProvider')] + public function testRequestIsLeftUntouched(string $method, array $query, array $scopes): void + { + $request = new Request($query); + $request->setMethod($method); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, $scopes); + + $this->listener->expandCompressedCriteria(self::createEvent($request)); + + static::assertSame($query, $request->query->all()); + } + + /** + * @return iterable, scopes: list}> + */ + public static function untouchedRequestProvider(): iterable + { + yield 'POST requests carry their parameters in the body' => [ + 'method' => Request::METHOD_POST, + 'query' => ['_criteria' => self::compress(['limit' => 2])], + 'scopes' => [StoreApiRouteScope::ID], + ]; + + yield 'GET request without compressed criteria' => [ + 'method' => Request::METHOD_GET, + 'query' => ['limit' => '2'], + 'scopes' => [StoreApiRouteScope::ID], + ]; + + yield 'only Store API routes are handled' => [ + 'method' => Request::METHOD_GET, + 'query' => ['_criteria' => self::compress(['limit' => 2])], + 'scopes' => [ApiRouteScope::ID], + ]; + + yield 'route without a scope' => [ + 'method' => Request::METHOD_GET, + 'query' => ['_criteria' => self::compress(['limit' => 2])], + 'scopes' => [], + ]; + } + + #[WithoutErrorHandler] + public function testInvalidCompressedCriteriaIsRejected(): void + { + $request = self::createStoreApiRequest(['_criteria' => 'not-base64!!']); + + $this->expectExceptionObject( + DataAbstractionLayerException::invalidCompressedCriteriaParameter('Failed to decode base64url data') + ); + + $this->listener->expandCompressedCriteria(self::createEvent($request)); + } + + /** + * @param array $query + */ + private static function createStoreApiRequest(array $query): Request + { + $request = new Request($query); + $request->setMethod(Request::METHOD_GET); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [StoreApiRouteScope::ID]); + + return $request; + } + + private static function createEvent(Request $request): ControllerEvent + { + return new ControllerEvent( + static::createStub(HttpKernelInterface::class), + static fn () => null, + $request, + HttpKernelInterface::MAIN_REQUEST + ); + } + + /** + * @param array $payload + */ + private static function compress(array $payload): string + { + $compressed = gzencode(json_encode($payload, \JSON_THROW_ON_ERROR)); + static::assertNotFalse($compressed, 'Gzip compressing failed'); + + return Base64::urlEncode($compressed); + } +} diff --git a/tests/unit/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilderTest.php b/tests/unit/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilderTest.php index 432ff53911d..7b89730da47 100644 --- a/tests/unit/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilderTest.php +++ b/tests/unit/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilderTest.php @@ -1053,6 +1053,25 @@ public static function searchInfoHeaderProvider(): iterable ]; } + public function testIncludeSearchInfoHeaderIsReadWithCompressedCriteria(): void + { + $request = new Request([ + '_criteria' => self::gzipAndBase64UrlEncode(json_encode(['limit' => 5], \JSON_THROW_ON_ERROR)), + ]); + $request->setMethod(Request::METHOD_GET); + $request->headers->set(PlatformRequest::HEADER_INCLUDE_SEARCH_INFO, '0'); + + $criteria = $this->requestCriteriaBuilder->handleRequest( + $request, + new Criteria(), + $this->staticDefinitionRegistry->get(ProductDefinition::class), + Context::createDefaultContext() + ); + + static::assertSame(5, $criteria->getLimit()); + static::assertTrue($criteria->hasState(Criteria::STATE_DISABLE_SEARCH_INFO)); + } + public function testCompressedCriteriaParameter(): void { $criteriaData = [ diff --git a/tests/unit/Core/System/SalesChannel/Api/ResponseFieldsTest.php b/tests/unit/Core/System/SalesChannel/Api/ResponseFieldsTest.php index 58e02bd9897..1e58a843734 100644 --- a/tests/unit/Core/System/SalesChannel/Api/ResponseFieldsTest.php +++ b/tests/unit/Core/System/SalesChannel/Api/ResponseFieldsTest.php @@ -7,6 +7,7 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\System\SalesChannel\Api\ResponseFields; use Shopware\Core\System\SalesChannel\SalesChannelException; +use Symfony\Component\HttpFoundation\Request; /** * @internal @@ -60,4 +61,52 @@ public function testHasNestedReturnsFalseWhenPropertyDoesNotHavePrefix(): void $responseFields = new ResponseFields(['alias' => ['otherprefix.property']]); static::assertFalse($responseFields->hasNested('alias', 'prefix')); } + + public function testFromRequestReadsTheQueryOfAGetRequest(): void + { + $request = new Request([ + 'includes' => ['product' => ['id', 'name']], + 'excludes' => ['product' => ['name']], + ]); + + $responseFields = ResponseFields::fromRequest($request); + + static::assertTrue($responseFields->isAllowed('product', 'id')); + static::assertFalse($responseFields->isAllowed('product', 'name')); + static::assertFalse($responseFields->isAllowed('product', 'description')); + } + + public function testFromRequestReadsTheBodyOfAPostRequest(): void + { + $request = new Request(request: ['includes' => ['product' => ['id']]]); + $request->setMethod(Request::METHOD_POST); + + $responseFields = ResponseFields::fromRequest($request); + + static::assertTrue($responseFields->isAllowed('product', 'id')); + static::assertFalse($responseFields->isAllowed('product', 'name')); + } + + public function testFromRequestAllowsEverythingWithoutParameters(): void + { + $request = new Request(request: ['includes' => null, 'excludes' => null]); + $request->setMethod(Request::METHOD_POST); + + static::assertTrue(ResponseFields::fromRequest($request)->isAllowed('product', 'id')); + static::assertTrue(ResponseFields::fromRequest(new Request())->isAllowed('product', 'id')); + } + + public function testFromRequestRejectsIncludesThatAreNotAnArray(): void + { + $this->expectExceptionObject(SalesChannelException::invalidType('The includes must be of the type array, string given')); + + ResponseFields::fromRequest(new Request(['includes' => '{"product":["id"]}'])); + } + + public function testFromRequestRejectsExcludesThatAreNotAnArray(): void + { + $this->expectExceptionObject(SalesChannelException::invalidType('The excludes must be of the type array, string given')); + + ResponseFields::fromRequest(new Request(['excludes' => 'name'])); + } } diff --git a/tests/unit/Core/System/SalesChannel/Api/StoreApiResponseListenerTest.php b/tests/unit/Core/System/SalesChannel/Api/StoreApiResponseListenerTest.php index 4059a734196..fd72156b522 100644 --- a/tests/unit/Core/System/SalesChannel/Api/StoreApiResponseListenerTest.php +++ b/tests/unit/Core/System/SalesChannel/Api/StoreApiResponseListenerTest.php @@ -17,6 +17,7 @@ use Shopware\Core\System\SalesChannel\Api\StoreApiResponseListener; use Shopware\Core\System\SalesChannel\Api\StructEncoder; use Shopware\Core\System\SalesChannel\GenericStoreApiResponse; +use Shopware\Core\System\SalesChannel\SalesChannelException; use Shopware\Core\System\SalesChannel\StoreApiResponse; use Symfony\Component\EventDispatcher\EventDispatcher; use Symfony\Component\HttpFoundation\JsonResponse; @@ -175,4 +176,48 @@ public function testEncodeResponseUsesFieldsFromResolvedCriteria(): void $listener = new StoreApiResponseListener($encoder, new EventDispatcher(), $this->seoUrlPlaceholderHandler, $this->mediaUrlPlaceholderHandler); $listener->encodeResponse($event); } + + public function testEncodeResponseUsesFieldsFromRequestWithoutResolvedCriteria(): void + { + $request = new Request(['includes' => ['category' => ['id']]]); + + $encoder = $this->createMock(StructEncoder::class); + $encoder->expects($this->once()) + ->method('encode') + ->willReturnCallback(static function (Struct $struct, ResponseFields $fields): array { + static::assertTrue($fields->isAllowed('category', 'id')); + static::assertFalse($fields->isAllowed('category', 'name')); + + return ['encoded' => 'data']; + }); + + $event = new ResponseEvent( + static::createStub(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + new GenericStoreApiResponse(200, new ArrayStruct()) + ); + + $listener = new StoreApiResponseListener($encoder, new EventDispatcher(), $this->seoUrlPlaceholderHandler, $this->mediaUrlPlaceholderHandler); + $listener->encodeResponse($event); + } + + public function testEncodeResponseRejectsIncludesThatAreNotAnArray(): void + { + $encoder = $this->createMock(StructEncoder::class); + $encoder->expects($this->never())->method('encode'); + + $event = new ResponseEvent( + static::createStub(HttpKernelInterface::class), + new Request(['includes' => '{"category":["id"]}']), + HttpKernelInterface::MAIN_REQUEST, + new GenericStoreApiResponse(200, new ArrayStruct()) + ); + + $listener = new StoreApiResponseListener($encoder, new EventDispatcher(), $this->seoUrlPlaceholderHandler, $this->mediaUrlPlaceholderHandler); + + $this->expectExceptionObject(SalesChannelException::invalidType('The includes must be of the type array, string given')); + + $listener->encodeResponse($event); + } } diff --git a/tests/unit/Core/System/SalesChannel/SalesChannelExceptionTest.php b/tests/unit/Core/System/SalesChannel/SalesChannelExceptionTest.php index 634ff801a2f..37854c63770 100644 --- a/tests/unit/Core/System/SalesChannel/SalesChannelExceptionTest.php +++ b/tests/unit/Core/System/SalesChannel/SalesChannelExceptionTest.php @@ -106,5 +106,12 @@ public static function exceptionDataProvider(): iterable 'errorCode' => SalesChannelException::CONTEXT_TOKEN_NOT_ACCESSIBLE, 'message' => 'The context token is not accessible in Twig rendering context, as the token should never be leaked in HTML content.', ]; + + yield 'a wrong type in the request is a client error' => [ + 'exception' => SalesChannelException::invalidType('The includes for type "product" must be of the type array, string given'), + 'statusCode' => Response::HTTP_BAD_REQUEST, + 'errorCode' => SalesChannelException::INVALID_TYPE, + 'message' => 'The includes for type "product" must be of the type array, string given', + ]; } } From 18de9a1bf6a8aa0fe5fb7c813678054caec3a8ea Mon Sep 17 00:00:00 2001 From: mkucmus Date: Mon, 28 Sep 2026 15:29:05 +0200 Subject: [PATCH 2/8] fix(store-api): apply query parameters next to the compressed _criteria --- RELEASE_INFO-6.7.md | 1 + .../components/parameters/criteria.json | 4 +- .../Search/RequestCriteriaBuilder.php | 37 +++++++++---- .../SalesChannel/CategoryListRouteTest.php | 21 +++++++ .../Search/RequestCriteriaBuilderTest.php | 55 +++++++++++++++++++ 5 files changed, 106 insertions(+), 12 deletions(-) diff --git a/RELEASE_INFO-6.7.md b/RELEASE_INFO-6.7.md index 9c8b14fe91a..cba51ca0e3f 100644 --- a/RELEASE_INFO-6.7.md +++ b/RELEASE_INFO-6.7.md @@ -72,6 +72,7 @@ A Store API `GET` request with `_criteria` now returns the same as the `POST` re 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. +- Query parameters that are not part of `_criteria` are applied next to it. Before, the criteria of a route were built from `_criteria` alone, so a plain `limit` or `filter` next to it was ignored. - An invalid `_criteria` value is answered with `400` on every Store API route. Routes without criteria ignored it before. - The `sw-include-search-info` header is respected together with `_criteria`. - `includes` and `excludes` that are not an array are answered with `400` instead of `500`. diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json index 262a87da2dd..f24ca646380 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json @@ -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). The object can also contain the other fields that the POST request of the same route accepts in its body. A field in `_criteria` takes precedence over a query parameter of the same name.", + "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). The object can also contain the other fields that the POST request of the same route accepts in its body. 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", @@ -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). The object can also contain the other fields that the POST request of the same route accepts in its body. A field in `_criteria` takes precedence over a query parameter of the same name.", + "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). The object can also contain the other fields that the POST request of the same route accepts in its body. 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", diff --git a/src/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilder.php b/src/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilder.php index 63a7b916a97..54120315813 100644 --- a/src/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilder.php +++ b/src/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilder.php @@ -75,16 +75,7 @@ public function __construct( public function handleRequest(Request $request, Criteria $criteria, EntityDefinition $definition, Context $context): Criteria { - if (!$request->isMethod(Request::METHOD_GET)) { - $payload = $request->request->all(); - } elseif ($request->query->has('_criteria')) { - // the _criteria parameter takes precedence over the individual query parameters - $payload = $this->compressedCriteriaDecoder->decode((string) $request->query->get('_criteria')); - } else { - $payload = $request->query->all(); - } - - $criteria = $this->fromArray($payload, $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') { @@ -124,6 +115,32 @@ public function addTotalCountMode(string $totalCountMode, Criteria $criteria): v } } + /** + * @return array + */ + 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 $payload */ diff --git a/tests/integration/Core/Content/Category/SalesChannel/CategoryListRouteTest.php b/tests/integration/Core/Content/Category/SalesChannel/CategoryListRouteTest.php index b14ca0e8a17..cf11c1052a0 100644 --- a/tests/integration/Core/Content/Category/SalesChannel/CategoryListRouteTest.php +++ b/tests/integration/Core/Content/Category/SalesChannel/CategoryListRouteTest.php @@ -10,6 +10,7 @@ use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\QueueTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour; +use Shopware\Core\Framework\Util\Base64; use Shopware\Core\Test\Stub\Framework\IdsCollection; use Symfony\Bundle\FrameworkBundle\KernelBrowser; @@ -113,6 +114,26 @@ public function testIncludes(): void static::assertArrayNotHasKey('parentId', $response['elements'][0]); } + public function testQueryParametersAreAppliedNextToCompressedCriteria(): void + { + $compressed = gzencode(json_encode(['includes' => ['category' => ['id', 'name']]], \JSON_THROW_ON_ERROR)); + static::assertNotFalse($compressed, 'Gzip compressing failed'); + + $this->browser->request( + 'GET', + '/store-api/category', + [ + 'limit' => 1, + '_criteria' => Base64::urlEncode($compressed), + ] + ); + + $response = json_decode((string) $this->browser->getResponse()->getContent(), true, 512, \JSON_THROW_ON_ERROR); + + static::assertCount(1, $response['elements'], 'The limit of the query is applied'); + static::assertSame(['name', 'id', 'apiAlias'], array_keys($response['elements'][0]), 'The includes of the compressed criteria are applied'); + } + private function createData(): void { $data = [ diff --git a/tests/unit/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilderTest.php b/tests/unit/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilderTest.php index 7b89730da47..290dab11baa 100644 --- a/tests/unit/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilderTest.php +++ b/tests/unit/Core/Framework/DataAbstractionLayer/Search/RequestCriteriaBuilderTest.php @@ -1149,6 +1149,61 @@ public function testCompressedCriteriaParameterTakesPrecedenceOverIndividualPara static::assertSame(['product.active'], $filter->getFields()); } + public function testIndividualParametersAreKeptNextToCompressedCriteria(): void + { + $encodedCriteria = self::gzipAndBase64UrlEncode(json_encode([ + 'filter' => [ + ['type' => 'equals', 'field' => 'active', 'value' => true], + ], + ], \JSON_THROW_ON_ERROR)); + + $request = new Request([ + '_criteria' => $encodedCriteria, + 'limit' => '10', + 'page' => '2', + ]); + $request->setMethod(Request::METHOD_GET); + + $criteria = $this->requestCriteriaBuilder->handleRequest( + $request, + new Criteria(), + $this->staticDefinitionRegistry->get(ProductDefinition::class), + Context::createDefaultContext() + ); + + static::assertSame(10, $criteria->getLimit()); + static::assertSame(10, $criteria->getOffset()); + static::assertCount(1, $criteria->getFilters()); + } + + public function testCompressedCriteriaFieldReplacesTheIndividualParameterAsAWhole(): void + { + $encodedCriteria = self::gzipAndBase64UrlEncode(json_encode([ + 'filter' => [ + ['type' => 'equals', 'field' => 'active', 'value' => true], + ], + ], \JSON_THROW_ON_ERROR)); + + $request = new Request([ + '_criteria' => $encodedCriteria, + 'filter' => [ + ['type' => 'equals', 'field' => 'name', 'value' => 'test'], + ['type' => 'equals', 'field' => 'stock', 'value' => '1'], + ], + ]); + $request->setMethod(Request::METHOD_GET); + + $criteria = $this->requestCriteriaBuilder->handleRequest( + $request, + new Criteria(), + $this->staticDefinitionRegistry->get(ProductDefinition::class), + Context::createDefaultContext() + ); + + static::assertCount(1, $criteria->getFilters(), 'The filters of both sources must not be mixed'); + static::assertSame(['product.active'], $criteria->getFilters()[0]->getFields()); + } + #[WithoutErrorHandler] public function testInvalidCompressedCriteriaParameterThrowsException(): void { From 3c024b8348ed3826e8f1c5edf1cdabfb5d25ce45 Mon Sep 17 00:00:00 2001 From: mkucmus Date: Mon, 28 Sep 2026 18:23:02 +0200 Subject: [PATCH 3/8] fix(store-api): read the compressed _criteria on cacheable routes only --- RELEASE_INFO-6.7.md | 8 +++-- .../components/parameters/criteria.json | 4 +-- .../Schema/StoreApi/paths/media.json | 3 ++ .../Schema/StoreApi/paths/product.json | 3 ++ .../CompressedCriteriaRequestListener.php | 24 +++++++++++++-- .../Script/Api/ScriptStoreApiRoute.php | 6 +++- .../Cart/SalesChannel/CartLoadRouteTest.php | 14 +++++++++ .../Script/Api/ScriptStoreApiRouteTest.php | 9 ------ .../CompressedCriteriaRequestListenerTest.php | 29 +++++++++++++++++-- .../SalesChannelExceptionTest.php | 14 +++++---- 10 files changed, 88 insertions(+), 26 deletions(-) diff --git a/RELEASE_INFO-6.7.md b/RELEASE_INFO-6.7.md index 44b751d6506..b7593de9b85 100644 --- a/RELEASE_INFO-6.7.md +++ b/RELEASE_INFO-6.7.md @@ -106,7 +106,7 @@ The Store API OpenAPI schema was corrected where it contradicted the real respon ### Store API reads every field of the compressed `_criteria` parameter -A Store API `GET` request with `_criteria` now returns the same as the `POST` request with that body. Before, several routes ignored fields inside `_criteria`: +On the cacheable Store API read routes, a `GET` request with `_criteria` now returns the same as the `POST` request with that body. Before, several routes ignored fields inside `_criteria`: | Route | Ignored fields | |---|---| @@ -121,11 +121,13 @@ Sending these fields as plain query parameters keeps working. If you send `_crit - A field in `_criteria` takes precedence over a query parameter of the same name. Before, a plain `limit` took precedence on the listing routes. - Query parameters that are not part of `_criteria` are applied next to it. Before, the criteria of a route were built from `_criteria` alone, so a plain `limit` or `filter` next to it was ignored. -- An invalid `_criteria` value is answered with `400` on every Store API route. Routes without criteria ignored it before. +- An invalid `_criteria` value is answered with `400` on every cacheable Store API read route. Routes without criteria ignored it before. - The `sw-include-search-info` header is respected together with `_criteria`. - `includes` and `excludes` that are not an array are answered with `400` instead of `500`. -The OpenAPI schema declares `_criteria` for `readCategoryGet`, `readCmsGet`, `readLandingPageGet`, `searchPageGet` and `searchSuggestGet`. +Routes that are not marked as cacheable, such as the cart or the custom script endpoints, do not read other fields from `_criteria`. + +The OpenAPI schema declares `_criteria` for `readCategoryGet`, `readCmsGet`, `readLandingPageGet`, `searchPageGet`, `searchSuggestGet`, `readMediaGet` and `searchProductVariantIdsGet`. ## Administration diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json index f24ca646380..f5f8bbf2cf1 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json @@ -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). The object can also contain the other fields that the POST request of the same route accepts in its body. A field in `_criteria` takes precedence over a query parameter of the same name, other query parameters are applied next to it.", + "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). On the cacheable read routes the object can also contain the other fields that the POST request of the same route accepts in its body. 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", @@ -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). The object can also contain the other fields that the POST request of the same route accepts in its body. A field in `_criteria` takes precedence over a query parameter of the same name, other query parameters are applied next to it.", + "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). On the cacheable read routes the object can also contain the other fields that the POST request of the same route accepts in its body. 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", diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/media.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/media.json index ef4996d2348..1e7f4dcf8c0 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/media.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/media.json @@ -90,6 +90,9 @@ } } }, + { + "$ref": "#/components/parameters/CompressedCriteria" + }, { "$ref": "#/components/parameters/swLanguageId" } diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/product.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/product.json index d9f6375843d..db9c895661d 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/product.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/product.json @@ -841,6 +841,9 @@ "type": "string" } }, + { + "$ref": "#/components/parameters/CompressedCriteria" + }, { "$ref": "#/components/parameters/swLanguageId" } diff --git a/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php b/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php index c0651ba044b..b60c6917d5b 100644 --- a/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php +++ b/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php @@ -2,6 +2,7 @@ namespace Shopware\Core\Framework\Api\EventListener; +use Shopware\Core\Framework\Adapter\Cache\Http\CacheAttribute; use Shopware\Core\Framework\DataAbstractionLayer\Search\CompressedCriteriaDecoder; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Routing\KernelListenerPriorities; @@ -14,9 +15,11 @@ /** * Copies the fields of the compressed `_criteria` parameter into the query parameters, - * so a Store API GET request is read the same way as a POST request with that body. + * so a cacheable Store API GET request is read the same way as a POST request with that body. * * @internal + * + * @phpstan-import-type CacheAttributeType from CacheAttribute */ #[Package('framework')] class CompressedCriteriaRequestListener implements EventSubscriberInterface @@ -46,8 +49,7 @@ public function expandCompressedCriteria(ControllerEvent $event): void return; } - $scopes = (array) $request->attributes->get(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, []); - if (!\in_array(StoreApiRouteScope::ID, $scopes, true)) { + if (!$this->isCacheableStoreApiRoute($request)) { return; } @@ -64,4 +66,20 @@ public function expandCompressedCriteria(ControllerEvent $event): void $request->query->set($field, $value); } } + + /** + * The compressed criteria exists to make read routes cacheable, every other route keeps reading its plain parameters only. + */ + private function isCacheableStoreApiRoute(Request $request): bool + { + $scopes = (array) $request->attributes->get(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, []); + if (!\in_array(StoreApiRouteScope::ID, $scopes, true)) { + return false; + } + + /** @var CacheAttributeType|null $cache */ + $cache = $request->attributes->get(PlatformRequest::ATTRIBUTE_HTTP_CACHE); + + return CacheAttribute::fromAttributeValue($cache) !== null; + } } diff --git a/src/Core/Framework/Script/Api/ScriptStoreApiRoute.php b/src/Core/Framework/Script/Api/ScriptStoreApiRoute.php index 2416db65040..04f43cad494 100644 --- a/src/Core/Framework/Script/Api/ScriptStoreApiRoute.php +++ b/src/Core/Framework/Script/Api/ScriptStoreApiRoute.php @@ -5,6 +5,7 @@ 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; @@ -61,7 +62,10 @@ public function execute(string $hook, Request $request, SalesChannelContext $con // hook: store-api-{hook} $this->executor->execute($responseHook); - $fields = ResponseFields::fromRequest($request); + $fields = new ResponseFields( + RequestParamHelper::get($request, 'includes', []), + RequestParamHelper::get($request, 'excludes', []), + ); $symfonyResponse = $this->scriptResponseEncoder->encodeToSymfonyResponse( $responseHook->getScriptResponse(), diff --git a/tests/integration/Core/Checkout/Cart/SalesChannel/CartLoadRouteTest.php b/tests/integration/Core/Checkout/Cart/SalesChannel/CartLoadRouteTest.php index 8cc90f98baf..ced90a49d3c 100644 --- a/tests/integration/Core/Checkout/Cart/SalesChannel/CartLoadRouteTest.php +++ b/tests/integration/Core/Checkout/Cart/SalesChannel/CartLoadRouteTest.php @@ -23,6 +23,7 @@ use Shopware\Core\Framework\Rule\Rule; use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour; +use Shopware\Core\Framework\Util\Base64; use Shopware\Core\System\SalesChannel\Context\AbstractSalesChannelContextFactory; use Shopware\Core\System\SalesChannel\Context\SalesChannelContextFactory; use Shopware\Core\System\SalesChannel\SalesChannelContext; @@ -88,6 +89,19 @@ public function testEmptyCart(): void static::assertEmpty($response['errors']); } + public function testCompressedCriteriaDoesNotAddRequestParameters(): void + { + $compressed = gzencode(json_encode(['includes' => ['cart' => ['token']]], \JSON_THROW_ON_ERROR)); + static::assertNotFalse($compressed, 'Gzip compressing failed'); + + $this->browser->request('GET', '/store-api/checkout/cart', ['_criteria' => Base64::urlEncode($compressed)]); + + $response = json_decode((string) $this->browser->getResponse()->getContent(), true, 512, \JSON_THROW_ON_ERROR); + + static::assertArrayHasKey('price', $response, 'The cart is not a cacheable read route, so the compressed criteria is not read'); + static::assertArrayHasKey('lineItems', $response); + } + /** * @param array>|null $ruleConditions */ diff --git a/tests/integration/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php b/tests/integration/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php index 640aa2fe8e8..c773fa08624 100644 --- a/tests/integration/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php +++ b/tests/integration/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php @@ -77,15 +77,6 @@ public function testApiEndpointWithSlashInHookName(): void static::assertSame('store_api_simple_script_response', $response['apiAlias']); } - public function testIncludesThatAreNotAnArrayAreRejected(): void - { - $this->loadAppsFromDir(__DIR__ . '/_fixtures'); - - $this->browser->request('POST', '/store-api/script/simple-script', ['includes' => 'foo']); - - static::assertSame(Response::HTTP_BAD_REQUEST, $this->browser->getResponse()->getStatusCode(), (string) $this->browser->getResponse()->getContent()); - } - public function testRepositoryCall(): void { $this->loadAppsFromDir(__DIR__ . '/_fixtures'); diff --git a/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php b/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php index afa1cf6bc99..091db10c251 100644 --- a/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php +++ b/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php @@ -101,16 +101,29 @@ public function testListPayloadDoesNotFail(): void static::assertSame('second', $request->query->get('1')); } + public function testRouteWithCacheSettingsIsHandled(): void + { + $request = self::createStoreApiRequest(['_criteria' => self::compress(['limit' => 2])]); + $request->attributes->set(PlatformRequest::ATTRIBUTE_HTTP_CACHE, ['sharedMaxAge' => 60]); + + $this->listener->expandCompressedCriteria(self::createEvent($request)); + + static::assertSame(2, $request->query->getInt('limit')); + } + /** * @param array $query * @param list $scopes */ #[DataProvider('untouchedRequestProvider')] - public function testRequestIsLeftUntouched(string $method, array $query, array $scopes): void + public function testRequestIsLeftUntouched(string $method, array $query, array $scopes, bool $cacheable): void { $request = new Request($query); $request->setMethod($method); $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, $scopes); + if ($cacheable) { + $request->attributes->set(PlatformRequest::ATTRIBUTE_HTTP_CACHE, true); + } $this->listener->expandCompressedCriteria(self::createEvent($request)); @@ -118,7 +131,7 @@ public function testRequestIsLeftUntouched(string $method, array $query, array $ } /** - * @return iterable, scopes: list}> + * @return iterable, scopes: list, cacheable: bool}> */ public static function untouchedRequestProvider(): iterable { @@ -126,24 +139,35 @@ public static function untouchedRequestProvider(): iterable 'method' => Request::METHOD_POST, 'query' => ['_criteria' => self::compress(['limit' => 2])], 'scopes' => [StoreApiRouteScope::ID], + 'cacheable' => true, ]; yield 'GET request without compressed criteria' => [ 'method' => Request::METHOD_GET, 'query' => ['limit' => '2'], 'scopes' => [StoreApiRouteScope::ID], + 'cacheable' => true, + ]; + + yield 'Store API route that is not marked as cacheable, such as the cart' => [ + 'method' => Request::METHOD_GET, + 'query' => ['_criteria' => self::compress(['token' => 'other-cart', 'includes' => ['cart' => ['token']]])], + 'scopes' => [StoreApiRouteScope::ID], + 'cacheable' => false, ]; yield 'only Store API routes are handled' => [ 'method' => Request::METHOD_GET, 'query' => ['_criteria' => self::compress(['limit' => 2])], 'scopes' => [ApiRouteScope::ID], + 'cacheable' => true, ]; yield 'route without a scope' => [ 'method' => Request::METHOD_GET, 'query' => ['_criteria' => self::compress(['limit' => 2])], 'scopes' => [], + 'cacheable' => true, ]; } @@ -167,6 +191,7 @@ private static function createStoreApiRequest(array $query): Request $request = new Request($query); $request->setMethod(Request::METHOD_GET); $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [StoreApiRouteScope::ID]); + $request->attributes->set(PlatformRequest::ATTRIBUTE_HTTP_CACHE, true); return $request; } diff --git a/tests/unit/Core/System/SalesChannel/SalesChannelExceptionTest.php b/tests/unit/Core/System/SalesChannel/SalesChannelExceptionTest.php index 37854c63770..d0cad996435 100644 --- a/tests/unit/Core/System/SalesChannel/SalesChannelExceptionTest.php +++ b/tests/unit/Core/System/SalesChannel/SalesChannelExceptionTest.php @@ -106,12 +106,14 @@ public static function exceptionDataProvider(): iterable 'errorCode' => SalesChannelException::CONTEXT_TOKEN_NOT_ACCESSIBLE, 'message' => 'The context token is not accessible in Twig rendering context, as the token should never be leaked in HTML content.', ]; + } - yield 'a wrong type in the request is a client error' => [ - 'exception' => SalesChannelException::invalidType('The includes for type "product" must be of the type array, string given'), - 'statusCode' => Response::HTTP_BAD_REQUEST, - 'errorCode' => SalesChannelException::INVALID_TYPE, - 'message' => 'The includes for type "product" must be of the type array, string given', - ]; + public function testWrongTypeInTheRequestIsAClientError(): void + { + $exception = SalesChannelException::invalidType('The includes must be of the type array, string given'); + + static::assertSame(Response::HTTP_BAD_REQUEST, $exception->getStatusCode()); + static::assertSame(SalesChannelException::INVALID_TYPE, $exception->getErrorCode()); + static::assertSame('The includes must be of the type array, string given', $exception->getMessage()); } } From ba680caef4ea1fb86f6417a158173508359cadb2 Mon Sep 17 00:00:00 2001 From: mkucmus Date: Mon, 28 Sep 2026 19:08:04 +0200 Subject: [PATCH 4/8] fix(store-api): skip the compressed _criteria on GET-only routes --- RELEASE_INFO-6.7.md | 6 +- .../components/parameters/criteria.json | 4 +- .../CompressedCriteriaRequestListener.php | 34 +++++- .../Snippet/SalesChannel/SnippetRouteTest.php | 18 +++ .../CompressedCriteriaRequestListenerTest.php | 115 +++++++++++++++--- 5 files changed, 156 insertions(+), 21 deletions(-) diff --git a/RELEASE_INFO-6.7.md b/RELEASE_INFO-6.7.md index b7593de9b85..3cafc8d7d34 100644 --- a/RELEASE_INFO-6.7.md +++ b/RELEASE_INFO-6.7.md @@ -106,7 +106,7 @@ The Store API OpenAPI schema was corrected where it contradicted the real respon ### Store API reads every field of the compressed `_criteria` parameter -On the cacheable Store API read routes, a `GET` request with `_criteria` now returns the same as the `POST` request with that body. Before, several routes ignored fields inside `_criteria`: +On the Store API read routes listed below, a `GET` request with `_criteria` now returns the same as the `POST` request with that body. Before, they ignored fields inside `_criteria`: | Route | Ignored fields | |---|---| @@ -121,11 +121,11 @@ Sending these fields as plain query parameters keeps working. If you send `_crit - A field in `_criteria` takes precedence over a query parameter of the same name. Before, a plain `limit` took precedence on the listing routes. - Query parameters that are not part of `_criteria` are applied next to it. Before, the criteria of a route were built from `_criteria` alone, so a plain `limit` or `filter` next to it was ignored. -- An invalid `_criteria` value is answered with `400` on every cacheable Store API read route. Routes without criteria ignored it before. +- An invalid `_criteria` value is answered with `400` on these routes. Routes without criteria ignored it before. - The `sw-include-search-info` header is respected together with `_criteria`. - `includes` and `excludes` that are not an array are answered with `400` instead of `500`. -Routes that are not marked as cacheable, such as the cart or the custom script endpoints, do not read other fields from `_criteria`. +This applies to every cacheable Store API route that accepts `GET` and `POST`. Routes that only accept `GET`, such as `/snippet`, and routes that are not cacheable, such as the cart, read nothing but the criteria from `_criteria`. The OpenAPI schema declares `_criteria` for `readCategoryGet`, `readCmsGet`, `readLandingPageGet`, `searchPageGet`, `searchSuggestGet`, `readMediaGet` and `searchProductVariantIdsGet`. diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json index f5f8bbf2cf1..f24ca646380 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json @@ -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). On the cacheable read routes the object can also contain the other fields that the POST request of the same route accepts in its body. A field in `_criteria` takes precedence over a query parameter of the same name, other query parameters are applied next to it.", + "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). The object can also contain the other fields that the POST request of the same route accepts in its body. 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", @@ -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). On the cacheable read routes the object can also contain the other fields that the POST request of the same route accepts in its body. A field in `_criteria` takes precedence over a query parameter of the same name, other query parameters are applied next to it.", + "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). The object can also contain the other fields that the POST request of the same route accepts in its body. 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", diff --git a/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php b/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php index b60c6917d5b..8636b85ac89 100644 --- a/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php +++ b/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php @@ -12,10 +12,13 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\ControllerEvent; use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Routing\Attribute\Route; /** * Copies the fields of the compressed `_criteria` parameter into the query parameters, - * so a cacheable Store API GET request is read the same way as a POST request with that body. + * so a GET request is read the same way as a POST request with that body. + * + * Only cacheable Store API routes that accept POST as well are handled. * * @internal * @@ -49,7 +52,7 @@ public function expandCompressedCriteria(ControllerEvent $event): void return; } - if (!$this->isCacheableStoreApiRoute($request)) { + if (!$this->isCacheableStoreApiRoute($request) || !$this->routeAcceptsPost($request)) { return; } @@ -82,4 +85,31 @@ private function isCacheableStoreApiRoute(Request $request): bool return CacheAttribute::fromAttributeValue($cache) !== null; } + + /** + * The compressed criteria is the GET form of a POST body, so a route without a POST form has nothing to read from it. + * The route is read from the class named in `_controller`, because a decorator of a route does not repeat the attribute. + */ + private function routeAcceptsPost(Request $request): bool + { + $controller = $request->attributes->get('_controller'); + if (!\is_string($controller) || !str_contains($controller, '::')) { + return false; + } + + [$class, $method] = explode('::', $controller, 2); + if (!method_exists($class, $method)) { + return false; + } + + foreach ((new \ReflectionMethod($class, $method))->getAttributes(Route::class) as $attribute) { + $route = $attribute->newInstance(); + + if ($route->name === $request->attributes->get('_route')) { + return \in_array(Request::METHOD_POST, $route->methods, true); + } + } + + return false; + } } diff --git a/tests/integration/Core/System/Snippet/SalesChannel/SnippetRouteTest.php b/tests/integration/Core/System/Snippet/SalesChannel/SnippetRouteTest.php index 08a2603feff..2baa91fb06c 100644 --- a/tests/integration/Core/System/Snippet/SalesChannel/SnippetRouteTest.php +++ b/tests/integration/Core/System/Snippet/SalesChannel/SnippetRouteTest.php @@ -10,6 +10,7 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour; +use Shopware\Core\Framework\Util\Base64; use Shopware\Core\Framework\Uuid\Uuid; use Shopware\Core\System\Snippet\SnippetException; use Shopware\Core\Test\Stub\Framework\IdsCollection; @@ -123,6 +124,23 @@ public function testPrefixesLimitTheResultToWholeNamespaces(): void static::assertSame($set['snippets'], $setWithTrailingDot['snippets']); } + public function testCompressedCriteriaDoesNotAddRequestParameters(): void + { + $this->createSnippet('myPrefix.inside.key', 'Inside'); + $this->createSnippet('myPrefixOther.key', 'Outside'); + + $compressed = gzencode(json_encode(['prefixes' => 'myPrefix'], \JSON_THROW_ON_ERROR)); + static::assertNotFalse($compressed, 'Gzip compressing failed'); + + $this->browser->request('GET', '/store-api/snippet', ['_criteria' => Base64::urlEncode($compressed)]); + + $set = $this->getJsonResponse()['sets'][0]; + + // the route has no POST form and does not opt in, so the prefixes are not read from the compressed criteria + static::assertSame('Inside', $set['snippets']['myPrefix.inside.key'] ?? null); + static::assertSame('Outside', $set['snippets']['myPrefixOther.key'] ?? null); + } + public function testEtagRevalidationReturnsNotModified(): void { $this->browser->request('GET', '/store-api/snippet'); diff --git a/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php b/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php index 091db10c251..ff9cd49f17e 100644 --- a/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php +++ b/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php @@ -19,6 +19,7 @@ use Symfony\Component\HttpKernel\Event\ControllerEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Routing\Attribute\Route; /** * @internal @@ -27,6 +28,11 @@ #[CoversClass(CompressedCriteriaRequestListener::class)] class CompressedCriteriaRequestListenerTest extends TestCase { + private const ROUTES = [ + 'test.read' => 'read', + 'test.read-only' => 'readOnly', + ]; + private CompressedCriteriaRequestListener $listener; protected function setUp(): void @@ -91,24 +97,64 @@ public function testCompressedParameterIsKeptForTheCriteriaBuilder(): void static::assertSame($compressed, $request->query->get('_criteria')); } - public function testListPayloadDoesNotFail(): void + public function testRouteIsReadFromTheRouteClassAndNotFromItsDecorator(): void { - $request = self::createStoreApiRequest(['_criteria' => self::compress(['first', 'second'])]); + $request = self::createStoreApiRequest(['_criteria' => self::compress(['limit' => 2])]); + + $event = new ControllerEvent( + static::createStub(HttpKernelInterface::class), + (new CompressedCriteriaTestRouteDecorator())->read(...), + $request, + HttpKernelInterface::MAIN_REQUEST + ); + + $this->listener->expandCompressedCriteria($event); + + static::assertSame(2, $request->query->getInt('limit')); + } + + /** + * @param array $attributes + */ + #[DataProvider('unknownControllerProvider')] + public function testRequestWithoutAKnownRouteIsLeftUntouched(array $attributes): void + { + $query = ['_criteria' => self::compress(['limit' => 2])]; + + $request = self::createStoreApiRequest($query); + $request->attributes->add($attributes); $this->listener->expandCompressedCriteria(self::createEvent($request)); - static::assertSame('first', $request->query->get('0')); - static::assertSame('second', $request->query->get('1')); + static::assertSame($query, $request->query->all()); } - public function testRouteWithCacheSettingsIsHandled(): void + /** + * @return iterable}> + */ + public static function unknownControllerProvider(): iterable { - $request = self::createStoreApiRequest(['_criteria' => self::compress(['limit' => 2])]); - $request->attributes->set(PlatformRequest::ATTRIBUTE_HTTP_CACHE, ['sharedMaxAge' => 60]); + yield 'controller is not a class method' => [ + 'attributes' => ['_controller' => 'some.service.id'], + ]; + + yield 'controller method does not exist' => [ + 'attributes' => ['_controller' => CompressedCriteriaTestRoutes::class . '::missing'], + ]; + + yield 'controller method carries another route' => [ + 'attributes' => ['_route' => 'test.other'], + ]; + } + + public function testListPayloadDoesNotFail(): void + { + $request = self::createStoreApiRequest(['_criteria' => self::compress(['first', 'second'])]); $this->listener->expandCompressedCriteria(self::createEvent($request)); - static::assertSame(2, $request->query->getInt('limit')); + static::assertSame('first', $request->query->get('0')); + static::assertSame('second', $request->query->get('1')); } /** @@ -116,14 +162,14 @@ public function testRouteWithCacheSettingsIsHandled(): void * @param list $scopes */ #[DataProvider('untouchedRequestProvider')] - public function testRequestIsLeftUntouched(string $method, array $query, array $scopes, bool $cacheable): void + public function testRequestIsLeftUntouched(string $method, array $query, array $scopes, bool $cacheable, string $route): void { $request = new Request($query); $request->setMethod($method); $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, $scopes); - if ($cacheable) { - $request->attributes->set(PlatformRequest::ATTRIBUTE_HTTP_CACHE, true); - } + $request->attributes->set(PlatformRequest::ATTRIBUTE_HTTP_CACHE, $cacheable); + $request->attributes->set('_route', $route); + $request->attributes->set('_controller', CompressedCriteriaTestRoutes::class . '::' . self::ROUTES[$route]); $this->listener->expandCompressedCriteria(self::createEvent($request)); @@ -131,7 +177,7 @@ public function testRequestIsLeftUntouched(string $method, array $query, array $ } /** - * @return iterable, scopes: list, cacheable: bool}> + * @return iterable, scopes: list, cacheable: bool, route: string}> */ public static function untouchedRequestProvider(): iterable { @@ -140,6 +186,7 @@ public static function untouchedRequestProvider(): iterable 'query' => ['_criteria' => self::compress(['limit' => 2])], 'scopes' => [StoreApiRouteScope::ID], 'cacheable' => true, + 'route' => 'test.read', ]; yield 'GET request without compressed criteria' => [ @@ -147,13 +194,23 @@ public static function untouchedRequestProvider(): iterable 'query' => ['limit' => '2'], 'scopes' => [StoreApiRouteScope::ID], 'cacheable' => true, + 'route' => 'test.read', ]; - yield 'Store API route that is not marked as cacheable, such as the cart' => [ + yield 'cacheable route without a POST form, such as the breadcrumb' => [ + 'method' => Request::METHOD_GET, + 'query' => ['_criteria' => self::compress(['type' => 'category', 'referrerCategoryId' => 'other'])], + 'scopes' => [StoreApiRouteScope::ID], + 'cacheable' => true, + 'route' => 'test.read-only', + ]; + + yield 'route with a POST form that is not cacheable, such as the cart' => [ 'method' => Request::METHOD_GET, 'query' => ['_criteria' => self::compress(['token' => 'other-cart', 'includes' => ['cart' => ['token']]])], 'scopes' => [StoreApiRouteScope::ID], 'cacheable' => false, + 'route' => 'test.read', ]; yield 'only Store API routes are handled' => [ @@ -161,6 +218,7 @@ public static function untouchedRequestProvider(): iterable 'query' => ['_criteria' => self::compress(['limit' => 2])], 'scopes' => [ApiRouteScope::ID], 'cacheable' => true, + 'route' => 'test.read', ]; yield 'route without a scope' => [ @@ -168,6 +226,7 @@ public static function untouchedRequestProvider(): iterable 'query' => ['_criteria' => self::compress(['limit' => 2])], 'scopes' => [], 'cacheable' => true, + 'route' => 'test.read', ]; } @@ -192,6 +251,8 @@ private static function createStoreApiRequest(array $query): Request $request->setMethod(Request::METHOD_GET); $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [StoreApiRouteScope::ID]); $request->attributes->set(PlatformRequest::ATTRIBUTE_HTTP_CACHE, true); + $request->attributes->set('_route', 'test.read'); + $request->attributes->set('_controller', CompressedCriteriaTestRoutes::class . '::' . self::ROUTES['test.read']); return $request; } @@ -217,3 +278,29 @@ private static function compress(array $payload): string return Base64::urlEncode($compressed); } } + +/** + * @internal + */ +class CompressedCriteriaTestRoutes +{ + #[Route(path: '/store-api/test', name: 'test.read', methods: [Request::METHOD_GET, Request::METHOD_POST])] + public function read(): void + { + } + + #[Route(path: '/store-api/test-read-only', name: 'test.read-only', methods: [Request::METHOD_GET])] + public function readOnly(): void + { + } +} + +/** + * @internal + */ +class CompressedCriteriaTestRouteDecorator extends CompressedCriteriaTestRoutes +{ + public function read(): void + { + } +} From e19db4cc8235771cb646a1aba4d9ac361dadc576 Mon Sep 17 00:00:00 2001 From: mkucmus Date: Tue, 29 Sep 2026 10:55:54 +0200 Subject: [PATCH 5/8] fix(store-api): read the compressed _criteria on every Store API GET route --- RELEASE_INFO-6.7.md | 20 +-- adr/2025-09-15-store-api-cache-strategy.md | 4 + .../components/parameters/criteria.json | 4 +- .../Schema/StoreApi/paths/product.json | 3 + .../CompressedCriteriaRequestListener.php | 58 +-------- .../Script/Api/ScriptStoreApiRoute.php | 6 +- .../Cart/SalesChannel/CartLoadRouteTest.php | 6 +- .../Order/SalesChannel/OrderRouteTest.php | 58 +++++++++ .../SalesChannel/CategoryListRouteTest.php | 2 +- .../SalesChannel/ProductSearchRouteTest.php | 48 ++++++++ .../Script/Api/ScriptStoreApiRouteTest.php | 9 ++ .../Snippet/SalesChannel/SnippetRouteTest.php | 9 +- .../CompressedCriteriaRequestListenerTest.php | 116 +----------------- 13 files changed, 154 insertions(+), 189 deletions(-) diff --git a/RELEASE_INFO-6.7.md b/RELEASE_INFO-6.7.md index 3cafc8d7d34..18570a13ff8 100644 --- a/RELEASE_INFO-6.7.md +++ b/RELEASE_INFO-6.7.md @@ -106,28 +106,32 @@ The Store API OpenAPI schema was corrected where it contradicted the real respon ### Store API reads every field of the compressed `_criteria` parameter -On the Store API read routes listed below, a `GET` request with `_criteria` now returns the same as the `POST` request with that body. Before, they ignored fields inside `_criteria`: +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, only the criteria were read from `_criteria`, and routes ignored 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` | +| `/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 the filter flags of the listing routes, such as `manufacturer-filter` or `property-whitelist`. App scripts of `/store-api/script/{hook}` find the fields of `_criteria` 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. -- Query parameters that are not part of `_criteria` are applied next to it. Before, the criteria of a route were built from `_criteria` alone, so a plain `limit` or `filter` next to it was ignored. -- An invalid `_criteria` value is answered with `400` on these routes. Routes without criteria ignored it before. -- The `sw-include-search-info` header is respected together with `_criteria`. -- `includes` and `excludes` that are not an array are answered with `400` instead of `500`. +- 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. -This applies to every cacheable Store API route that accepts `GET` and `POST`. Routes that only accept `GET`, such as `/snippet`, and routes that are not cacheable, such as the cart, read nothing but the criteria from `_criteria`. +`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, such as `includes[product]=name`. -The OpenAPI schema declares `_criteria` for `readCategoryGet`, `readCmsGet`, `readLandingPageGet`, `searchPageGet`, `searchSuggestGet`, `readMediaGet` and `searchProductVariantIdsGet`. +The OpenAPI schema declares `_criteria` for `readCategoryGet`, `readCmsGet`, `readLandingPageGet`, `searchPageGet`, `searchSuggestGet`, `readMediaGet`, `searchProductVariantIdsGet` and `readProductCrossSellingsGet`. ## Administration diff --git a/adr/2025-09-15-store-api-cache-strategy.md b/adr/2025-09-15-store-api-cache-strategy.md index 2923711e798..5f6a733010b 100644 --- a/adr/2025-09-15-store-api-cache-strategy.md +++ b/adr/2025-09-15-store-api-cache-strategy.md @@ -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. It does not change which routes should be called with `GET`. diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json index f24ca646380..cf55cfb08b8 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/components/parameters/criteria.json @@ -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). The object can also contain the other fields that the POST request of the same route accepts in its body. A field in `_criteria` takes precedence over a query parameter of the same name, other query parameters are applied next to it.", + "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", @@ -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). The object can also contain the other fields that the POST request of the same route accepts in its body. A field in `_criteria` takes precedence over a query parameter of the same name, other query parameters are applied next to it.", + "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", diff --git a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/product.json b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/product.json index db9c895661d..f12681290a8 100644 --- a/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/product.json +++ b/src/Core/Framework/Api/ApiDefinition/Generator/Schema/StoreApi/paths/product.json @@ -85,6 +85,9 @@ "type": "boolean" } }, + { + "$ref": "#/components/parameters/CompressedCriteria" + }, { "$ref": "#/components/parameters/swLanguageId" } diff --git a/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php b/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php index 8636b85ac89..58944c18292 100644 --- a/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php +++ b/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php @@ -2,7 +2,6 @@ namespace Shopware\Core\Framework\Api\EventListener; -use Shopware\Core\Framework\Adapter\Cache\Http\CacheAttribute; use Shopware\Core\Framework\DataAbstractionLayer\Search\CompressedCriteriaDecoder; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Routing\KernelListenerPriorities; @@ -12,17 +11,12 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\ControllerEvent; use Symfony\Component\HttpKernel\KernelEvents; -use Symfony\Component\Routing\Attribute\Route; /** - * Copies the fields of the compressed `_criteria` parameter into the query parameters, - * so a GET request is read the same way as a POST request with that body. - * - * Only cacheable Store API routes that accept POST as well are handled. + * 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 is read the same way as a POST request with that body. * * @internal - * - * @phpstan-import-type CacheAttributeType from CacheAttribute */ #[Package('framework')] class CompressedCriteriaRequestListener implements EventSubscriberInterface @@ -36,7 +30,7 @@ public function __construct(private readonly CompressedCriteriaDecoder $decoder) public static function getSubscribedEvents(): array { return [ - // runs after the access key check and before anything reads the request parameters + // 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, @@ -52,7 +46,8 @@ public function expandCompressedCriteria(ControllerEvent $event): void return; } - if (!$this->isCacheableStoreApiRoute($request) || !$this->routeAcceptsPost($request)) { + $scopes = (array) $request->attributes->get(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, []); + if (!\in_array(StoreApiRouteScope::ID, $scopes, true)) { return; } @@ -69,47 +64,4 @@ public function expandCompressedCriteria(ControllerEvent $event): void $request->query->set($field, $value); } } - - /** - * The compressed criteria exists to make read routes cacheable, every other route keeps reading its plain parameters only. - */ - private function isCacheableStoreApiRoute(Request $request): bool - { - $scopes = (array) $request->attributes->get(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, []); - if (!\in_array(StoreApiRouteScope::ID, $scopes, true)) { - return false; - } - - /** @var CacheAttributeType|null $cache */ - $cache = $request->attributes->get(PlatformRequest::ATTRIBUTE_HTTP_CACHE); - - return CacheAttribute::fromAttributeValue($cache) !== null; - } - - /** - * The compressed criteria is the GET form of a POST body, so a route without a POST form has nothing to read from it. - * The route is read from the class named in `_controller`, because a decorator of a route does not repeat the attribute. - */ - private function routeAcceptsPost(Request $request): bool - { - $controller = $request->attributes->get('_controller'); - if (!\is_string($controller) || !str_contains($controller, '::')) { - return false; - } - - [$class, $method] = explode('::', $controller, 2); - if (!method_exists($class, $method)) { - return false; - } - - foreach ((new \ReflectionMethod($class, $method))->getAttributes(Route::class) as $attribute) { - $route = $attribute->newInstance(); - - if ($route->name === $request->attributes->get('_route')) { - return \in_array(Request::METHOD_POST, $route->methods, true); - } - } - - return false; - } } diff --git a/src/Core/Framework/Script/Api/ScriptStoreApiRoute.php b/src/Core/Framework/Script/Api/ScriptStoreApiRoute.php index 04f43cad494..2416db65040 100644 --- a/src/Core/Framework/Script/Api/ScriptStoreApiRoute.php +++ b/src/Core/Framework/Script/Api/ScriptStoreApiRoute.php @@ -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; @@ -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(), diff --git a/tests/integration/Core/Checkout/Cart/SalesChannel/CartLoadRouteTest.php b/tests/integration/Core/Checkout/Cart/SalesChannel/CartLoadRouteTest.php index ced90a49d3c..98f158515ef 100644 --- a/tests/integration/Core/Checkout/Cart/SalesChannel/CartLoadRouteTest.php +++ b/tests/integration/Core/Checkout/Cart/SalesChannel/CartLoadRouteTest.php @@ -89,7 +89,7 @@ public function testEmptyCart(): void static::assertEmpty($response['errors']); } - public function testCompressedCriteriaDoesNotAddRequestParameters(): void + public function testIncludesOfTheCompressedCriteriaAreApplied(): void { $compressed = gzencode(json_encode(['includes' => ['cart' => ['token']]], \JSON_THROW_ON_ERROR)); static::assertNotFalse($compressed, 'Gzip compressing failed'); @@ -98,8 +98,8 @@ public function testCompressedCriteriaDoesNotAddRequestParameters(): void $response = json_decode((string) $this->browser->getResponse()->getContent(), true, 512, \JSON_THROW_ON_ERROR); - static::assertArrayHasKey('price', $response, 'The cart is not a cacheable read route, so the compressed criteria is not read'); - static::assertArrayHasKey('lineItems', $response); + static::assertArrayHasKey('token', $response); + static::assertArrayNotHasKey('price', $response, 'The includes of the compressed criteria are applied'); } /** diff --git a/tests/integration/Core/Checkout/Order/SalesChannel/OrderRouteTest.php b/tests/integration/Core/Checkout/Order/SalesChannel/OrderRouteTest.php index 40e1ac37532..6a3fd4cbee2 100644 --- a/tests/integration/Core/Checkout/Order/SalesChannel/OrderRouteTest.php +++ b/tests/integration/Core/Checkout/Order/SalesChannel/OrderRouteTest.php @@ -2,6 +2,7 @@ namespace Shopware\Tests\Integration\Core\Checkout\Order\SalesChannel; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; use Shopware\Core\Checkout\Cart\Price\Struct\CalculatedPrice; @@ -32,6 +33,7 @@ use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\MailTemplateTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour; +use Shopware\Core\Framework\Util\Base64; use Shopware\Core\Framework\Uuid\Uuid; use Shopware\Core\PlatformRequest; use Shopware\Core\System\Country\CountryCollection; @@ -253,6 +255,62 @@ public function testGetOrderGuestWrongDeepLink(): void static::assertSame(Response::HTTP_FORBIDDEN, $this->browser->getResponse()->getStatusCode()); } + #[DataProvider('guestCredentialsProvider')] + public function testGuestCredentialsAreReadFromTheCompressedCriteria(string $zipcode, int $expectedStatus): void + { + $this->browser->setServerParameter('HTTP_SW_CONTEXT_TOKEN', ''); + + $criteria = new Criteria([$this->orderId]); + $criteria->addAssociation('orderCustomer'); + + $order = $this->orderRepository->search($criteria, Context::createDefaultContext())->getEntities()->get($this->orderId); + + static::assertNotNull($order); + static::assertNotNull($order->getOrderCustomer()); + + $this->customerRepository->update([ + [ + 'id' => $order->getOrderCustomer()->getCustomerId(), + 'guest' => true, + ], + ], Context::createDefaultContext()); + + $criteria = new Criteria([$this->orderId]); + $criteria->addFilter(new EqualsFilter('deepLinkCode', $this->deepLinkCode)); + + $compressed = gzencode(json_encode( + \array_merge( + $this->requestCriteriaBuilder->toArray($criteria), + [ + 'email' => 'test@example.com', + 'zipcode' => $zipcode, + ] + ), + \JSON_THROW_ON_ERROR + )); + static::assertNotFalse($compressed, 'Gzip compressing failed'); + + $this->browser->request('GET', '/store-api/order', ['_criteria' => Base64::urlEncode($compressed)]); + + static::assertSame($expectedStatus, $this->browser->getResponse()->getStatusCode(), (string) $this->browser->getResponse()->getContent()); + } + + /** + * @return iterable + */ + public static function guestCredentialsProvider(): iterable + { + yield 'the order is returned for the right credentials' => [ + 'zipcode' => '59438-0403', + 'expectedStatus' => Response::HTTP_OK, + ]; + + yield 'a wrong zipcode is rejected the same as a plain query parameter' => [ + 'zipcode' => '00000', + 'expectedStatus' => Response::HTTP_FORBIDDEN, + ]; + } + public function testGetOrderGuestNoOrder(): void { $this->browser->setServerParameter('HTTP_SW_CONTEXT_TOKEN', ''); diff --git a/tests/integration/Core/Content/Category/SalesChannel/CategoryListRouteTest.php b/tests/integration/Core/Content/Category/SalesChannel/CategoryListRouteTest.php index cf11c1052a0..cea7689fb6d 100644 --- a/tests/integration/Core/Content/Category/SalesChannel/CategoryListRouteTest.php +++ b/tests/integration/Core/Content/Category/SalesChannel/CategoryListRouteTest.php @@ -131,7 +131,7 @@ public function testQueryParametersAreAppliedNextToCompressedCriteria(): void $response = json_decode((string) $this->browser->getResponse()->getContent(), true, 512, \JSON_THROW_ON_ERROR); static::assertCount(1, $response['elements'], 'The limit of the query is applied'); - static::assertSame(['name', 'id', 'apiAlias'], array_keys($response['elements'][0]), 'The includes of the compressed criteria are applied'); + static::assertEqualsCanonicalizing(['id', 'name', 'apiAlias'], array_keys($response['elements'][0]), 'The includes of the compressed criteria are applied'); } private function createData(): void diff --git a/tests/integration/Core/Content/Product/SalesChannel/ProductSearchRouteTest.php b/tests/integration/Core/Content/Product/SalesChannel/ProductSearchRouteTest.php index 41c2c000c74..4b787a33183 100644 --- a/tests/integration/Core/Content/Product/SalesChannel/ProductSearchRouteTest.php +++ b/tests/integration/Core/Content/Product/SalesChannel/ProductSearchRouteTest.php @@ -32,6 +32,7 @@ use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\KernelLifecycleManager; use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour; +use Shopware\Core\Framework\Util\Base64; use Shopware\Core\Framework\Uuid\Uuid; use Shopware\Core\Migration\V6_7\Migration1775460999AddParentNameToProductSearchConfig; use Shopware\Core\PlatformRequest; @@ -155,6 +156,42 @@ public function testNotFindingProducts(): void static::assertCount(0, $response['elements']); } + public function testLimitOfTheCompressedCriteriaIsApplied(): void + { + $browser = self::$browser; + $this->productSearchConfigRepository->update([ + ['id' => $this->productSearchConfigId, 'andLogic' => false], + ], Context::createDefaultContext()); + + $browser->request('GET', '/store-api/search', [ + 'limit' => 10, + '_criteria' => self::compress(['search' => 'Test-Product', 'limit' => 2]), + ]); + + $response = \json_decode((string) $browser->getResponse()->getContent(), true, 512, \JSON_THROW_ON_ERROR); + + static::assertSame(15, $response['total']); + static::assertCount(2, $response['elements'], 'The limit of the compressed criteria wins over the plain query parameter'); + } + + public function testSearchTermOfTheCompressedCriteriaIsReadBySuggest(): void + { + $browser = self::$browser; + $this->productSearchConfigRepository->update([ + ['id' => $this->productSearchConfigId, 'andLogic' => false], + ], Context::createDefaultContext()); + + $browser->request('GET', '/store-api/search-suggest', [ + '_criteria' => self::compress(['search' => 'Test-Product']), + ]); + + static::assertSame(200, $browser->getResponse()->getStatusCode(), (string) $browser->getResponse()->getContent()); + + $response = \json_decode((string) $browser->getResponse()->getContent(), true, 512, \JSON_THROW_ON_ERROR); + + static::assertSame(15, $response['total']); + } + public function testMissingSearchTermWithFilter(): void { $browser = self::$browser; @@ -868,6 +905,17 @@ private function proceedTestSearch(KernelBrowser $browser, string $term, array $ static::assertSame($expected, $resultProductName); } + /** + * @param array $payload + */ + private static function compress(array $payload): string + { + $compressed = gzencode(json_encode($payload, \JSON_THROW_ON_ERROR)); + static::assertNotFalse($compressed, 'Gzip compressing failed'); + + return Base64::urlEncode($compressed); + } + private function createNavigationCategory(IdsCollection $ids): void { $data = [ diff --git a/tests/integration/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php b/tests/integration/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php index c773fa08624..640aa2fe8e8 100644 --- a/tests/integration/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php +++ b/tests/integration/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php @@ -77,6 +77,15 @@ public function testApiEndpointWithSlashInHookName(): void static::assertSame('store_api_simple_script_response', $response['apiAlias']); } + public function testIncludesThatAreNotAnArrayAreRejected(): void + { + $this->loadAppsFromDir(__DIR__ . '/_fixtures'); + + $this->browser->request('POST', '/store-api/script/simple-script', ['includes' => 'foo']); + + static::assertSame(Response::HTTP_BAD_REQUEST, $this->browser->getResponse()->getStatusCode(), (string) $this->browser->getResponse()->getContent()); + } + public function testRepositoryCall(): void { $this->loadAppsFromDir(__DIR__ . '/_fixtures'); diff --git a/tests/integration/Core/System/Snippet/SalesChannel/SnippetRouteTest.php b/tests/integration/Core/System/Snippet/SalesChannel/SnippetRouteTest.php index 2baa91fb06c..31db91e16a5 100644 --- a/tests/integration/Core/System/Snippet/SalesChannel/SnippetRouteTest.php +++ b/tests/integration/Core/System/Snippet/SalesChannel/SnippetRouteTest.php @@ -124,11 +124,14 @@ public function testPrefixesLimitTheResultToWholeNamespaces(): void static::assertSame($set['snippets'], $setWithTrailingDot['snippets']); } - public function testCompressedCriteriaDoesNotAddRequestParameters(): void + public function testPrefixesAreReadFromTheCompressedCriteria(): void { $this->createSnippet('myPrefix.inside.key', 'Inside'); $this->createSnippet('myPrefixOther.key', 'Outside'); + $this->browser->request('GET', '/store-api/snippet?prefixes=myPrefix'); + $expected = $this->getJsonResponse()['sets'][0]; + $compressed = gzencode(json_encode(['prefixes' => 'myPrefix'], \JSON_THROW_ON_ERROR)); static::assertNotFalse($compressed, 'Gzip compressing failed'); @@ -136,9 +139,9 @@ public function testCompressedCriteriaDoesNotAddRequestParameters(): void $set = $this->getJsonResponse()['sets'][0]; - // the route has no POST form and does not opt in, so the prefixes are not read from the compressed criteria static::assertSame('Inside', $set['snippets']['myPrefix.inside.key'] ?? null); - static::assertSame('Outside', $set['snippets']['myPrefixOther.key'] ?? null); + static::assertArrayNotHasKey('myPrefixOther.key', $set['snippets']); + static::assertSame($expected['snippets'], $set['snippets'], 'The compressed criteria is read like the query string'); } public function testEtagRevalidationReturnsNotModified(): void diff --git a/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php b/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php index ff9cd49f17e..afa1cf6bc99 100644 --- a/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php +++ b/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php @@ -19,7 +19,6 @@ use Symfony\Component\HttpKernel\Event\ControllerEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; use Symfony\Component\HttpKernel\KernelEvents; -use Symfony\Component\Routing\Attribute\Route; /** * @internal @@ -28,11 +27,6 @@ #[CoversClass(CompressedCriteriaRequestListener::class)] class CompressedCriteriaRequestListenerTest extends TestCase { - private const ROUTES = [ - 'test.read' => 'read', - 'test.read-only' => 'readOnly', - ]; - private CompressedCriteriaRequestListener $listener; protected function setUp(): void @@ -97,56 +91,6 @@ public function testCompressedParameterIsKeptForTheCriteriaBuilder(): void static::assertSame($compressed, $request->query->get('_criteria')); } - public function testRouteIsReadFromTheRouteClassAndNotFromItsDecorator(): void - { - $request = self::createStoreApiRequest(['_criteria' => self::compress(['limit' => 2])]); - - $event = new ControllerEvent( - static::createStub(HttpKernelInterface::class), - (new CompressedCriteriaTestRouteDecorator())->read(...), - $request, - HttpKernelInterface::MAIN_REQUEST - ); - - $this->listener->expandCompressedCriteria($event); - - static::assertSame(2, $request->query->getInt('limit')); - } - - /** - * @param array $attributes - */ - #[DataProvider('unknownControllerProvider')] - public function testRequestWithoutAKnownRouteIsLeftUntouched(array $attributes): void - { - $query = ['_criteria' => self::compress(['limit' => 2])]; - - $request = self::createStoreApiRequest($query); - $request->attributes->add($attributes); - - $this->listener->expandCompressedCriteria(self::createEvent($request)); - - static::assertSame($query, $request->query->all()); - } - - /** - * @return iterable}> - */ - public static function unknownControllerProvider(): iterable - { - yield 'controller is not a class method' => [ - 'attributes' => ['_controller' => 'some.service.id'], - ]; - - yield 'controller method does not exist' => [ - 'attributes' => ['_controller' => CompressedCriteriaTestRoutes::class . '::missing'], - ]; - - yield 'controller method carries another route' => [ - 'attributes' => ['_route' => 'test.other'], - ]; - } - public function testListPayloadDoesNotFail(): void { $request = self::createStoreApiRequest(['_criteria' => self::compress(['first', 'second'])]); @@ -162,14 +106,11 @@ public function testListPayloadDoesNotFail(): void * @param list $scopes */ #[DataProvider('untouchedRequestProvider')] - public function testRequestIsLeftUntouched(string $method, array $query, array $scopes, bool $cacheable, string $route): void + public function testRequestIsLeftUntouched(string $method, array $query, array $scopes): void { $request = new Request($query); $request->setMethod($method); $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, $scopes); - $request->attributes->set(PlatformRequest::ATTRIBUTE_HTTP_CACHE, $cacheable); - $request->attributes->set('_route', $route); - $request->attributes->set('_controller', CompressedCriteriaTestRoutes::class . '::' . self::ROUTES[$route]); $this->listener->expandCompressedCriteria(self::createEvent($request)); @@ -177,7 +118,7 @@ public function testRequestIsLeftUntouched(string $method, array $query, array $ } /** - * @return iterable, scopes: list, cacheable: bool, route: string}> + * @return iterable, scopes: list}> */ public static function untouchedRequestProvider(): iterable { @@ -185,48 +126,24 @@ public static function untouchedRequestProvider(): iterable 'method' => Request::METHOD_POST, 'query' => ['_criteria' => self::compress(['limit' => 2])], 'scopes' => [StoreApiRouteScope::ID], - 'cacheable' => true, - 'route' => 'test.read', ]; yield 'GET request without compressed criteria' => [ 'method' => Request::METHOD_GET, 'query' => ['limit' => '2'], 'scopes' => [StoreApiRouteScope::ID], - 'cacheable' => true, - 'route' => 'test.read', - ]; - - yield 'cacheable route without a POST form, such as the breadcrumb' => [ - 'method' => Request::METHOD_GET, - 'query' => ['_criteria' => self::compress(['type' => 'category', 'referrerCategoryId' => 'other'])], - 'scopes' => [StoreApiRouteScope::ID], - 'cacheable' => true, - 'route' => 'test.read-only', - ]; - - yield 'route with a POST form that is not cacheable, such as the cart' => [ - 'method' => Request::METHOD_GET, - 'query' => ['_criteria' => self::compress(['token' => 'other-cart', 'includes' => ['cart' => ['token']]])], - 'scopes' => [StoreApiRouteScope::ID], - 'cacheable' => false, - 'route' => 'test.read', ]; yield 'only Store API routes are handled' => [ 'method' => Request::METHOD_GET, 'query' => ['_criteria' => self::compress(['limit' => 2])], 'scopes' => [ApiRouteScope::ID], - 'cacheable' => true, - 'route' => 'test.read', ]; yield 'route without a scope' => [ 'method' => Request::METHOD_GET, 'query' => ['_criteria' => self::compress(['limit' => 2])], 'scopes' => [], - 'cacheable' => true, - 'route' => 'test.read', ]; } @@ -250,9 +167,6 @@ private static function createStoreApiRequest(array $query): Request $request = new Request($query); $request->setMethod(Request::METHOD_GET); $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [StoreApiRouteScope::ID]); - $request->attributes->set(PlatformRequest::ATTRIBUTE_HTTP_CACHE, true); - $request->attributes->set('_route', 'test.read'); - $request->attributes->set('_controller', CompressedCriteriaTestRoutes::class . '::' . self::ROUTES['test.read']); return $request; } @@ -278,29 +192,3 @@ private static function compress(array $payload): string return Base64::urlEncode($compressed); } } - -/** - * @internal - */ -class CompressedCriteriaTestRoutes -{ - #[Route(path: '/store-api/test', name: 'test.read', methods: [Request::METHOD_GET, Request::METHOD_POST])] - public function read(): void - { - } - - #[Route(path: '/store-api/test-read-only', name: 'test.read-only', methods: [Request::METHOD_GET])] - public function readOnly(): void - { - } -} - -/** - * @internal - */ -class CompressedCriteriaTestRouteDecorator extends CompressedCriteriaTestRoutes -{ - public function read(): void - { - } -} From 54c81f756ce6f6548a0cebf04a46ef5148cf397a Mon Sep 17 00:00:00 2001 From: mkucmus Date: Tue, 29 Sep 2026 11:20:33 +0200 Subject: [PATCH 6/8] fix(store-api): read _criteria values as strings and refresh the cache under the right key --- RELEASE_INFO-6.7.md | 8 +- adr/2025-09-15-store-api-cache-strategy.md | 2 +- .../RefreshHttpCacheMessageHandler.php | 3 +- .../CompressedCriteriaRequestListener.php | 19 ++++- .../Order/SalesChannel/OrderRouteTest.php | 17 ++++- .../Script/Api/ScriptStoreApiRouteTest.php | 18 +++++ .../RefreshHttpCacheMessageHandlerTest.php | 26 +++++++ .../CompressedCriteriaRequestListenerTest.php | 31 ++++++++ .../Script/Api/ScriptStoreApiRouteTest.php | 76 +++++++++++++++++++ 9 files changed, 188 insertions(+), 12 deletions(-) create mode 100644 tests/unit/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php diff --git a/RELEASE_INFO-6.7.md b/RELEASE_INFO-6.7.md index 8570d6997a1..70af36ee405 100644 --- a/RELEASE_INFO-6.7.md +++ b/RELEASE_INFO-6.7.md @@ -114,7 +114,7 @@ The Store API OpenAPI schema was corrected where it contradicted the real respon ### 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, only the criteria were read from `_criteria`, and routes ignored other fields inside it, for example: +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 | |---|---| @@ -128,7 +128,7 @@ On every Store API `GET` route, `_criteria` is now a compressed form of the quer | `/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 the filter flags of the listing routes, such as `manufacturer-filter` or `property-whitelist`. App scripts of `/store-api/script/{hook}` find the fields of `_criteria` in the query as well. +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. 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: @@ -137,9 +137,9 @@ Sending these fields as plain query parameters keeps working. If you send `_crit - 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, such as `includes[product]=name`. +`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 declares `_criteria` for `readCategoryGet`, `readCmsGet`, `readLandingPageGet`, `searchPageGet`, `searchSuggestGet`, `readMediaGet`, `searchProductVariantIdsGet` and `readProductCrossSellingsGet`. +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 diff --git a/adr/2025-09-15-store-api-cache-strategy.md b/adr/2025-09-15-store-api-cache-strategy.md index 5f6a733010b..cd88890d1d2 100644 --- a/adr/2025-09-15-store-api-cache-strategy.md +++ b/adr/2025-09-15-store-api-cache-strategy.md @@ -112,4 +112,4 @@ Important details: ## 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. It does not change which routes should be called with `GET`. +- 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`. diff --git a/src/Core/Framework/Adapter/Cache/Message/RefreshHttpCacheMessageHandler.php b/src/Core/Framework/Adapter/Cache/Message/RefreshHttpCacheMessageHandler.php index 03e6c04efd1..203522ba0ec 100644 --- a/src/Core/Framework/Adapter/Cache/Message/RefreshHttpCacheMessageHandler.php +++ b/src/Core/Framework/Adapter/Cache/Message/RefreshHttpCacheMessageHandler.php @@ -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); diff --git a/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php b/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php index 58944c18292..28a08cf2ccd 100644 --- a/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php +++ b/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php @@ -14,7 +14,8 @@ /** * 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 is read the same way as a POST request with that body. + * 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 */ @@ -61,7 +62,21 @@ public function expandCompressedCriteria(ControllerEvent $event): void } // the compressed criteria wins over a plain query parameter of the same name - $request->query->set($field, $value); + $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 => '', + }; + } } diff --git a/tests/integration/Core/Checkout/Order/SalesChannel/OrderRouteTest.php b/tests/integration/Core/Checkout/Order/SalesChannel/OrderRouteTest.php index 6a3fd4cbee2..22893284029 100644 --- a/tests/integration/Core/Checkout/Order/SalesChannel/OrderRouteTest.php +++ b/tests/integration/Core/Checkout/Order/SalesChannel/OrderRouteTest.php @@ -18,6 +18,7 @@ use Shopware\Core\Checkout\Order\Aggregate\OrderDelivery\OrderDeliveryStates; use Shopware\Core\Checkout\Order\Aggregate\OrderTransaction\OrderTransactionStates; use Shopware\Core\Checkout\Order\OrderCollection; +use Shopware\Core\Checkout\Order\OrderException; use Shopware\Core\Checkout\Order\OrderStates; use Shopware\Core\Checkout\Payment\PaymentMethodCollection; use Shopware\Core\Checkout\Payment\PaymentMethodEntity; @@ -256,7 +257,7 @@ public function testGetOrderGuestWrongDeepLink(): void } #[DataProvider('guestCredentialsProvider')] - public function testGuestCredentialsAreReadFromTheCompressedCriteria(string $zipcode, int $expectedStatus): void + public function testGuestCredentialsAreReadFromTheCompressedCriteria(string $zipcode, int $expectedStatus, ?string $expectedErrorCode): void { $this->browser->setServerParameter('HTTP_SW_CONTEXT_TOKEN', ''); @@ -292,22 +293,30 @@ public function testGuestCredentialsAreReadFromTheCompressedCriteria(string $zip $this->browser->request('GET', '/store-api/order', ['_criteria' => Base64::urlEncode($compressed)]); - static::assertSame($expectedStatus, $this->browser->getResponse()->getStatusCode(), (string) $this->browser->getResponse()->getContent()); + $content = (string) $this->browser->getResponse()->getContent(); + static::assertSame($expectedStatus, $this->browser->getResponse()->getStatusCode(), $content); + + if ($expectedErrorCode !== null) { + $response = json_decode($content, true, 512, \JSON_THROW_ON_ERROR); + static::assertSame($expectedErrorCode, $response['errors'][0]['code']); + } } /** - * @return iterable + * @return iterable */ public static function guestCredentialsProvider(): iterable { yield 'the order is returned for the right credentials' => [ 'zipcode' => '59438-0403', 'expectedStatus' => Response::HTTP_OK, + 'expectedErrorCode' => null, ]; - yield 'a wrong zipcode is rejected the same as a plain query parameter' => [ + yield 'a wrong zipcode is checked and rejected the same as a plain query parameter' => [ 'zipcode' => '00000', 'expectedStatus' => Response::HTTP_FORBIDDEN, + 'expectedErrorCode' => OrderException::CHECKOUT_GUEST_WRONG_CREDENTIALS, ]; } diff --git a/tests/integration/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php b/tests/integration/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php index 640aa2fe8e8..5d6595d8e56 100644 --- a/tests/integration/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php +++ b/tests/integration/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php @@ -11,6 +11,7 @@ use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour; +use Shopware\Core\Framework\Util\Base64; use Shopware\Core\Test\AppSystemTestBehaviour; use Shopware\Core\Test\Stub\Framework\IdsCollection; use Symfony\Bundle\FrameworkBundle\KernelBrowser; @@ -86,6 +87,23 @@ public function testIncludesThatAreNotAnArrayAreRejected(): void static::assertSame(Response::HTTP_BAD_REQUEST, $this->browser->getResponse()->getStatusCode(), (string) $this->browser->getResponse()->getContent()); } + public function testIncludesOfTheCompressedCriteriaAreApplied(): void + { + $this->loadAppsFromDir(__DIR__ . '/_fixtures'); + + $compressed = gzencode(json_encode(['includes' => ['store_api_simple_script_response' => ['apiAlias']]], \JSON_THROW_ON_ERROR)); + static::assertNotFalse($compressed, 'Gzip compressing failed'); + + $this->browser->request('GET', '/store-api/script/simple-script', ['_criteria' => Base64::urlEncode($compressed)]); + + $content = (string) $this->browser->getResponse()->getContent(); + static::assertSame(Response::HTTP_OK, $this->browser->getResponse()->getStatusCode(), $content); + + $response = \json_decode($content, true, 512, \JSON_THROW_ON_ERROR); + static::assertSame('store_api_simple_script_response', $response['apiAlias']); + static::assertArrayNotHasKey('foo', $response, 'The includes of the compressed criteria are applied'); + } + public function testRepositoryCall(): void { $this->loadAppsFromDir(__DIR__ . '/_fixtures'); diff --git a/tests/unit/Core/Framework/Adapter/Cache/Message/RefreshHttpCacheMessageHandlerTest.php b/tests/unit/Core/Framework/Adapter/Cache/Message/RefreshHttpCacheMessageHandlerTest.php index 7c3ec226933..2479b81ad0c 100644 --- a/tests/unit/Core/Framework/Adapter/Cache/Message/RefreshHttpCacheMessageHandlerTest.php +++ b/tests/unit/Core/Framework/Adapter/Cache/Message/RefreshHttpCacheMessageHandlerTest.php @@ -81,6 +81,32 @@ public function testInvokeHandlesMessage(): void ($this->handler)($message); } + public function testResponseIsStoredForTheRequestTheKernelDidNotChange(): void + { + $message = new RefreshHttpCacheMessage('lock-key', ['_criteria' => 'compressed']); + $response = new Response(); + + $this->kernel->expects($this->once()) + ->method('handle') + ->willReturnCallback(static function (Request $request) use ($response): Response { + // like the listener that copies the compressed criteria into the query parameters + $request->query->set('limit', '2'); + + return $response; + }); + + $this->store->expects($this->once()) + ->method('write') + ->with( + static::callback(static fn (Request $request): bool => $request->query->all() === ['_criteria' => 'compressed']), + static::identicalTo($response) + ); + + $this->cache->expects($this->once())->method('delete')->with('lock-key'); + + ($this->handler)($message); + } + public function testInvokeSetsTrustedProxies(): void { $originalTrustedIps = Request::getTrustedProxies(); diff --git a/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php b/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php index afa1cf6bc99..7c2bb939576 100644 --- a/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php +++ b/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php @@ -66,6 +66,37 @@ public function testFieldsOfTheCompressedCriteriaAreReadableAsQueryParameters(): static::assertSame(['category' => ['id', 'name']], $request->query->all('includes')); } + public function testValuesAreStringsAsInAQueryString(): void + { + $request = self::createStoreApiRequest([ + '_criteria' => self::compress([ + 'limit' => 2, + 'min-price' => 1.5, + 'buildTree' => false, + 'onlyAvailable' => true, + 'referrerCategoryId' => null, + 'options' => ['group' => 5], + ]), + ]); + + $this->listener->expandCompressedCriteria(self::createEvent($request)); + + $query = $request->query->all(); + unset($query['_criteria']); + + static::assertSame( + [ + 'limit' => '2', + 'min-price' => '1.5', + 'buildTree' => '0', + 'onlyAvailable' => '1', + 'referrerCategoryId' => '', + 'options' => ['group' => '5'], + ], + $query + ); + } + public function testCompressedCriteriaWinsOverPlainQueryParameter(): void { $request = self::createStoreApiRequest([ diff --git a/tests/unit/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php b/tests/unit/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php new file mode 100644 index 00000000000..cf3cd7da32e --- /dev/null +++ b/tests/unit/Core/Framework/Script/Api/ScriptStoreApiRouteTest.php @@ -0,0 +1,76 @@ +encoder = $this->createMock(ScriptResponseEncoder::class); + + $this->route = new ScriptStoreApiRoute( + static::createStub(ScriptExecutor::class), + $this->encoder, + static::createStub(TagAwareAdapterInterface::class), + new NullLogger() + ); + } + + public function testIncludesOfTheRequestAreApplied(): void + { + $this->encoder->expects($this->once()) + ->method('encodeToSymfonyResponse') + ->with( + static::anything(), + static::equalTo(new ResponseFields(['product' => ['name']], [])), + 'store_api_my_hook_response' + ) + ->willReturn(new Response()); + + $this->route->execute('my-hook', self::createPostRequest(['includes' => ['product' => ['name']]]), Generator::generateSalesChannelContext()); + } + + public function testIncludesThatAreNotAnArrayAreRejected(): void + { + $this->encoder->expects($this->never())->method('encodeToSymfonyResponse'); + + $this->expectExceptionObject(SalesChannelException::invalidType('The includes must be of the type array, string given')); + + $this->route->execute('my-hook', self::createPostRequest(['includes' => 'product']), Generator::generateSalesChannelContext()); + } + + /** + * @param array $body + */ + private static function createPostRequest(array $body): Request + { + $request = new Request([], $body); + $request->setMethod(Request::METHOD_POST); + + return $request; + } +} From 9a0ac0079f71b23610531d4f8e23ef4c6e5f877b Mon Sep 17 00:00:00 2001 From: mkucmus Date: Tue, 29 Sep 2026 13:04:25 +0200 Subject: [PATCH 7/8] fix(store-api): keep the listing processor off Store API requests and deprecate it --- RELEASE_INFO-6.7.md | 2 ++ UPGRADE-6.8.md | 4 +++ .../Content/DependencyInjection/product.php | 1 + .../CompressedCriteriaListingProcessor.php | 19 ++++++++++++ .../Listing/ProductListingRouteTest.php | 31 +++++++++++++++++++ ...CompressedCriteriaListingProcessorTest.php | 28 +++++++++++++++++ 6 files changed, 85 insertions(+) diff --git a/RELEASE_INFO-6.7.md b/RELEASE_INFO-6.7.md index 9ccd58b3184..4ecda716134 100644 --- a/RELEASE_INFO-6.7.md +++ b/RELEASE_INFO-6.7.md @@ -134,6 +134,8 @@ On every Store API `GET` route, `_criteria` is now a compressed form of the quer 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. App scripts of `/store-api/script/{hook}` find them in the query as well. +Reading listing parameters from `_criteria` on Storefront routes is deprecated and will be removed with 6.8.0.0. Send them as plain query parameters there. + 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. diff --git a/UPGRADE-6.8.md b/UPGRADE-6.8.md index 9a5dfe48f71..d97a09cc316 100644 --- a/UPGRADE-6.8.md +++ b/UPGRADE-6.8.md @@ -2353,6 +2353,10 @@ const isInside = event.target instanceof Node && this.$el.contains(event.target)
+## Storefront routes no longer read listing parameters from `_criteria` + +Product listings of Storefront routes no longer read listing parameters, such as `order`, `p` or filters, from the compressed `_criteria` query parameter. `_criteria` is a Store API parameter. Send these parameters as plain query parameters, or load the listing through the Store API. The internal `CompressedCriteriaListingProcessor` was removed. + ## Footer collapse headlines and columns now use semantic elements In `layout/footer/footer.html.twig`, the following nodes changed to semantic elements. diff --git a/src/Core/Content/DependencyInjection/product.php b/src/Core/Content/DependencyInjection/product.php index c8453e838dd..ebffbef1004 100644 --- a/src/Core/Content/DependencyInjection/product.php +++ b/src/Core/Content/DependencyInjection/product.php @@ -500,6 +500,7 @@ tagged_iterator('shopware.listing.processor'), ]); + // @deprecated tag:v6.8.0 Will be removed, Store API requests are handled by the CompressedCriteriaRequestListener $services->set(CompressedCriteriaListingProcessor::class) ->args([ service(CompressedCriteriaDecoder::class), diff --git a/src/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessor.php b/src/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessor.php index 9ffd661c182..ce542e5e28b 100644 --- a/src/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessor.php +++ b/src/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessor.php @@ -6,14 +6,18 @@ use Shopware\Core\Framework\DataAbstractionLayer\Search\CompressedCriteriaDecoder; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; use Shopware\Core\Framework\DataAbstractionLayer\Search\RequestCriteriaBuilder; +use Shopware\Core\Framework\Feature; 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 */ @@ -43,6 +47,21 @@ 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; + } + + // @deprecated tag:v6.8.0 - remove the processor, only Store API routes read the compressed criteria then + if (Feature::isActive('v6.8.0.0')) { + return; + } + + Feature::triggerDeprecationOrThrow( + 'v6.8.0.0', + 'Reading listing parameters from the compressed "_criteria" parameter outside of the Store API is deprecated and will be removed. Send them as plain query parameters instead.' + ); + $payload = $this->compressedCriteriaDecoder->decode((string) $request->query->get('_criteria')); foreach ($payload as $param => $value) { if (!\in_array($param, RequestCriteriaBuilder::KNOWN_FIELDS, true)) { diff --git a/tests/integration/Core/Content/Product/SalesChannel/Listing/ProductListingRouteTest.php b/tests/integration/Core/Content/Product/SalesChannel/Listing/ProductListingRouteTest.php index b456794e71e..e4ab16654d1 100644 --- a/tests/integration/Core/Content/Product/SalesChannel/Listing/ProductListingRouteTest.php +++ b/tests/integration/Core/Content/Product/SalesChannel/Listing/ProductListingRouteTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use Shopware\Core\Content\Category\CategoryCollection; use Shopware\Core\Content\Product\Aggregate\ProductVisibility\ProductVisibilityDefinition; +use Shopware\Core\Content\Product\Events\ProductListingCriteriaEvent; use Shopware\Core\Content\Product\ProductCollection; use Shopware\Core\Content\Product\SalesChannel\Listing\ProductListingRoute; use Shopware\Core\Content\ProductStream\Aggregate\ProductStreamFilter\ProductStreamFilterCollection; @@ -20,8 +21,10 @@ use Shopware\Core\Framework\DataAbstractionLayer\Search\AggregationResult\Metric\StatsResult; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Test\TestCaseBase\EventDispatcherBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\IntegrationTestBehaviour; use Shopware\Core\Framework\Test\TestCaseBase\SalesChannelApiTestBehaviour; +use Shopware\Core\Framework\Util\Base64; use Shopware\Core\Framework\Uuid\Uuid; use Shopware\Core\System\SalesChannel\Context\SalesChannelContextFactory; use Shopware\Core\Test\Stub\Framework\IdsCollection; @@ -36,6 +39,7 @@ #[Group('store-api')] class ProductListingRouteTest extends TestCase { + use EventDispatcherBehaviour; use IntegrationTestBehaviour; use SalesChannelApiTestBehaviour; @@ -109,6 +113,33 @@ public function testLoadProducts(): void static::assertSame('product', $response['elements'][0]['apiAlias']); } + public function testCustomListingParametersOfTheCompressedCriteriaAreReadLikeQueryParameters(): void + { + $this->createData(); + + $queries = []; + $this->addEventListener( + static::getContainer()->get('event_dispatcher'), + ProductListingCriteriaEvent::class, + static function (ProductListingCriteriaEvent $event) use (&$queries): void { + $query = $event->getRequest()->query->all(); + unset($query['_criteria']); + $queries[] = $query; + } + ); + + $url = '/store-api/product-listing/' . $this->ids->get('category'); + $this->browser->request('GET', $url, ['custom-filter' => '0', 'custom-flag' => '1', 'custom-number' => '5']); + + $compressed = gzencode(json_encode(['custom-filter' => false, 'custom-flag' => true, 'custom-number' => 5], \JSON_THROW_ON_ERROR)); + static::assertNotFalse($compressed, 'Gzip compressing failed'); + $this->browser->request('GET', $url, ['_criteria' => Base64::urlEncode($compressed)]); + + static::assertCount(2, $queries); + static::assertSame(['custom-filter' => '0', 'custom-flag' => '1', 'custom-number' => '5'], $queries[0]); + static::assertSame($queries[0], $queries[1], 'Listing processors of extensions read the same values as from plain query parameters'); + } + public function testReturnsHttpNotFoundWhenRequestedPageExceedsLastPage(): void { $this->createData(); diff --git a/tests/unit/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessorTest.php b/tests/unit/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessorTest.php index 94689dfdd04..edbbf118dc4 100644 --- a/tests/unit/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessorTest.php +++ b/tests/unit/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessorTest.php @@ -9,7 +9,10 @@ use Shopware\Core\Framework\DataAbstractionLayer\Search\CompressedCriteriaDecoder; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; use Shopware\Core\Framework\Log\Package; +use Shopware\Core\Framework\Routing\StoreApiRouteScope; +use Shopware\Core\PlatformRequest; use Shopware\Core\System\SalesChannel\SalesChannelContext; +use Shopware\Core\Test\Annotation\DisabledFeatures; use Symfony\Component\HttpFoundation\Request; /** @@ -49,6 +52,7 @@ public function testPrepareIgnoredMissingCriteria(): void $this->processor->prepare($request, new Criteria(), static::createStub(SalesChannelContext::class)); } + #[DisabledFeatures(['v6.8.0.0'])] public function testPrepareExtractsNonCriteriaFields(): void { $request = new Request(); @@ -78,4 +82,28 @@ public function testPrepareExtractsNonCriteriaFields(): void static::assertFalse($request->query->has('limit'), 'Standard param "limit" should NOT be in query'); } + + #[DisabledFeatures(['v6.8.0.0'])] + public function testStoreApiRequestsAreLeftToTheListener(): void + { + $request = new Request(['_criteria' => 'encoded-payload', 'custom-flag' => '1']); + $request->setMethod(Request::METHOD_GET); + $request->attributes->set(PlatformRequest::ATTRIBUTE_ROUTE_SCOPE, [StoreApiRouteScope::ID]); + + $this->decoder->expects($this->never())->method('decode'); + + $this->processor->prepare($request, new Criteria(), static::createStub(SalesChannelContext::class)); + + static::assertSame('1', $request->query->get('custom-flag'), 'The value the listener copied is not overwritten'); + } + + public function testNothingIsReadWithTheNextMajorVersion(): void + { + $request = new Request(['_criteria' => 'encoded-payload']); + $request->setMethod(Request::METHOD_GET); + + $this->decoder->expects($this->never())->method('decode'); + + $this->processor->prepare($request, new Criteria(), static::createStub(SalesChannelContext::class)); + } } From 6d9653889efa8486dc527ba62fd746308e3cafb6 Mon Sep 17 00:00:00 2001 From: mkucmus Date: Tue, 29 Sep 2026 13:16:46 +0200 Subject: [PATCH 8/8] fix(store-api): keep the listing processor off Store API requests --- RELEASE_INFO-6.7.md | 5 ++--- UPGRADE-6.8.md | 4 ---- src/Core/Content/DependencyInjection/product.php | 1 - .../CompressedCriteriaListingProcessor.php | 11 ----------- .../CompressedCriteriaRequestListener.php | 7 +++++++ .../Listing/ProductListingRouteTest.php | 2 +- .../CompressedCriteriaListingProcessorTest.php | 13 ------------- .../CompressedCriteriaRequestListenerTest.php | 15 +++++++++++++-- 8 files changed, 23 insertions(+), 35 deletions(-) diff --git a/RELEASE_INFO-6.7.md b/RELEASE_INFO-6.7.md index 4ecda716134..1845a9ef4ef 100644 --- a/RELEASE_INFO-6.7.md +++ b/RELEASE_INFO-6.7.md @@ -132,13 +132,12 @@ On every Store API `GET` route, `_criteria` is now a compressed form of the quer | `/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. App scripts of `/store-api/script/{hook}` find them in the query as well. - -Reading listing parameters from `_criteria` on Storefront routes is deprecated and will be removed with 6.8.0.0. Send them as plain query parameters there. +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. diff --git a/UPGRADE-6.8.md b/UPGRADE-6.8.md index d97a09cc316..9a5dfe48f71 100644 --- a/UPGRADE-6.8.md +++ b/UPGRADE-6.8.md @@ -2353,10 +2353,6 @@ const isInside = event.target instanceof Node && this.$el.contains(event.target)
-## Storefront routes no longer read listing parameters from `_criteria` - -Product listings of Storefront routes no longer read listing parameters, such as `order`, `p` or filters, from the compressed `_criteria` query parameter. `_criteria` is a Store API parameter. Send these parameters as plain query parameters, or load the listing through the Store API. The internal `CompressedCriteriaListingProcessor` was removed. - ## Footer collapse headlines and columns now use semantic elements In `layout/footer/footer.html.twig`, the following nodes changed to semantic elements. diff --git a/src/Core/Content/DependencyInjection/product.php b/src/Core/Content/DependencyInjection/product.php index ebffbef1004..c8453e838dd 100644 --- a/src/Core/Content/DependencyInjection/product.php +++ b/src/Core/Content/DependencyInjection/product.php @@ -500,7 +500,6 @@ tagged_iterator('shopware.listing.processor'), ]); - // @deprecated tag:v6.8.0 Will be removed, Store API requests are handled by the CompressedCriteriaRequestListener $services->set(CompressedCriteriaListingProcessor::class) ->args([ service(CompressedCriteriaDecoder::class), diff --git a/src/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessor.php b/src/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessor.php index ce542e5e28b..bc0278a37be 100644 --- a/src/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessor.php +++ b/src/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessor.php @@ -6,7 +6,6 @@ use Shopware\Core\Framework\DataAbstractionLayer\Search\CompressedCriteriaDecoder; use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria; use Shopware\Core\Framework\DataAbstractionLayer\Search\RequestCriteriaBuilder; -use Shopware\Core\Framework\Feature; use Shopware\Core\Framework\Log\Package; use Shopware\Core\Framework\Plugin\Exception\DecorationPatternException; use Shopware\Core\Framework\Routing\StoreApiRouteScope; @@ -52,16 +51,6 @@ public function prepare(Request $request, Criteria $criteria, SalesChannelContex return; } - // @deprecated tag:v6.8.0 - remove the processor, only Store API routes read the compressed criteria then - if (Feature::isActive('v6.8.0.0')) { - return; - } - - Feature::triggerDeprecationOrThrow( - 'v6.8.0.0', - 'Reading listing parameters from the compressed "_criteria" parameter outside of the Store API is deprecated and will be removed. Send them as plain query parameters instead.' - ); - $payload = $this->compressedCriteriaDecoder->decode((string) $request->query->get('_criteria')); foreach ($payload as $param => $value) { if (!\in_array($param, RequestCriteriaBuilder::KNOWN_FIELDS, true)) { diff --git a/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php b/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php index 28a08cf2ccd..e12ed01b90a 100644 --- a/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php +++ b/src/Core/Framework/Api/EventListener/CompressedCriteriaRequestListener.php @@ -61,6 +61,13 @@ public function expandCompressedCriteria(ControllerEvent $event): void 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)); } diff --git a/tests/integration/Core/Content/Product/SalesChannel/Listing/ProductListingRouteTest.php b/tests/integration/Core/Content/Product/SalesChannel/Listing/ProductListingRouteTest.php index e4ab16654d1..275fcd0512b 100644 --- a/tests/integration/Core/Content/Product/SalesChannel/Listing/ProductListingRouteTest.php +++ b/tests/integration/Core/Content/Product/SalesChannel/Listing/ProductListingRouteTest.php @@ -131,7 +131,7 @@ static function (ProductListingCriteriaEvent $event) use (&$queries): void { $url = '/store-api/product-listing/' . $this->ids->get('category'); $this->browser->request('GET', $url, ['custom-filter' => '0', 'custom-flag' => '1', 'custom-number' => '5']); - $compressed = gzencode(json_encode(['custom-filter' => false, 'custom-flag' => true, 'custom-number' => 5], \JSON_THROW_ON_ERROR)); + $compressed = gzencode(json_encode(['custom-filter' => false, 'custom-flag' => true, 'custom-number' => 5, 'custom-empty' => null], \JSON_THROW_ON_ERROR)); static::assertNotFalse($compressed, 'Gzip compressing failed'); $this->browser->request('GET', $url, ['_criteria' => Base64::urlEncode($compressed)]); diff --git a/tests/unit/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessorTest.php b/tests/unit/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessorTest.php index edbbf118dc4..044a0fac647 100644 --- a/tests/unit/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessorTest.php +++ b/tests/unit/Core/Content/Product/SalesChannel/Listing/Processor/CompressedCriteriaListingProcessorTest.php @@ -12,7 +12,6 @@ use Shopware\Core\Framework\Routing\StoreApiRouteScope; use Shopware\Core\PlatformRequest; use Shopware\Core\System\SalesChannel\SalesChannelContext; -use Shopware\Core\Test\Annotation\DisabledFeatures; use Symfony\Component\HttpFoundation\Request; /** @@ -52,7 +51,6 @@ public function testPrepareIgnoredMissingCriteria(): void $this->processor->prepare($request, new Criteria(), static::createStub(SalesChannelContext::class)); } - #[DisabledFeatures(['v6.8.0.0'])] public function testPrepareExtractsNonCriteriaFields(): void { $request = new Request(); @@ -83,7 +81,6 @@ public function testPrepareExtractsNonCriteriaFields(): void static::assertFalse($request->query->has('limit'), 'Standard param "limit" should NOT be in query'); } - #[DisabledFeatures(['v6.8.0.0'])] public function testStoreApiRequestsAreLeftToTheListener(): void { $request = new Request(['_criteria' => 'encoded-payload', 'custom-flag' => '1']); @@ -96,14 +93,4 @@ public function testStoreApiRequestsAreLeftToTheListener(): void static::assertSame('1', $request->query->get('custom-flag'), 'The value the listener copied is not overwritten'); } - - public function testNothingIsReadWithTheNextMajorVersion(): void - { - $request = new Request(['_criteria' => 'encoded-payload']); - $request->setMethod(Request::METHOD_GET); - - $this->decoder->expects($this->never())->method('decode'); - - $this->processor->prepare($request, new Criteria(), static::createStub(SalesChannelContext::class)); - } } diff --git a/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php b/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php index 7c2bb939576..c9adec48486 100644 --- a/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php +++ b/tests/unit/Core/Framework/Api/EventListener/CompressedCriteriaRequestListenerTest.php @@ -74,7 +74,6 @@ public function testValuesAreStringsAsInAQueryString(): void 'min-price' => 1.5, 'buildTree' => false, 'onlyAvailable' => true, - 'referrerCategoryId' => null, 'options' => ['group' => 5], ]), ]); @@ -90,13 +89,25 @@ public function testValuesAreStringsAsInAQueryString(): void 'min-price' => '1.5', 'buildTree' => '0', 'onlyAvailable' => '1', - 'referrerCategoryId' => '', 'options' => ['group' => '5'], ], $query ); } + public function testFieldWithoutValueIsNotSent(): void + { + $request = self::createStoreApiRequest([ + 'rating' => '3', + '_criteria' => self::compress(['rating' => null, 'reduce-aggregations' => null]), + ]); + + $this->listener->expandCompressedCriteria(self::createEvent($request)); + + static::assertFalse($request->query->has('rating'), 'The compressed criteria also wins over a plain query parameter here'); + static::assertFalse($request->query->has('reduce-aggregations')); + } + public function testCompressedCriteriaWinsOverPlainQueryParameter(): void { $request = self::createStoreApiRequest([