diff --git a/.gitignore b/.gitignore index 856e230..7ee7d73 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ tests/trees /vendor/ +composer.lock diff --git a/src/RouterInterface.php b/src/RouterInterface.php index cd1185c..310bc46 100644 --- a/src/RouterInterface.php +++ b/src/RouterInterface.php @@ -4,6 +4,6 @@ namespace Sharkk\Router; interface RouterInterface { - public function add(string $method, string $route, callable $handler): RouterInterface; - public function lookup(string $method, string $uri): array; + public static function add(array &$routes, string $method, string $route, callable $handler): RouterInterface; + public static function lookup(array &$routes, string $method, string $uri): array; } diff --git a/src/SegmentRouter.php b/src/SegmentRouter.php index 3e304db..fe8bd99 100644 --- a/src/SegmentRouter.php +++ b/src/SegmentRouter.php @@ -2,188 +2,7 @@ namespace Sharkk\Router; -trait SegmentRouterTrait -{ - /** - * Add a route to the route tree. The route must be a URI path, and contain dynamic segments - * using a colon prefix. (:id, :slug, etc) - * - * Example: - * `$r->add('GET', '/posts/:id', function($id) { echo "Viewing post $id"; });` - */ - public function add(string $method, string $route, callable $handler): RouterInterface - { - // Recursively build the node tree - $this->addNode( - $this->routes, - array_map( // Expand the route into segments and make dynamic segments into a common placeholder - fn($segment) => str_starts_with($segment, ':') ? ':x' : $segment, - explode('/', trim($route, '/')) - ), - $method, - $handler - ); - - return $this; - } - - private function addNode(array &$node, array $segments, string $method, callable $handler): void - { - // Base case: if no more segments, add the handler - if (empty($segments)) { - $node[$method] = $handler; - return; - } - - // Get the current segment - $segment = array_shift($segments); - - // Skip empty segments - if ($segment === '') { - $this->addNode($node, $segments, $method, $handler); - return; - } - - // Ensure the segment exists in the node - if (!isset($node[$segment])) { - $node[$segment] = []; - } - - // Recur for the next segment - $this->addNode($node[$segment], $segments, $method, $handler); - } - - /** - * Perform a lookup in the route tree for a given method and URI. Returns an array with a result code, - * a handler if found, and any dynamic parameters. Codes are 200 for success, 404 for not found, and - * 405 for method not allowed. - * - * @return array ['code', 'handler', 'params'] - */ - public function lookup(string $method, string $uri): array - { - // node is a reference to our current location in the node tree - $node = $this->routes; - - // if the URI is just a slash, we can return the handler for the root node - if ($uri === '/') { - return isset($node[$method]) - ? ['code' => 200, 'handler' => $node[$method], 'params' => null] - : ['code' => 405, 'handler' => null, 'params' => null]; - } - - // params will hold any dynamic segments we find - $params = array_reduce( - explode('/', trim($uri, '/')), - function ($carry, $segment) use (&$node) { - if (isset($node[$segment])) { - $node = $node[$segment]; - } elseif (isset($node[':x'])) { - $carry[] = $segment; - $node = $node[':x']; - } else { - throw new \Exception('404'); - } - return $carry; - }, - [] - ); - - // if we found a handler for the method, return it and any params. if not, return a 405 - return isset($node[$method]) - ? [ - 'code' => 200, - 'handler' => $node[$method], - 'params' => $params ?? []] - : [ - 'code' => 405, - 'handler' => null, - 'params' => [] - ]; - } - - /** - * Clear all routes from the router. - */ - public function clear(): RouterInterface - { - $this->routes = []; - return $this; - } - - /** - * Dump the route tree as an array. - */ - public function dump(): array - { - return $this->routes; - } - - /** - * Register a GET route. - */ - public function get(string $route, callable $handler): RouterInterface - { - return $this->add('GET', $route, $handler); - } - - /** - * Register a POST route. - */ - public function post(string $route, callable $handler): RouterInterface - { - return $this->add('POST', $route, $handler); - } - - /** - * Register a PUT route. - */ - public function put(string $route, callable $handler): RouterInterface - { - return $this->add('PUT', $route, $handler); - } - - /** - * Register a PATCH route. - */ - public function patch(string $route, callable $handler): RouterInterface - { - return $this->add('PATCH', $route, $handler); - } - - /** - * Register a DELETE route. - */ - public function delete(string $route, callable $handler): RouterInterface - { - return $this->add('DELETE', $route, $handler); - } - - /** - * Register a HEAD route. - */ - public function head(string $route, callable $handler): RouterInterface - { - return $this->add('HEAD', $route, $handler); - } -} - -interface SegmentRouterInterface extends RouterInterface -{ - //private function addNode(array &$node, array $segments, string $method, callable $handler): void; - public function clear(): RouterInterface; - public function dump(): array; - public function get(string $route, callable $handler): RouterInterface; - public function post(string $route, callable $handler): RouterInterface; - public function put(string $route, callable $handler): RouterInterface; - public function patch(string $route, callable $handler): RouterInterface; - public function delete(string $route, callable $handler): RouterInterface; - public function head(string $route, callable $handler): RouterInterface; -} - class SegmentRouter implements SegmentRouterInterface { - private array $routes = []; - - use SegmentRouterTrait; + use SegmentRouterTrait; } diff --git a/src/SegmentRouterInterface.php b/src/SegmentRouterInterface.php new file mode 100644 index 0000000..4800a6d --- /dev/null +++ b/src/SegmentRouterInterface.php @@ -0,0 +1,16 @@ +add('GET', '/posts/:id', function($id) { echo "Viewing post $id"; });` + */ + public static function add(array &$routes, string $method, string $route, callable $handler): RouterInterface + { + // Recursively build the node tree + self::addNode( + $routes, + array_map( // Expand the route into segments and make dynamic segments into a common placeholder + fn($segment) => str_starts_with($segment, ':') ? ':x' : $segment, + explode('/', trim($route, '/')) + ), + $method, + $handler + ); + + return new SegmentRouter($routes); + } + + private static function addNode(array &$node, array $segments, string $method, callable $handler): void + { + // Base case: if no more segments, add the handler + if (empty($segments)) { + $node[$method] = $handler; + return; + } + + // Get the current segment + $segment = array_shift($segments); + + // Skip empty segments + if ($segment === '') { + self::addNode($node, $segments, $method, $handler); + return; + } + + // Ensure the segment exists in the node + if (!isset($node[$segment])) { + $node[$segment] = []; + } + + // Recur for the next segment + self::addNode($node[$segment], $segments, $method, $handler); + } + + /** + * Perform a lookup in the route tree for a given method and URI. Returns an array with a result code, + * a handler if found, and any dynamic parameters. Codes are 200 for success, 404 for not found, and + * 405 for method not allowed. + * + * @return array ['code', 'handler', 'params'] + */ + public static function lookup(array &$routes, string $method, string $uri): array + { + // node is a reference to our current location in the node tree + $node = $routes; + + // if the URI is just a slash, we can return the handler for the root node + if ($uri === '/') { + return isset($node[$method]) + ? ['code' => 200, 'handler' => $node[$method], 'params' => null] + : ['code' => 405, 'handler' => null, 'params' => null]; + } + + // params will hold any dynamic segments we find + $params = array_reduce( + explode('/', trim($uri, '/')), + function ($carry, $segment) use (&$node) { + if (isset($node[$segment])) { + $node = $node[$segment]; + } elseif (isset($node[':x'])) { + $carry[] = $segment; + $node = $node[':x']; + } else { + throw new \Exception('404'); + } + return $carry; + }, + [] + ); + + // if we found a handler for the method, return it and any params. if not, return a 405 + return isset($node[$method]) + ? [ + 'code' => 200, + 'handler' => $node[$method], + 'params' => $params ?? []] + : [ + 'code' => 405, + 'handler' => null, + 'params' => [] + ]; + } + + /** + * Clear all routes from the router. + */ + public static function clear(array &$routes): RouterInterface + { + $routes = []; + return new SegmentRouter($routes); + } + + /** + * Dump the route tree as an array. + */ + /*public static function dump(array &$routes): array + { + return $routes; + }*/ + + /** + * Register a GET route. + */ + public static function get(array &$routes, string $route, callable $handler): RouterInterface + { + return self::add($routes, 'GET', $route, $handler); + } + + /** + * Register a POST route. + */ + public static function post(array &$routes, string $route, callable $handler): RouterInterface + { + return self::add($routes, 'POST', $route, $handler); + } + + /** + * Register a PUT route. + */ + public static function put(array &$routes, string $route, callable $handler): RouterInterface + { + return self::add($routes, 'PUT', $route, $handler); + } + + /** + * Register a PATCH route. + */ + public static function patch(array &$routes, string $route, callable $handler): RouterInterface + { + return self::add($routes, 'PATCH', $route, $handler); + } + + /** + * Register a DELETE route. + */ + public static function delete(array &$routes, string $route, callable $handler): RouterInterface + { + return self::add($routes, 'DELETE', $route, $handler); + } + + /** + * Register a HEAD route. + */ + public static function head(array &$routes, string $route, callable $handler): RouterInterface + { + return self::add($routes, 'HEAD', $route, $handler); + } +} \ No newline at end of file diff --git a/tests/test.php b/tests/test.php index 1ea63b1..f6c08c0 100644 --- a/tests/test.php +++ b/tests/test.php @@ -2,6 +2,8 @@ require_once 'color.php'; require_once __DIR__ . '/../src/RouterInterface.php'; +require_once __DIR__ . '/../src/SegmentRouterTrait.php'; +require_once __DIR__ . '/../src/SegmentRouterInterface.php'; require_once __DIR__ . '/../src/SegmentRouter.php'; const ROUTES = __DIR__ . '/routes/'; @@ -11,180 +13,181 @@ use Sharkk\Router\SegmentRouter; // if there's a flag, reset the opcache if (in_array('-f', $argv)) { - opcache_reset(); - echoTitle("opcache reset"); + opcache_reset(); + echoTitle("opcache reset"); } // create the trees directory if it doesn't exist if (!is_dir(TREES)) mkdir(TREES); -$r = new SegmentRouter(); +$routes = []; // Blog lookups -$blog = readAndAddRoutes(ROUTES . 'blog.txt', $r); -writeRoutesToFile($r->dump(), TREES . 'blog.txt'); +$blog = readAndAddRoutes(ROUTES . 'blog.txt', $routes); +writeRoutesToFile($routes, TREES . 'blog.txt'); echoTitle("Starting blog lookups"); -runIterations(10000, $r, $blog); -runIterations(100000, $r, $blog); -runIterations(1000000, $r, $blog); +runIterations(10000, $routes, $blog); +runIterations(100000, $routes, $blog); +runIterations(1000000, $routes, $blog); unset($blog); // Github lookups -$r->clear(); -$github = readAndAddRoutes(ROUTES . 'github.txt', $r); -writeRoutesToFile($r->dump(), TREES . 'github.txt'); +SegmentRouter::clear($routes); +$github = readAndAddRoutes(ROUTES . 'github.txt', $routes); +writeRoutesToFile($routes, TREES . 'github.txt'); echoTitle("Starting github lookups"); -runIterations(10000, $r, $github); -runIterations(100000, $r, $github); -runIterations(1000000, $r, $github); +runIterations(10000, $routes, $github); +runIterations(100000, $routes, $github); +runIterations(1000000, $routes, $github); unset($github); // Big lookups -$r->clear(); -$big = readAndAddRoutes(ROUTES . 'big.txt', $r); -writeRoutesToFile($r->dump(), TREES . 'big.txt'); +SegmentRouter::clear($routes); +$big = readAndAddRoutes(ROUTES . 'big.txt', $routes); +writeRoutesToFile($routes, TREES . 'big.txt'); echoTitle("Starting big lookups"); -runIterations(10000, $r, $big); -runIterations(100000, $r, $big); -runIterations(1000000, $r, $big); +runIterations(10000, $routes, $big); +runIterations(100000, $routes, $big); +runIterations(1000000, $routes, $big); unset($big); // Parameter testing -$r->clear(); +SegmentRouter::clear($routes); echoTitle("Testing parameters"); -$routes = [ - ['GET', '/blog/:id', function($id) { - echo $id."\n"; - }], - ['GET', '/blog/:id/:slug', function($id, $slug) { - echo $id . ' - ' . $slug."\n"; - }], - ['GET', '/blog/:id/:slug/:page', function($id, $slug, $page) { - echo $id . ' - ' . $slug . ' - ' . $page."\n"; - }], - ['GET', '/blog/:id/:slug/:page/:extra', function($id, $slug, $page, $extra) { - echo $id . ' - ' . $slug . ' - ' . $page . ' - ' . $extra."\n"; - }], +$testRoutes = [ + ['GET', '/blog/:id', function($id) { + echo $id."\n"; + }], + ['GET', '/blog/:id/:slug', function($id, $slug) { + echo $id . ' - ' . $slug."\n"; + }], + ['GET', '/blog/:id/:slug/:page', function($id, $slug, $page) { + echo $id . ' - ' . $slug . ' - ' . $page."\n"; + }], + ['GET', '/blog/:id/:slug/:page/:extra', function($id, $slug, $page, $extra) { + echo $id . ' - ' . $slug . ' - ' . $page . ' - ' . $extra."\n"; + }], ]; -array_walk($routes, fn($route) => $r->add($route[0], $route[1], $route[2])); +array_walk($testRoutes, fn($route) => SegmentRouter::add($routes, $route[0], $route[1], $route[2])); + for ($i = 0; $i < 10; $i++) { - [$method, $uri] = $routes[array_rand($routes)]; + [$method, $uri] = $testRoutes[array_rand($testRoutes)]; - // Generate some random parameters - $uri = str_replace(':id', rand(1, 100), $uri); - $uri = str_replace(':slug', 'slug-' . rand(1, 100), $uri); - $uri = str_replace(':page', rand(1, 100), $uri); - $uri = str_replace(':extra', 'extra-' . rand(1, 100), $uri); + // Generate some random parameters + $uri = str_replace(':id', rand(1, 100), $uri); + $uri = str_replace(':slug', 'slug-' . rand(1, 100), $uri); + $uri = str_replace(':page', rand(1, 100), $uri); + $uri = str_replace(':extra', 'extra-' . rand(1, 100), $uri); - $res = $r->lookup($method, $uri); - if ($res['code'] !== 200) { - echo "Failed to handle request for $uri - $res\n"; - exit(1); - } - $res['handler'](...$res['params']); + $res = SegmentRouter::lookup($routes, $method, $uri); + if ($res['code'] !== 200) { + echo "Failed to handle request for $uri - $res\n"; + exit(1); + } + $res['handler'](...$res['params']); } -$r->clear(); +SegmentRouter::clear($routes); echoTitle('Testing root node'); -$r->add('GET', '/', function() { - echo "Root node is gtg!\n"; +SegmentRouter::add($routes, 'GET', '/', function() { + echo "Root node is gtg!\n"; }); -$res = $r->lookup('GET', '/'); +$res = SegmentRouter::lookup($routes, 'GET', '/'); if ($res['code'] !== 200) { - echo "Failed to handle request for /\n"; - exit(1); + echo "Failed to handle request for /\n"; + exit(1); } $res['handler'](); echo "\n".Color::blue("āœ”ļø Done!")."\n\n"; function echoTitle(string $title) { - echo "\n"; - echo Color::bold(Color::black("===============================================================")) . "\n"; - echo "\n"; - echo Color::bold(Color::blue($title))."\n"; - echo "\n"; - echo Color::bold(Color::black("===============================================================")) . "\n"; - echo "\n"; + echo "\n"; + echo Color::bold(Color::black("===============================================================")) . "\n"; + echo "\n"; + echo Color::bold(Color::blue($title))."\n"; + echo "\n"; + echo Color::bold(Color::black("===============================================================")) . "\n"; + echo "\n"; } -function readAndAddRoutes(string $file, &$r): array +function readAndAddRoutes(string $file, &$routes): array { - return array_map(function($route) use ($r) { - [$method, $path] = explode(' ', $route); - $path = trim($path); - $r->add($method, $path, fn() => true); - return [$method, $path]; - }, file($file)); + return array_map(function($route) use (&$routes) { + [$method, $path] = explode(' ', $route); + $path = trim($path); + SegmentRouter::add($routes, $method, $path, fn() => true); + return [$method, $path]; + }, file($file)); } -function runIterations(int $iterations, $r, array $routes) { - echo "\nšŸš€ Running $iterations iterations...\n"; +function runIterations(int $iterations, &$routes, array $routesList) { + echo "\nšŸš€ Running $iterations iterations...\n"; - $start = microtime(true); // start the timer - $reqs = 0; // track the timing of lookups - $longest = 0; // track the longest lookup time - $shortest = 0; // track the shortest lookup time - $longestRoute = ''; - $shortestRoute = ''; + $start = microtime(true); // start the timer + $reqs = 0; // track the timing of lookups + $longest = 0; // track the longest lookup time + $shortest = 0; // track the shortest lookup time + $longestRoute = ''; + $shortestRoute = ''; - for ($i = 0; $i < $iterations; $i++) { - // pick a random route from the array - [$method, $path] = $routes[array_rand($routes)]; + for ($i = 0; $i < $iterations; $i++) { + // pick a random route from the array + [$method, $path] = $routesList[array_rand($routesList)]; - // replace all :params/ with random values - $uri = preg_replace_callback('/:(\w+)/', function($matches) { - return rand(1, 100); - }, $path); + // replace all :params/ with random values + $uri = preg_replace_callback('/:(\w+)/', function($matches) { + return rand(1, 100); + }, $path); - $start2 = microtime(true); // start the timer for the lookup + $start2 = microtime(true); // start the timer for the lookup - $res = $r->lookup($method, $uri); // run the lookup + $res = SegmentRouter::lookup($routes, $method, $uri); // run the lookup - $reqs += microtime(true) - $start2; // add this lookup time - if ($shortest == 0 || microtime(true) - $start2 < $shortest) { - $shortest = microtime(true) - $start2; // track the shortest lookup time - $shortestRoute = "$method $uri"; - } - if (microtime(true) - $start2 > $longest) { - $longest = microtime(true) - $start2; // track the longest lookup time - $longestRoute = "$method $uri"; - } + $reqs += microtime(true) - $start2; // add this lookup time + if ($shortest == 0 || microtime(true) - $start2 < $shortest) { + $shortest = microtime(true) - $start2; // track the shortest lookup time + $shortestRoute = "$method $uri"; + } + if (microtime(true) - $start2 > $longest) { + $longest = microtime(true) - $start2; // track the longest lookup time + $longestRoute = "$method $uri"; + } - // if any error was encountered, print it and exit - if ($res['code'] !== 200) { - echo Color::red("Failed to handle request.\n$method {$res['code']}\n"."ā”œā”€ URI: $uri\n└─ Path: $path\n"); - echo Color::yellow("Completed $i iterations before failure.\n"); - exit(1); - } - } - echo Color::blue("āœ”ļø Done!")."\n"; + // if any error was encountered, print it and exit + if ($res['code'] !== 200) { + echo Color::red("Failed to handle request.\n$method {$res['code']}\n"."ā”œā”€ URI: $uri\n└─ Path: $path\n"); + echo Color::yellow("Completed $i iterations before failure.\n"); + exit(1); + } + } + echo Color::blue("āœ”ļø Done!")."\n"; - // echo peak memory usage - echo "Peak memory: " . Color::magenta(round(memory_get_peak_usage() / 1024, 1) . " kb\n"); + // echo peak memory usage + echo "Peak memory: " . Color::magenta(round(memory_get_peak_usage() / 1024, 1) . " kb\n"); - // total time used for this run - echo "Time: " . Color::cyan(number_format(microtime(true) - $start, 10) . " s\n"); + // total time used for this run + echo "Time: " . Color::cyan(number_format(microtime(true) - $start, 10) . " s\n"); - // echo the average time per request - echo "Avg/lookup: " . Color::yellow(number_format($reqs / $iterations, 10) . " s\n"); + // echo the average time per request + echo "Avg/lookup: " . Color::yellow(number_format($reqs / $iterations, 10) . " s\n"); - // echo the shortest lookup time - echo "Shortest lookup: " . Color::green(number_format($shortest, 10) . " s\n"); + // echo the shortest lookup time + echo "Shortest lookup: " . Color::green(number_format($shortest, 10) . " s\n"); - // echo the longest lookup time - echo "Longest lookup: " . Color::red(number_format($longest, 10) . " s\n"); + // echo the longest lookup time + echo "Longest lookup: " . Color::red(number_format($longest, 10) . " s\n"); - echo Color::black("==============================================") . "\n"; + echo Color::black("==============================================") . "\n"; - // echo the longest and shortest routes - echo "Shortest route: " . Color::green($shortestRoute) . "\n"; - echo "Longest route: " . Color::red($longestRoute) . "\n"; + // echo the longest and shortest routes + echo "Shortest route: " . Color::green($shortestRoute) . "\n"; + echo "Longest route: " . Color::red($longestRoute) . "\n"; } // take a route tree (array) and store it in a file to be read @@ -194,8 +197,8 @@ function writeRoutesToFile(array $routes, string $file) { $fp = fopen($file, 'w'); - // write a / to the first line of the file - fwrite($fp, "/\n"); + // write a / to the first line of the file + fwrite($fp, "/\n"); // Start writing from the root level with an indentation of 0 and no prefix writeNode($routes, 0, '', $fp); @@ -204,23 +207,23 @@ function writeRoutesToFile(array $routes, string $file) { } function writeNode($node, $indent, $prefix, $fp) { - $totalItems = count($node); - $currentItem = 0; + $totalItems = count($node); + $currentItem = 0; - foreach ($node as $key => $value) { - $currentItem++; - $isLastChild = ($currentItem === $totalItems); - $connector = $isLastChild ? '└── ' : 'ā”œā”€ā”€ '; + foreach ($node as $key => $value) { + $currentItem++; + $isLastChild = ($currentItem === $totalItems); + $connector = $isLastChild ? '└── ' : 'ā”œā”€ā”€ '; - $key = empty($key) ? '/' : $key; + $key = empty($key) ? '/' : $key; - // Write the current node's key with the tree symbol - fwrite($fp, $prefix . $connector . $key . "\n"); + // Write the current node's key with the tree symbol + fwrite($fp, $prefix . $connector . $key . "\n"); - // If the value is an array, it represents a child node, so recurse - if (is_array($value)) { - $newPrefix = $prefix . ($isLastChild ? ' ' : '│ '); - writeNode($value, $indent + 1, $newPrefix, $fp); - } - } + // If the value is an array, it represents a child node, so recurse + if (is_array($value)) { + $newPrefix = $prefix . ($isLastChild ? ' ' : '│ '); + writeNode($value, $indent + 1, $newPrefix, $fp); + } + } }