diff --git a/bootstrap/middlewares.php b/bootstrap/middlewares.php index 5b7fb89..00a0dd9 100644 --- a/bootstrap/middlewares.php +++ b/bootstrap/middlewares.php @@ -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, ]); diff --git a/src/Presentation/Api/V1/Http/Middlewares/NormalizeQueryStringArrays.php b/src/Presentation/Api/V1/Http/Middlewares/NormalizeQueryStringArrays.php new file mode 100644 index 0000000..d64abf3 --- /dev/null +++ b/src/Presentation/Api/V1/Http/Middlewares/NormalizeQueryStringArrays.php @@ -0,0 +1,103 @@ + + */ + 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 + */ + 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; + } +} diff --git a/tests/Feature/Api/V1/Middlewares/NormalizeQueryStringArraysTest.php b/tests/Feature/Api/V1/Middlewares/NormalizeQueryStringArraysTest.php new file mode 100644 index 0000000..181d12a --- /dev/null +++ b/tests/Feature/Api/V1/Middlewares/NormalizeQueryStringArraysTest.php @@ -0,0 +1,92 @@ +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); + }); +});