Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/Core/Router/SimpleRouter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -78,6 +79,7 @@ protected function compareUrl(Endpoint $endpoint, ApiRequest $request): ?ApiRequ
$url = '/' . trim($url, '/');

// Try to match against the pattern
/** @var array<string, string>|null $match */
$match = Regex::match($url, $endpoint->getPattern());

// Skip if there's no match
Expand All @@ -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
Expand Down
187 changes: 187 additions & 0 deletions src/Core/Utils/NestedParameterResolver.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
<?php declare(strict_types = 1);

namespace Apitte\Core\Utils;

/**
* Utility class for resolving nested parameter names like JSON:API style
* parameters (e.g., page[number], filter[status], page:number).
*/
final class NestedParameterResolver
{

/**
* Parse a parameter name into path segments.
* Supports bracket notation (page[number]) and colon notation (page:number).
*
* @return string[]
*/
public static function parsePath(string $name): array
{
// If the name contains brackets, parse bracket notation
if (str_contains($name, '[')) {
return self::parseBracketNotation($name);
}

// If the name contains colons, parse colon notation
if (str_contains($name, ':')) {
return self::parseColonNotation($name);
}

// Simple parameter name
return [$name];
}

/**
* Parse bracket notation like "page[number]" or "filter[status][]"
*
* @return string[]
*/
private static function parseBracketNotation(string $name): array
{
$segments = [];

// Match the first part (before first bracket) and all bracket contents
/** @var array<int, array<int, string>>|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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $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<string, mixed> $data
* @return array<string, mixed>
*/
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<string, mixed> $data
* @param string[] $path
* @return array<string, mixed>
*/
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;
}

}
136 changes: 136 additions & 0 deletions tests/Cases/Core/Router/SimpleRouter.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -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]']);
});
Loading
Loading