From 0f76a297b6b3a174ca1babf1c43be49a9de5ad57 Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Mon, 1 Sep 2025 15:55:37 +0200 Subject: [PATCH] feat(laravel): add make:filter command to generate API Platform filters Allow to generate Laravel Eloquent filters --- src/Laravel/ApiPlatformProvider.php | 1 + .../Maker/AbstractMakeStateCommand.php | 29 ++--- .../Console/Maker/MakeFilterCommand.php | 73 +++++++++++ .../Resources/skeleton/EloquentFilter.php.tpl | 22 ++++ .../Utils/FilterAppServiceProviderTagger.php | 74 +++++++++++ .../Maker/Utils/FilterTemplateGenerator.php | 50 ++++++++ ....php => StateAppServiceProviderTagger.php} | 2 +- .../Maker/Utils/SuccessMessageTrait.php | 6 +- .../Console/Maker/MakeFilterCommandTest.php | 117 ++++++++++++++++++ .../Maker/MakeStateProcessorCommandTest.php | 23 ++-- .../Maker/MakeStateProviderCommandTest.php | 23 ++-- .../Console/Maker/Utils/PathResolver.php | 8 +- 12 files changed, 377 insertions(+), 51 deletions(-) create mode 100644 src/Laravel/Console/Maker/MakeFilterCommand.php create mode 100644 src/Laravel/Console/Maker/Resources/skeleton/EloquentFilter.php.tpl create mode 100644 src/Laravel/Console/Maker/Utils/FilterAppServiceProviderTagger.php create mode 100644 src/Laravel/Console/Maker/Utils/FilterTemplateGenerator.php rename src/Laravel/Console/Maker/Utils/{AppServiceProviderTagger.php => StateAppServiceProviderTagger.php} (98%) create mode 100644 src/Laravel/Tests/Console/Maker/MakeFilterCommandTest.php diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index efe961b0c1d..86b10e66975 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -936,6 +936,7 @@ public function register(): void Console\InstallCommand::class, Console\Maker\MakeStateProcessorCommand::class, Console\Maker\MakeStateProviderCommand::class, + Console\Maker\MakeFilterCommand::class, OpenApiCommand::class, ]); } diff --git a/src/Laravel/Console/Maker/AbstractMakeStateCommand.php b/src/Laravel/Console/Maker/AbstractMakeStateCommand.php index c2359af59cf..d1ed3f1ab4d 100644 --- a/src/Laravel/Console/Maker/AbstractMakeStateCommand.php +++ b/src/Laravel/Console/Maker/AbstractMakeStateCommand.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Laravel\Console\Maker; -use ApiPlatform\Laravel\Console\Maker\Utils\AppServiceProviderTagger; +use ApiPlatform\Laravel\Console\Maker\Utils\StateAppServiceProviderTagger; use ApiPlatform\Laravel\Console\Maker\Utils\StateTemplateGenerator; use ApiPlatform\Laravel\Console\Maker\Utils\StateTypeEnum; use ApiPlatform\Laravel\Console\Maker\Utils\SuccessMessageTrait; @@ -28,7 +28,7 @@ abstract class AbstractMakeStateCommand extends Command public function __construct( private readonly Filesystem $filesystem, private readonly StateTemplateGenerator $stateTemplateGenerator, - private readonly AppServiceProviderTagger $appServiceProviderTagger, + private readonly StateAppServiceProviderTagger $stateAppServiceProviderTagger, ) { parent::__construct(); } @@ -38,7 +38,13 @@ public function __construct( */ public function handle(): int { - $stateName = $this->askForStateName(); + $stateType = $this->getStateType()->name; + $stateName = $this->ask(\sprintf('Choose a class name for your state %s (e.g. AwesomeState%s)', strtolower($stateType), ucfirst($stateType))); + if (null === $stateName || '' === $stateName) { + $this->error('[ERROR] The name argument cannot be blank.'); + + return self::FAILURE; + } $directoryPath = base_path('app/State/'); $this->filesystem->ensureDirectoryExists($directoryPath); @@ -57,25 +63,12 @@ public function handle(): int return self::FAILURE; } - $this->appServiceProviderTagger->addTagToServiceProvider($stateName, $this->getStateType()); + $this->stateAppServiceProviderTagger->addTagToServiceProvider($stateName, $this->getStateType()); - $this->writeSuccessMessage($filePath, $this->getStateType()); + $this->writeSuccessMessage($filePath, \sprintf('State %s', ucfirst($this->getStateType()->name))); return self::SUCCESS; } - protected function askForStateName(): string - { - do { - $stateType = $this->getStateType()->name; - $stateName = $this->ask(\sprintf('Choose a class name for your state %s (e.g. AwesomeState%s)', strtolower($stateType), ucfirst($stateType))); - if (empty($stateName)) { - $this->error('[ERROR] This value cannot be blank.'); - } - } while (empty($stateName)); - - return $stateName; - } - abstract protected function getStateType(): StateTypeEnum; } diff --git a/src/Laravel/Console/Maker/MakeFilterCommand.php b/src/Laravel/Console/Maker/MakeFilterCommand.php new file mode 100644 index 00000000000..6d3756bf6c4 --- /dev/null +++ b/src/Laravel/Console/Maker/MakeFilterCommand.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Console\Maker; + +use ApiPlatform\Laravel\Console\Maker\Utils\FilterAppServiceProviderTagger; +use ApiPlatform\Laravel\Console\Maker\Utils\FilterTemplateGenerator; +use ApiPlatform\Laravel\Console\Maker\Utils\SuccessMessageTrait; +use Illuminate\Console\Command; +use Illuminate\Contracts\Filesystem\FileNotFoundException; +use Illuminate\Filesystem\Filesystem; + +final class MakeFilterCommand extends Command +{ + use SuccessMessageTrait; + + protected $signature = 'make:filter'; + protected $description = 'Creates an API Platform filter'; + + public function __construct( + private readonly Filesystem $filesystem, + private readonly FilterTemplateGenerator $filterTemplateGenerator, + private readonly FilterAppServiceProviderTagger $filterAppServiceProviderTagger, + ) { + parent::__construct(); + } + + /** + * @throws FileNotFoundException + */ + public function handle(): int + { + $nameArgument = $this->ask('Choose a class name for your filter (e.g. AwesomeFilter)'); + if (null === $nameArgument || '' === $nameArgument) { + $this->error('[ERROR] The name argument cannot be blank.'); + + return self::FAILURE; + } + + $directoryPath = base_path('app/Filter/'); + $this->filesystem->ensureDirectoryExists($directoryPath); + + $filePath = $this->filterTemplateGenerator->getFilePath($directoryPath, $nameArgument); + if ($this->filesystem->exists($filePath)) { + $this->error(\sprintf('[ERROR] The file "%s" can\'t be generated because it already exists.', $filePath)); + + return self::FAILURE; + } + + $this->filterTemplateGenerator->generate($filePath, $nameArgument); + if (!$this->filesystem->exists($filePath)) { + $this->error(\sprintf('[ERROR] The file "%s" could not be created.', $filePath)); + + return self::FAILURE; + } + + $this->filterAppServiceProviderTagger->addTagToServiceProvider($nameArgument); + + $this->writeSuccessMessage($filePath, 'Eloquent Filter'); + + return self::SUCCESS; + } +} diff --git a/src/Laravel/Console/Maker/Resources/skeleton/EloquentFilter.php.tpl b/src/Laravel/Console/Maker/Resources/skeleton/EloquentFilter.php.tpl new file mode 100644 index 00000000000..4ca1ac477b8 --- /dev/null +++ b/src/Laravel/Console/Maker/Resources/skeleton/EloquentFilter.php.tpl @@ -0,0 +1,22 @@ + $builder + * @param array $context + */ + public function apply(Builder $builder, mixed $values, Parameter $parameter, array $context = []): Builder + { + // TODO: make your awesome query using the $builder + // return $builder-> + } +} diff --git a/src/Laravel/Console/Maker/Utils/FilterAppServiceProviderTagger.php b/src/Laravel/Console/Maker/Utils/FilterAppServiceProviderTagger.php new file mode 100644 index 00000000000..61d6ef5ef9e --- /dev/null +++ b/src/Laravel/Console/Maker/Utils/FilterAppServiceProviderTagger.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Console\Maker\Utils; + +use Illuminate\Contracts\Filesystem\FileNotFoundException; +use Illuminate\Filesystem\Filesystem; + +final readonly class FilterAppServiceProviderTagger +{ + /** @var string */ + private const APP_SERVICE_PROVIDER_PATH = 'Providers/AppServiceProvider.php'; + + /** @var string */ + private const FILTER_INTERFACE_USE_STATEMENT = 'use ApiPlatform\Laravel\Eloquent\Filter\FilterInterface;'; + + public function __construct(private Filesystem $filesystem) + { + } + + /** + * @throws FileNotFoundException + */ + public function addTagToServiceProvider(string $filterName): void + { + $appServiceProviderPath = app_path(self::APP_SERVICE_PROVIDER_PATH); + if (!$this->filesystem->exists($appServiceProviderPath)) { + throw new \RuntimeException('The AppServiceProvider is missing!'); + } + + $serviceProviderContent = $this->filesystem->get($appServiceProviderPath); + + $this->addUseStatements($serviceProviderContent, $filterName); + $this->addTag($serviceProviderContent, $filterName, $appServiceProviderPath); + } + + private function addUseStatements(string &$content, string $filterName): void + { + $useStatements = [self::FILTER_INTERFACE_USE_STATEMENT, \sprintf('use App\\Filter\\%s;', $filterName)]; + $statementsString = implode("\n", $useStatements)."\n"; + + $content = preg_replace( + '/^(namespace\s[^;]+;\s*)/m', + "$1\n$statementsString", + $content, + 1 + ); + } + + private function addTag(string &$content, string $filterName, string $serviceProviderPath): void + { + $tagStatement = \sprintf("\n\n\t\t\$this->app->tag(%s::class, FilterInterface::class);", $filterName); + + if (!str_contains($content, $tagStatement)) { + $content = preg_replace( + '/(public function register\(\)[^{]*{)(.*?)(\s*}\s*})/s', + "$1$2$tagStatement$3", + $content + ); + + $this->filesystem->put($serviceProviderPath, $content); + } + } +} diff --git a/src/Laravel/Console/Maker/Utils/FilterTemplateGenerator.php b/src/Laravel/Console/Maker/Utils/FilterTemplateGenerator.php new file mode 100644 index 00000000000..be2733eeee3 --- /dev/null +++ b/src/Laravel/Console/Maker/Utils/FilterTemplateGenerator.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Console\Maker\Utils; + +use Illuminate\Contracts\Filesystem\FileNotFoundException; +use Illuminate\Filesystem\Filesystem; + +final readonly class FilterTemplateGenerator +{ + public function __construct(private Filesystem $filesystem) + { + } + + public function getFilePath(string $directoryPath, string $filterFileName): string + { + return \sprintf('%s%s.php', $directoryPath, $filterFileName); + } + + /** + * @throws FileNotFoundException + */ + public function generate(string $pathLink, string $filterName): void + { + $namespace = 'App\\Filter'; + $template = $this->filesystem->get( + \sprintf( + '%s/Resources/skeleton/EloquentFilter.php.tpl', + \dirname(__DIR__), + ) + ); + + $content = strtr($template, [ + '{{ namespace }}' => $namespace, + '{{ class_name }}' => $filterName, + ]); + + $this->filesystem->put($pathLink, $content); + } +} diff --git a/src/Laravel/Console/Maker/Utils/AppServiceProviderTagger.php b/src/Laravel/Console/Maker/Utils/StateAppServiceProviderTagger.php similarity index 98% rename from src/Laravel/Console/Maker/Utils/AppServiceProviderTagger.php rename to src/Laravel/Console/Maker/Utils/StateAppServiceProviderTagger.php index 0db5f800442..a683d46bfdc 100644 --- a/src/Laravel/Console/Maker/Utils/AppServiceProviderTagger.php +++ b/src/Laravel/Console/Maker/Utils/StateAppServiceProviderTagger.php @@ -16,7 +16,7 @@ use Illuminate\Contracts\Filesystem\FileNotFoundException; use Illuminate\Filesystem\Filesystem; -final readonly class AppServiceProviderTagger +final readonly class StateAppServiceProviderTagger { /** @var string */ private const APP_SERVICE_PROVIDER_PATH = 'Providers/AppServiceProvider.php'; diff --git a/src/Laravel/Console/Maker/Utils/SuccessMessageTrait.php b/src/Laravel/Console/Maker/Utils/SuccessMessageTrait.php index e4f112d23c2..d06ae7febac 100644 --- a/src/Laravel/Console/Maker/Utils/SuccessMessageTrait.php +++ b/src/Laravel/Console/Maker/Utils/SuccessMessageTrait.php @@ -15,10 +15,8 @@ trait SuccessMessageTrait { - private function writeSuccessMessage(string $filePath, StateTypeEnum $stateTypeEnum): void + private function writeSuccessMessage(string $filePath, string $customText): void { - $stateText = strtolower($stateTypeEnum->name); - $this->newLine(); $this->line(' '); $this->line(' Success! '); @@ -26,6 +24,6 @@ private function writeSuccessMessage(string $filePath, StateTypeEnum $stateTypeE $this->newLine(); $this->line('created: '.$filePath.''); $this->newLine(); - $this->line("Next: Open your new state $stateText class and start customizing it."); + $this->line("Next: Open your new $customText class and start customizing it."); } } diff --git a/src/Laravel/Tests/Console/Maker/MakeFilterCommandTest.php b/src/Laravel/Tests/Console/Maker/MakeFilterCommandTest.php new file mode 100644 index 00000000000..ce71d1a3fd2 --- /dev/null +++ b/src/Laravel/Tests/Console/Maker/MakeFilterCommandTest.php @@ -0,0 +1,117 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Laravel\Tests\Console\Maker; + +use ApiPlatform\Laravel\Tests\Console\Maker\Utils\AppServiceFileGenerator; +use ApiPlatform\Laravel\Tests\Console\Maker\Utils\PathResolver; +use Illuminate\Console\Command; +use Illuminate\Contracts\Filesystem\FileNotFoundException; +use Illuminate\Filesystem\Filesystem; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; + +class MakeFilterCommandTest extends TestCase +{ + use WithWorkbench; + + /** @var string */ + private const MAKE_FILTER_COMMAND = 'make:filter'; + /** @var string */ + private const FILTER_CLASS_NAME = 'Choose a class name for your filter (e.g. AwesomeFilter)'; + + private Filesystem $filesystem; + private PathResolver $pathResolver; + private AppServiceFileGenerator $appServiceFileGenerator; + + /** + * @throws FileNotFoundException + */ + protected function setup(): void + { + parent::setUp(); + + $this->filesystem = new Filesystem(); + $this->pathResolver = new PathResolver(); + $this->appServiceFileGenerator = new AppServiceFileGenerator($this->filesystem); + + $this->appServiceFileGenerator->regenerateProviderFile(); + } + + /** + * @throws FileNotFoundException + */ + public function testMakeStateFilterCommand(): void + { + $filterName = 'MyFilter'; + $filePath = $this->pathResolver->generateFilterFilename($filterName); + $appServiceFilterPath = $this->pathResolver->getServiceProviderFilePath(); + + $this->artisan(self::MAKE_FILTER_COMMAND) + ->expectsQuestion(self::FILTER_CLASS_NAME, $filterName) + ->expectsOutputToContain('Success!') + ->expectsOutputToContain("created: $filePath") + ->expectsOutputToContain('Next: Open your new Eloquent Filter class and start customizing it.') + ->assertExitCode(Command::SUCCESS); + + $this->assertFileExists($filePath); + + $appServiceFilterContent = $this->filesystem->get($appServiceFilterPath); + $this->assertStringContainsString('use App\\Filter\\MyFilter;', $appServiceFilterContent); + $this->assertStringContainsString('use ApiPlatform\Laravel\Eloquent\Filter\FilterInterface;', $appServiceFilterContent); + $this->assertStringContainsString('$this->app->tag(MyFilter::class, FilterInterface::class);', $appServiceFilterContent); + + $this->filesystem->delete($filePath); + } + + public function testWhenStateFilterClassAlreadyExists(): void + { + $filterName = 'ExistingFilter'; + $existingFile = $this->pathResolver->generateFilterFilename($filterName); + $this->filesystem->put($existingFile, 'artisan(self::MAKE_FILTER_COMMAND) + ->expectsQuestion(self::FILTER_CLASS_NAME, $filterName) + ->expectsOutput($expectedError) + ->assertExitCode(Command::FAILURE); + + $this->filesystem->delete($existingFile); + } + + #[DataProvider('nullProvider')] + public function testMakeStateFilterCommandWithoutGivenClassName(?string $value): void + { + $this->artisan(self::MAKE_FILTER_COMMAND) + ->expectsQuestion(self::FILTER_CLASS_NAME, $value) + ->assertExitCode(Command::FAILURE); + } + + public static function nullProvider(): \Generator + { + yield 'null value used' => ['value' => null]; + yield 'empty string used' => ['value' => '']; + } + + /** + * @throws FileNotFoundException + */ + protected function tearDown(): void + { + parent::tearDown(); + + $this->appServiceFileGenerator->regenerateProviderFile(); + } +} diff --git a/src/Laravel/Tests/Console/Maker/MakeStateProcessorCommandTest.php b/src/Laravel/Tests/Console/Maker/MakeStateProcessorCommandTest.php index fcb05239fce..b21b55d1d2d 100644 --- a/src/Laravel/Tests/Console/Maker/MakeStateProcessorCommandTest.php +++ b/src/Laravel/Tests/Console/Maker/MakeStateProcessorCommandTest.php @@ -20,6 +20,7 @@ use Illuminate\Filesystem\Filesystem; use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; class MakeStateProcessorCommandTest extends TestCase { @@ -61,7 +62,7 @@ public function testMakeStateProviderCommand(): void ->expectsQuestion(self::CHOSEN_CLASS_NAME, $processorName) ->expectsOutputToContain('Success!') ->expectsOutputToContain("created: $filePath") - ->expectsOutputToContain('Next: Open your new state processor class and start customizing it.') + ->expectsOutputToContain('Next: Open your new State Processor class and start customizing it.') ->assertExitCode(Command::SUCCESS); $this->assertFileExists($filePath); @@ -90,20 +91,18 @@ public function testWhenStateProviderClassAlreadyExists(): void $this->filesystem->delete($existingFile); } - public function testMakeStateProviderCommandWithoutGivenClassName(): void + #[DataProvider('nullProvider')] + public function testMakeStateFilterCommandWithoutGivenClassName(?string $value): void { - $processorName = 'NoEmptyClassName'; - $filePath = $this->pathResolver->generateStateFilename($processorName); - $this->artisan(self::STATE_PROCESSOR_COMMAND) - ->expectsQuestion(self::CHOSEN_CLASS_NAME, '') - ->expectsOutput('[ERROR] This value cannot be blank.') - ->expectsQuestion(self::CHOSEN_CLASS_NAME, $processorName) - ->assertExitCode(Command::SUCCESS); - - $this->assertFileExists($filePath); + ->expectsQuestion(self::CHOSEN_CLASS_NAME, $value) + ->assertExitCode(Command::FAILURE); + } - $this->filesystem->delete($filePath); + public static function nullProvider(): \Generator + { + yield 'null value used' => ['value' => null]; + yield 'empty string used' => ['value' => '']; } /** diff --git a/src/Laravel/Tests/Console/Maker/MakeStateProviderCommandTest.php b/src/Laravel/Tests/Console/Maker/MakeStateProviderCommandTest.php index f7d8c8e8e5b..5334bdb1a91 100644 --- a/src/Laravel/Tests/Console/Maker/MakeStateProviderCommandTest.php +++ b/src/Laravel/Tests/Console/Maker/MakeStateProviderCommandTest.php @@ -20,6 +20,7 @@ use Illuminate\Filesystem\Filesystem; use Orchestra\Testbench\Concerns\WithWorkbench; use Orchestra\Testbench\TestCase; +use PHPUnit\Framework\Attributes\DataProvider; class MakeStateProviderCommandTest extends TestCase { @@ -61,7 +62,7 @@ public function testMakeStateProviderCommand(): void ->expectsQuestion(self::STATE_PROVIDER_CLASS_NAME, $providerName) ->expectsOutputToContain('Success!') ->expectsOutputToContain("created: $filePath") - ->expectsOutputToContain('Next: Open your new state provider class and start customizing it.') + ->expectsOutputToContain('Next: Open your new State Provider class and start customizing it.') ->assertExitCode(Command::SUCCESS); $this->assertFileExists($filePath); @@ -90,20 +91,18 @@ public function testWhenStateProviderClassAlreadyExists(): void $this->filesystem->delete($existingFile); } - public function testMakeStateProviderCommandWithoutGivenClassName(): void + #[DataProvider('nullProvider')] + public function testMakeStateFilterCommandWithoutGivenClassName(?string $value): void { - $providerName = 'NoEmptyClassName'; - $filePath = $this->pathResolver->generateStateFilename($providerName); - $this->artisan(self::MAKE_STATE_PROVIDER_COMMAND) - ->expectsQuestion(self::STATE_PROVIDER_CLASS_NAME, '') - ->expectsOutput('[ERROR] This value cannot be blank.') - ->expectsQuestion(self::STATE_PROVIDER_CLASS_NAME, $providerName) - ->assertExitCode(Command::SUCCESS); - - $this->assertFileExists($filePath); + ->expectsQuestion(self::STATE_PROVIDER_CLASS_NAME, $value) + ->assertExitCode(Command::FAILURE); + } - $this->filesystem->delete($filePath); + public static function nullProvider(): \Generator + { + yield 'null value used' => ['value' => null]; + yield 'empty string used' => ['value' => '']; } /** diff --git a/src/Laravel/Tests/Console/Maker/Utils/PathResolver.php b/src/Laravel/Tests/Console/Maker/Utils/PathResolver.php index ba04cacfbfc..0ddaeb29ddf 100644 --- a/src/Laravel/Tests/Console/Maker/Utils/PathResolver.php +++ b/src/Laravel/Tests/Console/Maker/Utils/PathResolver.php @@ -20,13 +20,13 @@ public function getServiceProviderFilePath(): string return base_path('app/Providers/AppServiceProvider.php'); } - public function generateStateFilename(string $stateFilename): string + public function generateFilterFilename(string $stateFilename): string { - return $this->getStateDirectoryPath().$stateFilename.'.php'; + return \sprintf('%s/app/Filter/%s.php', base_path(), $stateFilename); } - public function getStateDirectoryPath(): string + public function generateStateFilename(string $stateFilename): string { - return base_path('app/State/'); + return \sprintf('%s/app/State/%s.php', base_path(), $stateFilename); } }