From 6164aa3e07cac89e4da423fc464d9c041ac9e000 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 1 Jun 2026 10:44:40 +0200 Subject: [PATCH 1/2] fix(jsonapi): keep flat custom params with flat page PR #8193 set `_api_filters` from flat pagination params even when no `filter[...]` bracket was present. `ReadProvider` then skipped its raw query fallback (`parseRequestParams`), silently dropping flat custom filter params like `?city_id=3&order[distance]=asc&page=1`. Parse the raw query string in `JsonApiProvider` and merge it as a base layer; JSON:API transformations still win via `array_replace`. fixes #8216 --- src/JsonApi/State/JsonApiProvider.php | 7 ++++++ .../Tests/State/JsonApiProviderTest.php | 22 +++++++++++++++++ .../Functional/JsonApiFlatPaginationTest.php | 24 +++++++++++++++++++ 3 files changed, 53 insertions(+) diff --git a/src/JsonApi/State/JsonApiProvider.php b/src/JsonApi/State/JsonApiProvider.php index 0976d9ce84..b7be54878e 100644 --- a/src/JsonApi/State/JsonApiProvider.php +++ b/src/JsonApi/State/JsonApiProvider.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\Operation; use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\Util\RequestParser; final class JsonApiProvider implements ProviderInterface { @@ -87,6 +88,12 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } if ($filters) { + // ReadProvider skips its raw-query fallback when _api_filters is set, + // so preserve flat custom params here too. JSON:API transforms win. + $queryString = RequestParser::getQueryString($request); + $rawParams = $queryString ? RequestParser::parseRequestParams($queryString) : []; + $filters = array_replace($rawParams, $filters); + $request->attributes->set('_api_filters', $filters); } diff --git a/src/JsonApi/Tests/State/JsonApiProviderTest.php b/src/JsonApi/Tests/State/JsonApiProviderTest.php index 6e5999bbdb..8766764e3c 100644 --- a/src/JsonApi/Tests/State/JsonApiProviderTest.php +++ b/src/JsonApi/Tests/State/JsonApiProviderTest.php @@ -55,4 +55,26 @@ public function testProvideMergesFlatPaginationWithBracketFilter(): void 'pagination' => 'true', ], $request->attributes->get('_api_filters')); } + + // #8216: flat custom params must survive when _api_filters is pre-set by JSON:API. + public function testProvidePreservesFlatCustomQueryParamsWithoutBracketFilter(): void + { + $request = Request::create('/sessions?city_id=3152&order[distance]=asc&page=1'); + $request->setRequestFormat('jsonapi'); + + $operation = new Get(class: \stdClass::class, shortName: 'dummy'); + $context = ['request' => $request]; + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->with($operation, [], $context); + + $provider = new JsonApiProvider($decorated); + $provider->provide($operation, [], $context); + + $filters = $request->attributes->get('_api_filters'); + + $this->assertIsArray($filters); + $this->assertSame('3152', $filters['city_id'] ?? null); + $this->assertSame(['distance' => 'asc'], $filters['order'] ?? null); + $this->assertSame('1', $filters['page'] ?? null); + } } diff --git a/tests/Functional/JsonApiFlatPaginationTest.php b/tests/Functional/JsonApiFlatPaginationTest.php index 8495531a2f..8766b2e1fa 100644 --- a/tests/Functional/JsonApiFlatPaginationTest.php +++ b/tests/Functional/JsonApiFlatPaginationTest.php @@ -70,6 +70,26 @@ public function testFlatPageWithBracketFilterDrivesCurrentPage(): void $this->assertSame(5, $data['meta']['totalItems'] ?? null); } + // #8216: flat filter param combined with flat page must still drive filtering. + public function testFlatCustomParamWithFlatPagePreservesFilter(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Not tested with mongodb.'); + } + + $response = self::createClient()->request('GET', '/dummies?name=foo&page=1', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + + $data = $response->toArray(); + + $this->assertSame(1, $data['meta']['currentPage'] ?? null); + $this->assertSame(5, $data['meta']['totalItems'] ?? null); + } + private function loadFixtures(): void { $manager = $this->getManager(); @@ -80,6 +100,10 @@ private function loadFixtures(): void $manager->persist($dummy); } + $bar = new Dummy(); + $bar->setName('bar'); + $manager->persist($bar); + $manager->flush(); } } From fd9a813d7ce155096a1b57c1b87a3c1b127d9c9d Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 1 Jun 2026 11:15:50 +0200 Subject: [PATCH 2/2] refactor(jsonapi): reuse _api_query_parameters request attribute Use the documented `_api_query_parameters` request attribute as the source of truth for raw query parsing. ParameterProvider already populates it before JsonApiProvider runs (see ReadListener), and users can pre-populate it from a custom listener to replace the default parsing. Falls back to `RequestParser::parseRequestParams` when the attribute is unset (GraphQL, Laravel, or any setup without ParameterProvider) and writes the parsed result back so downstream code sees the same shape. --- src/JsonApi/State/JsonApiProvider.php | 8 +++++-- .../Tests/State/JsonApiProviderTest.php | 21 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/src/JsonApi/State/JsonApiProvider.php b/src/JsonApi/State/JsonApiProvider.php index b7be54878e..c09c2fdb68 100644 --- a/src/JsonApi/State/JsonApiProvider.php +++ b/src/JsonApi/State/JsonApiProvider.php @@ -90,8 +90,12 @@ public function provide(Operation $operation, array $uriVariables = [], array $c if ($filters) { // ReadProvider skips its raw-query fallback when _api_filters is set, // so preserve flat custom params here too. JSON:API transforms win. - $queryString = RequestParser::getQueryString($request); - $rawParams = $queryString ? RequestParser::parseRequestParams($queryString) : []; + $rawParams = $request->attributes->get('_api_query_parameters'); + if (null === $rawParams) { + $queryString = RequestParser::getQueryString($request); + $rawParams = $queryString ? RequestParser::parseRequestParams($queryString) : []; + $request->attributes->set('_api_query_parameters', $rawParams); + } $filters = array_replace($rawParams, $filters); $request->attributes->set('_api_filters', $filters); diff --git a/src/JsonApi/Tests/State/JsonApiProviderTest.php b/src/JsonApi/Tests/State/JsonApiProviderTest.php index 8766764e3c..e315e8dbb2 100644 --- a/src/JsonApi/Tests/State/JsonApiProviderTest.php +++ b/src/JsonApi/Tests/State/JsonApiProviderTest.php @@ -77,4 +77,25 @@ public function testProvidePreservesFlatCustomQueryParamsWithoutBracketFilter(): $this->assertSame(['distance' => 'asc'], $filters['order'] ?? null); $this->assertSame('1', $filters['page'] ?? null); } + + // _api_query_parameters set by an earlier listener / ParameterProvider must be reused. + public function testProvideHonoursPrePopulatedApiQueryParameters(): void + { + $request = Request::create('/sessions?page=1'); + $request->setRequestFormat('jsonapi'); + $request->attributes->set('_api_query_parameters', ['custom_override' => 'yes', 'page' => '1']); + + $operation = new Get(class: \stdClass::class, shortName: 'dummy'); + $context = ['request' => $request]; + $decorated = $this->createMock(ProviderInterface::class); + $decorated->expects($this->once())->method('provide')->with($operation, [], $context); + + $provider = new JsonApiProvider($decorated); + $provider->provide($operation, [], $context); + + $filters = $request->attributes->get('_api_filters'); + + $this->assertSame('yes', $filters['custom_override'] ?? null); + $this->assertSame('1', $filters['page'] ?? null); + } }