diff --git a/readme.md b/readme.md index 56b94ed..fe4a0cb 100644 --- a/readme.md +++ b/readme.md @@ -183,6 +183,57 @@ return [ This section is currently under construction. + +## Caching Routes +If you want to cache the routes in all languages, you will need to use special Artisan commands. **Using `artisan route:cache`** will not work correctly! + +### Setup + +For the route caching solution to work, it is required to make a minor adjustment to your application route provision. + +In your App's `RouteServiceProvider`, use the `LoadsTranslatedCachedRoutes` trait: + +```php +files = $files; + } + + /** + * Execute the console command. + */ + public function handle() + { + $this->call('route:trans:clear'); + + $this->cacheRoutesPerLocale(); + + $this->info('Routes cached successfully for all locales!'); + } + + /** + * Cache the routes separately for each locale. + */ + protected function cacheRoutesPerLocale() + { + // Store the default routes cache, + // this way the Application will detect that routes are cached. + $allLocales = array_keys($this->getSupportedLocales()); + + array_push($allLocales, null); + + foreach ($allLocales as $locale) { + $routes = $this->getFreshApplicationRoutes($locale); + + if (count($routes) == 0) { + $this->error("Your application doesn't have any routes."); + + return; + } + + foreach ($routes as $route) { + $route->prepareForSerialization(); + } + + $this->files->put( + $this->makeLocaleRoutesPath($locale), $this->buildRouteCacheFile($routes) + ); + } + } + + /** + * Boot a fresh copy of the application and get the routes. + * + * @param string|null $locale + * + * @return \Illuminate\Routing\RouteCollection + */ + protected function getFreshApplicationRoutes($locale = null) + { + $key = $this->getLocaleEnvKey(); + if (null !== $locale) { + putenv("{$key}={$locale}"); + } + $app = require $this->getBootstrapPath().'/app.php'; + $app->make(Kernel::class)->bootstrap(); + $routes = $app['router']->getRoutes(); + putenv("{$key}"); + + return $routes; + } + + /** + * Build the route cache file. + * + * @param \Illuminate\Routing\RouteCollection $routes + * + * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException + * + * @return string + */ + protected function buildRouteCacheFile(RouteCollection $routes) + { + $stub = $this->files->get( + realpath( + __DIR__ + .DIRECTORY_SEPARATOR.'..' + .DIRECTORY_SEPARATOR.'..' + .DIRECTORY_SEPARATOR.'stubs' + .DIRECTORY_SEPARATOR.'routes.stub' + ) + ); + + return str_replace( + [ + '{{routes}}', + ], + [ + base64_encode(serialize($routes)), + ], + $stub + ); + } +} diff --git a/src/Commands/RouteTranslationsClearCommand.php b/src/Commands/RouteTranslationsClearCommand.php new file mode 100644 index 0000000..1be1194 --- /dev/null +++ b/src/Commands/RouteTranslationsClearCommand.php @@ -0,0 +1,61 @@ +files = $files; + } + + /** + * Execute the console command. + * + * @return void + */ + public function handle() + { + foreach ($this->getSupportedLocales() as $locale => $localeValue) { + $path = $this->makeLocaleRoutesPath($locale); + if ($this->files->exists($path)) { + $this->files->delete($path); + } + } + $path = $this->laravel->getCachedRoutesPath(); + if ($this->files->exists($path)) { + $this->files->delete($path); + } + $this->info('Route caches for locales cleared!'); + } +} diff --git a/src/Commands/RouteTranslationsListCommand.php b/src/Commands/RouteTranslationsListCommand.php new file mode 100644 index 0000000..1e8d6d0 --- /dev/null +++ b/src/Commands/RouteTranslationsListCommand.php @@ -0,0 +1,92 @@ +argument('locale'); + + if (!$this->isSupportedLocale($locale)) { + return $this->error("Unsupported locale: '{$locale}'."); + } + + $this->displayRoutes($this->getLocaleRoutes($locale)); + } + + /** + * Compile the locale routes into a displayable format. + * + * @return array + */ + protected function getLocaleRoutes($locale) + { + $routes = $this->getFreshApplicationRoutes($locale); + + $routes = collect($routes)->map(function ($route) { + return $this->getRouteInformation($route); + })->filter()->all(); + + if ($sort = $this->option('sort')) { + $routes = $this->sortRoutes($sort, $routes); + } + + if ($this->option('reverse')) { + $routes = array_reverse($routes); + } + + return $this->pluckColumns($routes); + } + + /** + * Boot a fresh copy of the application and get the routes. + * + * @param string $locale + * + * @return \Illuminate\Routing\RouteCollection + */ + protected function getFreshApplicationRoutes($locale) + { + $key = $this->getLocaleEnvKey(); + putenv("{$key}={$locale}"); + $app = require $this->getBootstrapPath().'/app.php'; + $app->make(Kernel::class)->bootstrap(); + $routes = $app['router']->getRoutes(); + putenv("{$key}"); + + return $routes; + } + + /** + * Get the console command arguments. + * + * @return array + */ + protected function getArguments() + { + return [ + ['locale', InputArgument::REQUIRED, 'The locale to list routes for.'], + ]; + } +} diff --git a/src/I18nService.php b/src/I18nService.php index 09f06fc..6f288dc 100644 --- a/src/I18nService.php +++ b/src/I18nService.php @@ -9,6 +9,11 @@ class I18nService { + /** + * The env key that the forced locale for routing is stored in. + */ + const ENV_ROUTE_KEY = 'I18N_ROUTING_LOCALE'; + /** * I18n configuration. * @@ -75,7 +80,7 @@ public function __construct(Request $request) */ public function defaultLocale() { - $fallback = $this->getConfig('fallback_language'); + $fallback = env(static::ENV_ROUTE_KEY, $this->getConfig('fallback_language')); $locale = $this->getLocale($fallback); if (!$locale instanceof Locale) { diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index f045db0..ae44b80 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -5,6 +5,9 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Collection; use Illuminate\Support\ServiceProvider as Provider; +use RichanFongdasen\I18n\Commands\RouteTranslationsCacheCommand; +use RichanFongdasen\I18n\Commands\RouteTranslationsClearCommand; +use RichanFongdasen\I18n\Commands\RouteTranslationsListCommand; class ServiceProvider extends Provider { @@ -17,6 +20,7 @@ public function boot() { $this->publishAssets(); $this->registerMacro(); + $this->registerCommands(); } /** @@ -70,4 +74,15 @@ protected function registerMacro() }); }); } + + protected function registerCommands() + { + if ($this->app->runningInConsole()) { + $this->commands([ + RouteTranslationsCacheCommand::class, + RouteTranslationsClearCommand::class, + RouteTranslationsListCommand::class, + ]); + } + } } diff --git a/src/Traits/LoadsTranslatedCachedRoutes.php b/src/Traits/LoadsTranslatedCachedRoutes.php new file mode 100644 index 0000000..b48a02f --- /dev/null +++ b/src/Traits/LoadsTranslatedCachedRoutes.php @@ -0,0 +1,80 @@ +getI18nService(); + + $locale = $localization->routePrefix(); + + // First, try to load the routes specifically cached for this locale + // if they do not exist, write a warning to the log and load the default + // routes instead. Note that this is guaranteed to exist, because the + // 'cached routes' check in the Application checks its existence. + + $path = $this->makeLocaleRoutesPath($locale); + + if (!file_exists($path)) { + Log::warning("Routes cached, but no cached routes found for locale '{$locale}'!"); + + $path = $this->getDefaultCachedRoutePath(); + } + + $this->app->booted(function () use ($path) { + require $path; + }); + } + + /** + * Returns the path to the cached routes file for a given locale. + * + * @param string|null $locale + * + * @return string + */ + protected function makeLocaleRoutesPath($locale = null) + { + $path = $this->getDefaultCachedRoutePath(); + if ($locale === null) { + return $path; + } + + return substr($path, 0, -4).'_'.$locale.'.php'; + } + + /** + * Returns the path to the standard cached routes file. + * + * @return string + */ + protected function getDefaultCachedRoutePath() + { + return $this->app->getCachedRoutesPath(); + } + + /** + * @return \RichanFongdasen\I18n\I18nService + */ + protected function getI18nService() + { + return new I18nService($this->app['request']); + } +} diff --git a/src/Traits/TranslatedRouteCommandContext.php b/src/Traits/TranslatedRouteCommandContext.php new file mode 100644 index 0000000..fe4ad76 --- /dev/null +++ b/src/Traits/TranslatedRouteCommandContext.php @@ -0,0 +1,70 @@ +getI18nService()->getLocale($locale) !== null; + } + + /** + * @return string[] + */ + protected function getSupportedLocales() + { + return $this->getI18nService()->getLocale()->toArray(); + } + + /** + * @return \RichanFongdasen\I18n\I18nService + */ + protected function getI18nService() + { + return app(I18nService::class); + } + + /** + * @return string + */ + protected function getBootstrapPath() + { + return $this->laravel->bootstrapPath(); + } + + /** + * @param string|null $locale + * + * @return string + */ + protected function makeLocaleRoutesPath($locale = null) + { + $path = $this->laravel->getCachedRoutesPath(); + if ($locale === null) { + return $path; + } + $path = substr($path, 0, -4).'_'.$locale.'.php'; + + return $path; + } + + /** + * The env key that the forced locale for routing is stored in. + * + * @return string + */ + protected function getLocaleEnvKey() + { + return I18nService::ENV_ROUTE_KEY; + } +} diff --git a/stubs/routes.stub b/stubs/routes.stub new file mode 100644 index 0000000..ff98848 --- /dev/null +++ b/stubs/routes.stub @@ -0,0 +1,17 @@ +setRoutes( + unserialize(base64_decode('{{routes}}')) +); diff --git a/tests/Commands/RouteTranslationTestTrait.php b/tests/Commands/RouteTranslationTestTrait.php new file mode 100644 index 0000000..6abb49a --- /dev/null +++ b/tests/Commands/RouteTranslationTestTrait.php @@ -0,0 +1,26 @@ +assertTrue(file_exists($this->makeLocaleRoutesPath())); + $this->assertTrue(file_exists($this->makeLocaleRoutesPath('de'))); + $this->assertTrue(file_exists($this->makeLocaleRoutesPath('en'))); + $this->assertTrue(file_exists($this->makeLocaleRoutesPath('es'))); + } + + protected function assertFalseLocaleCache() + { + $this->assertFalse(file_exists($this->makeLocaleRoutesPath())); + $this->assertFalse(file_exists($this->makeLocaleRoutesPath('de'))); + $this->assertFalse(file_exists($this->makeLocaleRoutesPath('en'))); + $this->assertFalse(file_exists($this->makeLocaleRoutesPath('es'))); + } +} \ No newline at end of file diff --git a/tests/Commands/RouteTranslationsCacheCommandTests.php b/tests/Commands/RouteTranslationsCacheCommandTests.php new file mode 100644 index 0000000..e8282de --- /dev/null +++ b/tests/Commands/RouteTranslationsCacheCommandTests.php @@ -0,0 +1,109 @@ +app->bootstrapPath().'/app.php' + ); + } + + /** @test */ + public function it_will_get_error_because_no_route_exists() + { + static::$useRoute = false; + $this->artisan('route:trans:cache') + ->expectsOutput('Your application doesn\'t have any routes.') + ->assertExitCode(0); + static::$useRoute = true; + } + + /** @test */ + public function it_will_generate_cache_files() + { + $this->laravel = $this->app; + $this->doCache(); + } + + /** @test */ + public function it_can_load_routes_from_cache_file() + { + $this->laravel = $this->app; + $this->doCache(); + + $allSupportedLocale = array_keys(\I18n::getLocale()->toArray()); + array_push($allSupportedLocale, null); + + foreach ($allSupportedLocale as $locale) { + $this->assertLocaleCacheExist($locale, $locale ?? 'en'); + } + } + + /** @test */ + public function it_will_create_a_log_message_and_use_default_when_not_supported_locale() + { + $this->laravel = $this->app; + $this->doCache(); + + $expectedLocale = 'jp'; + $defaultLocale = $this->app['config']->get('i18n.fallback_language'); + $this->assertLocaleCacheExist($expectedLocale, $defaultLocale); + } + + /** @test */ + public function it_will_create_a_log_message_and_use_default_when_cached_file_has_gone() + { + $this->laravel = $this->app; + $this->doCache(); + $locale = 'de'; + // delete 'de' locale route file + if (file_exists($localePath = $this->makeLocaleRoutesPath($locale))) { + unlink($localePath); + } + $defaultLocale = $this->app['config']->get('i18n.fallback_language'); + $this->assertLocaleCacheExist($locale, $defaultLocale); + } + + protected function doCache() + { + $this->artisan('route:trans:cache') + ->expectsOutput('Routes cached successfully for all locales!') + ->assertExitCode(0); + $this->assertTrueLocaleCache(); + } + + protected function assertLocaleCacheExist($expectedLocale, $actualLocale) + { + $this->request = \Mockery::mock(Request::class); + + $this->request->shouldReceive('segment') + ->with(1) + ->andReturn($expectedLocale); + + $this->loadCachedRoutes(); + $routes = app('router')->getRoutes()->getRoutes(); + $expectsRoutes = Arr::pluck($routes, 'uri'); + $this->assertEquals($expectsRoutes, [$actualLocale.'/foo', $actualLocale.'/bar']); + } +} diff --git a/tests/Commands/RouteTranslationsClearCommandTests.php b/tests/Commands/RouteTranslationsClearCommandTests.php new file mode 100644 index 0000000..e63d275 --- /dev/null +++ b/tests/Commands/RouteTranslationsClearCommandTests.php @@ -0,0 +1,28 @@ +laravel = $this->app; + + $this->artisan('route:trans:cache') + ->assertExitCode(0); + $this->assertTrueLocaleCache(); + + $this->artisan('route:trans:clear') + ->expectsOutput('Route caches for locales cleared!') + ->assertExitCode(0); + $this->assertFalseLocaleCache(); + } +} \ No newline at end of file diff --git a/tests/Commands/RouteTranslationsListCommandTests.php b/tests/Commands/RouteTranslationsListCommandTests.php new file mode 100644 index 0000000..c968733 --- /dev/null +++ b/tests/Commands/RouteTranslationsListCommandTests.php @@ -0,0 +1,41 @@ +app->bootstrapPath().'/app.php' + ); + } + + /** @test */ + public function it_will_get_error_when_locale_not_supported() + { + $this->artisan('route:trans:list', ['locale' => 'jp']) + ->expectsOutput("Unsupported locale: 'jp'.") + ->assertExitCode(0); + } + + /** @test */ + public function it_will_get_show_supported_locale_route() + { + $this->artisan('route:trans:list', ['locale' => 'es', '--json' => true, '--sort' => 'name', '--reverse'=> true, '--compact' => true]) + ->expectsOutput('[{"method":"GET|HEAD","uri":"es\/foo","action":"RichanFongdasen\\\\I18n\\\\Tests\\\\Supports\\\\Controllers\\\\FooController"},{"method":"GET|HEAD","uri":"es\/bar","action":"RichanFongdasen\\\\I18n\\\\Tests\\\\Supports\\\\Controllers\\\\BarController"}]') + ->assertExitCode(0); + } +} diff --git a/tests/Supports/Controllers/BarController.php b/tests/Supports/Controllers/BarController.php new file mode 100644 index 0000000..eafd33a --- /dev/null +++ b/tests/Supports/Controllers/BarController.php @@ -0,0 +1,13 @@ +request = request(); + + parent::boot(); + } + + /** + * Define the routes for the application. + * + * @return void + */ + public function map() + { + Route::middleware('web') + ->namespace($this->namespace) + ->group(realpath(__DIR__.'/../routes/web.php')); + } +} diff --git a/tests/Supports/app.php b/tests/Supports/app.php new file mode 100644 index 0000000..d054cd7 --- /dev/null +++ b/tests/Supports/app.php @@ -0,0 +1,3 @@ + \I18n::routePrefix()], function () { + Route::get('foo', FooController::class)->name('foo'); + Route::get('bar', BarController::class)->name('bar'); +}); diff --git a/tests/WithRouteTestCase.php b/tests/WithRouteTestCase.php new file mode 100644 index 0000000..633de01 --- /dev/null +++ b/tests/WithRouteTestCase.php @@ -0,0 +1,46 @@ +refreshApplication(); + return $instance->app; + } + + /** + * Define package service provider + * + * @param \Illuminate\Foundation\Application $app + * @return array + */ + protected function getPackageProviders($app) + { + $this->app = $app; + + return static::$useRoute ? $this->useRouteProviders() : $this->withoutRouteProviders(); + } + + protected function useRouteProviders() + { + return [ + \Illuminate\Cache\CacheServiceProvider::class, + \RichanFongdasen\I18n\Tests\Supports\Providers\RouteServiceProvider::class, + \Orchestra\Database\ConsoleServiceProvider::class, + \RichanFongdasen\I18n\ServiceProvider::class, + ]; + } + + protected function withoutRouteProviders() + { + return [ + \Illuminate\Cache\CacheServiceProvider::class, + \Orchestra\Database\ConsoleServiceProvider::class, + \RichanFongdasen\I18n\ServiceProvider::class, + ]; + } +} \ No newline at end of file