Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion bin/openapi
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,6 @@ $options = $definition->getOptions();
unset($options['version'], $options['no-interaction']);
$definition->setOptions($options);

$app->addCommand(new GenerateCommand($logger));
$app->addCommands([new GenerateCommand($logger)]);
$app->setDefaultCommand('openapi', true);
$app->run($input, $output);
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"phpstan/phpdoc-parser": "^2.0",
"psr/log": "^1.1 || ^2.0 || ^3.0",
"radebatz/type-info-extras": "^1.0.2",
"symfony/console": "^7.4 || ^8.0",
"symfony/console": "^6.4 || ^7.0 || ^8.0",
"symfony/deprecation-contracts": "^2 || ^3",
"symfony/finder": "^5.0 || ^6.0 || ^7.0 || ^8.0",
"symfony/yaml": "^5.4 || ^6.0 || ^7.0 || ^8.0"
Expand Down
49 changes: 37 additions & 12 deletions src/Console/GenerateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@
use OpenApi\Utils\Pipeline;
use OpenApi\Utils\SourceFinder;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Attribute\MapInput;
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\Logger\ConsoleLogger;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
Expand All @@ -21,47 +24,69 @@
name: 'openapi',
description: 'Generate OpenAPI documentation',
)]
class GenerateCommand
class GenerateCommand extends Command
{
public function __construct(
private ConsoleLogger $logger,
) {
parent::__construct();
}

public function __invoke(#[MapInput] GenerateInput $input, SymfonyStyle $io): int
protected function configure(): void
{
$io->setVerbosity($input->debug ? OutputInterface::VERBOSITY_DEBUG : $io->getVerbosity());
$this
->addArgument('paths', InputArgument::REQUIRED | InputArgument::IS_ARRAY, 'Source path(s) to scan')
->addOption('config', 'c', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Generator/Augmenter config; keys differ per mode, see -D (e.g. -c operationId.hash=false)')
->addOption('defaults', 'D', InputOption::VALUE_NONE, 'Show default config')
->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'Path to store the generated documentation (e.g. -o openapi.yaml)')
->addOption('format', 'f', InputOption::VALUE_REQUIRED, 'Force yaml or json', GenerateFormat::AUTO->value)
->addOption('exclude', 'e', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Exclude path(s) (e.g. -e vendor -e library/Zend)')
->addOption('pattern', 'n', InputOption::VALUE_REQUIRED, 'Pattern of files to scan (e.g. -n "/\.(phps|php)$/")', '*.php')
->addOption('bootstrap', 'b', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Bootstrap php file(s) for defining constants, etc. (e.g. -b config/constants.php)')
->addOption('add-processor', 'a', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Register an additional processor')
->addOption('remove-processor', 'r', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Remove an existing processor')
->addOption('version', null, InputOption::VALUE_REQUIRED, 'The OpenAPI version')
->addOption('mode', 'm', InputOption::VALUE_REQUIRED, 'Set mode classic, hybrid or spec', Builder\Mode::CLASSIC->value)
->addOption('debug', 'd', InputOption::VALUE_NONE, 'Show additional error information');
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$generateInput = GenerateInput::fromInput($input);

$io->setVerbosity($generateInput->debug ? OutputInterface::VERBOSITY_DEBUG : $io->getVerbosity());

foreach ($input->getBootstrapFilenames() as $filename) {
foreach ($generateInput->getBootstrapFilenames() as $filename) {
if ($io->isVerbose()) {
$io->info('Bootstrapping: ' . $filename);
}

require_once $filename;
}

if ($input->defaults) {
if ($generateInput->defaults) {
$io->title('Default config');
$io->writeln(json_encode($this->getDefaultConfig($input), JSON_PRETTY_PRINT));
$io->writeln(json_encode($this->getDefaultConfig($generateInput), JSON_PRETTY_PRINT));

return 0;
}

$result = $this->generate($input);
$result = $this->generate($generateInput);

if (!$input->output) {
if ($input->format->isJson()) {
if (!$generateInput->output) {
if ($generateInput->format->isJson()) {
echo $result->toJson();
} else {
echo $result->toYaml();
}
echo "\n";
} else {
$outputPath = $input->output;
$outputPath = $generateInput->output;
if (is_dir($outputPath)) {
$outputPath .= '/openapi.yaml';
}
$result->saveAs($outputPath, $input->format->value);
$result->saveAs($outputPath, $generateInput->format->value);
}

return $this->logger->hasErrored() ? 1 : 0;
Expand Down
77 changes: 61 additions & 16 deletions src/Console/GenerateInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,51 +7,71 @@
namespace OpenApi\Console;

use OpenApi\Builder\Mode;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\InvalidOptionException;
use Symfony\Component\Console\Input\InputInterface;

class GenerateInput
{
#[Argument('Source path(s) to scan')]
public array $paths;
/** @var array<string> */
public array $paths = [];

#[Option('Generator/Augmenter config; keys differ per mode, see -D (e.g. -c operationId.hash=false)', shortcut: 'c')]
/** @var array<string> */
public array $config = [];

#[Option('Show default config', shortcut: 'D')]
public bool $defaults = false;

#[Option('Path to store the generated documentation (e.g. -o openapi.yaml)', shortcut: 'o')]
public ?string $output = null;

#[Option('Force yaml or json', shortcut: 'f')]
public GenerateFormat $format = GenerateFormat::AUTO;

#[Option('Exclude path(s) (e.g. -e vendor -e library/Zend)', shortcut: 'e')]
/** @var array<string> */
public array $exclude = [];

#[Option('Pattern of files to scan (e.g. -n "/\.(phps|php)$/")', shortcut: 'n')]
public string $pattern = '*.php';

#[Option('Bootstrap php file(s) for defining constants, etc. (e.g. -b config/constants.php)', shortcut: 'b')]
/** @var array<string> */
public array $bootstrap = [];

#[Option('Register an additional processor', shortcut: 'a')]
/** @var array<string> */
public array $addProcessor = [];

#[Option('Remove an existing processor', shortcut: 'r')]
/** @var array<string> */
public array $removeProcessor = [];

#[Option('The OpenAPI version')]
public ?string $version = null;

#[Option('Set mode classic, hybrid or spec', shortcut: 'm')]
public Mode $mode = Mode::CLASSIC;

#[Option('Show additional error information', shortcut: 'd')]
public bool $debug = false;

/**
* Map the console input onto this data object.
*
* The console definition lives in {@see GenerateCommand::configure()}; the two
* are aligned by hand. Option names arrive kebab-cased, properties are camelCase.
*/
public static function fromInput(InputInterface $input): self
{
$generateInput = new self();

$generateInput->paths = $input->getArgument('paths');
$generateInput->config = $input->getOption('config');
$generateInput->defaults = (bool) $input->getOption('defaults');
$generateInput->output = $input->getOption('output');
$generateInput->format = self::enum(GenerateFormat::class, 'format', $input->getOption('format'));
$generateInput->exclude = $input->getOption('exclude');
$generateInput->pattern = $input->getOption('pattern');
$generateInput->bootstrap = $input->getOption('bootstrap');
$generateInput->addProcessor = $input->getOption('add-processor');
$generateInput->removeProcessor = $input->getOption('remove-processor');
$generateInput->version = $input->getOption('version');
$generateInput->mode = self::enum(Mode::class, 'mode', $input->getOption('mode'));
$generateInput->debug = (bool) $input->getOption('debug');

return $generateInput;
}

/**
* @return iterable<string>
*/
Expand All @@ -67,4 +87,29 @@ public function getBootstrapFilenames(): iterable
yield from $filenames;
}
}

/**
* Resolve a backed enum option, reporting unknown values the way Symfony does.
*
* @template T of \BackedEnum
*
* @param class-string<T> $enum
*
* @return T
*/
protected static function enum(string $enum, string $name, string $value): \BackedEnum
{
$case = $enum::tryFrom($value);

if ($case === null) {
throw new InvalidOptionException(sprintf(
'The value "%s" is not valid for the "%s" option. Supported values are "%s".',
$value,
$name,
implode('", "', array_column($enum::cases(), 'value'))
));
}

return $case;
}
}
17 changes: 17 additions & 0 deletions tests/CommandlineTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,23 @@ public function testMissingArg(): void
$this->assertStringContainsString('The "--exclude" option requires a value.', $output);
}

public static function invalidEnumOptionCases(): iterable
{
yield 'format' => ['-f xml', 'The value "xml" is not valid for the "format" option. Supported values are "json", "yaml", "auto".'];
yield 'mode' => ['-m bogus', 'The value "bogus" is not valid for the "mode" option. Supported values are "classic", "hybrid", "spec".'];
}

#[DataProvider('invalidEnumOptionCases')]
public function testInvalidEnumOption(string $args, string $expected): void
{
$basePath = self::examplePath('petstore');
$path = "{$basePath}/annotations";
exec($this->getCommandToExecute(__DIR__ . '/../bin/openapi ' . $args . ' ' . escapeshellarg($path) . ' 2>&1'), $output, $retval);

$this->assertSame(1, $retval);
$this->assertStringContainsString($expected, (string) preg_replace('/\s+/', ' ', implode(' ', $output)));
}

public static function versionCases(): iterable
{
yield 'default' => ['', '3.1.0'];
Expand Down
25 changes: 24 additions & 1 deletion tests/DocsAccuracyTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,11 @@ public function testCliHelpOutputMatchesDocs(): void
$this->assertSame(0, $ret);
$actual = implode("\n", $lines);

$this->assertSame($documented, $actual, 'openapi -h output has drifted from docs/guide/generating-openapi-documents.md');
$this->assertSame(
$this->ownHelpOptions($documented),
$this->ownHelpOptions($actual),
'openapi -h output has drifted from docs/guide/generating-openapi-documents.md'
);
}

public function testIsRootClassificationMatchesDocs(): void
Expand Down Expand Up @@ -221,6 +225,25 @@ public function testNoRequestBodyOnReadOnlyOperations(): void
}
}

/**
* Drop the built-in options Symfony Console appends, from `-h, --help` on.
*
* Their wording differs per Symfony version, so only the part the command
* itself defines is compared. The docs still show the full help output.
*/
private function ownHelpOptions(string $help): string
{
$lines = explode("\n", $help);

foreach ($lines as $i => $line) {
if (str_starts_with(ltrim($line), '-h, --help')) {
return implode("\n", array_slice($lines, 0, $i));
}
}

$this->fail('Could not find the "-h, --help" line in the help output');
}

/**
* @return list<class-string<AttributeInterface>>
*/
Expand Down