From 04c39e2573865e49d5fe4aa8a50f3e9a6ec311ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Munsch?= Date: Fri, 18 Sep 2026 10:32:52 +0200 Subject: [PATCH] refactor(api): use League URI for RFC query string normalization and nested bracket support --- composer.json | 1 + composer.lock | 150 +++++++++++++++++- .../NormalizeQueryStringArrays.php | 75 +++------ .../NormalizeQueryStringArraysTest.php | 54 ++++++- .../V1/IncoSheets/IncoSheetOpenApiTest.php | 2 +- 5 files changed, 219 insertions(+), 63 deletions(-) diff --git a/composer.json b/composer.json index 0ac90a9..4af067d 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "ext-oci8": "*", "laravel/framework": "^13.24", "laravel/sanctum": "^4.0", + "league/uri-interfaces": "^7.8", "psr/clock": "^1.0", "sentry/sentry-laravel": "^4.27", "webmozart/assert": "^2.4", diff --git a/composer.lock b/composer.lock index 7120240..9d3cd46 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "be8373b5ec01af6a0375ffef89d984b6", + "content-hash": "2b2ac2ab1af547320c5214191cbe9513", "packages": [ { "name": "brick/math", @@ -8075,6 +8075,75 @@ }, "time": "2026-08-10T15:35:50+00:00" }, + { + "name": "laravel/tinker", + "version": "v3.0.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/tinker.git", + "reference": "4faba77764bd33411735936acdf30446d058c78b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/tinker/zipball/4faba77764bd33411735936acdf30446d058c78b", + "reference": "4faba77764bd33411735936acdf30446d058c78b", + "shasum": "" + }, + "require": { + "illuminate/console": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/contracts": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "illuminate/support": "^8.0|^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.1", + "psy/psysh": "^0.12.0", + "symfony/var-dumper": "^5.4|^6.0|^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "~1.3.3|^1.4.2", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^10.5|^11.5" + }, + "suggest": { + "illuminate/database": "The Illuminate Database package (^8.0|^9.0|^10.0|^11.0|^12.0|^13.0)." + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Tinker\\TinkerServiceProvider" + ] + }, + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Laravel\\Tinker\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Powerful REPL for the Laravel framework.", + "keywords": [ + "REPL", + "Tinker", + "laravel", + "psysh" + ], + "support": { + "issues": "https://github.com/laravel/tinker/issues", + "source": "https://github.com/laravel/tinker/tree/v3.0.2" + }, + "time": "2026-03-17T14:54:13+00:00" + }, { "name": "marc-mabe/php-enum", "version": "v4.7.2", @@ -10335,6 +10404,85 @@ ], "time": "2026-07-28T13:58:09+00:00" }, + { + "name": "psy/psysh", + "version": "v0.12.24", + "source": { + "type": "git", + "url": "https://github.com/bobthecow/psysh.git", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "reference": "ca0fdcf8a7617afa3adfdf1b5fef573dffb69ca1", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-tokenizer": "*", + "nikic/php-parser": "^5.0 || ^4.0", + "php": "^8.0 || ^7.4", + "symfony/console": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4", + "symfony/var-dumper": "^8.0 || ^7.0 || ^6.0 || ^5.0 || ^4.0 || ^3.4" + }, + "conflict": { + "symfony/console": "4.4.37 || 5.3.14 || 5.3.15 || 5.4.3 || 5.4.4 || 6.0.3 || 6.0.4" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.2", + "composer/class-map-generator": "^1.6" + }, + "suggest": { + "composer/class-map-generator": "Improved tab completion performance with better class discovery.", + "ext-pcntl": "Enabling the PCNTL extension makes PsySH a lot happier :)", + "ext-posix": "If you have PCNTL, you'll want the POSIX extension as well." + }, + "bin": [ + "bin/psysh" + ], + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": false, + "forward-command": false + }, + "branch-alias": { + "dev-main": "0.12.x-dev" + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Psy\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Justin Hileman", + "email": "justin@justinhileman.info" + } + ], + "description": "An interactive shell for modern PHP.", + "homepage": "https://psysh.org", + "keywords": [ + "REPL", + "console", + "interactive", + "shell" + ], + "support": { + "issues": "https://github.com/bobthecow/psysh/issues", + "source": "https://github.com/bobthecow/psysh/tree/v0.12.24" + }, + "time": "2026-06-29T15:41:09+00:00" + }, { "name": "react/event-loop", "version": "v1.6.0", diff --git a/src/Presentation/Api/V1/Http/Middlewares/NormalizeQueryStringArrays.php b/src/Presentation/Api/V1/Http/Middlewares/NormalizeQueryStringArrays.php index d64abf3..531ffb3 100644 --- a/src/Presentation/Api/V1/Http/Middlewares/NormalizeQueryStringArrays.php +++ b/src/Presentation/Api/V1/Http/Middlewares/NormalizeQueryStringArrays.php @@ -5,26 +5,18 @@ declare(strict_types=1); namespace Presentation\Api\V1\Http\Middlewares; use Illuminate\Http\Request; +use League\Uri\KeyValuePair\Converter; +use League\Uri\QueryString; use Symfony\Component\HttpFoundation\Response; final class NormalizeQueryStringArrays { - /** - * Query parameters expected to be arrays in API V1. - * - * @var list - */ - private const ARRAY_QUERY_PARAMS = [ - 'brands', - 'siteIds', - ]; - /** * @param \Closure(Request): Response $next */ public function handle(Request $request, \Closure $next): Response { - if (! $request->is('api/*')) { + if (! $request->is('api/v1', 'api/v1/*')) { /** @var Response */ return $next($request); } @@ -44,60 +36,37 @@ final class NormalizeQueryStringArrays } /** - * Parse raw query string while properly aggregating repeated keys, - * bracket keys (e.g. siteIds[]=A), and known array parameters into PHP arrays. + * Parse raw query string while properly aggregating repeated keys + * and preserving bracket keys (both flat and nested) according to RFC 3986. * * @return array */ private function parseQueryString(string $queryString): array { - $pairs = explode('&', $queryString); - $result = []; + $pairs = Converter::fromRFC3986()->toPairs($queryString); - foreach ($pairs as $pair) { - if ($pair === '') { + if (empty($pairs)) { + return []; + } + + $counts = []; + foreach ($pairs as [$key]) { + if ($key === null || $key === '') { continue; } + $counts[$key] = ($counts[$key] ?? 0) + 1; + } - $parts = explode('=', $pair, 2); - $rawKey = urldecode($parts[0]); - $value = isset($parts[1]) ? urldecode($parts[1]) : ''; - - // Strip trailing empty brackets: 'brands[]' -> 'brands' - if (str_ends_with($rawKey, '[]')) { - $key = substr($rawKey, 0, -2); - if (! isset($result[$key]) || ! is_array($result[$key])) { - $result[$key] = []; - } - $result[$key][] = $value; - } elseif (isset($result[$rawKey])) { - // Repeated parameter: 'siteIds=A&siteIds=B' - if (! is_array($result[$rawKey])) { - $result[$rawKey] = [$result[$rawKey]]; - } - $result[$rawKey][] = $value; + $normalizedPairs = []; + foreach ($pairs as [$key, $value]) { + if ($key !== null && ($counts[$key] ?? 0) > 1 && ! str_ends_with($key, ']')) { + $normalizedPairs[] = [$key . '[]', $value]; } else { - $result[$rawKey] = $value; + $normalizedPairs[] = [$key, $value]; } } - // For known array parameters, normalize single scalars or comma-separated lists into arrays - foreach (self::ARRAY_QUERY_PARAMS as $arrayParam) { - if (isset($result[$arrayParam])) { - $val = $result[$arrayParam]; - if (is_string($val)) { - if (str_contains($val, ',')) { - $result[$arrayParam] = array_values(array_filter( - explode(',', $val), - static fn (string $item): bool => $item !== '' - )); - } else { - $result[$arrayParam] = [$val]; - } - } - } - } - - return $result; + /** @var array */ + return QueryString::convert($normalizedPairs); } } diff --git a/tests/Feature/Api/V1/Middlewares/NormalizeQueryStringArraysTest.php b/tests/Feature/Api/V1/Middlewares/NormalizeQueryStringArraysTest.php index 181d12a..435dc00 100644 --- a/tests/Feature/Api/V1/Middlewares/NormalizeQueryStringArraysTest.php +++ b/tests/Feature/Api/V1/Middlewares/NormalizeQueryStringArraysTest.php @@ -41,14 +41,19 @@ describe('NormalizeQueryStringArrays Middleware Feature Tests', function (): voi expect($response->getStatusCode())->toBe(200); }); - it('normalizes single scalar brand query parameter into array', function (): void { + it('preserves and normalizes nested bracket parameters into multidimensional array', function (): void { // GIVEN $middleware = new NormalizeQueryStringArrays(); - $request = Request::create('/api/v1/inco-sheets?brands=LGL01', 'GET'); + $request = Request::create('/api/v1/employees?filter[date][from]=2024-01-01&filter[date][to]=2024-01-02', 'GET'); // WHEN $response = $middleware->handle($request, function (Request $req): Response { - expect($req->query('brands'))->toBe(['LGL01']); + expect($req->query('filter'))->toBe([ + 'date' => [ + 'from' => '2024-01-01', + 'to' => '2024-01-02', + ], + ]); return new Response(); }); @@ -57,14 +62,14 @@ describe('NormalizeQueryStringArrays Middleware Feature Tests', function (): voi expect($response->getStatusCode())->toBe(200); }); - it('normalizes comma separated brands into array', function (): void { + it('preserves dots in query parameter keys without converting to underscores', function (): void { // GIVEN $middleware = new NormalizeQueryStringArrays(); - $request = Request::create('/api/v1/inco-sheets?brands=LGL01,MCQ01', 'GET'); + $request = Request::create('/api/v1/employees?filter.status=active', 'GET'); // WHEN $response = $middleware->handle($request, function (Request $req): Response { - expect($req->query('brands'))->toBe(['LGL01', 'MCQ01']); + expect($req->query('filter.status'))->toBe('active'); return new Response(); }); @@ -73,14 +78,47 @@ describe('NormalizeQueryStringArrays Middleware Feature Tests', function (): voi expect($response->getStatusCode())->toBe(200); }); - it('bypasses non-api routes', function (): void { + it('preserves percent-encoded values without double decoding', function (): void { + // GIVEN + $middleware = new NormalizeQueryStringArrays(); + $encodedVal = urlencode('%9999999%'); + $request = Request::create("/api/v1/inco-sheets/search?incoIdLike={$encodedVal}", 'GET'); + + // WHEN + $response = $middleware->handle($request, function (Request $req): Response { + expect($req->query('incoIdLike'))->toBe('%9999999%'); + + return new Response(); + }); + + // THEN + expect($response->getStatusCode())->toBe(200); + }); + + it('handles empty query string gracefully', function (): void { + // GIVEN + $middleware = new NormalizeQueryStringArrays(); + $request = Request::create('/api/v1/employees', 'GET'); + + // WHEN + $response = $middleware->handle($request, function (Request $req): Response { + expect($req->query->all())->toBeEmpty(); + + return new Response(); + }); + + // THEN + expect($response->getStatusCode())->toBe(200); + }); + + it('bypasses non-v1 routes and preserves native PHP query behavior', function (): void { // GIVEN $middleware = new NormalizeQueryStringArrays(); $request = Request::create('/ping?siteIds=LDCSER&siteIds=SLELA', 'GET'); // WHEN $response = $middleware->handle($request, function (Request $req): Response { - // PHP native behavior: last value wins if not normalized + // Native PHP behavior: last value wins if not normalized expect($req->query('siteIds'))->toBe('SLELA'); return new Response(); diff --git a/tests/Feature/OpenApi/V1/IncoSheets/IncoSheetOpenApiTest.php b/tests/Feature/OpenApi/V1/IncoSheets/IncoSheetOpenApiTest.php index 992ecb6..a90d14e 100644 --- a/tests/Feature/OpenApi/V1/IncoSheets/IncoSheetOpenApiTest.php +++ b/tests/Feature/OpenApi/V1/IncoSheets/IncoSheetOpenApiTest.php @@ -43,7 +43,7 @@ describe('IncoSheet OpenAPI Conformance', function (): void { // WHEN /** @var \Illuminate\Testing\TestResponse<\Symfony\Component\HttpFoundation\Response> $response */ - $response = $test->getJson('/api/v1/inco-sheets?brands=LGL01'); + $response = $test->getJson('/api/v1/inco-sheets?brands=LGL01&brands=MCQ01'); // THEN $response->assertStatus(200);