diff --git a/.gitignore b/.gitignore index 002c90b..8b66452 100755 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /vendor/ /.idea/ -*.cache \ No newline at end of file +*.cache + +/benchmarking \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 905bd06..e0f397d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,11 +11,11 @@ RUN composer install --ignore-platform-reqs --optimize-autoloader \ --no-plugins --no-scripts --prefer-dist \ `if [ "$TESTING" != "true" ]; then echo "--no-dev"; fi` -FROM php:8.1-cli-alpine as final +FROM php:8.3-cli-alpine as final LABEL maintainer="team@appwrite.io" ENV DEBIAN_FRONTEND=noninteractive \ - PHP_VERSION=82 + PHP_VERSION=83 RUN \ apk add --no-cache --virtual .deps \ diff --git a/composer.json b/composer.json index 1e95f27..2953b93 100644 --- a/composer.json +++ b/composer.json @@ -19,10 +19,12 @@ "format": "vendor/bin/pint", "check": "vendor/bin/phpstan analyse -c phpstan.neon --memory-limit 512M", "test": "vendor/bin/phpunit --configuration phpunit.xml", + "test:unit": "vendor/bin/phpunit tests/ --exclude-group e2e", + "test:e2e": "php tests/e2e/runner.php", "bench": "vendor/bin/phpbench run --report=benchmark" }, "require": { - "php": ">=8.3", + "php": ">=8.3.0", "utopia-php/compression": "0.1.*", "utopia-php/telemetry": "0.1.*", "utopia-php/validators": "0.1.*" diff --git a/src/App.php b/src/App.php index aebfe23..90df1d5 100755 --- a/src/App.php +++ b/src/App.php @@ -6,10 +6,22 @@ use Utopia\Telemetry\Adapter\None as NoTelemetry; use Utopia\Telemetry\Histogram; use Utopia\Telemetry\UpDownCounter; +use Utopia\Validator\ArrayList; +use Utopia\Validator\Boolean; +use Utopia\Validator\FloatValidator; +use Utopia\Validator\Integer; +use Utopia\Validator\Text; class App { - public const COMPRESSION_MIN_SIZE_DEFAULT = 1024; + /** + * Hook type constants + */ + public const string HOOK_INIT = 'init'; + public const string HOOK_SHUTDOWN = 'shutdown'; + public const string HOOK_ERRORS = 'errors'; + + public const int COMPRESSION_MIN_SIZE_DEFAULT = 1024; /** * Request method constants @@ -83,6 +95,13 @@ class App */ protected static array $shutdown = []; + /** + * @var array> + */ + protected static array $indexedHooks = []; + + protected static bool $hooksIndexed = false; + /** * Options * @@ -278,8 +297,12 @@ public static function init(): Hook { $hook = new Hook(); $hook->groups(['*']); + $hook->onGroupsUpdated(function () { + self::$hooksIndexed = false; + }); self::$init[] = $hook; + self::$hooksIndexed = false; return $hook; } @@ -295,8 +318,12 @@ public static function shutdown(): Hook { $hook = new Hook(); $hook->groups(['*']); + $hook->onGroupsUpdated(function () { + self::$hooksIndexed = false; + }); self::$shutdown[] = $hook; + self::$hooksIndexed = false; return $hook; } @@ -329,8 +356,12 @@ public static function error(): Hook { $hook = new Hook(); $hook->groups(['*']); + $hook->onGroupsUpdated(function () { + self::$hooksIndexed = false; + }); self::$errors[] = $hook; + self::$hooksIndexed = false; return $hook; } @@ -582,6 +613,11 @@ public function match(Request $request, bool $fresh = false): ?Route */ public function execute(Route $route, Request $request, Response $response): static { + if (!self::$hooksIndexed) { + $this->buildHookIndexes(); + self::$hooksIndexed = true; + } + $arguments = []; $groups = $route->getGroups(); @@ -590,8 +626,8 @@ public function execute(Route $route, Request $request, Response $response): sta try { if ($route->getHook()) { - foreach (self::$init as $hook) { // Global init hooks - if (in_array('*', $hook->getGroups())) { + if (isset(self::$indexedHooks[self::HOOK_INIT]['*'])) { + foreach (self::$indexedHooks[self::HOOK_INIT]['*'] as $hook) { $arguments = $this->getArguments($hook, $pathValues, $request->getParams()); \call_user_func_array($hook->getAction(), $arguments); } @@ -599,8 +635,8 @@ public function execute(Route $route, Request $request, Response $response): sta } foreach ($groups as $group) { - foreach (self::$init as $hook) { // Group init hooks - if (in_array($group, $hook->getGroups())) { + if (isset(self::$indexedHooks[self::HOOK_INIT][$group])) { + foreach (self::$indexedHooks[self::HOOK_INIT][$group] as $hook) { $arguments = $this->getArguments($hook, $pathValues, $request->getParams()); \call_user_func_array($hook->getAction(), $arguments); } @@ -616,8 +652,8 @@ public function execute(Route $route, Request $request, Response $response): sta foreach ($groups as $group) { - foreach (self::$shutdown as $hook) { // Group shutdown hooks - if (in_array($group, $hook->getGroups())) { + if (isset(self::$indexedHooks[self::HOOK_SHUTDOWN][$group])) { + foreach (self::$indexedHooks[self::HOOK_SHUTDOWN][$group] as $hook) { $arguments = $this->getArguments($hook, $pathValues, $request->getParams()); \call_user_func_array($hook->getAction(), $arguments); } @@ -625,8 +661,8 @@ public function execute(Route $route, Request $request, Response $response): sta } if ($route->getHook()) { - foreach (self::$shutdown as $hook) { // Group shutdown hooks - if (in_array('*', $hook->getGroups())) { + if (isset(self::$indexedHooks[self::HOOK_SHUTDOWN]['*'])) { + foreach (self::$indexedHooks[self::HOOK_SHUTDOWN]['*'] as $hook) { $arguments = $this->getArguments($hook, $pathValues, $request->getParams()); \call_user_func_array($hook->getAction(), $arguments); } @@ -636,8 +672,8 @@ public function execute(Route $route, Request $request, Response $response): sta self::setResource('error', fn () => $e); foreach ($groups as $group) { - foreach (self::$errors as $error) { // Group error hooks - if (in_array($group, $error->getGroups())) { + if (isset(self::$indexedHooks[self::HOOK_ERRORS][$group])) { + foreach (self::$indexedHooks[self::HOOK_ERRORS][$group] as $error) { try { $arguments = $this->getArguments($error, $pathValues, $request->getParams()); \call_user_func_array($error->getAction(), $arguments); @@ -648,8 +684,8 @@ public function execute(Route $route, Request $request, Response $response): sta } } - foreach (self::$errors as $error) { // Global error hooks - if (in_array('*', $error->getGroups())) { + if (isset(self::$indexedHooks[self::HOOK_ERRORS]['*'])) { + foreach (self::$indexedHooks[self::HOOK_ERRORS]['*'] as $error) { try { $arguments = $this->getArguments($error, $pathValues, $request->getParams()); \call_user_func_array($error->getAction(), $arguments); @@ -718,7 +754,7 @@ protected function getArguments(Hook $hook, array $values, array $requestParams) } if (\is_array($value)) { $validator = $param['validator']; - $isArrayList = $validator instanceof \Utopia\Validator\ArrayList; + $isArrayList = $validator instanceof ArrayList; if ($isArrayList) { try { @@ -747,7 +783,26 @@ protected function getArguments(Hook $hook, array $values, array $requestParams) !($param['optional'] && $value === null) && $paramExists ) { - $this->validate($key, $param, $value); + $validator = $this->validate($key, $param, $value); + + if ($existsInRequest && $value !== null) { + if ($validator instanceof Boolean) { + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($value === null) { + throw new Exception('Invalid boolean value for param "' . $key . '"', 400); + } + } elseif ($validator instanceof Integer && \is_string($value)) { + if (\is_numeric($value)) { + $value = (int)$value; + } + } elseif ($validator instanceof FloatValidator && \is_string($value)) { + if (\is_numeric($value)) { + $value = (float)$value; + } + } elseif ($validator instanceof Text && !\is_string($value)) { + $value = (string)$value; + } + } } $hook->setParamValue($key, $value); @@ -766,6 +821,11 @@ protected function getArguments(Hook $hook, array $values, array $requestParams) */ public function run(Request $request, Response $response): static { + if (!self::$hooksIndexed) { + $this->buildHookIndexes(); + self::$hooksIndexed = true; + } + $this->activeRequests->add(1, [ 'http.request.method' => $request->getMethod(), 'url.scheme' => $request->getProtocol(), @@ -933,11 +993,11 @@ private function runInternal(Request $request, Response $response): static * @param string $key * @param array $param * @param mixed $value - * @return void + * @return Validator * * @throws Exception */ - protected function validate(string $key, array $param, mixed $value): void + protected function validate(string $key, array $param, mixed $value): Validator { $validator = $param['validator']; // checking whether the class exists @@ -952,6 +1012,8 @@ protected function validate(string $key, array $param, mixed $value): void if (!$validator->isValid($value)) { throw new Exception('Invalid `' . $key . '` param: ' . $validator->getDescription(), 400); } + + return $validator; } /** @@ -959,6 +1021,33 @@ protected function validate(string $key, array $param, mixed $value): void * * @return void */ + protected function buildHookIndexes(): void + { + self::$indexedHooks = [ + self::HOOK_INIT => [], + self::HOOK_SHUTDOWN => [], + self::HOOK_ERRORS => [] + ]; + + foreach (self::$init as $hook) { + foreach ($hook->getGroups() as $group) { + self::$indexedHooks[self::HOOK_INIT][$group][] = $hook; + } + } + + foreach (self::$shutdown as $hook) { + foreach ($hook->getGroups() as $group) { + self::$indexedHooks[self::HOOK_SHUTDOWN][$group][] = $hook; + } + } + + foreach (self::$errors as $error) { + foreach ($error->getGroups() as $group) { + self::$indexedHooks[self::HOOK_ERRORS][$group][] = $error; + } + } + } + public static function reset(): void { Router::reset(); @@ -968,5 +1057,7 @@ public static function reset(): void self::$init = []; self::$shutdown = []; self::$options = []; + self::$indexedHooks = []; + self::$hooksIndexed = false; } } diff --git a/src/Hook.php b/src/Hook.php index b679b78..35b0d50 100644 --- a/src/Hook.php +++ b/src/Hook.php @@ -81,6 +81,11 @@ public function getDesc(): string return $this->desc; } + /** + * @var callable|null + */ + protected $groupsUpdatedCallback = null; + /** * Add Group * @@ -91,6 +96,23 @@ public function groups(array $groups): static { $this->groups = $groups; + if ($this->groupsUpdatedCallback !== null) { + ($this->groupsUpdatedCallback)(); + } + + return $this; + } + + /** + * Set callback for when groups are updated + * + * @param callable $callback + * @return static + */ + public function onGroupsUpdated(callable $callback): static + { + $this->groupsUpdatedCallback = $callback; + return $this; } diff --git a/src/Request.php b/src/Request.php index 3710ddb..737c268 100755 --- a/src/Request.php +++ b/src/Request.php @@ -667,7 +667,7 @@ protected function generateHeaders(): array $headers = []; foreach ($_SERVER as $name => $value) { - if (\substr($name, 0, 5) == 'HTTP_') { + if (\str_starts_with($name, 'HTTP_')) { $headers[\str_replace(' ', '-', \strtolower(\str_replace('_', ' ', \substr($name, 5))))] = $value; } } diff --git a/src/Response.php b/src/Response.php index 6eb39b1..f0673b8 100755 --- a/src/Response.php +++ b/src/Response.php @@ -706,13 +706,15 @@ public function send(string $body = ''): void $headersSize = 0; foreach ($this->headers as $name => $values) { + $nameLength = \strlen($name) + 2; // ": " + if (\is_array($values)) { foreach ($values as $value) { - $headersSize += \strlen($name . ': ' . $value); + $headersSize += $nameLength + \strlen($value); } $headersSize += (\count($values) - 1) * 2; // linebreaks } else { - $headersSize += \strlen($name . ': ' . $values); + $headersSize += $nameLength + \strlen($values); } } $headersSize += (\count($this->headers) - 1) * 2; // linebreaks diff --git a/src/Router.php b/src/Router.php index eb2cea8..45eeddf 100644 --- a/src/Router.php +++ b/src/Router.php @@ -6,17 +6,13 @@ class Router { - /** - * Placeholder token for params in paths. - */ - public const PLACEHOLDER_TOKEN = ':::'; - public const WILDCARD_TOKEN = '*'; + public const string WILDCARD_TOKEN = '*'; + public const string PLACEHOLDER_TOKEN = ':::'; + public const int ROUTE_MATCH_CACHE_LIMIT = 10_000; protected static bool $allowOverride = false; - /** - * @var array - */ + /** @var array */ protected static array $routes = [ App::REQUEST_METHOD_GET => [], App::REQUEST_METHOD_POST => [], @@ -25,18 +21,18 @@ class Router App::REQUEST_METHOD_DELETE => [], ]; - /** - * Contains the positions of all params in the paths of all registered Routes. - * - * @var array - */ + /** @var array */ protected static array $params = []; - /** - * Get all registered routes. - * - * @return array - */ + /** @var array */ + protected static array $paramsIndex = []; + + /** @var array */ + protected static array $tries = []; + + /** @var array */ + protected static array $matchCache = []; + public static function getRoutes(): array { return self::$routes; @@ -69,117 +65,124 @@ public static function setAllowOverride(bool $value): void /** * Add route to router. * - * @param \Utopia\Route $route + * @param Route $route * @return void - * @throws \Exception + * @throws Exception */ public static function addRoute(Route $route): void { - [$path, $params] = self::preparePath($route->getPath()); - if (!array_key_exists($route->getMethod(), self::$routes)) { throw new Exception("Method ({$route->getMethod()}) not supported."); } - if (array_key_exists($path, self::$routes[$route->getMethod()]) && !self::$allowOverride) { - throw new Exception("Route for ({$route->getMethod()}:{$path}) already registered."); - } - - foreach ($params as $key => $index) { - $route->setPathParam($key, $index, $path); - } - - self::$routes[$route->getMethod()][$path] = $route; + [$path, $params] = self::preparePath($route->getPath()); + self::registerRoute($route, $route->getPath(), $path, $params); } /** - * Add route to router. - * - * @param \Utopia\Route $route - * @return void - * @throws \Exception + * @throws Exception */ public static function addRouteAlias(string $path, Route $route): void { + if (!array_key_exists($route->getMethod(), self::$routes)) { + throw new Exception("Method ({$route->getMethod()}) not supported."); + } + [$alias, $params] = self::preparePath($path); + self::registerRoute($route, $path, $alias, $params); + } - if (array_key_exists($alias, self::$routes[$route->getMethod()]) && !self::$allowOverride) { - throw new Exception("Route for ({$route->getMethod()}:{$alias}) already registered."); + /** + * @throws Exception + */ + protected static function registerRoute(Route $route, string $originalPath, string $pattern, array $params): void + { + if (isset(self::$routes[$route->getMethod()][$pattern]) && !self::$allowOverride) { + throw new Exception("Route for ({$route->getMethod()}:$pattern) already registered."); } foreach ($params as $key => $index) { - $route->setPathParam($key, $index, $alias); + $route->setPathParam($key, $index, $pattern); } - self::$routes[$route->getMethod()][$alias] = $route; - } + self::$routes[$route->getMethod()][$pattern] = $route; - /** - * Match route against the method and path. - * - * @param string $method - * @param string $path - * @return \Utopia\Route|null - */ + if (!isset(self::$tries[$route->getMethod()])) { + self::$tries[$route->getMethod()] = new RouterTrie(); + } + + if (!str_contains($originalPath, self::WILDCARD_TOKEN)) { + $segments = array_values(array_filter(explode('/', $originalPath))); + self::$tries[$route->getMethod()]->insert($segments, $route, $pattern); + } + + self::$matchCache = []; + } public static function match(string $method, string $path): Route|null { if (!array_key_exists($method, self::$routes)) { return null; } - $parts = array_values(array_filter(explode('/', $path))); - $length = count($parts) - 1; - $filteredParams = array_filter(self::$params, fn ($i) => $i <= $length); - - foreach (self::combinations($filteredParams) as $sample) { - $sample = array_filter($sample, fn (int $i) => $i <= $length); - $match = implode( - '/', - array_replace( - $parts, - array_fill_keys($sample, self::PLACEHOLDER_TOKEN) - ) - ); + $cacheKey = $method . ':' . $path; + if (array_key_exists($cacheKey, self::$matchCache)) { + $cached = self::$matchCache[$cacheKey]; + + unset(self::$matchCache[$cacheKey]); + self::$matchCache[$cacheKey] = $cached; + + if ($cached === false) { + return null; + } + + $cached['route']->setMatchedPath($cached['pattern']); + return $cached['route']; + } + + $segments = array_values(array_filter(explode('/', $path))); + + if (isset(self::$tries[$method])) { + $result = self::$tries[$method]->match($segments); + + if ($result['route'] !== null && $result['pattern'] !== null) { + $route = $result['route']; + $route->setMatchedPath($result['pattern']); + self::cacheResult($cacheKey, $route, $result['pattern']); + return $route; + } + } + for ($i = count($segments); $i > 0; $i--) { + $current = implode('/', array_slice($segments, 0, $i)) . '/'; + $match = $current . self::WILDCARD_TOKEN; if (array_key_exists($match, self::$routes[$method])) { $route = self::$routes[$method][$match]; $route->setMatchedPath($match); + self::cacheResult($cacheKey, $route, $match); return $route; } } - /** - * Match root wildcard. - */ $match = self::WILDCARD_TOKEN; if (array_key_exists($match, self::$routes[$method])) { $route = self::$routes[$method][$match]; $route->setMatchedPath($match); + self::cacheResult($cacheKey, $route, $match); return $route; } - /** - * Match wildcard for path segments. - */ - foreach ($parts as $part) { - $current = ($current ?? '') . "{$part}/"; - $match = $current . self::WILDCARD_TOKEN; - if (array_key_exists($match, self::$routes[$method])) { - $route = self::$routes[$method][$match]; - $route->setMatchedPath($match); - return $route; - } - } - + self::cacheResult($cacheKey, null, null); return null; } - /** - * Get all combinations of the given set. - * - * @param array $set - * @return iterable - */ + protected static function cacheResult(string $cacheKey, ?Route $route, ?string $pattern): void + { + if (count(self::$matchCache) >= self::ROUTE_MATCH_CACHE_LIMIT) { + unset(self::$matchCache[array_key_first(self::$matchCache)]); + } + self::$matchCache[$cacheKey] = $route ? ['route' => $route, 'pattern' => $pattern] : false; + } + protected static function combinations(array $set): iterable { yield []; @@ -195,46 +198,32 @@ protected static function combinations(array $set): iterable } } } - - /** - * Prepare path for matching - * - * @param string $path - * @return array - */ public static function preparePath(string $path): array { - $parts = array_values(array_filter(explode('/', $path))); - $prepare = ''; $params = []; + $prepared = []; + $parts = array_values(array_filter(explode('/', $path))); foreach ($parts as $key => $part) { - if ($key !== 0) { - $prepare .= '/'; - } - if (str_starts_with($part, ':')) { - $prepare .= self::PLACEHOLDER_TOKEN; - $params[ltrim($part, ':')] = $key; - if (!in_array($key, self::$params)) { + $prepared[] = self::PLACEHOLDER_TOKEN; + $params[substr($part, 1)] = $key; + if (!isset(self::$paramsIndex[$key])) { self::$params[] = $key; + self::$paramsIndex[$key] = true; } } else { - $prepare .= $part; + $prepared[] = $part; } } - return [$prepare, $params]; + return [implode('/', $prepared), $params]; } - /** - * Reset router - * - * @return void - */ public static function reset(): void { self::$params = []; + self::$paramsIndex = []; self::$routes = [ App::REQUEST_METHOD_GET => [], App::REQUEST_METHOD_POST => [], @@ -242,5 +231,7 @@ public static function reset(): void App::REQUEST_METHOD_PATCH => [], App::REQUEST_METHOD_DELETE => [], ]; + self::$tries = []; + self::$matchCache = []; } } diff --git a/src/RouterTrie.php b/src/RouterTrie.php new file mode 100644 index 0000000..eb4e895 --- /dev/null +++ b/src/RouterTrie.php @@ -0,0 +1,114 @@ +children[$key])) { + $node->children[$key] = new self(); + $node->children[$key]->segment = $segment; + } + + $node = $node->children[$key]; + } + + $node->route = $route; + $node->matchedPattern = $matchedPattern; + } + + /** + * @return array{route:Route|null,pattern:string|null} + */ + public function match(array $segments): array + { + $result = $this->matchRecursive($segments, 0); + return [ + 'route' => $result['route'], + 'pattern' => $result['pattern'] + ]; + } + + /** + * @return array{route:Route|null,pattern:string|null} + */ + private function matchRecursive(array $segments, int $index): array + { + if ($index >= count($segments)) { + return [ + 'route' => $this->route, + 'pattern' => $this->matchedPattern + ]; + } + + $segment = $segments[$index]; + + if (isset($this->children[$segment])) { + $result = $this->children[$segment]->matchRecursive($segments, $index + 1); + if ($result['route'] !== null) { + return $result; + } + } + + if (isset($this->children[':'])) { + $result = $this->children[':']->matchRecursive($segments, $index + 1); + if ($result['route'] !== null) { + return $result; + } + } + + return ['route' => null, 'pattern' => null]; + } + + /** + * Get trie statistics for debugging + * + * @return array Statistics about the trie structure + */ + public function getStats(): array + { + $nodes = 0; + $maxDepth = 0; + $routes = 0; + + $this->collectStats($nodes, $maxDepth, $routes, 0); + + return [ + 'total_nodes' => $nodes, + 'max_depth' => $maxDepth, + 'total_routes' => $routes, + ]; + } + + /** + * Collect statistics recursively + */ + private function collectStats(int &$nodes, int &$maxDepth, int &$routes, int $currentDepth): void + { + $nodes++; + $maxDepth = max($maxDepth, $currentDepth); + + if ($this->route !== null) { + $routes++; + } + + foreach ($this->children as $child) { + $child->collectStats($nodes, $maxDepth, $routes, $currentDepth + 1); + } + } +} diff --git a/tests/AppTest.php b/tests/AppTest.php index 2c9d136..317039e 100755 --- a/tests/AppTest.php +++ b/tests/AppTest.php @@ -151,6 +151,7 @@ public function testCanGetDefaultValueWithFunction(): void echo $x; }); + \ob_start(); $request = new UtopiaRequestTest(); $request::_setParams(['x' => 'count']); $this->app->execute($route, $request, new Response()); diff --git a/tests/RouterTrieTest.php b/tests/RouterTrieTest.php new file mode 100644 index 0000000..edec990 --- /dev/null +++ b/tests/RouterTrieTest.php @@ -0,0 +1,525 @@ +assertEquals($routeExact, $matched); + $this->assertEquals('/users/me', $matched->getPath()); + + $matched = Router::match(App::REQUEST_METHOD_GET, '/users/123'); + $this->assertEquals($routeParam, $matched); + $this->assertEquals('users/:::', $matched->getMatchedPath()); + } + + public function testTrieDeepNesting(): void + { + $route = new Route(App::REQUEST_METHOD_GET, '/a/:b/c/:d/e/:f/g/:h'); + + Router::addRoute($route); + + $matched = Router::match(App::REQUEST_METHOD_GET, '/a/1/c/2/e/3/g/4'); + $this->assertNotNull($matched); + $this->assertEquals($route, $matched); + $this->assertEquals('a/:::/c/:::/e/:::/g/:::', $matched->getMatchedPath()); + } + + public function testTrieMultipleConsecutiveParams(): void + { + $route = new Route(App::REQUEST_METHOD_GET, '/api/:version/:resource/:id'); + + Router::addRoute($route); + + $matched = Router::match(App::REQUEST_METHOD_GET, '/api/v1/users/123'); + $this->assertNotNull($matched); + $this->assertEquals($route, $matched); + $this->assertEquals('api/:::/:::/:::', $matched->getMatchedPath()); + } + + public function testTriePartialPathRejection(): void + { + $route = new Route(App::REQUEST_METHOD_GET, '/users/:id/posts'); + + Router::addRoute($route); + + $this->assertNull(Router::match(App::REQUEST_METHOD_GET, '/users/123')); + $this->assertNull(Router::match(App::REQUEST_METHOD_GET, '/users')); + + $matched = Router::match(App::REQUEST_METHOD_GET, '/users/123/posts'); + $this->assertNotNull($matched); + $this->assertEquals($route, $matched); + } + + public function testTrieCacheLimitBounded(): void + { + $route = new Route(App::REQUEST_METHOD_GET, '/users/:id'); + Router::addRoute($route); + + for ($i = 0; $i < 11000; $i++) { + Router::match(App::REQUEST_METHOD_GET, "/users/$i"); + } + + $reflection = new ReflectionClass(Router::class); + $property = $reflection->getProperty('matchCache'); + $cache = $property->getValue(); + + $this->assertLessThanOrEqual(10000, count($cache)); + } + + public function testCacheInvalidationOnRouteAdd(): void + { + $route1 = new Route(App::REQUEST_METHOD_GET, '/users/:id'); + Router::addRoute($route1); + + $matched = Router::match(App::REQUEST_METHOD_GET, '/users/123'); + $this->assertEquals($route1, $matched); + + $reflection = new ReflectionClass(Router::class); + $property = $reflection->getProperty('matchCache'); + $cacheBefore = $property->getValue(); + $this->assertGreaterThan(0, count($cacheBefore)); + + $route2 = new Route(App::REQUEST_METHOD_GET, '/posts/:id'); + Router::addRoute($route2); + + $cacheAfter = $property->getValue(); + $this->assertCount(0, $cacheAfter); + + $matched = Router::match(App::REQUEST_METHOD_GET, '/users/123'); + $this->assertEquals($route1, $matched); + } + + public function testNegativeResultCaching(): void + { + $route = new Route(App::REQUEST_METHOD_GET, '/users/:id'); + Router::addRoute($route); + + $this->assertNull(Router::match(App::REQUEST_METHOD_GET, '/nonexistent')); + + $reflection = new ReflectionClass(Router::class); + $property = $reflection->getProperty('matchCache'); + $cache = $property->getValue(); + + $this->assertArrayHasKey('GET:/nonexistent', $cache); + $this->assertFalse($cache['GET:/nonexistent']); + } + + public function testAliasWithDifferentParamStructure(): void + { + $route = new Route(App::REQUEST_METHOD_GET, '/v1/databases/:databaseId/collections'); + Router::addRoute($route); + Router::addRouteAlias('/v1/db/:databaseId/collections', $route); + + $matched1 = Router::match(App::REQUEST_METHOD_GET, '/v1/databases/mydb/collections'); + $this->assertEquals($route, $matched1); + $this->assertEquals('v1/databases/:::/collections', $matched1->getMatchedPath()); + + $matched2 = Router::match(App::REQUEST_METHOD_GET, '/v1/db/mydb/collections'); + $this->assertEquals($route, $matched2); + $this->assertEquals('v1/db/:::/collections', $matched2->getMatchedPath()); + } + + public function testMethodIsolationInCache(): void + { + $getRoute = new Route(App::REQUEST_METHOD_GET, '/users/:id'); + $postRoute = new Route(App::REQUEST_METHOD_POST, '/users/:id'); + + Router::addRoute($getRoute); + Router::addRoute($postRoute); + + $matchedGet = Router::match(App::REQUEST_METHOD_GET, '/users/123'); + $matchedPost = Router::match(App::REQUEST_METHOD_POST, '/users/123'); + + $this->assertEquals($getRoute, $matchedGet); + $this->assertEquals($postRoute, $matchedPost); + + $reflection = new ReflectionClass(Router::class); + $property = $reflection->getProperty('matchCache'); + $cache = $property->getValue(); + + $this->assertArrayHasKey('GET:/users/123', $cache); + $this->assertArrayHasKey('POST:/users/123', $cache); + } + + public function testWildcardAndTrieInteraction(): void + { + $wildcardRoute = new Route(App::REQUEST_METHOD_GET, '/api/*'); + $trieRoute = new Route(App::REQUEST_METHOD_GET, '/api/users/:id'); + + Router::addRoute($wildcardRoute); + Router::addRoute($trieRoute); + + $matched = Router::match(App::REQUEST_METHOD_GET, '/api/users/123'); + $this->assertEquals($trieRoute, $matched); + $this->assertEquals('api/users/:::', $matched->getMatchedPath()); + + $matched = Router::match(App::REQUEST_METHOD_GET, '/api/something/else'); + $this->assertEquals($wildcardRoute, $matched); + $this->assertEquals('api/*', $matched->getMatchedPath()); + } + + public function testEmptyAndRootPathHandling(): void + { + $rootRoute = new Route(App::REQUEST_METHOD_GET, '/'); + + Router::addRoute($rootRoute); + + $matched = Router::match(App::REQUEST_METHOD_GET, '/'); + $this->assertEquals($rootRoute, $matched); + + $matched = Router::match(App::REQUEST_METHOD_GET, ''); + $this->assertEquals($rootRoute, $matched); + } + + public function testMixedStaticAndParamsInPath(): void + { + $route = new Route(App::REQUEST_METHOD_GET, '/api/v1/:resource/items/:id/details'); + + Router::addRoute($route); + + $matched = Router::match(App::REQUEST_METHOD_GET, '/api/v1/users/items/123/details'); + $this->assertNotNull($matched); + $this->assertEquals($route, $matched); + $this->assertEquals('api/v1/:::/items/:::/details', $matched->getMatchedPath()); + } + + public function testLRUCachePromotion(): void + { + $route = new Route(App::REQUEST_METHOD_GET, '/users/:id'); + Router::addRoute($route); + + Router::match(App::REQUEST_METHOD_GET, '/users/1'); + Router::match(App::REQUEST_METHOD_GET, '/users/2'); + Router::match(App::REQUEST_METHOD_GET, '/users/3'); + + Router::match(App::REQUEST_METHOD_GET, '/users/1'); + + $reflection = new ReflectionClass(Router::class); + $property = $reflection->getProperty('matchCache'); + $cache = $property->getValue(); + + $keys = array_keys($cache); + $this->assertEquals('GET:/users/1', end($keys)); + } + + public function testPatternStorageCorrectness(): void + { + $route1 = new Route(App::REQUEST_METHOD_GET, '/users/:userId/posts/:postId'); + $route2 = new Route(App::REQUEST_METHOD_GET, '/users/:id'); + + Router::addRoute($route1); + Router::addRoute($route2); + + $matched = Router::match(App::REQUEST_METHOD_GET, '/users/123/posts/456'); + $this->assertEquals($route1, $matched); + $this->assertEquals('users/:::/posts/:::', $matched->getMatchedPath()); + + $matched = Router::match(App::REQUEST_METHOD_GET, '/users/123'); + $this->assertEquals($route2, $matched); + $this->assertEquals('users/:::', $matched->getMatchedPath()); + } + + public function testVeryLongPath(): void + { + $route = new Route( + App::REQUEST_METHOD_GET, + '/a/:b/c/:d/e/:f/g/:h/i/:j/k/:l/m/:n/o/:p/q/:r/s/:t' + ); + + Router::addRoute($route); + + $matched = Router::match( + App::REQUEST_METHOD_GET, + '/a/1/c/2/e/3/g/4/i/5/k/6/m/7/o/8/q/9/s/10' + ); + $this->assertNotNull($matched); + $this->assertEquals($route, $matched); + $this->assertEquals('a/:::/c/:::/e/:::/g/:::/i/:::/k/:::/m/:::/o/:::/q/:::/s/:::', $matched->getMatchedPath()); + } + + public function testTrailingSlashNormalization(): void + { + $route = new Route(App::REQUEST_METHOD_GET, '/users/:id'); + + Router::addRoute($route); + + $matched = Router::match(App::REQUEST_METHOD_GET, '/users/123/'); + $this->assertNotNull($matched); + $this->assertEquals($route, $matched); + } + + public function testMultipleAliasesForSameRoute(): void + { + $route = new Route(App::REQUEST_METHOD_GET, '/v1/databases/:databaseId'); + Router::addRoute($route); + Router::addRouteAlias('/v1/db/:databaseId', $route); + Router::addRouteAlias('/v1/database/:databaseId', $route); + + $matched1 = Router::match(App::REQUEST_METHOD_GET, '/v1/databases/test'); + $matched2 = Router::match(App::REQUEST_METHOD_GET, '/v1/db/test'); + $matched3 = Router::match(App::REQUEST_METHOD_GET, '/v1/database/test'); + + $this->assertEquals($route, $matched1); + $this->assertEquals($route, $matched2); + $this->assertEquals($route, $matched3); + + $this->assertContains($matched1->getMatchedPath(), ['v1/databases/:::', 'v1/db/:::', 'v1/database/:::']); + $this->assertContains($matched2->getMatchedPath(), ['v1/databases/:::', 'v1/db/:::', 'v1/database/:::']); + $this->assertContains($matched3->getMatchedPath(), ['v1/databases/:::', 'v1/db/:::', 'v1/database/:::']); + } + + public function testAllowOverrideInvalidatesCacheAndReplacesRoute(): void + { + Router::setAllowOverride(true); + + $route1 = new Route(App::REQUEST_METHOD_GET, '/users/:id'); + Router::addRoute($route1); + + $matched = Router::match(App::REQUEST_METHOD_GET, '/users/123'); + $this->assertEquals($route1, $matched); + + $reflection = new ReflectionClass(Router::class); + $property = $reflection->getProperty('matchCache'); + $cacheBefore = $property->getValue(); + $this->assertGreaterThan(0, count($cacheBefore)); + + $route2 = new Route(App::REQUEST_METHOD_GET, '/users/:id'); + Router::addRoute($route2); + + $cacheAfter = $property->getValue(); + $this->assertEquals(0, count($cacheAfter)); + + $matched = Router::match(App::REQUEST_METHOD_GET, '/users/123'); + $this->assertEquals($route2, $matched); + $this->assertNotEquals($route1, $matched); + + Router::setAllowOverride(false); + } + + public function testMethodValidationOnAlias(): void + { + $route = new Route(App::REQUEST_METHOD_GET, '/users/:id'); + Router::addRoute($route); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Method (INVALID) not supported.'); + + $invalidRoute = new Route('INVALID', '/users/:id'); + Router::addRouteAlias('/alias/:id', $invalidRoute); + } + + public function testWildcardPrecedenceRootVsPathSpecific(): void + { + $rootWildcard = new Route(App::REQUEST_METHOD_GET, '*'); + $pathWildcard = new Route(App::REQUEST_METHOD_GET, '/api/*'); + + Router::addRoute($rootWildcard); + Router::addRoute($pathWildcard); + + $matched = Router::match(App::REQUEST_METHOD_GET, '/api/something'); + $this->assertEquals($pathWildcard, $matched); + $this->assertEquals('api/*', $matched->getMatchedPath()); + + $matched = Router::match(App::REQUEST_METHOD_GET, '/other/path'); + $this->assertEquals($rootWildcard, $matched); + $this->assertEquals('*', $matched->getMatchedPath()); + } + + public function testNegativeResultInvalidatedByLaterRouteAdd(): void + { + $route1 = new Route(App::REQUEST_METHOD_GET, '/users/:id'); + Router::addRoute($route1); + + $this->assertNull(Router::match(App::REQUEST_METHOD_GET, '/posts/123')); + + $reflection = new ReflectionClass(Router::class); + $property = $reflection->getProperty('matchCache'); + $cache = $property->getValue(); + $this->assertArrayHasKey('GET:/posts/123', $cache); + $this->assertFalse($cache['GET:/posts/123']); + + $route2 = new Route(App::REQUEST_METHOD_GET, '/posts/:id'); + Router::addRoute($route2); + + $cacheAfter = $property->getValue(); + $this->assertEquals(0, count($cacheAfter)); + + $matched = Router::match(App::REQUEST_METHOD_GET, '/posts/123'); + $this->assertEquals($route2, $matched); + } + + public function testTrieWinsOverWildcardWithTrailingSlash(): void + { + $wildcardRoute = new Route(App::REQUEST_METHOD_GET, '/api/*'); + $trieRoute = new Route(App::REQUEST_METHOD_GET, '/api/users/:id'); + + Router::addRoute($wildcardRoute); + Router::addRoute($trieRoute); + + $matched = Router::match(App::REQUEST_METHOD_GET, '/api/users/123/'); + $this->assertEquals($trieRoute, $matched); + $this->assertEquals('api/users/:::', $matched->getMatchedPath()); + } + + public function testParamExtractionCorrectnessForAliases(): void + { + $route = new Route(App::REQUEST_METHOD_GET, '/v1/databases/:databaseId/collections/:collectionId'); + Router::addRoute($route); + Router::addRouteAlias('/v1/db/:dbId/col/:colId', $route); + + $matched1 = Router::match(App::REQUEST_METHOD_GET, '/v1/databases/mydb/collections/mycol'); + $this->assertEquals($route, $matched1); + $this->assertEquals('v1/databases/:::/collections/:::', $matched1->getMatchedPath()); + + $reflection = new ReflectionClass(Route::class); + $property = $reflection->getProperty('pathParams'); + $pathParams = $property->getValue($matched1); + + $this->assertArrayHasKey('v1/databases/:::/collections/:::', $pathParams); + $this->assertArrayHasKey('databaseId', $pathParams['v1/databases/:::/collections/:::']); + $this->assertArrayHasKey('collectionId', $pathParams['v1/databases/:::/collections/:::']); + $this->assertEquals(2, $pathParams['v1/databases/:::/collections/:::']['databaseId']); + $this->assertEquals(4, $pathParams['v1/databases/:::/collections/:::']['collectionId']); + + $matched2 = Router::match(App::REQUEST_METHOD_GET, '/v1/db/testdb/col/testcol'); + $this->assertEquals($route, $matched2); + $this->assertEquals('v1/db/:::/col/:::', $matched2->getMatchedPath()); + + $this->assertArrayHasKey('v1/db/:::/col/:::', $pathParams); + $this->assertArrayHasKey('dbId', $pathParams['v1/db/:::/col/:::']); + $this->assertArrayHasKey('colId', $pathParams['v1/db/:::/col/:::']); + $this->assertEquals(2, $pathParams['v1/db/:::/col/:::']['dbId']); + $this->assertEquals(4, $pathParams['v1/db/:::/col/:::']['colId']); + } + + public function testActualParamExtractionViaGetPathValues(): void + { + $route = new Route(App::REQUEST_METHOD_GET, '/users/:userId/posts/:postId'); + Router::addRoute($route); + Router::addRouteAlias('/u/:uid/p/:pid', $route); + + $matched1 = Router::match(App::REQUEST_METHOD_GET, '/users/123/posts/456'); + $this->assertEquals($route, $matched1); + + $request1 = new Request(); + $request1->setURI('/users/123/posts/456'); + $params1 = $matched1->getPathValues($request1); + $this->assertArrayHasKey('userId', $params1); + $this->assertArrayHasKey('postId', $params1); + $this->assertEquals('123', $params1['userId']); + $this->assertEquals('456', $params1['postId']); + + $matched2 = Router::match(App::REQUEST_METHOD_GET, '/u/abc/p/xyz'); + $this->assertEquals($route, $matched2); + + $request2 = new Request(); + $request2->setURI('/u/abc/p/xyz'); + $params2 = $matched2->getPathValues($request2, $matched2->getMatchedPath()); + $this->assertArrayHasKey('uid', $params2); + $this->assertArrayHasKey('pid', $params2); + $this->assertEquals('abc', $params2['uid']); + $this->assertEquals('xyz', $params2['pid']); + } + + public function testLRUEvictionOrder(): void + { + $route = new Route(App::REQUEST_METHOD_GET, '/users/:id'); + Router::addRoute($route); + + for ($i = 0; $i < Router::ROUTE_MATCH_CACHE_LIMIT + 5; $i++) { + Router::match(App::REQUEST_METHOD_GET, "/users/$i"); + } + + $reflection = new ReflectionClass(Router::class); + $property = $reflection->getProperty('matchCache'); + $cache = $property->getValue(); + + $this->assertCount(Router::ROUTE_MATCH_CACHE_LIMIT, $cache); + + $keys = array_keys($cache); + $firstKey = $keys[0]; + $lastKey = end($keys); + + $this->assertEquals('GET:/users/5', $firstKey); + $this->assertEquals('GET:/users/' . (Router::ROUTE_MATCH_CACHE_LIMIT + 4), $lastKey); + + Router::match(App::REQUEST_METHOD_GET, '/users/5'); + + $cacheAfterPromotion = $property->getValue(); + $keysAfterPromotion = array_keys($cacheAfterPromotion); + $lastKeyAfterPromotion = end($keysAfterPromotion); + + $this->assertEquals('GET:/users/5', $lastKeyAfterPromotion); + } + + public function testTrieStatsReflectStructure(): void + { + $routes = [ + '/api/v1/users/:id', + '/api/v1/posts/:id', + '/api/v2/users/:id/comments/:commentId', + '/api/v2/posts/:postId/likes/:likeId', + '/api/v3/search/:query', + ]; + + foreach ($routes as $path) { + Router::addRoute(new Route(App::REQUEST_METHOD_GET, $path)); + } + + for ($i = 1; $i <= 10; $i++) { + Router::addRoute(new Route(App::REQUEST_METHOD_POST, "/data/batch/$i/:itemId")); + } + + $reflection = new ReflectionClass(Router::class); + $property = $reflection->getProperty('tries'); + $tries = $property->getValue(); + + $this->assertArrayHasKey(App::REQUEST_METHOD_GET, $tries); + $this->assertArrayHasKey(App::REQUEST_METHOD_POST, $tries); + + $stats = $tries[App::REQUEST_METHOD_GET]->getStats(); + + $this->assertArrayHasKey('total_nodes', $stats); + $this->assertArrayHasKey('max_depth', $stats); + $this->assertArrayHasKey('total_routes', $stats); + + $this->assertEquals(5, $stats['total_routes']); + $this->assertGreaterThan(0, $stats['total_nodes']); + $this->assertGreaterThanOrEqual(5, $stats['max_depth']); + + $postStats = $tries[App::REQUEST_METHOD_POST]->getStats(); + $this->assertEquals(10, $postStats['total_routes']); + $this->assertGreaterThan(0, $postStats['total_nodes']); + } + + public function testInsertWithNullPatternThrowsException(): void + { + $this->expectException(\TypeError::class); + + $trie = new RouterTrie(); + $route = new Route(App::REQUEST_METHOD_GET, '/test'); + + // @phpstan-ignore-next-line - Testing type error with null + $trie->insert(['test'], $route, null); + } +} diff --git a/tests/e2e/ResponseTest.php b/tests/e2e/ResponseTest.php index b51755b..bdd3fb4 100644 --- a/tests/e2e/ResponseTest.php +++ b/tests/e2e/ResponseTest.php @@ -5,6 +5,9 @@ use PHPUnit\Framework\TestCase; use Tests\E2E\Client; +/** + * @group e2e + */ class ResponseTest extends TestCase { protected Client $client; diff --git a/tests/e2e/runner.php b/tests/e2e/runner.php new file mode 100755 index 0000000..48d2e18 --- /dev/null +++ b/tests/e2e/runner.php @@ -0,0 +1,52 @@ +#!/usr/bin/env php +&1'); +} + +function waitForWebServer(int $timeoutSeconds = 15): bool +{ + $iterations = $timeoutSeconds * 2; + + for ($i = 0; $i < $iterations; $i++) { + exec('docker compose exec -T web wget -q -O- http://localhost 2>&1', $output, $exitCode); + if ($exitCode === 0) { + return true; + } + usleep(500000); + } + + return false; +} + +register_shutdown_function(function () { + if (error_get_last() !== null) { + cleanup(); + } +}); + +runCommand('docker compose up -d --build'); + +if (!waitForWebServer()) { + echo "Error: Web server failed to start\n"; + cleanup(); + exit(1); +} + +$exitCode = runCommand('docker compose exec -T web php vendor/bin/phpunit --configuration phpunit.xml', false); + +cleanup(); + +exit($exitCode);