Skip to content

Commit 942c192

Browse files
committed
Add make:api-component command (#145)
Generates a new AbstractComponent entity with optional #[Timestamped], #[Publishable], and #[Uploadable] behaviours. Registered only when MakerBundle is present. Output reminds developers to run make:migration and review the generated migration before executing it.
1 parent e3b1c8a commit 942c192

5 files changed

Lines changed: 363 additions & 0 deletions

File tree

src/DependencyInjection/SilverbackApiComponentsExtension.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,10 @@ private function loadServiceConfig(ContainerBuilder $container): void
258258

259259
$loader->load('services_doctrine_orm_http_cache_purger.php');
260260
$loader->load('services_doctrine_orm_mercure_publisher.php');
261+
262+
if (class_exists(\Symfony\Bundle\MakerBundle\Maker\AbstractMaker::class)) {
263+
$loader->load('services_maker.php');
264+
}
261265
}
262266

263267
public function prepend(ContainerBuilder $container): void

src/Maker/MakeApiComponent.php

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Silverback API Components Bundle Project
5+
*
6+
* (c) Daniel West <daniel@silverback.is>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Silverback\ApiComponentsBundle\Maker;
13+
14+
use Silverback\ApiComponentsBundle\Annotation as Silverback;
15+
use Silverback\ApiComponentsBundle\Entity\Core\AbstractComponent;
16+
use Silverback\ApiComponentsBundle\Entity\Utility\PublishableTrait;
17+
use Silverback\ApiComponentsBundle\Entity\Utility\TimestampedTrait;
18+
use Silverback\ApiComponentsBundle\Entity\Utility\UploadableTrait;
19+
use Symfony\Bundle\MakerBundle\ConsoleStyle;
20+
use Symfony\Bundle\MakerBundle\DependencyBuilder;
21+
use Symfony\Bundle\MakerBundle\Generator;
22+
use Symfony\Bundle\MakerBundle\InputConfiguration;
23+
use Symfony\Bundle\MakerBundle\Maker\AbstractMaker;
24+
use Symfony\Bundle\MakerBundle\Util\UseStatementGenerator;
25+
use Symfony\Component\Console\Command\Command;
26+
use Symfony\Component\Console\Input\InputArgument;
27+
use Symfony\Component\Console\Input\InputInterface;
28+
use Symfony\Component\Console\Input\InputOption;
29+
use Symfony\Component\HttpFoundation\File\File;
30+
31+
final class MakeApiComponent extends AbstractMaker
32+
{
33+
public static function getCommandName(): string
34+
{
35+
return 'make:api-component';
36+
}
37+
38+
public static function getCommandDescription(): string
39+
{
40+
return 'Create a new CWA API component entity';
41+
}
42+
43+
public function configureCommand(Command $command, InputConfiguration $inputConf): void
44+
{
45+
$command
46+
->addArgument('name', InputArgument::OPTIONAL, 'The class name for the component (e.g. <fg=yellow>HeroBlock</>)')
47+
->addOption('timestamped', null, InputOption::VALUE_NONE, 'Add <comment>#[Timestamped]</comment> behaviour (createdAt / updatedAt)')
48+
->addOption('publishable', null, InputOption::VALUE_NONE, 'Add <comment>#[Publishable]</comment> behaviour (draft/published lifecycle)')
49+
->addOption('uploadable', null, InputOption::VALUE_NONE, 'Add <comment>#[Uploadable]</comment> behaviour (includes a file property)');
50+
}
51+
52+
public function interact(InputInterface $input, ConsoleStyle $io, Command $command): void
53+
{
54+
if (!$input->getOption('timestamped')) {
55+
$input->setOption('timestamped', $io->confirm('Add <comment>#[Timestamped]</comment> behaviour (createdAt / updatedAt)?', false));
56+
}
57+
if (!$input->getOption('publishable')) {
58+
$input->setOption('publishable', $io->confirm('Add <comment>#[Publishable]</comment> behaviour (draft/published lifecycle)?', false));
59+
}
60+
if (!$input->getOption('uploadable')) {
61+
$input->setOption('uploadable', $io->confirm('Add <comment>#[Uploadable]</comment> behaviour (includes a file property)?', false));
62+
}
63+
}
64+
65+
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
66+
{
67+
$timestamped = (bool) $input->getOption('timestamped');
68+
$publishable = (bool) $input->getOption('publishable');
69+
$uploadable = (bool) $input->getOption('uploadable');
70+
71+
$classNameDetails = $generator->createClassNameDetails(
72+
$input->getArgument('name'),
73+
'Entity\\Component\\'
74+
);
75+
76+
$useStatements = new UseStatementGenerator([
77+
'ApiPlatform\\Metadata\\ApiResource',
78+
['Doctrine\\ORM\\Mapping' => 'ORM'],
79+
['Silverback\\ApiComponentsBundle\\Annotation' => 'Silverback'],
80+
AbstractComponent::class,
81+
]);
82+
83+
if ($timestamped) {
84+
$useStatements->addUseStatement(TimestampedTrait::class);
85+
}
86+
if ($publishable) {
87+
$useStatements->addUseStatement(PublishableTrait::class);
88+
}
89+
if ($uploadable) {
90+
$useStatements->addUseStatement(UploadableTrait::class);
91+
$useStatements->addUseStatement(File::class);
92+
}
93+
94+
$generator->generateClass(
95+
$classNameDetails->getFullName(),
96+
__DIR__.'/../Resources/skeleton/component/Component.tpl.php',
97+
[
98+
'use_statements' => $useStatements,
99+
'timestamped' => $timestamped,
100+
'publishable' => $publishable,
101+
'uploadable' => $uploadable,
102+
]
103+
);
104+
105+
$generator->writeChanges();
106+
107+
$this->writeSuccessMessage($io);
108+
109+
$io->text([
110+
'Next: run <comment>php bin/console make:migration</comment> to generate the migration for your new component.',
111+
'<fg=yellow>Always review the generated migration</> before running it — adjust column types, lengths, or indexes to match your requirements.',
112+
]);
113+
}
114+
115+
public function configureDependencies(DependencyBuilder $dependencies): void
116+
{
117+
}
118+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Silverback API Components Bundle Project
5+
*
6+
* (c) Daniel West <daniel@silverback.is>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Silverback\ApiComponentsBundle\Resources\config;
13+
14+
use Silverback\ApiComponentsBundle\Maker\MakeApiComponent;
15+
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
16+
17+
return static function (ContainerConfigurator $container): void {
18+
$services = $container->services();
19+
20+
$services->set('silverback.api_components.maker.make_api_component', MakeApiComponent::class)
21+
->tag('maker.command')
22+
->public();
23+
24+
$services->alias(MakeApiComponent::class, 'silverback.api_components.maker.make_api_component');
25+
};
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?= "<?php\n" ?>
2+
3+
namespace <?= $namespace; ?>;
4+
5+
<?= $use_statements; ?>
6+
<?php if ($timestamped): ?>#[Silverback\Timestamped]
7+
<?php endif; ?>
8+
<?php if ($publishable): ?>#[Silverback\Publishable]
9+
<?php endif; ?>
10+
<?php if ($uploadable): ?>#[Silverback\Uploadable]
11+
<?php endif; ?>#[ApiResource]
12+
#[ORM\Entity]
13+
class <?= $class_name ?> extends AbstractComponent
14+
{
15+
<?php if ($timestamped): ?> use TimestampedTrait;
16+
<?php endif; ?>
17+
<?php if ($publishable): ?> use PublishableTrait;
18+
<?php endif; ?>
19+
<?php if ($uploadable): ?> use UploadableTrait;
20+
21+
#[Silverback\UploadableField(adapter: 'local')]
22+
public ?File $file = null;
23+
<?php endif; ?>
24+
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Silverback API Components Bundle Project
5+
*
6+
* (c) Daniel West <daniel@silverback.is>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Silverback\ApiComponentsBundle\Tests\Maker;
13+
14+
use PHPUnit\Framework\TestCase;
15+
use Silverback\ApiComponentsBundle\Maker\MakeApiComponent;
16+
use Symfony\Bundle\MakerBundle\ConsoleStyle;
17+
use Symfony\Bundle\MakerBundle\Generator;
18+
use Symfony\Bundle\MakerBundle\InputConfiguration;
19+
use Symfony\Bundle\MakerBundle\Util\ClassNameDetails;
20+
use Symfony\Component\Console\Application;
21+
use Symfony\Component\Console\Command\Command;
22+
use Symfony\Component\Console\Input\ArrayInput;
23+
use Symfony\Component\Console\Output\BufferedOutput;
24+
25+
class MakeApiComponentTest extends TestCase
26+
{
27+
private function makeMaker(): MakeApiComponent
28+
{
29+
return new MakeApiComponent();
30+
}
31+
32+
private function configuredCommand(): Command
33+
{
34+
$maker = $this->makeMaker();
35+
$command = new Command('make:api-component');
36+
$maker->configureCommand($command, new InputConfiguration());
37+
38+
return $command;
39+
}
40+
41+
private function boundInput(array $params): ArrayInput
42+
{
43+
$command = $this->configuredCommand();
44+
$input = new ArrayInput($params, $command->getDefinition());
45+
$input->setInteractive(false);
46+
47+
return $input;
48+
}
49+
50+
private function makeIo(BufferedOutput $output): ConsoleStyle
51+
{
52+
return new ConsoleStyle(new ArrayInput([]), $output);
53+
}
54+
55+
private function makeGenerator(string $expectedClass, array &$capturedVars): Generator
56+
{
57+
$generator = $this->createMock(Generator::class);
58+
59+
$generator->method('createClassNameDetails')
60+
->willReturn(new ClassNameDetails($expectedClass, 'Entity\\Component\\'));
61+
62+
$generator->expects($this->once())
63+
->method('generateClass')
64+
->willReturnCallback(function (string $className, string $template, array $vars) use (&$capturedVars): string {
65+
$capturedVars = $vars;
66+
67+
return 'src/Entity/Component/'.basename($className).'.php';
68+
});
69+
70+
$generator->expects($this->once())->method('writeChanges');
71+
72+
return $generator;
73+
}
74+
75+
public function testCommandName(): void
76+
{
77+
$this->assertSame('make:api-component', MakeApiComponent::getCommandName());
78+
}
79+
80+
public function testCommandDescription(): void
81+
{
82+
$this->assertNotEmpty(MakeApiComponent::getCommandDescription());
83+
}
84+
85+
public function testGeneratesMinimalComponent(): void
86+
{
87+
$vars = [];
88+
$generator = $this->makeGenerator('App\\Entity\\Component\\MyComponent', $vars);
89+
$input = $this->boundInput(['name' => 'MyComponent', '--timestamped' => false, '--publishable' => false, '--uploadable' => false]);
90+
$output = new BufferedOutput();
91+
92+
$this->makeMaker()->generate($input, $this->makeIo($output), $generator);
93+
94+
$this->assertFalse($vars['timestamped']);
95+
$this->assertFalse($vars['publishable']);
96+
$this->assertFalse($vars['uploadable']);
97+
}
98+
99+
public function testGeneratesWithTimestamped(): void
100+
{
101+
$vars = [];
102+
$generator = $this->makeGenerator('App\\Entity\\Component\\MyComponent', $vars);
103+
$input = $this->boundInput(['name' => 'MyComponent', '--timestamped' => true, '--publishable' => false, '--uploadable' => false]);
104+
$output = new BufferedOutput();
105+
106+
$this->makeMaker()->generate($input, $this->makeIo($output), $generator);
107+
108+
$this->assertTrue($vars['timestamped']);
109+
$this->assertFalse($vars['publishable']);
110+
$this->assertFalse($vars['uploadable']);
111+
}
112+
113+
public function testGeneratesWithPublishable(): void
114+
{
115+
$vars = [];
116+
$generator = $this->makeGenerator('App\\Entity\\Component\\MyComponent', $vars);
117+
$input = $this->boundInput(['name' => 'MyComponent', '--timestamped' => false, '--publishable' => true, '--uploadable' => false]);
118+
$output = new BufferedOutput();
119+
120+
$this->makeMaker()->generate($input, $this->makeIo($output), $generator);
121+
122+
$this->assertFalse($vars['timestamped']);
123+
$this->assertTrue($vars['publishable']);
124+
$this->assertFalse($vars['uploadable']);
125+
}
126+
127+
public function testGeneratesWithUploadable(): void
128+
{
129+
$vars = [];
130+
$generator = $this->makeGenerator('App\\Entity\\Component\\MyComponent', $vars);
131+
$input = $this->boundInput(['name' => 'MyComponent', '--timestamped' => false, '--publishable' => false, '--uploadable' => true]);
132+
$output = new BufferedOutput();
133+
134+
$this->makeMaker()->generate($input, $this->makeIo($output), $generator);
135+
136+
$this->assertFalse($vars['timestamped']);
137+
$this->assertFalse($vars['publishable']);
138+
$this->assertTrue($vars['uploadable']);
139+
}
140+
141+
public function testGeneratesWithAllAnnotations(): void
142+
{
143+
$vars = [];
144+
$generator = $this->makeGenerator('App\\Entity\\Component\\MyComponent', $vars);
145+
$input = $this->boundInput(['name' => 'MyComponent', '--timestamped' => true, '--publishable' => true, '--uploadable' => true]);
146+
$output = new BufferedOutput();
147+
148+
$this->makeMaker()->generate($input, $this->makeIo($output), $generator);
149+
150+
$this->assertTrue($vars['timestamped']);
151+
$this->assertTrue($vars['publishable']);
152+
$this->assertTrue($vars['uploadable']);
153+
}
154+
155+
public function testOutputIncludesMigrationReminder(): void
156+
{
157+
$vars = [];
158+
$generator = $this->makeGenerator('App\\Entity\\Component\\MyComponent', $vars);
159+
$input = $this->boundInput(['name' => 'MyComponent', '--timestamped' => false, '--publishable' => false, '--uploadable' => false]);
160+
$output = new BufferedOutput();
161+
162+
$this->makeMaker()->generate($input, $this->makeIo($output), $generator);
163+
164+
$text = $output->fetch();
165+
$this->assertStringContainsString('make:migration', $text);
166+
$this->assertStringContainsString('review', $text);
167+
}
168+
169+
public function testInteractAsksAllThreeQuestions(): void
170+
{
171+
$command = $this->configuredCommand();
172+
173+
$stream = fopen('php://memory', 'r+');
174+
fwrite($stream, "yes\nno\nno\n");
175+
rewind($stream);
176+
177+
$input = new ArrayInput(['name' => 'MyComponent'], $command->getDefinition());
178+
$input->setStream($stream);
179+
$input->setInteractive(true);
180+
181+
$output = new BufferedOutput();
182+
$io = new ConsoleStyle($input, $output);
183+
184+
$this->makeMaker()->interact($input, $io, $command);
185+
186+
$this->assertTrue($input->getOption('timestamped'));
187+
$this->assertFalse($input->getOption('publishable'));
188+
$this->assertFalse($input->getOption('uploadable'));
189+
190+
fclose($stream);
191+
}
192+
}

0 commit comments

Comments
 (0)