refactor(api): use League URI for RFC query string normalization and nested bracket support

This commit is contained in:
Jérémy Munsch 2026-09-18 10:32:52 +02:00
parent 2b2ca42c99
commit 04c39e2573
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
5 changed files with 219 additions and 63 deletions

View file

@ -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",

150
composer.lock generated
View file

@ -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",

View file

@ -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<string>
*/
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<string, mixed>
*/
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<string, mixed> */
return QueryString::convert($normalizedPairs);
}
}

View file

@ -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();

View file

@ -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);