From d5daef07445fca610f66a0109f71d26600c5cf35 Mon Sep 17 00:00:00 2001 From: Mohammad Alavi Date: Wed, 1 Jan 2025 21:06:24 +0330 Subject: [PATCH] feat(wip): make routing configurable --- phpunit.xml | 37 +++-- src/Foundation/Apiato.php | 155 +++++------------- .../Configuration/ApplicationBuilder.php | 7 + src/Foundation/Configuration/Localization.php | 2 +- src/Foundation/Configuration/Routing.php | 104 ++++++++++++ .../Providers/ApiatoServiceProvider.php | 8 +- .../Providers/ConfigurationTest.php | 26 +++ .../Support/Doubles/Fakes/Laravel/.gitignore | 1 + .../UI/API/Routes/ListAuthors.v3.public.php | 7 + .../Author/UI/WEB/Routes/ListAuthors.php | 7 + .../UI/API/Routes/CreateBook.v1.private.php | 28 +--- .../UI/API/Routes/ListBooks.v1.private.php | 7 + .../UI/WEB/Routes/CreateBook.v1.private.php | 3 +- .../UI/WEB/Routes/ListBooks.v1.private.php | 7 + .../Doubles/Fakes/Laravel/bootstrap/app.php | 2 + .../Configuration/LocalizationTest.php | 2 +- 16 files changed, 231 insertions(+), 172 deletions(-) create mode 100644 src/Foundation/Configuration/Routing.php create mode 100644 tests/Support/Doubles/Fakes/Laravel/app/Containers/MySection/Author/UI/API/Routes/ListAuthors.v3.public.php create mode 100644 tests/Support/Doubles/Fakes/Laravel/app/Containers/MySection/Author/UI/WEB/Routes/ListAuthors.php create mode 100644 tests/Support/Doubles/Fakes/Laravel/app/Containers/MySection/Book/UI/API/Routes/ListBooks.v1.private.php create mode 100644 tests/Support/Doubles/Fakes/Laravel/app/Containers/MySection/Book/UI/WEB/Routes/ListBooks.v1.private.php diff --git a/phpunit.xml b/phpunit.xml index ca430456d..a5df437bf 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,19 +1,22 @@ - - - - tests/Functional - - - tests/Unit - - - - - - - - src - - + + + + tests/Functional + + + tests/Unit + + + + + + + + + src + + diff --git a/src/Foundation/Apiato.php b/src/Foundation/Apiato.php index b1e91b1c3..5faa8c53e 100644 --- a/src/Foundation/Apiato.php +++ b/src/Foundation/Apiato.php @@ -4,15 +4,11 @@ use Apiato\Foundation\Configuration\ApplicationBuilder; use Apiato\Foundation\Configuration\Localization; +use Apiato\Foundation\Configuration\Routing; use Apiato\Foundation\Middlewares\ProcessETag; use Apiato\Foundation\Middlewares\Profiler; use Apiato\Foundation\Middlewares\ValidateJsonContent; -use Apiato\Foundation\Support\PathHelper; use Composer\Autoload\ClassLoader; -use Illuminate\Support\Arr; -use Illuminate\Support\Facades\File; -use Illuminate\Support\Facades\Route; -use Symfony\Component\Finder\SplFileInfo; final class Apiato { @@ -23,9 +19,10 @@ final class Apiato private array $commandPaths = []; private array $helperPaths = []; private Localization $localization; + private Routing $routing; private function __construct( - private string $basePath, + private readonly string $basePath, ) { } @@ -57,7 +54,8 @@ public static function configure(string|null $basePath = null): ApplicationBuild ...glob($basePath . '/app/Containers/*/*/UI/Console', GLOB_ONLYDIR | GLOB_NOSORT), )->withHelpers( $basePath . '/app/Ship/Helpers', - )->withTranslations(); + )->withTranslations() + ->withRouting(); } /** @@ -71,14 +69,19 @@ public static function inferBasePath(): string }; } - public static function instance(): self + public function withRouting(callable|null $callback = null): self { - return self::$instance; - } + // TODO: maybe make the configuration parametrized like web: api:, like the way Laravel does it? + $this->routing = (new Routing()) + ->loadApiRoutesFrom( + ...glob($this->basePath . '/app/Containers/*/*/UI/API/Routes', GLOB_ONLYDIR | GLOB_NOSORT), + )->loadWebRoutesFrom( + ...glob($this->basePath . '/app/Containers/*/*/UI/WEB/Routes', GLOB_ONLYDIR | GLOB_NOSORT), + ); - public function withProviders(string ...$path): self - { - $this->providerPaths = $path; + if (!is_null($callback)) { + $callback($this->routing); + } return $this; } @@ -86,7 +89,7 @@ public function withProviders(string ...$path): self public function withTranslations(callable|null $callback = null): self { $this->localization = (new Localization()) - ->loadTranslationsFrom( + ->loadFrom( $this->basePath . '/app/Ship/Languages', ...glob($this->basePath . '/app/Containers/*/*/Languages', GLOB_ONLYDIR | GLOB_NOSORT), ); @@ -98,9 +101,14 @@ public function withTranslations(callable|null $callback = null): self return $this; } - public function withConfigs(string ...$path): void + public function withHelpers(string ...$path): void { - $this->configPaths = $path; + $this->helperPaths = $path; + } + + public function withCommands(string ...$path): void + { + $this->commandPaths = $path; } public function withEvents(string ...$path): void @@ -108,14 +116,21 @@ public function withEvents(string ...$path): void $this->listenerPaths = $path; } - public function withCommands(string ...$path): void + public function withConfigs(string ...$path): void { - $this->commandPaths = $path; + $this->configPaths = $path; } - public function withHelpers(string ...$path): void + public function withProviders(string ...$path): self { - $this->helperPaths = $path; + $this->providerPaths = $path; + + return $this; + } + + public static function instance(): self + { + return self::$instance; } public function providerPaths(): array @@ -157,107 +172,13 @@ public function commands(): array return $this->commandPaths; } - // TODO: separate Api and Web route registration public function registerRoutes(): void { - $allContainerPaths = PathHelper::getContainerPaths(); - - foreach ($allContainerPaths as $containerPath) { - $this->loadContainerApiRoutes($containerPath); - $this->loadContainerWebRoutes($containerPath); - } - } - - private function loadContainerApiRoutes(string $containerPath): void - { - $apiRoutesPath = $this->getRoutePathsForUI($containerPath, 'API'); - - if (File::isDirectory($apiRoutesPath)) { - $files = $this->getFilesSortedByName($apiRoutesPath); - foreach ($files as $file) { - $this->loadApiRoute($file); - } - } - } - - private function getRoutePathsForUI(string $containerPath, string $ui): string - { - return $containerPath . DIRECTORY_SEPARATOR . 'UI' . DIRECTORY_SEPARATOR . $ui . DIRECTORY_SEPARATOR . 'Routes'; - } - - /** - * @return array|SplFileInfo[] - */ - private function getFilesSortedByName(string $apiRoutesPath): array - { - $files = File::allFiles($apiRoutesPath); - - return Arr::sort($files, static fn ($file) => $file->getFilename()); - } - - private function loadApiRoute(SplFileInfo $file): void - { - $routeGroupArray = $this->getApiRouteGroup($file); - - Route::group($routeGroupArray, static function () use ($file): void { - require $file->getPathname(); - }); - } - - public function getApiRouteGroup(SplFileInfo|string $endpointFileOrPrefixString): array - { - return [ - 'middleware' => $this->getMiddlewares(), - 'domain' => config('apiato.api.url'), - // If $endpointFileOrPrefixString is a string, use that string as prefix - // else, if it is a file then get the version name from the file name, and use it as prefix - 'prefix' => is_string($endpointFileOrPrefixString) ? $endpointFileOrPrefixString : $this->getApiVersionPrefix($endpointFileOrPrefixString), - ]; - } - - private function getMiddlewares(): array - { - $middlewares = ['api']; - if (config('apiato.api.rate-limiter.enabled')) { - $middlewares[] = 'throttle:' . config('apiato.api.rate-limiter.name'); - } - - return $middlewares; - } - - private function getApiVersionPrefix(SplFileInfo $file): string - { - return config('apiato.api.prefix') . (config('apiato.api.enable_version_prefix') ? $this->getRouteFileVersionFromFileName($file) : ''); - } - - private function getRouteFileVersionFromFileName(SplFileInfo $file): string|bool - { - $fileNameWithoutExtension = pathinfo($file->getFilename(), PATHINFO_FILENAME); - $fileNameWithoutExtensionExploded = explode('.', $fileNameWithoutExtension); - - end($fileNameWithoutExtensionExploded); - - return prev($fileNameWithoutExtensionExploded); - } - - private function loadContainerWebRoutes($containerPath): void - { - $webRoutesPath = $this->getRoutePathsForUI($containerPath, 'WEB'); - - if (File::isDirectory($webRoutesPath)) { - $files = $this->getFilesSortedByName($webRoutesPath); - foreach ($files as $file) { - $this->loadWebRoute($file); - } - } + $this->routing->registerApiRoutes(); } - private function loadWebRoute(SplFileInfo $file): void + public function webRoutes(): array { - Route::group([ - 'middleware' => ['web'], - ], static function () use ($file) { - require $file->getPathname(); - }); + return $this->routing->webRoutes(); } } diff --git a/src/Foundation/Configuration/ApplicationBuilder.php b/src/Foundation/Configuration/ApplicationBuilder.php index 21226ede2..b1e6bd697 100644 --- a/src/Foundation/Configuration/ApplicationBuilder.php +++ b/src/Foundation/Configuration/ApplicationBuilder.php @@ -53,6 +53,13 @@ public function withTranslations(callable|null $callback = null): self return $this; } + public function withRouting(callable|null $callback = null): self + { + $this->apiato->withRouting($callback); + + return $this; + } + public function create(): Apiato { return $this->apiato; diff --git a/src/Foundation/Configuration/Localization.php b/src/Foundation/Configuration/Localization.php index ed82ca595..4e3d1e63a 100644 --- a/src/Foundation/Configuration/Localization.php +++ b/src/Foundation/Configuration/Localization.php @@ -38,7 +38,7 @@ public function paths(): array return $this->paths; } - public function loadTranslationsFrom(string ...$paths): self + public function loadFrom(string ...$paths): self { $this->paths = $paths; diff --git a/src/Foundation/Configuration/Routing.php b/src/Foundation/Configuration/Routing.php new file mode 100644 index 000000000..109e639f7 --- /dev/null +++ b/src/Foundation/Configuration/Routing.php @@ -0,0 +1,104 @@ +apiRouteDirs = $path; + + return $this; + } + + public function loadWebRoutesFrom(string ...$path): self + { + $this->webRouteDirs = $path; + + return $this; + } + + public function registerApiRoutes(): void + { + collect($this->apiRouteDirs) + ->map(fn ($path) => $this->getFilesSortedByName($path)) + ->flatten() + ->each(fn (SplFileInfo $file) => $this->loadApiRoute($file)); + } + + /** + * @return SplFileInfo[] + */ + private function getFilesSortedByName(string $apiRoutesPath): array + { + $files = File::allFiles($apiRoutesPath); + + return Arr::sort($files, static fn ($file) => $file->getFilename()); + } + + private function loadApiRoute(SplFileInfo $file): void + { + $routeGroupArray = $this->getApiRouteGroup($file); + + LaravelRoute::group($routeGroupArray, static function () use ($file): void { + require $file->getPathname(); + }); + } + + public function getApiRouteGroup(SplFileInfo|string $endpointFileOrPrefixString): array + { + return [ + 'middleware' => $this->getMiddlewares(), + 'domain' => config('apiato.api.url'), + // If $endpointFileOrPrefixString is a string, use that string as prefix + // else, if it is a file then get the version name from the file name, and use it as prefix + 'prefix' => is_string($endpointFileOrPrefixString) ? $endpointFileOrPrefixString : $this->getApiVersionPrefix($endpointFileOrPrefixString), + ]; + } + + private function getMiddlewares(): array + { + $middlewares = ['api']; + if (config('apiato.api.rate-limiter.enabled')) { + $middlewares[] = 'throttle:' . config('apiato.api.rate-limiter.name'); + } + + return $middlewares; + } + + private function getApiVersionPrefix(SplFileInfo $file): string + { + return config('apiato.api.prefix') . (config('apiato.api.enable_version_prefix') ? $this->getRouteFileVersionFromFileName($file) : ''); + } + + private function getRouteFileVersionFromFileName(SplFileInfo $file): string|bool + { + $fileNameWithoutExtension = pathinfo($file->getFilename(), PATHINFO_FILENAME); + $fileNameWithoutExtensionExploded = explode('.', $fileNameWithoutExtension); + + end($fileNameWithoutExtensionExploded); + + return prev($fileNameWithoutExtensionExploded); + } + + public function webRoutes(): array + { + $files = []; + foreach ($this->webRouteDirs as $path) { + foreach (glob($path . '/*.php') as $file) { + $files[] = $file; + } + } + usort($files, 'strcmp'); + + return $files; + } +} diff --git a/src/Foundation/Providers/ApiatoServiceProvider.php b/src/Foundation/Providers/ApiatoServiceProvider.php index be7da014f..ff4244bc2 100644 --- a/src/Foundation/Providers/ApiatoServiceProvider.php +++ b/src/Foundation/Providers/ApiatoServiceProvider.php @@ -82,8 +82,6 @@ private function registerCoreCommands(): void } } - // TODO: can we NOT do this and move the providers in the fake Laravel app? - private function mergeConfigs(): void { // The order of these statements matter! DO NOT CHANGE! @@ -99,6 +97,7 @@ private function mergeConfigs(): void ); } + // TODO: can we NOT do this and move the providers in the fake Laravel app? private function setUpTestProviders(Application $app): void { $currentProviders = $this->providers; @@ -124,11 +123,6 @@ public function boot(): void $this->configureRateLimiting(); // TODO: move to route service provider - // dd(Apiato::instance()->create()->getServiceProviders()); - // dd(app()->getProviders(AggregateServiceProvider::class)); - // dd(AliasLoader::getInstance()->getAliases()); - // dd(Event::getRawListeners()); - AboutCommand::add('Apiato', static fn () => ['Version' => '13.0.0']); } diff --git a/tests/Functional/Providers/ConfigurationTest.php b/tests/Functional/Providers/ConfigurationTest.php index 1fec642a4..f351884ab 100644 --- a/tests/Functional/Providers/ConfigurationTest.php +++ b/tests/Functional/Providers/ConfigurationTest.php @@ -73,4 +73,30 @@ expect($actual->has($command->value))->toBeTrue(); }); }); + + it('load web routes from configured path', function (): void { + $endpoints = [ + '/authors', + '/books', + ]; + + expect($endpoints) + ->each(function (Expectation $endpoint) { + $response = $this->get($endpoint->value); + $response->assertOk(); + }); + }); + + it('load api routes from configured path', function (): void { + $endpoints = [ + '/v3/authors', + '/v1/books', + ]; + + expect($endpoints) + ->each(function (Expectation $endpoint) { + $response = $this->get($endpoint->value); + $response->assertOk(); + }); + }); })->covers(ApplicationBuilder::class); diff --git a/tests/Support/Doubles/Fakes/Laravel/.gitignore b/tests/Support/Doubles/Fakes/Laravel/.gitignore index 8548e2b5f..6ed3c4862 100644 --- a/tests/Support/Doubles/Fakes/Laravel/.gitignore +++ b/tests/Support/Doubles/Fakes/Laravel/.gitignore @@ -4,3 +4,4 @@ testbench.yaml testbench.yaml.backup +storage diff --git a/tests/Support/Doubles/Fakes/Laravel/app/Containers/MySection/Author/UI/API/Routes/ListAuthors.v3.public.php b/tests/Support/Doubles/Fakes/Laravel/app/Containers/MySection/Author/UI/API/Routes/ListAuthors.v3.public.php new file mode 100644 index 000000000..47e785b4c --- /dev/null +++ b/tests/Support/Doubles/Fakes/Laravel/app/Containers/MySection/Author/UI/API/Routes/ListAuthors.v3.public.php @@ -0,0 +1,7 @@ + '', 'roles' => ''] - * - * @apiHeader {String} accept=application/json - * @apiHeader {String} authorization=Bearer - * - * @apiParam {String} parameters here... - * - * @apiSuccessExample {json} Success-Response: - * HTTP/1.1 200 OK - * { - * // Insert the response of the request here... - * } - */ - use Illuminate\Support\Facades\Route; use Tests\Support\Doubles\Fakes\Laravel\app\Containers\MySection\Book\UI\API\Controllers\CreateBookController; -Route::post('books', CreateBookController::class) - ->middleware(['auth:api']); +Route::post('books', CreateBookController::class); diff --git a/tests/Support/Doubles/Fakes/Laravel/app/Containers/MySection/Book/UI/API/Routes/ListBooks.v1.private.php b/tests/Support/Doubles/Fakes/Laravel/app/Containers/MySection/Book/UI/API/Routes/ListBooks.v1.private.php new file mode 100644 index 000000000..6d1b9843f --- /dev/null +++ b/tests/Support/Doubles/Fakes/Laravel/app/Containers/MySection/Book/UI/API/Routes/ListBooks.v1.private.php @@ -0,0 +1,7 @@ +middleware(['auth:web']); +Route::get('books/create', [CreateBookController::class, 'create']); diff --git a/tests/Support/Doubles/Fakes/Laravel/app/Containers/MySection/Book/UI/WEB/Routes/ListBooks.v1.private.php b/tests/Support/Doubles/Fakes/Laravel/app/Containers/MySection/Book/UI/WEB/Routes/ListBooks.v1.private.php new file mode 100644 index 000000000..6d1b9843f --- /dev/null +++ b/tests/Support/Doubles/Fakes/Laravel/app/Containers/MySection/Book/UI/WEB/Routes/ListBooks.v1.private.php @@ -0,0 +1,7 @@ +withEvents($apiato->events()) ->withRouting( + web: $apiato->webRoutes(), then: static fn () => $apiato->registerRoutes(), ) ->withMiddleware(function (Middleware $middleware) use ($apiato) { $middleware->api($apiato->apiMiddlewares()); + // $middleware->redirectUsersTo('login'); }) ->withCommands($apiato->commands()) ->create(); diff --git a/tests/Unit/Foundation/Configuration/LocalizationTest.php b/tests/Unit/Foundation/Configuration/LocalizationTest.php index b5dcf4a74..0ca31e9b4 100644 --- a/tests/Unit/Foundation/Configuration/LocalizationTest.php +++ b/tests/Unit/Foundation/Configuration/LocalizationTest.php @@ -25,7 +25,7 @@ it('can set translation paths', function (): void { $localization = new Localization(); - $localization->loadTranslationsFrom( + $localization->loadFrom( __DIR__ . '/../../../Support/Doubles/Fakes/Laravel/app/Ship/Languages', __DIR__ . '/../../../Support/Doubles/Fakes/Laravel/app/Containers/MySection/Book/Languages', );