diff --git a/src/Symfony/Bundle/Resources/config/maker.php b/src/Symfony/Bundle/Resources/config/maker.php index e05ec7520df..5236fd925f3 100644 --- a/src/Symfony/Bundle/Resources/config/maker.php +++ b/src/Symfony/Bundle/Resources/config/maker.php @@ -27,4 +27,8 @@ $services->set('api_platform.maker.command.filter', 'ApiPlatform\Symfony\Maker\MakeFilter') ->args([param('api_platform.maker.namespace_prefix')]) ->tag('maker.command'); + + $services->set('api_platform.maker.command.api_resource', 'ApiPlatform\Symfony\Maker\MakeApiResource') + ->args([param('api_platform.maker.namespace_prefix')]) + ->tag('maker.command'); }; diff --git a/src/Symfony/Maker/MakeApiResource.php b/src/Symfony/Maker/MakeApiResource.php new file mode 100644 index 00000000000..49522f14c6c --- /dev/null +++ b/src/Symfony/Maker/MakeApiResource.php @@ -0,0 +1,293 @@ + + * + * 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\Symfony\Maker; + +use DateTimeImmutable; +use Symfony\Bundle\MakerBundle\ConsoleStyle; +use Symfony\Bundle\MakerBundle\DependencyBuilder; +use Symfony\Bundle\MakerBundle\Generator; +use Symfony\Bundle\MakerBundle\InputConfiguration; +use Symfony\Bundle\MakerBundle\Maker\AbstractMaker; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Question\Question; +use function count; +use function sprintf; +use Symfony\Component\Validator\Constraints\NotBlank; + +final class MakeApiResource extends AbstractMaker +{ + private const OPERATION_CHOICES = [ + 'Get', + 'GetCollection', + 'Post', + 'Put', + 'Patch', + 'Delete', + ]; + + private const FIELD_TYPES = [ + 'string', + 'int', + 'float', + 'bool', + 'array', + DateTimeImmutable::class, + ]; + + public function __construct(private readonly string $namespacePrefix = '') + { + } + + public static function getCommandName(): string + { + return 'make:api-resource'; + } + + public static function getCommandDescription(): string + { + return 'Creates an API Platform resource'; + } + + public function configureCommand(Command $command, InputConfiguration $inputConfig): void + { + $command + ->addArgument('name', InputArgument::REQUIRED, 'Choose a class name for your API resource (e.g. BookResource)') + ->addOption('namespace-prefix', 'p', InputOption::VALUE_REQUIRED, 'Specify the namespace prefix to use for the resource class', $this->namespacePrefix.'ApiResource') + ->setHelp(file_get_contents(__DIR__.'/Resources/help/MakeApiResource.txt')); + } + + public function configureDependencies(DependencyBuilder $dependencies): void + { + } + + public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void + { + $namespacePrefix = trim($input->getOption('namespace-prefix'), '\\').'\\'; + + [$fields, $validatedFields] = $this->getFields($io); + + $operations = $this->getOperations($io); + + [$providerClass, $providerShort] = $this->getStateProvider($io, $input, $generator, $operations); + [$processorClass, $processorShort] = $this->getStateProcessor($io, $input, $generator, $operations); + + $resourceDetails = $generator->createClassNameDetails($input->getArgument('name'), $namespacePrefix); + + $generator->generateClass( + $resourceDetails->getFullName(), + __DIR__.'/Resources/skeleton/ApiResource.php.tpl', + [ + 'fields' => $fields, + 'operations' => $operations, + 'has_validator' => class_exists(NotBlank::class) && count($validatedFields) > 0, + 'validated_fields' => $validatedFields, + 'provider_class' => $providerClass, + 'provider_short' => $providerShort, + 'processor_class' => $processorClass, + 'processor_short' => $processorShort, + ], + ); + + if ($providerClass) { + $generator->generateClass( + $providerClass, + __DIR__ . '/Resources/skeleton/ApiResourceStateProvider.php.tpl', + [ + 'operations' => $operations, + ] + ); + } + + if ($processorClass) { + $generator->generateClass( + $processorClass, + __DIR__ . '/Resources/skeleton/ApiResourceStateProcessor.php.tpl', + [ + 'operations' => $operations, + ] + ); + } + + $generator->writeChanges(); + + $this->writeSuccessMessage($io); + + $generatedFiles = [$resourceDetails->getFullName()]; + if ($providerClass) { + $generatedFiles[] = $providerClass; + } + if ($processorClass) { + $generatedFiles[] = $processorClass; + } + + $io->text([ + 'Generated classes:', + ...array_map(static fn (string $class) => sprintf(' - %s', $class), $generatedFiles), + ]); + } + + private function getFields(ConsoleStyle $io): array + { + $fields = []; + $validatedFields = []; + $io->writeln(''); + $io->writeln('Add fields to your API resource (press enter with an empty name to stop):'); + while (true) { + $fieldName = $io->ask('Field name (press enter to stop adding fields)'); + if (!$fieldName) { + break; + } + + $question = new Question('Field type (enter ? to see types)', 'string'); + $question->setAutocompleterValues(self::FIELD_TYPES); + $fieldType = $io->askQuestion($question); + + if ('?' === $fieldType) { + foreach (self::FIELD_TYPES as $item) { + $io->writeln(\sprintf(' * %s', $item)); + } + $fieldType = null; + continue; + } + + if ($fieldType && \class_exists('\\'.$fieldType) && \in_array('\\'.$fieldType, self::FIELD_TYPES, true)) { + $fieldType = '\\'.$fieldType; + } + + do { + if ($fieldType && !\in_array($fieldType, self::FIELD_TYPES, true)) { + foreach ($fieldType as $item) { + $io->writeln(\sprintf(' * %s', $item)); + } + $io->error(\sprintf('Invalid field type "%s".', $fieldType)); + $io->writeln(''); + $fieldType = null; + } + } while ($fieldType === null); + + $nullable = $io->confirm('Can this field be null?', false); + + if (!$nullable && $io->confirm('Should this field be validated as not blank/not null?', true)) { + if (!class_exists(NotBlank::class)) { + $io->warning('symfony/validator is not installed. Skipping validation constraint.'); + } else { + $validatedFields[] = $fieldName; + } + } + + $fields[] = [ + 'name' => $fieldName, + 'type' => $fieldType, + 'nullable' => $nullable, + ]; + } + + return [$fields, $validatedFields]; + } + + /** + * @return string[] $operations + */ + private function getOperations(ConsoleStyle $io): array + { + $operations = []; + + $io->writeln(''); + $io->writeln('Select operations for your API resource:'); + while (true) { + $remaining = array_values(array_diff(self::OPERATION_CHOICES, $operations)); + if (0 === count($remaining)) { + break; + } + + $question = new Question('Add operation (enter ? to see all operations, leave empty to skip)'); + $question->setAutocompleterValues($remaining); + $operation = $io->askQuestion($question); + + if (null === $operation) { + break; + } + + if ('?' === $operation) { + foreach ($remaining as $item) { + $io->writeln(\sprintf(' * %s', $item)); + } + $operation = null; + continue; + } + + if ($operation && !\in_array($operation, $remaining, true)) { + foreach ($remaining as $item) { + $io->writeln(\sprintf(' * %s', $item)); + } + $io->error(\sprintf('Invalid operation "%s".', $operation)); + $io->writeln(''); + $operation = null; + continue; + } + + $operations[] = $operation; + $io->writeln(sprintf(' Added %s operation', $operation)); + } + + return $operations; + } + + /** + * @param string[] $operations + * @return array [?string, ?string] + */ + private function getStateProvider(ConsoleStyle $io, InputInterface $input, Generator $generator, array $operations): array + { + $providerClass = null; + $providerShort = null; + + if ($io->confirm('Do you want to create a StateProvider?', false)) { + $providerName = $input->getArgument('name'); + if (!str_ends_with($providerName, 'Provider')) { + $providerName .= 'Provider'; + } + $providerDetails = $generator->createClassNameDetails($providerName, $this->namespacePrefix.'State\\'); + $providerClass = $providerDetails->getFullName(); + $providerShort = $providerDetails->getShortName(); + } + + return [$providerClass, $providerShort]; + } + + /** + * @param string[] $operations + * @return array [?string, ?string] + */ + private function getStateProcessor(ConsoleStyle $io, InputInterface $input, Generator $generator, array $operations): array + { + $processorClass = null; + $processorShort = null; + + if ($io->confirm('Do you want to create a StateProcessor?', false)) { + $processorName = $input->getArgument('name'); + if (!str_ends_with($processorName, 'Processor')) { + $processorName .= 'Processor'; + } + $processorDetails = $generator->createClassNameDetails($processorName, $this->namespacePrefix.'State\\'); + $processorClass = $processorDetails->getFullName(); + $processorShort = $processorDetails->getShortName(); + } + + return [$processorClass, $processorShort]; + } +} diff --git a/src/Symfony/Maker/Resources/help/MakeApiResource.txt b/src/Symfony/Maker/Resources/help/MakeApiResource.txt new file mode 100644 index 00000000000..1ee5ca78f05 --- /dev/null +++ b/src/Symfony/Maker/Resources/help/MakeApiResource.txt @@ -0,0 +1,7 @@ +The %command.name% command generates a new API Platform ApiResource class (DTO) with optional fields, operations, state provider, and state processor. + +php %command.full_name% BookResource + +If the argument is missing, the command will ask for the class name interactively. + +The command will guide you through adding fields, selecting operations, and optionally generating a StateProvider and StateProcessor. diff --git a/src/Symfony/Maker/Resources/skeleton/ApiResource.php.tpl b/src/Symfony/Maker/Resources/skeleton/ApiResource.php.tpl new file mode 100644 index 00000000000..daa5fc6d7ce --- /dev/null +++ b/src/Symfony/Maker/Resources/skeleton/ApiResource.php.tpl @@ -0,0 +1,48 @@ + + +namespace ; + +use ApiPlatform\Metadata\ApiResource; + +use ApiPlatform\Metadata\; + + +use ; + + +use ; + + +use Symfony\Component\Validator\Constraints as Assert; + + +#[ApiResource( + + operations: [ + + new (), + + ], + + + provider: ::class, + + + processor: ::class, + +)] +class +{ + $field): ?> + + + #[Assert\NotBlank] + + public $; + + + + +} diff --git a/src/Symfony/Maker/Resources/skeleton/ApiResourceStateProcessor.php.tpl b/src/Symfony/Maker/Resources/skeleton/ApiResourceStateProcessor.php.tpl new file mode 100644 index 00000000000..53f868c352a --- /dev/null +++ b/src/Symfony/Maker/Resources/skeleton/ApiResourceStateProcessor.php.tpl @@ -0,0 +1,29 @@ + + +namespace ; + + +use ApiPlatform\Metadata\; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; + +class implements ProcessorInterface +{ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + + if ($operation instanceof ) { + // TODO: process state for operation + } + + + + // Handle the state + + + return null; + } +} diff --git a/src/Symfony/Maker/Resources/skeleton/ApiResourceStateProvider.php.tpl b/src/Symfony/Maker/Resources/skeleton/ApiResourceStateProvider.php.tpl new file mode 100644 index 00000000000..db810ff0b47 --- /dev/null +++ b/src/Symfony/Maker/Resources/skeleton/ApiResourceStateProvider.php.tpl @@ -0,0 +1,28 @@ + + +namespace ; + + +use ApiPlatform\Metadata\; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; + +class implements ProviderInterface +{ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + + if ($operation instanceof ) { + // TODO: provide state for operation + } + + + + // Retrieve the state from somewhere + + return null; + } +} diff --git a/tests/Fixtures/Symfony/Maker/EntityApiResource.fixture b/tests/Fixtures/Symfony/Maker/EntityApiResource.fixture new file mode 100644 index 00000000000..5f1d974d2d7 --- /dev/null +++ b/tests/Fixtures/Symfony/Maker/EntityApiResource.fixture @@ -0,0 +1,18 @@ +namespace App\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; +use App\State\BookProcessor; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new Post(), + ], + processor: BookProcessor::class, +)] +class Book +{ + #[Assert\NotBlank] + public string $name; +} diff --git a/tests/Fixtures/Symfony/Maker/EntityApiResourceStateProcessor.fixture b/tests/Fixtures/Symfony/Maker/EntityApiResourceStateProcessor.fixture new file mode 100644 index 00000000000..826d7c9e186 --- /dev/null +++ b/tests/Fixtures/Symfony/Maker/EntityApiResourceStateProcessor.fixture @@ -0,0 +1,18 @@ +namespace App\State; + +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProcessorInterface; + +class BookProcessor implements ProcessorInterface +{ + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + if ($operation instanceof Post) { + // TODO: process state for Post operation + } + + + return null; + } +} diff --git a/tests/Fixtures/Symfony/Maker/FieldsApiResource.fixture b/tests/Fixtures/Symfony/Maker/FieldsApiResource.fixture new file mode 100644 index 00000000000..78ebc1a3006 --- /dev/null +++ b/tests/Fixtures/Symfony/Maker/FieldsApiResource.fixture @@ -0,0 +1,20 @@ +namespace App\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use App\State\BookStateProvider; + +#[ApiResource( + operations: [ + new Get(), + new GetCollection(), + ], + provider: BookStateProvider::class, +)] +class Book +{ + public string $title; + + public ?int $price = null; +} diff --git a/tests/Fixtures/Symfony/Maker/FieldsApiResourceStateProvider.fixture b/tests/Fixtures/Symfony/Maker/FieldsApiResourceStateProvider.fixture new file mode 100644 index 00000000000..d61cbef42fb --- /dev/null +++ b/tests/Fixtures/Symfony/Maker/FieldsApiResourceStateProvider.fixture @@ -0,0 +1,22 @@ +namespace App\State; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; + +class BookStateProvider implements ProviderInterface +{ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + if ($operation instanceof Get) { + // TODO: provide state for Get operation + } + + if ($operation instanceof GetCollection) { + // TODO: provide state for GetCollection operation + } + + return null; + } +} diff --git a/tests/Fixtures/Symfony/Maker/MinimalApiResource.fixture b/tests/Fixtures/Symfony/Maker/MinimalApiResource.fixture new file mode 100644 index 00000000000..8358df3b7c9 --- /dev/null +++ b/tests/Fixtures/Symfony/Maker/MinimalApiResource.fixture @@ -0,0 +1,9 @@ +namespace App\ApiResource; + +use ApiPlatform\Metadata\ApiResource; + +#[ApiResource( +)] +class Minimal +{ +} diff --git a/tests/Fixtures/Symfony/Maker/NamespacedMinimalApiResource.fixture b/tests/Fixtures/Symfony/Maker/NamespacedMinimalApiResource.fixture new file mode 100644 index 00000000000..eaa1398dd27 --- /dev/null +++ b/tests/Fixtures/Symfony/Maker/NamespacedMinimalApiResource.fixture @@ -0,0 +1,9 @@ +namespace App\Api\Resource; + +use ApiPlatform\Metadata\ApiResource; + +#[ApiResource( +)] +class Minimal +{ +} diff --git a/tests/Symfony/Maker/MakeApiResourceTest.php b/tests/Symfony/Maker/MakeApiResourceTest.php new file mode 100644 index 00000000000..fdb1cd61e42 --- /dev/null +++ b/tests/Symfony/Maker/MakeApiResourceTest.php @@ -0,0 +1,114 @@ + + * + * 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\Tests\Symfony\Maker; + +use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Filesystem\Filesystem; + +class MakeApiResourceTest extends KernelTestCase +{ + protected function setup(): void + { + (new Filesystem())->remove(self::tempDir()); + } + + public function testMakeMinimalResource(): void + { + $tester = new CommandTester((new Application(self::bootKernel()))->find('make:api-resource')); + $tester->setInputs([ + '', // no fields + '', // no operations + 'no', // provider + 'no', // processor + ]); + $tester->execute(['name' => 'Minimal']); + + $resourceFile = self::tempFile('src/ApiResource/Minimal.php'); + $this->assertFileExists($resourceFile); + + $expected = preg_replace('~\R~u', "\n", file_get_contents(__DIR__.'/../../Fixtures/Symfony/Maker/MinimalApiResource.fixture')); + $result = preg_replace('~\R~u', "\n", file_get_contents($resourceFile)); + $this->assertStringContainsString($expected, $result); + + $display = $tester->getDisplay(); + $this->assertStringContainsString('Success!', $display); + } + + public function testMakeResourceWithValidation(): void + { + $tester = new CommandTester((new Application(self::bootKernel()))->find('make:api-resource')); + $tester->setInputs([ + 'name', // field name + 'string', // type: string + 'no', // nullable + 'yes', // validated + '', // stop fields + 'Post', // Post + '', // no more operations + 'no', // provider + 'yes', // processor + ]); + $tester->execute(['name' => 'Book']); + + $resourceFile = self::tempFile('src/ApiResource/Book.php'); + $processorFile = self::tempFile('src/State/BookProcessor.php'); + $this->assertFileExists($resourceFile); + $this->assertFileExists($processorFile); + + $expectedResource = preg_replace('~\R~u', "\n", file_get_contents(__DIR__.'/../../Fixtures/Symfony/Maker/EntityApiResource.fixture')); + $resultResource = preg_replace('~\R~u', "\n", file_get_contents($resourceFile)); + $this->assertStringContainsString($expectedResource, $resultResource); + + $expectedProcessor = preg_replace('~\R~u', "\n", file_get_contents(__DIR__.'/../../Fixtures/Symfony/Maker/EntityApiResourceStateProcessor.fixture')); + $resultProcessor = preg_replace('~\R~u', "\n", file_get_contents($processorFile)); + $this->assertStringContainsString($expectedProcessor, $resultProcessor); + + $display = $tester->getDisplay(); + $this->assertStringContainsString('Success!', $display); + } + + public function testMakeResourceWithCustomNamespace(): void + { + $tester = new CommandTester((new Application(self::bootKernel()))->find('make:api-resource')); + $tester->setInputs([ + '', // no fields + '', // no operations + 'no', // provider + 'no', // processor + ]); + $tester->execute(['name' => 'Minimal', '--namespace-prefix' => 'Api\\Resource\\']); + + $resourceFile = self::tempFile('src/Api/Resource/Minimal.php'); + $this->assertFileExists($resourceFile); + + $expected = preg_replace('~\R~u', "\n", file_get_contents(__DIR__.'/../../Fixtures/Symfony/Maker/NamespacedMinimalApiResource.fixture')); + $result = preg_replace('~\R~u', "\n", file_get_contents($resourceFile)); + $this->assertStringContainsString($expected, $result); + + $display = $tester->getDisplay(); + $this->assertStringContainsString('Success!', $display); + } + + private static function tempDir(): string + { + return __DIR__.'/../../Fixtures/app/var/tmp'; + } + + private static function tempFile(string $path): string + { + return \sprintf('%s/%s', self::tempDir(), $path); + } +}