refactor(api): use League URI for RFC query string normalization and nested bracket support
This commit is contained in:
parent
2b2ca42c99
commit
04c39e2573
5 changed files with 219 additions and 63 deletions
|
|
@ -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
150
composer.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue