feat(api): add NormalizeQueryStringArrays middleware to support RFC and bracket query array formats

This commit is contained in:
Jérémy Munsch 2026-09-17 18:11:39 +02:00
parent 86584c5b94
commit 00aff9bf63
Signed by: forge-joniko
GPG key ID: D7EFE2F5CB6CC0E4
3 changed files with 198 additions and 0 deletions

View file

@ -4,11 +4,14 @@ declare(strict_types=1);
use Illuminate\Foundation\Configuration\Middleware;
use Presentation\Api\V1\Http\Middlewares\EnsureApiV1AcceptsJson;
use Presentation\Api\V1\Http\Middlewares\NormalizeQueryStringArrays;
use Presentation\Api\V1\Http\Middlewares\SetApiV1Etag;
use Presentation\Api\V1\Http\Middlewares\TransformApiV1Response;
use Presentation\Api\V1\Http\Middlewares\VerifyHmacSignature;
return function (Middleware $middleware): void {
$middleware->prepend(NormalizeQueryStringArrays::class);
$middleware->alias([
'auth.hmac' => VerifyHmacSignature::class,
]);

View file

@ -0,0 +1,103 @@
<?php
declare(strict_types=1);
namespace Presentation\Api\V1\Http\Middlewares;
use Illuminate\Http\Request;
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/*')) {
/** @var Response */
return $next($request);
}
$queryString = $request->server->get('QUERY_STRING');
if (is_string($queryString) && $queryString !== '') {
$normalized = $this->parseQueryString($queryString);
if (! empty($normalized)) {
$request->query->replace(array_merge($request->query->all(), $normalized));
}
}
/** @var Response */
return $next($request);
}
/**
* Parse raw query string while properly aggregating repeated keys,
* bracket keys (e.g. siteIds[]=A), and known array parameters into PHP arrays.
*
* @return array<string, mixed>
*/
private function parseQueryString(string $queryString): array
{
$pairs = explode('&', $queryString);
$result = [];
foreach ($pairs as $pair) {
if ($pair === '') {
continue;
}
$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;
} else {
$result[$rawKey] = $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;
}
}

View file

@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace Tests\Feature\Api\V1\Middlewares;
use Illuminate\Http\Request;
use Presentation\Api\V1\Http\Middlewares\NormalizeQueryStringArrays;
use Symfony\Component\HttpFoundation\Response;
describe('NormalizeQueryStringArrays Middleware Feature Tests', function (): void {
it('normalizes repeated query parameters into array', function (): void {
// GIVEN
$middleware = new NormalizeQueryStringArrays();
$request = Request::create('/api/v1/employees?siteIds=LDCSER&siteIds=SLELA', 'GET');
// WHEN
$response = $middleware->handle($request, function (Request $req): Response {
expect($req->query('siteIds'))->toBe(['LDCSER', 'SLELA']);
return new Response();
});
// THEN
expect($response->getStatusCode())->toBe(200);
});
it('normalizes bracket query parameters into array without trailing brackets in key', function (): void {
// GIVEN
$middleware = new NormalizeQueryStringArrays();
$request = Request::create('/api/v1/employees?siteIds[]=LDCSER&siteIds[]=SLELA', 'GET');
// WHEN
$response = $middleware->handle($request, function (Request $req): Response {
expect($req->query('siteIds'))->toBe(['LDCSER', 'SLELA']);
return new Response();
});
// THEN
expect($response->getStatusCode())->toBe(200);
});
it('normalizes single scalar brand query parameter into array', function (): void {
// GIVEN
$middleware = new NormalizeQueryStringArrays();
$request = Request::create('/api/v1/inco-sheets?brands=LGL01', 'GET');
// WHEN
$response = $middleware->handle($request, function (Request $req): Response {
expect($req->query('brands'))->toBe(['LGL01']);
return new Response();
});
// THEN
expect($response->getStatusCode())->toBe(200);
});
it('normalizes comma separated brands into array', function (): void {
// GIVEN
$middleware = new NormalizeQueryStringArrays();
$request = Request::create('/api/v1/inco-sheets?brands=LGL01,MCQ01', 'GET');
// WHEN
$response = $middleware->handle($request, function (Request $req): Response {
expect($req->query('brands'))->toBe(['LGL01', 'MCQ01']);
return new Response();
});
// THEN
expect($response->getStatusCode())->toBe(200);
});
it('bypasses non-api routes', 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
expect($req->query('siteIds'))->toBe('SLELA');
return new Response();
});
// THEN
expect($response->getStatusCode())->toBe(200);
});
});