feat(api): add NormalizeQueryStringArrays middleware to support RFC and bracket query array formats
This commit is contained in:
parent
86584c5b94
commit
00aff9bf63
3 changed files with 198 additions and 0 deletions
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue