Skip to content

Commit 68dcd7f

Browse files
committed
various updates
1 parent 7f506f1 commit 68dcd7f

8 files changed

Lines changed: 956 additions & 28 deletions

File tree

src/Command/ComponentGeneration/Route/RouteTuiGenerator.php

Lines changed: 279 additions & 25 deletions
Large diffs are not rendered by default.

src/Command/ComponentGeneration/Route/RouteTuiManager.php

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,31 @@ public function getAvailableExamples(): array
117117
return array_values(array_unique($names));
118118
}
119119

120+
/** @return array<int, string> */
121+
public function getAvailableTags(): array
122+
{
123+
$sourcePath = (string)$this->parameterBag->get('ehyiah_api_doc.source_path');
124+
$directory = $this->kernel->getProjectDir() . $sourcePath;
125+
126+
$tags = [];
127+
if (is_dir($directory)) {
128+
$finder = new \Symfony\Component\Finder\Finder();
129+
$finder->files()->in($directory)->name(['*.yaml', '*.yml']);
130+
foreach ($finder as $file) {
131+
$config = \Symfony\Component\Yaml\Yaml::parseFile($file->getRealPath());
132+
if (isset($config['documentation']['tags']) && is_array($config['documentation']['tags'])) {
133+
foreach ($config['documentation']['tags'] as $tag) {
134+
if (isset($tag['name'])) {
135+
$tags[] = (string)$tag['name'];
136+
}
137+
}
138+
}
139+
}
140+
}
141+
142+
return array_values(array_unique($tags));
143+
}
144+
120145
public function registerSchema(\Ehyiah\ApiDocBundle\Builder\ApiDocBuilder $builder, string $schemaName): void
121146
{
122147
$builder->addSchema($schemaName)->setRefName($schemaName)->end();
@@ -156,7 +181,7 @@ public function findRouteFile(string $routeName): ?string
156181
/**
157182
* Load existing route configuration from YAML file, returning per-method config.
158183
*
159-
* @return array{methodsConfig: array<string, array{summary: string, description: string, security: string[], requestBodySchema: ?string, requestBodyExample: ?string, responses: array<int, array{schema: ?string, description: string, example: ?string}>, operationId: string}>}
184+
* @return array{methodsConfig: array<string, array{summary: string, description: string, security: string[], tags: string[], requestBodySchema: ?string, requestBodyExample: ?string, responses: array<int, array{schema: ?string, description: string, example: ?string}>, operationId: string}>}
160185
*/
161186
public function loadRouteConfig(string $routeName, string $componentType): array
162187
{
@@ -190,7 +215,7 @@ public function loadRouteConfig(string $routeName, string $componentType): array
190215
*
191216
* @param array<string, mixed> $config
192217
*
193-
* @return array<string, array{summary: string, description: string, security: string[], requestBodySchema: ?string, requestBodyExample: ?string, responses: array<int, array{schema: ?string, description: string, example: ?string}>, operationId: string}>
218+
* @return array<string, array{summary: string, description: string, security: string[], tags: string[], requestBodySchema: ?string, requestBodyExample: ?string, responses: array<int, array{schema: ?string, description: string, example: ?string}>, operationId: string}>
194219
*/
195220
private function extractMethodsConfig(array $config, string $path): array
196221
{
@@ -212,6 +237,11 @@ private function extractMethodsConfig(array $config, string $path): array
212237
}
213238
}
214239

240+
$tags = [];
241+
if (isset($definition['tags']) && is_array($definition['tags'])) {
242+
$tags = array_values(array_unique(array_map('strval', $definition['tags'])));
243+
}
244+
215245
$requestBodySchema = null;
216246
if (isset($definition['requestBody']['content']['application/json']['schema']['$ref'])) {
217247
$requestBodySchema = $this->extractSchemaName($definition['requestBody']['content']['application/json']['schema']['$ref']);
@@ -263,6 +293,7 @@ private function extractMethodsConfig(array $config, string $path): array
263293
'summary' => (string)($definition['summary'] ?? ''),
264294
'description' => (string)($definition['description'] ?? ''),
265295
'security' => array_values(array_unique($security)),
296+
'tags' => $tags,
266297
'requestBodySchema' => $requestBodySchema,
267298
'requestBodyExample' => $requestBodyExample,
268299
'responses' => $responses,

src/Command/ComponentGeneration/Route/RouteTuiState.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ class RouteTuiState
1414
* Key: HTTP method (GET, POST, PUT, DELETE, PATCH)
1515
* Value: method-specific config
1616
*
17-
* @var array<string, array{summary: string, description: string, security: string[], requestBodySchema: ?string, requestBodyExample: ?string, responses: array<int, array{schema: ?string, description: string, example: ?string}>, operationId: string}>
17+
* @var array<string, array{summary: string, description: string, security: string[], tags: string[], requestBodySchema: ?string, requestBodyExample: ?string, responses: array<int, array{schema: ?string, description: string, example: ?string}>, operationId: string}>
1818
*/
1919
public array $methodsConfig = [];
2020
}
Lines changed: 281 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,281 @@
1+
<?php
2+
3+
namespace Ehyiah\ApiDocBundle\Command\ComponentGeneration\Tag;
4+
5+
use Ehyiah\ApiDocBundle\Attributes\AsTuiGenerator;
6+
use Ehyiah\ApiDocBundle\Command\ComponentGeneration\AbstractTuiComponentGenerator;
7+
use Ehyiah\ApiDocBundle\Enum\ComponentType;
8+
use Ehyiah\ApiDocBundle\Helper\LoadApiDocConfigHelper;
9+
use Symfony\Component\Console\Input\InputInterface;
10+
use Symfony\Component\Console\Output\OutputInterface;
11+
use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
12+
use Symfony\Component\HttpKernel\KernelInterface;
13+
use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface;
14+
use Symfony\Component\Tui\Event\CancelEvent;
15+
use Symfony\Component\Tui\Event\SelectEvent;
16+
use Symfony\Component\Tui\Event\SettingChangeEvent;
17+
use Symfony\Component\Tui\Event\SubmitEvent;
18+
use Symfony\Component\Tui\Tui;
19+
use Symfony\Component\Tui\Widget\ContainerWidget;
20+
use Symfony\Component\Tui\Widget\InputWidget;
21+
use Symfony\Component\Tui\Widget\SelectListWidget;
22+
use Symfony\Component\Tui\Widget\SettingItem;
23+
use Symfony\Component\Tui\Widget\SettingsListWidget;
24+
use Symfony\Component\Tui\Widget\TextWidget;
25+
26+
use function Symfony\Component\String\u;
27+
28+
#[AsTuiGenerator]
29+
class TagTuiGenerator extends AbstractTuiComponentGenerator
30+
{
31+
private ?OutputInterface $currentOutput = null;
32+
33+
public function __construct(
34+
private readonly TagTuiManager $manager,
35+
KernelInterface $kernel,
36+
ParameterBagInterface $parameterBag,
37+
PropertyInfoExtractorInterface $propertyInfoExtractor,
38+
LoadApiDocConfigHelper $apiDocConfigHelper,
39+
) {
40+
parent::__construct($kernel, $parameterBag, $propertyInfoExtractor, $apiDocConfigHelper);
41+
}
42+
43+
public function getLabel(): string
44+
{
45+
return 'Tag';
46+
}
47+
48+
public function getDescription(): string
49+
{
50+
return 'Generate an OpenAPI tag for grouping operations';
51+
}
52+
53+
public function isSupported(): bool
54+
{
55+
return true;
56+
}
57+
58+
public function run(Tui $tui, InputInterface $input, OutputInterface $output, callable $onBack): void
59+
{
60+
$this->currentOutput = $output;
61+
$this->showComponentList($tui, $onBack);
62+
}
63+
64+
private function showComponentList(Tui $tui, callable $onBack): void
65+
{
66+
$existing = $this->manager->getExistingComponents();
67+
$choices = [];
68+
foreach ($existing as $name) {
69+
$choices[] = ['value' => $name, 'label' => $this->currentOutput->getFormatter()->format(sprintf(' %-20s', $name))];
70+
}
71+
$choices[] = ['value' => '__new__', 'label' => $this->currentOutput->getFormatter()->format(' <fg=green>[+ New]</fg=green>')];
72+
$choices[] = ['value' => '__back__', 'label' => $this->currentOutput->getFormatter()->format(' <comment>[← Back]</comment>')];
73+
74+
$selectWidget = new SelectListWidget($choices, 12);
75+
76+
$tui->clear();
77+
$container = new ContainerWidget();
78+
$container->expandVertically(true);
79+
$container->add(new TextWidget($this->currentOutput->getFormatter()->format("\n<info>+---------------------------------------------+</info>")));
80+
$container->add(new TextWidget($this->currentOutput->getFormatter()->format('<info>| Tags |</info>')));
81+
$container->add(new TextWidget($this->currentOutput->getFormatter()->format("<info>+---------------------------------------------+</info>\n")));
82+
$container->add($selectWidget);
83+
$container->add(new TextWidget($this->currentOutput->getFormatter()->format("\n<fg=gray>--------------------------------------------------</fg=gray>")));
84+
$container->add(new TextWidget($this->currentOutput->getFormatter()->format('<fg=gray> ↑↓ Navigate ↵ Select Esc Back</fg=gray>')));
85+
$tui->add($container);
86+
$tui->setFocus($selectWidget);
87+
88+
$selectListener = function (SelectEvent $event) use ($tui, $selectWidget, $onBack, &$selectListener, &$cancelListener) {
89+
if ($event->getTarget() !== $selectWidget) {
90+
return;
91+
}
92+
$tui->getEventDispatcher()->removeListener(SelectEvent::class, $selectListener);
93+
$tui->getEventDispatcher()->removeListener(CancelEvent::class, $cancelListener);
94+
95+
$value = $event->getValue();
96+
if ('__back__' === $value) {
97+
$onBack();
98+
99+
return;
100+
}
101+
102+
$state = new TagTuiState();
103+
$state->outputDir = $this->manager->getDefaultDumpLocation();
104+
if ('__new__' !== $value) {
105+
$state->name = $value;
106+
$existing = $this->manager->loadComponentConfig($value);
107+
if (null !== $existing) {
108+
$state->name = $existing['name'];
109+
$state->description = $existing['description'];
110+
$state->externalDocsUrl = $existing['externalDocsUrl'];
111+
$state->externalDocsDescription = $existing['externalDocsDescription'];
112+
}
113+
$state->loadedFrom = $this->manager->findComponentFile($value);
114+
}
115+
$this->showForm($tui, $state, $onBack);
116+
};
117+
118+
$cancelListener = static function (CancelEvent $event) use ($tui, $selectWidget, $onBack, &$selectListener, &$cancelListener) {
119+
if ($event->getTarget() === $selectWidget) {
120+
$tui->getEventDispatcher()->removeListener(SelectEvent::class, $selectListener);
121+
$tui->getEventDispatcher()->removeListener(CancelEvent::class, $cancelListener);
122+
$onBack();
123+
}
124+
};
125+
126+
$tui->addListener($selectListener);
127+
$tui->addListener($cancelListener);
128+
}
129+
130+
private function showForm(Tui $tui, TagTuiState $state, callable $onBack): void
131+
{
132+
$textInputCallback = static function (string $currentValue, callable $onDone) {
133+
$inputWidget = new InputWidget();
134+
$inputWidget->setValue($currentValue);
135+
$inputWidget->setPrompt('Input: ');
136+
$inputWidget->onSubmit(static function (SubmitEvent $event) use ($onDone) {
137+
$onDone($event->getValue());
138+
});
139+
$inputWidget->onCancel(static function (CancelEvent $event) use ($onDone) {
140+
$onDone(null);
141+
});
142+
143+
return $inputWidget;
144+
};
145+
146+
$settingItems = [];
147+
$settingItems[] = new SettingItem('name', 'Name', $state->name, 'Tag name (e.g. Users)', [], $textInputCallback);
148+
$settingItems[] = new SettingItem('desc', 'Description', $state->description, 'Tag description (supports Markdown)', [], $textInputCallback);
149+
$settingItems[] = new SettingItem('externalDocsUrl', 'External Docs URL', $state->externalDocsUrl, 'External documentation URL', [], $textInputCallback);
150+
$settingItems[] = new SettingItem('externalDocsDesc', 'External Docs Description', $state->externalDocsDescription, 'External documentation description', [], $textInputCallback);
151+
152+
$settingItems[] = new SettingItem('format_output', 'Output Format', $state->format_output, 'YAML or PHP', ['yaml', 'php']);
153+
$settingItems[] = new SettingItem('output', 'Output Directory', $state->outputDir, 'Target directory', [], $textInputCallback);
154+
$settingItems[] = new SettingItem('action_validate', 'Save', '✓ Confirm', 'Save and return to the list.', ['✓ Confirm']);
155+
$settingItems[] = new SettingItem('action_cancel', 'Cancel', '← Cancel', 'Return without saving.', ['← Cancel']);
156+
157+
$settingsWidget = new SettingsListWidget($settingItems, 12);
158+
159+
$tui->clear();
160+
$container = new ContainerWidget();
161+
$container->expandVertically(true);
162+
$title = $state->name ? "Edit: {$state->name}" : 'New Tag';
163+
$container->add(new TextWidget($this->currentOutput->getFormatter()->format("\n<info>+---------------------------------------------+</info>")));
164+
$container->add(new TextWidget($this->currentOutput->getFormatter()->format("<info>| {$title}</info>")));
165+
$container->add(new TextWidget($this->currentOutput->getFormatter()->format("<info>+---------------------------------------------+</info>\n")));
166+
$container->add($settingsWidget);
167+
$container->add(new TextWidget($this->currentOutput->getFormatter()->format("\n<fg=gray>--------------------------------------------------</fg=gray>")));
168+
$container->add(new TextWidget($this->currentOutput->getFormatter()->format('<fg=gray> ↵ Save Esc Cancel</fg=gray>')));
169+
$tui->add($container);
170+
$tui->setFocus($settingsWidget);
171+
172+
$changeListener = function (SettingChangeEvent $event) use ($tui, $settingsWidget, $state, $onBack, &$changeListener, &$cancelListener) {
173+
if ($event->getTarget() !== $settingsWidget) {
174+
return;
175+
}
176+
177+
switch ($event->getId()) {
178+
case 'action_cancel':
179+
$tui->getEventDispatcher()->removeListener(SettingChangeEvent::class, $changeListener);
180+
$tui->getEventDispatcher()->removeListener(CancelEvent::class, $cancelListener);
181+
$this->showComponentList($tui, $onBack);
182+
break;
183+
case 'action_validate':
184+
$state->name = $settingsWidget->getValue('name') ?? '';
185+
$state->description = $settingsWidget->getValue('desc') ?? '';
186+
$state->externalDocsUrl = $settingsWidget->getValue('externalDocsUrl') ?? '';
187+
$state->externalDocsDescription = $settingsWidget->getValue('externalDocsDesc') ?? '';
188+
$state->format_output = $settingsWidget->getValue('format_output') ?? 'yaml';
189+
$state->outputDir = $settingsWidget->getValue('output') ?? $this->manager->getDefaultDumpLocation();
190+
191+
$tui->getEventDispatcher()->removeListener(SettingChangeEvent::class, $changeListener);
192+
$tui->getEventDispatcher()->removeListener(CancelEvent::class, $cancelListener);
193+
$tui->stop();
194+
$this->generateComponent($state);
195+
break;
196+
}
197+
};
198+
199+
$cancelListener = function (CancelEvent $event) use ($tui, $settingsWidget, $onBack, &$changeListener, &$cancelListener) {
200+
if ($event->getTarget() === $settingsWidget) {
201+
$tui->getEventDispatcher()->removeListener(SettingChangeEvent::class, $changeListener);
202+
$tui->getEventDispatcher()->removeListener(CancelEvent::class, $cancelListener);
203+
$this->showComponentList($tui, $onBack);
204+
}
205+
};
206+
207+
$tui->addListener($changeListener);
208+
$tui->addListener($cancelListener);
209+
}
210+
211+
private function generateComponent(TagTuiState $state): void
212+
{
213+
$tag = [
214+
'name' => $state->name,
215+
];
216+
217+
if ('' !== $state->description) {
218+
$tag['description'] = $state->description;
219+
}
220+
221+
if ('' !== $state->externalDocsUrl) {
222+
$externalDocs = ['url' => $state->externalDocsUrl];
223+
if ('' !== $state->externalDocsDescription) {
224+
$externalDocs['description'] = $state->externalDocsDescription;
225+
}
226+
$tag['externalDocs'] = $externalDocs;
227+
}
228+
229+
$array = [
230+
'documentation' => [
231+
'tags' => [
232+
$tag,
233+
],
234+
],
235+
];
236+
237+
$destination = ComponentType::Tags->value;
238+
239+
if (null !== $state->loadedFrom && file_exists($state->loadedFrom)) {
240+
$dumpLocation = dirname($state->loadedFrom) . '/' . $state->name . '.yaml';
241+
// Merge with existing config to preserve manually-added fields
242+
$existingConfig = \Symfony\Component\Yaml\Yaml::parseFile($state->loadedFrom);
243+
if (isset($existingConfig['documentation']['tags']) && is_array($existingConfig['documentation']['tags'])) {
244+
$mergedTags = $existingConfig['documentation']['tags'];
245+
// Find and update existing tag or add new one
246+
$found = false;
247+
foreach ($mergedTags as &$existingTag) {
248+
if (isset($existingTag['name']) && $existingTag['name'] === $state->name) {
249+
$existingTag = array_merge($existingTag, $tag);
250+
$found = true;
251+
break;
252+
}
253+
}
254+
unset($existingTag);
255+
if (!$found) {
256+
$mergedTags[] = $tag;
257+
}
258+
$array['documentation']['tags'] = $mergedTags;
259+
}
260+
} else {
261+
$outputDir = u($state->outputDir)->ensureStart('/')->ensureEnd('/');
262+
$dumpDirectory = $this->kernel->getProjectDir() . $outputDir . u($destination)->ensureEnd('/');
263+
264+
if (!is_dir($dumpDirectory)) {
265+
mkdir($dumpDirectory, 0755, true);
266+
}
267+
268+
$dumpLocation = $dumpDirectory . $state->name . '.yaml';
269+
}
270+
271+
if ('yaml' === $state->format_output) {
272+
$this->writeYamlFile($array, $dumpLocation, $this->currentOutput);
273+
} else {
274+
$dumpLocation = str_replace('.yaml', '.php', $dumpLocation);
275+
$phpCode = $this->generatePhpBuilderCode($array, $state->name, $destination);
276+
$this->writePhpFile($phpCode, $dumpLocation, $this->currentOutput);
277+
}
278+
279+
$this->currentOutput->writeln(sprintf('<info>Tag "%s" generated successfully in %s</info>', $state->name, $dumpLocation));
280+
}
281+
}

0 commit comments

Comments
 (0)