diff --git a/.gitignore b/.gitignore index cf3b0654..68eee822 100755 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ Thumbs.db .idea/ .phpunit.result.cache .direnv -.envrc \ No newline at end of file +.envrc +.devenv \ No newline at end of file diff --git a/src/Illuminate/CachedRouting/Route.php b/src/Illuminate/CachedRouting/Route.php new file mode 100644 index 00000000..c0870cb4 --- /dev/null +++ b/src/Illuminate/CachedRouting/Route.php @@ -0,0 +1,105 @@ +routingToController($this->action) === true) { + $this->action = $app['router']->makeControllerActionClosure($this->action); + } + + return parent::run(); + } + + /** + * Determines whether routes should be compiled. Caches the value at a class + * level. + * + * @return bool + */ + protected function shouldCompile() + { + if (static::$shouldCompileRoute === null) { + // If the compiled variable exists, we should compile. + static::$shouldCompileRoute = array_key_exists('compiled', get_object_vars($this)); + } + + return static::$shouldCompileRoute; + } + + /** + * Compile the route into a Symfony CompiledRoute instance. + */ + #[\Override] + protected function compileRoute() + { + if ($this->shouldCompile() === true && $this->compiled === null) { + parent::compileRoute(); + } + } + + /** + * Prepare to go to sleep. Compiles the route so that doesn't have to happen + * on every subsequent request. + * + * @return array + */ + public function __sleep() + { + $this->compileRoute(); + + return array_keys(get_object_vars($this)); + } +} diff --git a/src/Illuminate/CachedRouting/RouteCollection.php b/src/Illuminate/CachedRouting/RouteCollection.php new file mode 100644 index 00000000..2f6f4fcd --- /dev/null +++ b/src/Illuminate/CachedRouting/RouteCollection.php @@ -0,0 +1,174 @@ +backup; + } + + /** + * Save the current collection of routes to the backup to start with an empty + * collection to cache. + */ + public function saveRouteCollection() + { + $this->backup = $this->getCacheableRouteContents(); + + foreach (array_keys($this->backup) as $k) { + $this->$k = array(); + } + } + + /** + * Restore the backed up routes. + */ + public function restoreRouteCollection() + { + $this->restoreRoutes($this->getBackup(), true); + } + + /** + * Get the routes in a form that can be saved to the cache. + * + * @return string + */ + public function getCacheableRoutes() + { + return serialize($this->getCacheableRouteContents()); + } + + /** + * Get the data that should be sent to the cache. + * + * @return array + */ + public function getCacheableRouteContents() + { + return array_except(get_object_vars($this), array('backup')); + } + + /** + * Restore a set of cached routes into this collection. + * + * @param string $cache + */ + public function restoreRouteCache($cache) + { + $routes = unserialize($cache); + + $this->restoreRoutes($routes); + } + + /** + * Add a set of routes back into this collection. + * + * @param bool $prepend + * @param array $routes + */ + protected function restoreRoutes($routes, $prepend = false) + { + foreach ($routes as $k => $v) { + if ($k === 'routes') { + $this->$k = $prepend === false ? + $this->mergeGroupedRoutes($this->$k, $v) : + $this->mergeGroupedRoutes($v, $this->$k); + } else { + $this->$k = $prepend === false ? + $this->mergeRoutes($this->$k, $v) : + $this->mergeRoutes($v, $this->$k); + } + } + } + + /** + * Merge two array, each containing routes grouped by method. + * + * @param array + * @param array + * @return array + */ + public function mergeGroupedRoutes(array $r1, array $r2) + { + $methods = array('GET', 'POST', 'HEAD', 'PATH', 'PUT', 'DELETE'); + foreach ($methods as $method) { + if (isset($r2[$method]) === false) { + continue; + } + if (isset($r1[$method]) === false) { + $r1[$method] = array(); + } + + $r1[$method] = $this->mergeRoutes($r1[$method], $r2[$method]); + } + + return $r1; + } + + /** + * Merge two arrays, each containing routes. + * + * @param array + * @param array + * @return array + */ + public function mergeRoutes(array $r1, array $r2) + { + foreach ($r2 as $uri => $route) { + $r1[$uri] = $route; + } + + return $r1; + } +} diff --git a/src/Illuminate/CachedRouting/Router.php b/src/Illuminate/CachedRouting/Router.php new file mode 100644 index 00000000..0a455603 --- /dev/null +++ b/src/Illuminate/CachedRouting/Router.php @@ -0,0 +1,251 @@ +routes = new RouteCollection; + } + + /** + * Indicate that the routes that are defined in the given callback + * should be cached. + * + * @param string $filename + * @param Closure $callback + * @param int $cacheMinutes + * @return string + */ + public function cache($filename, Closure $callback, $cacheMinutes = 1440) + { + // If $cacheMinutes is 0 or lower, there is no need to cache anything. + if ($cacheMinutes <= 0) { + // Call closure to define routes that should be cached. + call_user_func($callback, $this); + + // No cache key. + return null; + } + + $cacher = $this->container['cache']; + $cacheKey = $this->getCacheKey($filename); + + // Check if the current route group is cached. + if (($cache = $cacher->get($cacheKey)) !== null) { + $this->routes->restoreRouteCache($cache); + } else { + // Back up current RouteCollection contents. + $this->routes->saveRouteCollection(); + + // Call closure to define routes that should be cached. + call_user_func($callback, $this); + + // Put routes in cache. + $cache = $this->routes->getCacheableRoutes(); + $cacher->put($cacheKey, $cache, $cacheMinutes); + + // And restore the routes that shouldn't be cached. + $this->routes->restoreRouteCollection(); + } + + return $cacheKey; + } + + /** + * Clear the cached data for the given routes file. + * + * @param string $filename + */ + public function clearCache($filename) + { + $cacher = $this->container['cache']; + + $cacher->forget($this->getCacheKey($filename)); + } + + /** + * Get the key under which the routes cache for the given file should be stored. + * + * @param string $filename + * @return string + */ + protected function getCacheKey($filename) + { + return 'routes.cache.'.$this->cacheVersion.'.'.md5($filename).filemtime($filename); + } + + /** + * Determine if the action is routing to a controller. + * + * @param array $action + * @return bool + */ + #[\Override] + public function routingToController($action) + { + return parent::routingToController($action); + } + + /** + * Add a controller based route action to the action array. + * + * @param array|string $action + * @return array + */ + #[\Override] + protected function getControllerAction($action) + { + if (is_string($action) === true) { + $action = array('uses' => $action); + } + + // Here we'll get an instance of this controller dispatcher and hand it off to + // the Closure so it will be used to resolve the class instances out of our + // IoC container instance and call the appropriate methods on the class. + if (count($this->groupStack) > 0) { + $action['uses'] = $this->prependGroupUses($action['uses']); + } + + // Here we'll get an instance of this controller dispatcher and hand it off to + // the Closure so it will be used to resolve the class instances out of our + // IoC container instance and call the appropriate methods on the class. + $action['controller'] = $action['uses']; + + $closure = $action['uses']; + + return array_set($action, 'uses', $closure); + } + + /** + * Replace the string action in the given array with a Closure to call. + * + * @param array $action + * @return array + */ + public function makeControllerActionClosure(array $action) + { + $closure = $this->getClassClosure($action['uses']); + + return array_set($action, 'uses', $closure); + } + + /** + * Create a new route instance. + * + * @param array|string $methods + * @param string $uri + * @param mixed $action + * @return \Illuminate\Routing\Route + */ + #[\Override] + protected function createRoute($methods, $uri, $action) + { + // If the route is routing to a controller we will parse the route action into + // an acceptable array format before registering it and creating this route + // instance itself. We need to build the Closure that will call this out. + if ($this->routingToController($action) === true) { + $action = $this->getControllerAction($action); + } + + $route = $this->newRoute( + $methods, + $uri = $this->prefix($uri), + $action + ); + + // If we have groups that need to be merged, we will merge them now after this + // route has already been created and is ready to go. After we're done with + // the merge we will be ready to return the route back out to the caller. + if (empty($this->groupStack) === false) { + $this->mergeController($route); + } + + $this->addWhereClausesToRoute($route); + + return $route; + } + + /** + * Create a new Route object. + * + * @param array|string $methods + * @param string $uri + * @param mixed $action + * @return \Illuminate\Routing\Route + */ + #[\Override] + protected function newRoute($methods, $uri, $action) + { + return new Route($methods, $uri, $action); + } + + /** + * Add the necessary where clauses to the route based on its initial registration. + * + * @param \Illuminate\Routing\Route $route + * @return \Illuminate\Routing\Route + */ + #[\Override] + protected function addWhereClausesToRoute($route) + { + $route->where( + array_merge($this->patterns, array_get($route->getAction(), 'where', array())) + ); + return $route; + } +} diff --git a/src/Illuminate/CachedRouting/RoutingServiceProvider.php b/src/Illuminate/CachedRouting/RoutingServiceProvider.php new file mode 100644 index 00000000..eff880aa --- /dev/null +++ b/src/Illuminate/CachedRouting/RoutingServiceProvider.php @@ -0,0 +1,74 @@ +registerRouter(); + } + + /** + * Register the router instance. + * + * @return void + */ + protected function registerRouter() + { + $this->app['router'] = $this->app->share( + function ($app) { + $router = new Router($app['events'], $app); + + // If the current application environment is "testing", we will disable the + // routing filters, since they can be tested independently of the routes + // and just get in the way of our typical controller testing concerns. + if ($app['env'] === 'testing') { + $router->disableFilters(); + } + + return $router; + } + ); + } +} diff --git a/tests/CachedRouting/RoutingIntegrationTest.php b/tests/CachedRouting/RoutingIntegrationTest.php new file mode 100755 index 00000000..a826358c --- /dev/null +++ b/tests/CachedRouting/RoutingIntegrationTest.php @@ -0,0 +1,382 @@ +app === null) { + $this->refreshApplication(); + } + } + + /** + * Refresh the application instance. + */ + protected function refreshApplication(): void + { + $this->app = $this->createApplication(); + + Facade::setFacadeApplication($this->app); + + $this->app['env'] = 'testing'; + + $this->app['path.storage'] = __DIR__; + + $loader = $this->createMock('Illuminate\Config\LoaderInterface'); + + $loader->method('load')->willReturn([]); + $loader->method('exists')->willReturn(true); + $loader->method('getNamespaces')->willReturn([]); + $loader->method('cascadePackage')->willReturn([]); + + $this->app['config'] = new Repository($loader, $this->app['env']); + + $this->app['cache'] = new CacheManager($this->app); + $this->app['config']['cache.driver'] = 'array'; + + $this->app['session'] = new SessionManager($this->app); + $this->app['config']['session.driver'] = 'array'; + $this->app['session.store'] = $this->app['session']->driver(); + + $this->app->boot(); + } + + /** + * Creates the application. + */ + protected function createApplication(): Application + { + return new Application(); + } + + /** + * Create a router. + */ + protected function getRouter(): Router + { + return new Router($this->app['events'], $this->app); + } + + /** + * Create a new HttpKernel client instance. + */ + protected function createClient(array $server = array()): HttpKernelBrowser + { + return new HttpKernelBrowser($this->app, $server); + } + + public function testCacheRoutes(): void + { + $router = $this->getRouter(); + + $key = $router->cache(__FILE__, function () use ($router) { + $router->get('/', 'HomeController@actionIndex'); + }); + + static::assertTrue($this->app->cache->has($key), 'Routes must be in cache'); + static::assertEquals(1, $router->getRoutes()->count(), 'Routes must be in collection'); + + $cachedRoutes = unserialize($this->app->cache->get($key)); + static::assertArrayHasKey('routes', $cachedRoutes); + static::assertArrayHasKey('GET', $cachedRoutes['routes']); + static::assertCount(1, $cachedRoutes['routes']['GET']); + + // Next request should not call the callback. + $router = $this->getRouter(); + $router->cache(__FILE__, function () use ($router) { + throw new RuntimeException('This should not be called'); + }); + static::assertEquals(1, $router->getRoutes()->count(), 'Routes must be obtained from cache'); + } + + public function testCacheRoutesNoTtl(): void + { + $router = $this->getRouter(); + + $key = $router->cache(__FILE__, function () use ($router) { + $router->get('/', 'HomeController@actionIndex'); + }, 0); + + static::assertNull($key, 'Cache key should be null with TTL=0'); + static::assertFalse($this->app->cache->has($key), 'Key should not be stored in cache'); + static::assertEquals(1, $router->getRoutes()->count(), 'Route must be added to router'); + } + + public function testAllMethodsWorks(): void + { + $methods = ['get', 'post', 'put', 'patch', 'delete']; + + $router = $this->getRouter(); + + $key = $router->cache(__FILE__, function () use ($router, $methods) { + foreach ($methods as $method) { + $router->$method('/', 'HomeController@action' . ucfirst($method)); + } + }); + + static::assertTrue($this->app->cache->has($key), 'Routes must be in cache'); + static::assertEquals(count($methods), $router->getRoutes()->count(), 'Routes must be in collection'); + + $cachedRoutes = unserialize($this->app->cache->get($key)); + static::assertArrayHasKey('routes', $cachedRoutes); + + foreach ($methods as $method) { + static::assertArrayHasKey(strtoupper($method), $cachedRoutes['routes']); + static::assertCount(1, $cachedRoutes['routes'][strtoupper($method)]); + } + + // Next request should not call the callback. + $router = $this->getRouter(); + $router->cache(__FILE__, function () use ($router) { + throw new RuntimeException('This should not be called'); + }); + static::assertEquals(count($methods), $router->getRoutes()->count(), 'Routes must be obtained from cache'); + } + + public function testControllerRouting(): void + { + $router = $this->getRouter(); + + $controllerName = str_shuffle('abcdefghijklmnopqrstuvwxyz'); + + // Create a controller class. + eval('class ' . $controllerName . ' extends Illuminate\Routing\Controller { public function getHomePage() {} }'); + + $key = $router->cache(__FILE__, function () use ($router, $controllerName) { + $router->controller('/', $controllerName); + }); + + static::assertTrue($this->app->cache->has($key), 'Routes must be in cache'); + // 2 because controller adds missingMethod + static::assertEquals(2, $router->getRoutes()->count(), 'Routes must be in collection'); + + // Next request should not call the callback. + $router = $this->getRouter(); + $router->cache(__FILE__, function () use ($router) { + throw new Exception('This should not be called'); + }); + static::assertEquals(2, $router->getRoutes()->count(), 'Routes must be obtained from cache'); + } + + public function testCanDispatchRequest(): void + { + // Create a controller class. + $controllerName = str_shuffle('abcdefghijklmnopqrstuvwxyz'); + eval('class ' . $controllerName . ' extends Illuminate\Routing\Controller { + public function getIndex() { + return Illuminate\Support\Facades\Response::make(1); + } + }'); + + // First, define a route. + $router = $this->getRouter(); + $router->cache(__FILE__, function () use ($router, $controllerName) { + $router->get('/', $controllerName . '@getIndex'); + }); + + // Create a new router, set it on the app, and simulate a request. + $this->app['router'] = $this->getRouter(); + $this->app['router']->cache(__FILE__, function () use ($router) { + throw new RuntimeException('This should not be called'); + }); + + $client = $this->createClient(); + $client->request('get', '/'); + + $response = $client->getResponse(); + static::assertInstanceOf('Symfony\Component\HttpFoundation\Response', $response); + static::assertEquals(1, $response->getContent()); + } + + public function testCanRouteToClosure(): void + { + // Create a new router, set it on the app, and simulate a request. + $this->app['router'] = $this->getRouter(); + $this->app['router']->get('/', fn() => 1); + + $client = $this->createClient(); + $client->request('get', '/'); + + $response = $client->getResponse(); + static::assertInstanceOf('Symfony\Component\HttpFoundation\Response', $response); + static::assertEquals(1, $response->getContent()); + } + + public function testCanGroupRoutes(): void + { + $router = $this->getRouter(); + + $controllerName = str_shuffle('abcdefghijklmnopqrstuvwxyz'); + + // Create a controller class. + eval('class ' . $controllerName . ' extends Illuminate\Routing\Controller { + public function getHomePage() { + return Illuminate\Support\Facades\Response::make(1); + } + }'); + + $router->cache(__FILE__, function () use ($controllerName, $router) { + $router->group( + ['prefix' => 'grouped'], + function () use ($router, $controllerName) { + $router->get('/', $controllerName . '@getHomePage'); + $router->get('/dashboard', $controllerName . '@getHomePage'); + } + ); + }); + + // 2 routes originating from group closure + static::assertEquals(2, $router->getRoutes()->count(), 'Routes must be in collection'); + + // Create a new router, set it on the app, and simulate a request. + $this->app['router'] = $this->getRouter(); + $this->app['router']->cache(__FILE__, callback: function () use ($router) { + throw new RuntimeException('This should not be called'); + }); + + $client = $this->createClient(); + $client->request('GET', '/grouped/dashboard'); + + $response = $client->getResponse(); + static::assertInstanceOf('Symfony\Component\HttpFoundation\Response', $response); + static::assertEquals(1, $response->getContent()); + } + + public function testCanChainWheres(): void + { + $this->expectException(NotFoundHttpException::class); + + $this->app['router'] = $this->getRouter(); + $this->app['router']->get('/{foo}/{bar}', fn() => 'baz') + ->where('foo', '\w+') + ->where('bar', '\d+'); + + // /herp/derp should not match above route. + $client = $this->createClient(); + $client->catchExceptions(false); + $client->request('GET', '/herp/derp'); + } + + public function testWheresRetainedInCache(): void + { + $this->expectException(NotFoundHttpException::class); + + // Create a controller class. + $controllerName = str_shuffle('abcdefghijklmnopqrstuvwxyz'); + eval('class ' . $controllerName . ' extends Illuminate\Routing\Controller { + public function getIndex() { + return Illuminate\Support\Facades\Response::make(1); + } + }'); + + // First, define a route. + $router = $this->getRouter(); + $router->cache(__FILE__, function () use ($router, $controllerName) { + $router->get('/{foo}/{bar}', $controllerName . '@getIndex')->where('foo', '\w+')->where('bar', '\d+'); + }); + + // Create a new router, set it on the app, and simulate a request. + $this->app['router'] = $this->getRouter(); + $this->app['router']->cache(__FILE__, callback: function () use ($router) { + throw new RuntimeException('This should not be called'); + }); + + // /herp/derp should not match above route. + $client = $this->createClient(); + $client->catchExceptions(false); + $client->request('GET', '/herp/derp'); + } + + public function testCanUseResource(): void + { + // Create a controller class. + $controllerName = str_shuffle('abcdefghijklmnopqrstuvwxyz'); + eval('class ' . $controllerName . ' extends Illuminate\Routing\Controller { }'); + + // First, define a resource. + $router = $this->getRouter(); + $key = $router->cache(__FILE__, function () use ($router, $controllerName) { + $router->resource('item', $controllerName); + }); + + static::assertTrue($this->app->cache->has($key), 'Routes must be in cache'); + static::assertEquals(8, $router->getRoutes()->count(), 'Routes must be in collection'); + + // Next request should not call the callback. + $router = $this->getRouter(); + $router->cache(__FILE__, function () use ($router) { + throw new RuntimeException('This should not be called'); + }); + static::assertEquals(8, $router->getRoutes()->count(), 'Routes must be obtained from cache'); + } + + public function testCanClearCache(): void + { + // Create a controller class. + $controllerName = str_shuffle('abcdefghijklmnopqrstuvwxyz'); + eval('class ' . $controllerName . ' extends Illuminate\Routing\Controller { }'); + + // First, define a route. + $router = $this->getRouter(); + $key = $router->cache(__FILE__, function () use ($router, $controllerName) { + $router->get('/{foo}/{bar}', $controllerName . '@getIndex')->where('foo', '\w+')->where('bar', '\d+'); + }); + static::assertTrue($this->app->cache->has($key), 'Routes must be in cache'); + + // Next, clear it. + $router->clearCache(__FILE__); + static::assertFalse($this->app->cache->has($key), 'Routes must no longer be cached'); + } +} diff --git a/tests/CachedRouting/RoutingServiceProviderTest.php b/tests/CachedRouting/RoutingServiceProviderTest.php new file mode 100644 index 00000000..05588ca4 --- /dev/null +++ b/tests/CachedRouting/RoutingServiceProviderTest.php @@ -0,0 +1,97 @@ +app === null) { + $this->refreshApplication(); + } + } + + /** + * Refresh the application instance. + * + * @return void + */ + protected function refreshApplication(): void + { + $this->app = $this->createApplication(); + + $this->app['env'] = 'testing'; + + $this->app->boot(); + } + + /** + * Creates the application. + */ + public function createApplication(): Application + { + return new Application(); + } + + /** + * @covers ::register + * @covers ::registerRouter + */ + public function testRegister(): void + { + $provider = new RoutingServiceProvider($this->app); + $provider->register(); + + static::assertInstanceOf(Router::class, $this->app->router); + } +}