Skip to content

Commit 638e154

Browse files
committed
Add a maker to create an API Resource
1 parent 05a5d4d commit 638e154

13 files changed

+619
-0
lines changed

src/Symfony/Bundle/Resources/config/maker.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
1515

16+
use ApiPlatform\Symfony\Maker\MakeApiResource;
1617
use ApiPlatform\Symfony\Maker\MakeFilter;
1718
use ApiPlatform\Symfony\Maker\MakeStateProcessor;
1819
use ApiPlatform\Symfony\Maker\MakeStateProvider;
@@ -31,4 +32,8 @@
3132
$services->set('api_platform.maker.command.filter', MakeFilter::class)
3233
->args([param('api_platform.maker.namespace_prefix')])
3334
->tag('maker.command');
35+
36+
$services->set('api_platform.maker.command.api_resource', MakeApiResource::class)
37+
->args([param('api_platform.maker.namespace_prefix')])
38+
->tag('maker.command');
3439
};
Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the API Platform project.
5+
*
6+
* (c) Kévin Dunglas <dunglas@gmail.com>
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+
declare(strict_types=1);
13+
14+
namespace ApiPlatform\Symfony\Maker;
15+
16+
use Symfony\Bundle\MakerBundle\ConsoleStyle;
17+
use Symfony\Bundle\MakerBundle\DependencyBuilder;
18+
use Symfony\Bundle\MakerBundle\Generator;
19+
use Symfony\Bundle\MakerBundle\InputConfiguration;
20+
use Symfony\Bundle\MakerBundle\Maker\AbstractMaker;
21+
use Symfony\Component\Console\Command\Command;
22+
use Symfony\Component\Console\Input\InputArgument;
23+
use Symfony\Component\Console\Input\InputInterface;
24+
use Symfony\Component\Console\Input\InputOption;
25+
use Symfony\Component\Console\Question\Question;
26+
use Symfony\Component\Validator\Constraints\NotBlank;
27+
28+
final class MakeApiResource extends AbstractMaker
29+
{
30+
private const OPERATION_CHOICES = [
31+
'Get',
32+
'GetCollection',
33+
'Post',
34+
'Put',
35+
'Patch',
36+
'Delete',
37+
];
38+
39+
private const FIELD_TYPES = [
40+
'string',
41+
'int',
42+
'float',
43+
'bool',
44+
'array',
45+
\DateTimeImmutable::class,
46+
];
47+
48+
public function __construct(private readonly string $namespacePrefix = '')
49+
{
50+
}
51+
52+
public static function getCommandName(): string
53+
{
54+
return 'make:api-resource';
55+
}
56+
57+
public static function getCommandDescription(): string
58+
{
59+
return 'Creates an API Platform resource';
60+
}
61+
62+
public function configureCommand(Command $command, InputConfiguration $inputConfig): void
63+
{
64+
$command
65+
->addArgument('name', InputArgument::REQUIRED, 'Choose a class name for your API resource (e.g. <fg=yellow>BookResource</>)')
66+
->addOption('namespace-prefix', 'p', InputOption::VALUE_REQUIRED, 'Specify the namespace prefix to use for the resource class', $this->namespacePrefix.'ApiResource')
67+
->setHelp(file_get_contents(__DIR__.'/Resources/help/MakeApiResource.txt'));
68+
}
69+
70+
public function configureDependencies(DependencyBuilder $dependencies): void
71+
{
72+
}
73+
74+
public function generate(InputInterface $input, ConsoleStyle $io, Generator $generator): void
75+
{
76+
$namespacePrefix = trim($input->getOption('namespace-prefix'), '\\').'\\';
77+
78+
[$fields, $validatedFields] = $this->getFields($io);
79+
80+
$operations = $this->getOperations($io);
81+
82+
[$providerClass, $providerShort] = $this->getStateProvider($io, $input, $generator, $operations);
83+
[$processorClass, $processorShort] = $this->getStateProcessor($io, $input, $generator, $operations);
84+
85+
$resourceDetails = $generator->createClassNameDetails($input->getArgument('name'), $namespacePrefix);
86+
87+
$generator->generateClass(
88+
$resourceDetails->getFullName(),
89+
__DIR__.'/Resources/skeleton/ApiResource.php.tpl',
90+
[
91+
'fields' => $fields,
92+
'operations' => $operations,
93+
'has_validator' => class_exists(NotBlank::class) && \count($validatedFields) > 0,
94+
'validated_fields' => $validatedFields,
95+
'provider_class' => $providerClass,
96+
'provider_short' => $providerShort,
97+
'processor_class' => $processorClass,
98+
'processor_short' => $processorShort,
99+
],
100+
);
101+
102+
if ($providerClass) {
103+
$generator->generateClass(
104+
$providerClass,
105+
__DIR__.'/Resources/skeleton/ApiResourceStateProvider.php.tpl',
106+
[
107+
'operations' => $operations,
108+
]
109+
);
110+
}
111+
112+
if ($processorClass) {
113+
$generator->generateClass(
114+
$processorClass,
115+
__DIR__.'/Resources/skeleton/ApiResourceStateProcessor.php.tpl',
116+
[
117+
'operations' => $operations,
118+
]
119+
);
120+
}
121+
122+
$generator->writeChanges();
123+
124+
$this->writeSuccessMessage($io);
125+
126+
$generatedFiles = [$resourceDetails->getFullName()];
127+
if ($providerClass) {
128+
$generatedFiles[] = $providerClass;
129+
}
130+
if ($processorClass) {
131+
$generatedFiles[] = $processorClass;
132+
}
133+
134+
$io->text([
135+
'Generated classes:',
136+
...array_map(static fn (string $class) => \sprintf(' - <info>%s</info>', $class), $generatedFiles),
137+
]);
138+
}
139+
140+
private function getFields(ConsoleStyle $io): array
141+
{
142+
$fields = [];
143+
$validatedFields = [];
144+
$io->writeln('');
145+
$io->writeln('Add fields to your API resource (press <info>enter</info> with an empty name to stop):');
146+
while (true) {
147+
$fieldName = $io->ask('Field name (press <info>enter</info> to stop adding fields)');
148+
if (!$fieldName) {
149+
break;
150+
}
151+
152+
$question = new Question('Field type (enter <comment>?</comment> to see types)', 'string');
153+
$question->setAutocompleterValues(self::FIELD_TYPES);
154+
$fieldType = $io->askQuestion($question);
155+
156+
if ('?' === $fieldType) {
157+
foreach (self::FIELD_TYPES as $item) {
158+
$io->writeln(\sprintf(' * <comment>%s</>', $item));
159+
}
160+
$fieldType = null;
161+
continue;
162+
}
163+
164+
if ($fieldType && class_exists('\\'.$fieldType) && \in_array('\\'.$fieldType, self::FIELD_TYPES, true)) {
165+
$fieldType = '\\'.$fieldType;
166+
}
167+
168+
do {
169+
if ($fieldType && !\in_array($fieldType, self::FIELD_TYPES, true)) {
170+
foreach ($fieldType as $item) {
171+
$io->writeln(\sprintf(' * <comment>%s</>', $item));
172+
}
173+
$io->error(\sprintf('Invalid field type "%s".', $fieldType));
174+
$io->writeln('');
175+
$fieldType = null;
176+
}
177+
} while (null === $fieldType);
178+
179+
$nullable = $io->confirm('Can this field be null?', false);
180+
181+
if (!$nullable && $io->confirm('Should this field be validated as not blank/not null?', true)) {
182+
if (!class_exists(NotBlank::class)) {
183+
$io->warning('symfony/validator is not installed. Skipping validation constraint.');
184+
} else {
185+
$validatedFields[] = $fieldName;
186+
}
187+
}
188+
189+
$fields[] = [
190+
'name' => $fieldName,
191+
'type' => $fieldType,
192+
'nullable' => $nullable,
193+
];
194+
}
195+
196+
return [$fields, $validatedFields];
197+
}
198+
199+
/**
200+
* @return string[] $operations
201+
*/
202+
private function getOperations(ConsoleStyle $io): array
203+
{
204+
$operations = [];
205+
206+
$io->writeln('');
207+
$io->writeln('Select operations for your API resource:');
208+
while (true) {
209+
$remaining = array_values(array_diff(self::OPERATION_CHOICES, $operations));
210+
if (0 === \count($remaining)) {
211+
break;
212+
}
213+
214+
$question = new Question('Add operation (enter <comment>?</comment> to see all operations, leave empty to skip)');
215+
$question->setAutocompleterValues($remaining);
216+
$operation = $io->askQuestion($question);
217+
218+
if (null === $operation) {
219+
break;
220+
}
221+
222+
if ('?' === $operation) {
223+
foreach ($remaining as $item) {
224+
$io->writeln(\sprintf(' * <comment>%s</>', $item));
225+
}
226+
$operation = null;
227+
continue;
228+
}
229+
230+
if ($operation && !\in_array($operation, $remaining, true)) {
231+
foreach ($remaining as $item) {
232+
$io->writeln(\sprintf(' * <comment>%s</>', $item));
233+
}
234+
$io->error(\sprintf('Invalid operation "%s".', $operation));
235+
$io->writeln('');
236+
$operation = null;
237+
continue;
238+
}
239+
240+
$operations[] = $operation;
241+
$io->writeln(\sprintf(' <info>✓</info> Added <comment>%s</comment> operation', $operation));
242+
}
243+
244+
return $operations;
245+
}
246+
247+
/**
248+
* @param string[] $operations
249+
*
250+
* @return array [?string, ?string]
251+
*/
252+
private function getStateProvider(ConsoleStyle $io, InputInterface $input, Generator $generator, array $operations): array
253+
{
254+
$providerClass = null;
255+
$providerShort = null;
256+
257+
if ($io->confirm('Do you want to create a StateProvider?', false)) {
258+
$providerName = $input->getArgument('name');
259+
if (!str_ends_with($providerName, 'Provider')) {
260+
$providerName .= 'Provider';
261+
}
262+
$providerDetails = $generator->createClassNameDetails($providerName, $this->namespacePrefix.'State\\');
263+
$providerClass = $providerDetails->getFullName();
264+
$providerShort = $providerDetails->getShortName();
265+
}
266+
267+
return [$providerClass, $providerShort];
268+
}
269+
270+
/**
271+
* @param string[] $operations
272+
*
273+
* @return array [?string, ?string]
274+
*/
275+
private function getStateProcessor(ConsoleStyle $io, InputInterface $input, Generator $generator, array $operations): array
276+
{
277+
$processorClass = null;
278+
$processorShort = null;
279+
280+
if ($io->confirm('Do you want to create a StateProcessor?', false)) {
281+
$processorName = $input->getArgument('name');
282+
if (!str_ends_with($processorName, 'Processor')) {
283+
$processorName .= 'Processor';
284+
}
285+
$processorDetails = $generator->createClassNameDetails($processorName, $this->namespacePrefix.'State\\');
286+
$processorClass = $processorDetails->getFullName();
287+
$processorShort = $processorDetails->getShortName();
288+
}
289+
290+
return [$processorClass, $processorShort];
291+
}
292+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
The <info>%command.name%</info> command generates a new API Platform ApiResource class (DTO) with optional fields, operations, state provider, and state processor.
2+
3+
<info>php %command.full_name% BookResource</info>
4+
5+
If the argument is missing, the command will ask for the class name interactively.
6+
7+
The command will guide you through adding <info>fields</info>, selecting <info>operations</info>, and optionally generating a <info>StateProvider</info> and <info>StateProcessor</info>.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php declare(strict_types=1);
2+
echo "<?php\n";
3+
?>
4+
5+
namespace <?php echo $namespace; ?>;
6+
7+
use ApiPlatform\Metadata\ApiResource;
8+
<?php foreach ($operations as $op): ?>
9+
use ApiPlatform\Metadata\<?php echo $op; ?>;
10+
<?php endforeach; ?>
11+
<?php if ($provider_class): ?>
12+
use <?php echo $provider_class; ?>;
13+
<?php endif; ?>
14+
<?php if ($processor_class): ?>
15+
use <?php echo $processor_class; ?>;
16+
<?php endif; ?>
17+
<?php if ($has_validator): ?>
18+
use Symfony\Component\Validator\Constraints as Assert;
19+
<?php endif; ?>
20+
21+
#[ApiResource(
22+
<?php if ($operations): ?>
23+
operations: [
24+
<?php foreach ($operations as $op): ?>
25+
new <?php echo $op; ?>(),
26+
<?php endforeach; ?>
27+
],
28+
<?php endif; ?>
29+
<?php if ($provider_class): ?>
30+
provider: <?php echo $provider_short; ?>::class,
31+
<?php endif; ?>
32+
<?php if ($processor_class): ?>
33+
processor: <?php echo $processor_short; ?>::class,
34+
<?php endif; ?>
35+
)]
36+
class <?php echo $class_name."\n"; ?>
37+
{
38+
<?php foreach ($fields as $i => $field): ?>
39+
<?php $type = $field['nullable'] ? '?'.$field['type'] : $field['type']; ?>
40+
<?php if (in_array($field['name'], $validated_fields, true)): ?>
41+
#[Assert\NotBlank]
42+
<?php endif; ?>
43+
public <?php echo $type; ?> $<?php echo $field['name']; ?><?php echo $field['nullable'] ? ' = null' : ''; ?>;
44+
<?php if ($i < count($fields) - 1): ?>
45+
46+
<?php endif; ?>
47+
<?php endforeach; ?>
48+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<?php declare(strict_types=1);
2+
echo "<?php\n";
3+
?>
4+
5+
namespace <?php echo $namespace; ?>;
6+
7+
<?php foreach ($operations as $op): ?>
8+
use ApiPlatform\Metadata\<?php echo $op; ?>;
9+
<?php endforeach; ?>
10+
use ApiPlatform\Metadata\Operation;
11+
use ApiPlatform\State\ProcessorInterface;
12+
13+
class <?php echo $class_name; ?> implements ProcessorInterface
14+
{
15+
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): object|array|null
16+
{
17+
<?php foreach ($operations as $op): ?>
18+
if ($operation instanceof <?php echo $op; ?>) {
19+
// TODO: process state for <?php echo $op; ?> operation
20+
}
21+
22+
<?php endforeach; ?>
23+
<?php if (!$operations): ?>
24+
// Handle the state
25+
<?php endif; ?>
26+
27+
return null;
28+
}
29+
}

0 commit comments

Comments
 (0)