From 56fc071506667222fab0a8a8f2362289cd56052b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 15 Dec 2025 16:53:08 +0000 Subject: [PATCH] Add JSON:API style request parameter support (fixes #218) Implement support for JSON:API style nested query parameters using bracket notation (page[number], filter[status]) and colon notation (page:number). Changes: - Add NestedParameterResolver utility class for parsing nested parameter names and accessing values from nested arrays - Update SimpleRouter to use NestedParameterResolver for query param extraction, enabling JSON:API style parameters - Add comprehensive tests for both the utility class and router - Add example JsonApiPaginationController demonstrating the feature Example usage: #[RequestParameter(name: 'page[number]', type: 'int', in: 'query')] #[RequestParameter(name: 'filter[status]', type: 'string', in: 'query')] --- src/Core/Router/SimpleRouter.php | 8 +- src/Core/Utils/NestedParameterResolver.php | 187 ++++++++++++++++++ tests/Cases/Core/Router/SimpleRouter.phpt | 136 +++++++++++++ .../Core/Utils/NestedParameterResolver.phpt | 122 ++++++++++++ .../JsonApi/JsonApiPaginationController.php | 66 +++++++ 5 files changed, 517 insertions(+), 2 deletions(-) create mode 100644 src/Core/Utils/NestedParameterResolver.php create mode 100644 tests/Cases/Core/Utils/NestedParameterResolver.phpt create mode 100644 tests/Fixtures/Controllers/JsonApi/JsonApiPaginationController.php diff --git a/src/Core/Router/SimpleRouter.php b/src/Core/Router/SimpleRouter.php index e13f5ba..a512056 100644 --- a/src/Core/Router/SimpleRouter.php +++ b/src/Core/Router/SimpleRouter.php @@ -8,6 +8,7 @@ use Apitte\Core\Schema\Endpoint; use Apitte\Core\Schema\EndpointParameter; use Apitte\Core\Schema\Schema; +use Apitte\Core\Utils\NestedParameterResolver; use Apitte\Core\Utils\Regex; class SimpleRouter implements IRouter @@ -78,6 +79,7 @@ protected function compareUrl(Endpoint $endpoint, ApiRequest $request): ?ApiRequ $url = '/' . trim($url, '/'); // Try to match against the pattern + /** @var array|null $match */ $match = Regex::match($url, $endpoint->getPattern()); // Skip if there's no match @@ -90,14 +92,16 @@ protected function compareUrl(Endpoint $endpoint, ApiRequest $request): ?ApiRequ // Fill path parameters with matched variables foreach ($endpoint->getParametersByIn(EndpointParameter::IN_PATH) as $param) { $name = $param->getName(); - $parameters[$name] = $match[$name]; + $parameters[$name] = $match[$name] ?? null; } // Fill query parameters with query params + // Supports JSON:API style nested parameters (e.g., page[number], filter[status], page:number) $queryParams = $request->getQueryParams(); foreach ($endpoint->getParametersByIn(EndpointParameter::IN_QUERY) as $param) { $name = $param->getName(); - $parameters[$name] = $queryParams[$name] ?? null; + // Use NestedParameterResolver for bracket/colon notation support + $parameters[$name] = NestedParameterResolver::getValue($queryParams, $name); } // Set attributes to request diff --git a/src/Core/Utils/NestedParameterResolver.php b/src/Core/Utils/NestedParameterResolver.php new file mode 100644 index 0000000..47ca0e9 --- /dev/null +++ b/src/Core/Utils/NestedParameterResolver.php @@ -0,0 +1,187 @@ +>|null $matches */ + $matches = Regex::matchAll($name, '#([^\[\]]+)|\[([^\[\]]*)\]#'); + + if ($matches === null) { + return [$name]; + } + + foreach ($matches as $match) { + // Either the non-bracket part or the bracket content + $segment = ($match[1] ?? '') !== '' ? $match[1] : ($match[2] ?? ''); + if ($segment !== '') { + $segments[] = $segment; + } + } + + return $segments !== [] ? $segments : [$name]; + } + + /** + * Parse colon notation like "page:number" + * + * @return string[] + */ + private static function parseColonNotation(string $name): array + { + return explode(':', $name); + } + + /** + * Check if a parameter name is nested (uses bracket or colon notation). + */ + public static function isNested(string $name): bool + { + return str_contains($name, '[') || str_contains($name, ':'); + } + + /** + * Get a value from a nested array using a parameter name path. + * + * @param array $data + */ + public static function getValue(array $data, string $name, mixed $default = null): mixed + { + $path = self::parsePath($name); + + return self::getValueByPath($data, $path, $default); + } + + /** + * Get a value from a nested array using a path array. + * + * @param array $data + * @param string[] $path + */ + private static function getValueByPath(array $data, array $path, mixed $default = null): mixed + { + $current = $data; + + foreach ($path as $segment) { + if (!is_array($current) || !array_key_exists($segment, $current)) { + return $default; + } + + $current = $current[$segment]; + } + + return $current; + } + + /** + * Check if a value exists at the given parameter name path. + * + * @param array $data + */ + public static function hasValue(array $data, string $name): bool + { + $path = self::parsePath($name); + + return self::hasValueByPath($data, $path); + } + + /** + * Check if a value exists at the given path. + * + * @param array $data + * @param string[] $path + */ + private static function hasValueByPath(array $data, array $path): bool + { + $current = $data; + + foreach ($path as $segment) { + if (!is_array($current) || !array_key_exists($segment, $current)) { + return false; + } + + $current = $current[$segment]; + } + + return true; + } + + /** + * Set a value in a nested array using a parameter name path. + * + * @param array $data + * @return array + */ + public static function setValue(array $data, string $name, mixed $value): array + { + $path = self::parsePath($name); + + return self::setValueByPath($data, $path, $value); + } + + /** + * Set a value in a nested array using a path array. + * + * @param array $data + * @param string[] $path + * @return array + */ + private static function setValueByPath(array $data, array $path, mixed $value): array + { + if ($path === []) { + return $data; + } + + $key = array_shift($path); + + if ($path === []) { + $data[$key] = $value; + } else { + if (!isset($data[$key]) || !is_array($data[$key])) { + $data[$key] = []; + } + + $data[$key] = self::setValueByPath($data[$key], $path, $value); + } + + return $data; + } + +} diff --git a/tests/Cases/Core/Router/SimpleRouter.phpt b/tests/Cases/Core/Router/SimpleRouter.phpt index 5e58541..f4330c5 100644 --- a/tests/Cases/Core/Router/SimpleRouter.phpt +++ b/tests/Cases/Core/Router/SimpleRouter.phpt @@ -136,3 +136,139 @@ Toolkit::test(function (): void { Assert::null($matched); }); + +// Match JSON:API style query parameters with bracket notation (page[number], page[size]) +Toolkit::test(function (): void { + $handler = new EndpointHandler('class', 'method'); + + $endpoint = new Endpoint($handler); + $endpoint->addMethod('GET'); + $endpoint->setPattern('#^/users#'); + + // JSON:API style pagination parameters + $pageNumber = new EndpointParameter('page[number]', EndpointParameter::TYPE_STRING); + $pageNumber->setIn(EndpointParameter::IN_QUERY); + $endpoint->addParameter($pageNumber); + + $pageSize = new EndpointParameter('page[size]', EndpointParameter::TYPE_STRING); + $pageSize->setIn(EndpointParameter::IN_QUERY); + $endpoint->addParameter($pageSize); + + $schema = new Schema(); + $schema->addEndpoint($endpoint); + + // Simulate PHP parsing of ?page[number]=5&page[size]=10 + // PHP parses this into nested array: ['page' => ['number' => '5', 'size' => '10']] + $request = Psr7ServerRequestFactory::fromSuperGlobal() + ->withNewUri('http://example.com/users') + ->withQueryParams(['page' => ['number' => '5', 'size' => '10']]); + $request = new ApiRequest($request); + + $router = new SimpleRouter($schema); + $matched = $router->match($request); + + Assert::type($request, $matched); + $params = $matched->getAttribute(RequestAttributes::ATTR_PARAMETERS); + Assert::equal('5', $params['page[number]']); + Assert::equal('10', $params['page[size]']); +}); + +// Match JSON:API style filter parameters (filter[status], filter[user][id]) +Toolkit::test(function (): void { + $handler = new EndpointHandler('class', 'method'); + + $endpoint = new Endpoint($handler); + $endpoint->addMethod('GET'); + $endpoint->setPattern('#^/orders#'); + + // JSON:API style filter parameters + $filterStatus = new EndpointParameter('filter[status]', EndpointParameter::TYPE_STRING); + $filterStatus->setIn(EndpointParameter::IN_QUERY); + $endpoint->addParameter($filterStatus); + + $filterUserId = new EndpointParameter('filter[user][id]', EndpointParameter::TYPE_STRING); + $filterUserId->setIn(EndpointParameter::IN_QUERY); + $endpoint->addParameter($filterUserId); + + $schema = new Schema(); + $schema->addEndpoint($endpoint); + + // Simulate PHP parsing of ?filter[status]=active&filter[user][id]=123 + $request = Psr7ServerRequestFactory::fromSuperGlobal() + ->withNewUri('http://example.com/orders') + ->withQueryParams([ + 'filter' => [ + 'status' => 'active', + 'user' => ['id' => '123'], + ], + ]); + $request = new ApiRequest($request); + + $router = new SimpleRouter($schema); + $matched = $router->match($request); + + Assert::type($request, $matched); + $params = $matched->getAttribute(RequestAttributes::ATTR_PARAMETERS); + Assert::equal('active', $params['filter[status]']); + Assert::equal('123', $params['filter[user][id]']); +}); + +// Match colon notation query parameters (page:number) +Toolkit::test(function (): void { + $handler = new EndpointHandler('class', 'method'); + + $endpoint = new Endpoint($handler); + $endpoint->addMethod('GET'); + $endpoint->setPattern('#^/items#'); + + // Colon notation parameters + $pageNumber = new EndpointParameter('page:number', EndpointParameter::TYPE_STRING); + $pageNumber->setIn(EndpointParameter::IN_QUERY); + $endpoint->addParameter($pageNumber); + + $schema = new Schema(); + $schema->addEndpoint($endpoint); + + // For colon notation, the data is still nested (application-specific parsing) + $request = Psr7ServerRequestFactory::fromSuperGlobal() + ->withNewUri('http://example.com/items') + ->withQueryParams(['page' => ['number' => '3']]); + $request = new ApiRequest($request); + + $router = new SimpleRouter($schema); + $matched = $router->match($request); + + Assert::type($request, $matched); + $params = $matched->getAttribute(RequestAttributes::ATTR_PARAMETERS); + Assert::equal('3', $params['page:number']); +}); + +// Missing optional JSON:API parameter returns null +Toolkit::test(function (): void { + $handler = new EndpointHandler('class', 'method'); + + $endpoint = new Endpoint($handler); + $endpoint->addMethod('GET'); + $endpoint->setPattern('#^/users#'); + + $pageNumber = new EndpointParameter('page[number]', EndpointParameter::TYPE_STRING); + $pageNumber->setIn(EndpointParameter::IN_QUERY); + $pageNumber->setRequired(false); + $endpoint->addParameter($pageNumber); + + $schema = new Schema(); + $schema->addEndpoint($endpoint); + + // No query params provided + $request = Psr7ServerRequestFactory::fromSuperGlobal() + ->withNewUri('http://example.com/users') + ->withQueryParams([]); + $request = new ApiRequest($request); + + $router = new SimpleRouter($schema); + $matched = $router->match($request); + + Assert::type($request, $matched); + $params = $matched->getAttribute(RequestAttributes::ATTR_PARAMETERS); + Assert::null($params['page[number]']); +}); diff --git a/tests/Cases/Core/Utils/NestedParameterResolver.phpt b/tests/Cases/Core/Utils/NestedParameterResolver.phpt new file mode 100644 index 0000000..f01f89e --- /dev/null +++ b/tests/Cases/Core/Utils/NestedParameterResolver.phpt @@ -0,0 +1,122 @@ + 'John', 'age' => 30]; + Assert::equal('John', NestedParameterResolver::getValue($data, 'name')); + Assert::equal(30, NestedParameterResolver::getValue($data, 'age')); + Assert::null(NestedParameterResolver::getValue($data, 'missing')); + Assert::equal('default', NestedParameterResolver::getValue($data, 'missing', 'default')); +}); + +// NestedParameterResolver::getValue - bracket notation (JSON:API style) +Toolkit::test(function (): void { + // This is how PHP parses ?page[number]=5&page[size]=10 + $data = [ + 'page' => [ + 'number' => 5, + 'size' => 10, + ], + 'filter' => [ + 'status' => 'active', + 'user' => [ + 'id' => 123, + ], + ], + ]; + + Assert::equal(5, NestedParameterResolver::getValue($data, 'page[number]')); + Assert::equal(10, NestedParameterResolver::getValue($data, 'page[size]')); + Assert::equal('active', NestedParameterResolver::getValue($data, 'filter[status]')); + Assert::equal(123, NestedParameterResolver::getValue($data, 'filter[user][id]')); + Assert::null(NestedParameterResolver::getValue($data, 'page[missing]')); + Assert::null(NestedParameterResolver::getValue($data, 'missing[key]')); +}); + +// NestedParameterResolver::getValue - colon notation +Toolkit::test(function (): void { + $data = [ + 'page' => [ + 'number' => 5, + 'size' => 10, + ], + ]; + + Assert::equal(5, NestedParameterResolver::getValue($data, 'page:number')); + Assert::equal(10, NestedParameterResolver::getValue($data, 'page:size')); +}); + +// NestedParameterResolver::hasValue +Toolkit::test(function (): void { + $data = [ + 'name' => 'John', + 'page' => [ + 'number' => 5, + ], + ]; + + Assert::true(NestedParameterResolver::hasValue($data, 'name')); + Assert::true(NestedParameterResolver::hasValue($data, 'page[number]')); + Assert::true(NestedParameterResolver::hasValue($data, 'page:number')); + Assert::false(NestedParameterResolver::hasValue($data, 'missing')); + Assert::false(NestedParameterResolver::hasValue($data, 'page[size]')); +}); + +// NestedParameterResolver::setValue - simple parameter +Toolkit::test(function (): void { + $data = ['name' => 'John']; + $result = NestedParameterResolver::setValue($data, 'age', 30); + Assert::equal(['name' => 'John', 'age' => 30], $result); +}); + +// NestedParameterResolver::setValue - bracket notation +Toolkit::test(function (): void { + $data = []; + $result = NestedParameterResolver::setValue($data, 'page[number]', 5); + Assert::equal(['page' => ['number' => 5]], $result); + + $result = NestedParameterResolver::setValue($result, 'page[size]', 10); + Assert::equal(['page' => ['number' => 5, 'size' => 10]], $result); +}); + +// NestedParameterResolver::setValue - colon notation +Toolkit::test(function (): void { + $data = []; + $result = NestedParameterResolver::setValue($data, 'page:number', 5); + Assert::equal(['page' => ['number' => 5]], $result); +}); diff --git a/tests/Fixtures/Controllers/JsonApi/JsonApiPaginationController.php b/tests/Fixtures/Controllers/JsonApi/JsonApiPaginationController.php new file mode 100644 index 0000000..425f008 --- /dev/null +++ b/tests/Fixtures/Controllers/JsonApi/JsonApiPaginationController.php @@ -0,0 +1,66 @@ +getParameter('page[number]') + } + + /** + * List articles with JSON:API filtering. + * + * Example request: GET /api/v1/articles/filtered?filter[status]=published&filter[author][id]=123 + */ + #[Path('/filtered')] + #[Method('GET')] + #[RequestParameter(name: 'filter[status]', type: 'string', in: 'query', required: false, description: 'Filter by status')] + #[RequestParameter(name: 'filter[author][id]', type: 'int', in: 'query', required: false, description: 'Filter by author ID')] + public function filtered(): void + { + // Controller implementation + // Access via: $request->getParameter('filter[status]') + // Access via: $request->getParameter('filter[author][id]') + } + + /** + * Alternative colon notation for pagination. + * + * Example request: GET /api/v1/articles/alt?page:number=2&page:size=25 + * Note: Query params must be nested manually or via middleware. + */ + #[Path('/alt')] + #[Method('GET')] + #[RequestParameter(name: 'page:number', type: 'int', in: 'query', required: false, description: 'Page number (colon notation)')] + #[RequestParameter(name: 'page:size', type: 'int', in: 'query', required: false, description: 'Page size (colon notation)')] + public function altPagination(): void + { + // Controller implementation + } + +}