diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index 4401862..96773c9 100644 --- a/Annotations/AnnotationGenerator.php +++ b/Annotations/AnnotationGenerator.php @@ -357,7 +357,7 @@ public function getResponseInfoFromDocBlock(string $docBlock): array $responseInfo['description'] = 'Response of unknown type'; } if (!empty($returnTag->getDescription())) { - $responseInfo['description'] = $returnTag->getDescription(); + $responseInfo['description'] = $this->getDescriptionText($returnTag->getDescription()); } return $responseInfo; @@ -547,6 +547,22 @@ protected function normaliseDescriptionText(string $description): string return str_replace('"', '""', $description); } + /** + * Normalise phpDocumentor description values into plain strings. + * + * @param mixed $description + * + * @return string + */ + protected function getDescriptionText($description): string + { + if ($description instanceof Description) { + return $description->getBodyTemplate(); + } + + return is_string($description) ? $description : ''; + } + /** * Add an entry to the map of warnings about missing important information, like type and description of parameters * and returns. @@ -1149,10 +1165,7 @@ protected function determineResponses(array $rules, string $plugin, string $meth $successArray['ref'] = '#/components/responses/GenericSuccess'; } - $description = $responseInfo['description'] ?? null; - if ($description instanceof Description) { - $description = $description->getBodyTemplate(); - } + $description = $this->getDescriptionText($responseInfo['description'] ?? null); // If it's a generic type and there's no custom description, use one of the global generic responses if (empty($successArray['ref']) && !empty($responseInfo['type']) && empty($description)) { @@ -1927,17 +1940,19 @@ public function compileOperationLines(string $path, string $opId, string $plugin $operationValuesMap[] = ['@OA\Parameter' => $paramMap]; } foreach ($responses as $response) { + $responseDescription = $this->getDescriptionText($response['description'] ?? null); + // Don't use the reference if there are media type examples if (isset($response['ref']) && empty($response['mediaTypes'])) { $code = $response['code']; $codeFormatted = is_numeric($code) ? (string)$code : '"' . $code . '"'; - $description = !empty($response['description']) && strpos($response['description'], 'Example links: [') !== false - ? ', description="' . $this->normaliseDescriptionText($response['description']) . '"' : ''; + $description = $responseDescription !== '' && strpos($responseDescription, 'Example links: [') !== false + ? ', description="' . $this->normaliseDescriptionText($responseDescription) . '"' : ''; $operationValuesMap[] = '@OA\Response(response=' . $codeFormatted . $description . ', ref="' . $response['ref'] . '")'; } else { $responsePropertyArray = [ 'response=200', - 'description="' . $this->normaliseDescriptionText($response['description'] ?? 'OK') . '"', + 'description="' . $this->normaliseDescriptionText($responseDescription !== '' ? $responseDescription : 'OK') . '"', ]; if (!empty($response['schema'])) { $responsePropertyArray = array_merge($responsePropertyArray, $response['schema']); diff --git a/Commands/GenerateSpecFile.php b/Commands/GenerateSpecFile.php index cda0062..c593de5 100644 --- a/Commands/GenerateSpecFile.php +++ b/Commands/GenerateSpecFile.php @@ -11,8 +11,8 @@ use Piwik\Container\StaticContainer; use Piwik\Plugin\ConsoleCommand; -use Piwik\Plugins\OpenApiDocs\Annotations\AnnotationGenerator; -use Piwik\Plugins\OpenApiDocs\Specs\SpecGenerator; +use Piwik\Plugins\OpenApiDocs\Generation\SpecGenerationService; +use Piwik\Plugins\OpenApiDocs\OpenApiDocs; /** * This class lets you define a new command. To read more about commands have a look at our Matomo Console guide on @@ -23,6 +23,18 @@ */ class GenerateSpecFile extends ConsoleCommand { + /** + * @var SpecGenerationService + */ + private $specGenerationService; + + public function __construct(?SpecGenerationService $specGenerationService = null) + { + $this->specGenerationService = $specGenerationService ?: StaticContainer::get(SpecGenerationService::class); + + parent::__construct(); + } + /** * This method allows you to configure your command. Here you can define the name and description of your command * as well as all options and arguments you expect when executing it. @@ -31,7 +43,7 @@ protected function configure() { $this->setName('openapidocs:generate-spec-file'); $this->setDescription('Generate the OpenAPI documentation file for the Matomo APIs.'); - $this->addRequiredValueOption('plugin', 'p', 'Name of the plugin to document, use all to process every plugin'); + $this->addRequiredValueOption('plugin', 'p', 'Name of the plugin to document. Multiple plugins can be comma-separated'); $this->addRequiredValueOption('format', 'f', 'Format of the spec file (JSON or YAML). Default is JSON'); $this->addRequiredValueOption('api-version', null, 'Version of the spec file. Default is 1.0.0'); $this->addNoValueOption('not-dry-run', null, 'Flag to allow writing to file instead of outputting a dry run.'); @@ -78,17 +90,15 @@ protected function doExecute(): int $plugin = $input->getOption('plugin'); - if (empty($plugin)) { throw new \RuntimeException('Please specify a plugin name.'); } - if (strtolower($plugin) == 'all') { - $plugins = require __DIR__ . '/../config/plugins.php'; - $plugin = implode(',', $plugins); - } + $pluginNames = array_values(array_filter(array_map('trim', explode(',', $plugin)), static function (string $pluginName): bool { + return $pluginName !== ''; + })); $format = $input->getOption('format') ?: 'json'; - $version = $input->getOption('version') ?: '1.0.0'; + $version = $input->getOption('api-version') ?: OpenApiDocs::DEFAULT_SPEC_VERSION; $notDryRun = $input->getOption('not-dry-run') ?: false; $addAnnotations = $input->getOption('add-annotations') ?: false; @@ -96,24 +106,27 @@ protected function doExecute(): int $output->writeln($message); + $result = $this->specGenerationService->generateSpecForPlugins( + $plugin, + $format, + $version, + $notDryRun, + $addAnnotations + ); + if ($addAnnotations) { - $pluginsArray = explode(',', $plugin); - foreach ($pluginsArray as $pluginName) { - (StaticContainer::get(AnnotationGenerator::class))->generatePluginApiAnnotations($pluginName, true); + foreach ($pluginNames as $pluginName) { $output->writeln('Created Annotations for ' . $pluginName . ' and wrote results to plugins/OpenApiDocs/tmp/annotations.'); } } - $result = (new SpecGenerator())->generatePluginDoc($plugin, $format, $version, $notDryRun); - if ($notDryRun) { $output->writeln('Results written to plugins/OpenApiDocs/tmp/specs/ directory.'); - - return $result ? self::SUCCESS : self::FAILURE; + return self::SUCCESS; } $output->writeln($result); - return $result ? self::SUCCESS : self::FAILURE; + return self::SUCCESS; } } diff --git a/Generation/SpecGenerationService.php b/Generation/SpecGenerationService.php new file mode 100644 index 0000000..2fc4dcf --- /dev/null +++ b/Generation/SpecGenerationService.php @@ -0,0 +1,88 @@ +annotationGenerator = $annotationGenerator; + $this->specGenerator = $specGenerator; + } + + /** + * Generate an OpenAPI spec for one or more comma-separated plugin names. + * + * @param string $pluginNames Comma-separated plugin names to include in the generated spec. + * @param string $format Output format for the spec, for example `json` or `yaml`. + * @param string $version Version string written into the generated OpenAPI spec. + * @param bool $writeToFile Whether the generated spec should also be written to the plugin tmp specs directory. + * @param bool $addAnnotations Whether API annotations should be regenerated before building the spec. + * @return string The generated OpenAPI spec contents. + * @throws \RuntimeException If no non-empty plugin names are provided. + */ + public function generateSpecForPlugins( + string $pluginNames, + string $format = 'json', + string $version = OpenApiDocs::DEFAULT_SPEC_VERSION, + bool $writeToFile = false, + bool $addAnnotations = false + ): string { + $parsedPluginNames = $this->getPluginNames($pluginNames); + + if ($addAnnotations) { + $this->generateAnnotations($parsedPluginNames); + } + + return $this->specGenerator->generateSpec($parsedPluginNames, $format, $version, $writeToFile); + } + + /** + * @param string[] $pluginNames + */ + private function generateAnnotations(array $pluginNames): void + { + foreach ($pluginNames as $pluginName) { + $this->annotationGenerator->generatePluginApiAnnotations($pluginName, true); + } + } + + /** + * @return string[] + */ + private function getPluginNames(string $pluginNames): array + { + $plugins = array_filter(array_map('trim', explode(',', $pluginNames)), static function (string $pluginName): bool { + return $pluginName !== ''; + }); + + if (empty($plugins)) { + throw new \RuntimeException('At least one plugin name is required.'); + } + + return array_values($plugins); + } +} diff --git a/Specs/SpecGenerator.php b/Specs/SpecGenerator.php index efd272c..c7f8bfb 100644 --- a/Specs/SpecGenerator.php +++ b/Specs/SpecGenerator.php @@ -44,14 +44,6 @@ public function __construct() */ public function generatePluginDoc(string $pluginName, string $format = 'json', string $version = OpenApiDocs::DEFAULT_SPEC_VERSION, bool $writeToFile = false): string { - BaseValidator::check('plugin', $pluginName, [new NotEmpty()]); - - foreach (explode(',', $pluginName) as $currentPluginName) { - if (in_array($currentPluginName, OpenApiDocs::PLUGIN_BLOCKLIST, true)) { - throw new \RuntimeException('OpenAPI doc generation is blocked for ' . $currentPluginName . '.'); - } - } - return $this->generateSpec(explode(',', $pluginName), $format, $version, $writeToFile); } @@ -74,6 +66,12 @@ public function generateSpec(array $pluginNames, string $format = 'json', string BaseValidator::check('pluginNames', $pluginNames, [new NotEmpty()]); $currentPluginDir = Manager::getInstance()::getPluginDirectory('OpenApiDocs'); + foreach ($pluginNames as $currentPluginName) { + if (in_array($currentPluginName, OpenApiDocs::PLUGIN_BLOCKLIST, true)) { + throw new \RuntimeException('OpenAPI doc generation is blocked for ' . $currentPluginName . '.'); + } + } + $pluginDirs = []; foreach ($pluginNames as $pluginName) { BaseValidator::check('pluginName', $pluginName, [new NotEmpty()]); diff --git a/Tasks.php b/Tasks.php new file mode 100644 index 0000000..9228512 --- /dev/null +++ b/Tasks.php @@ -0,0 +1,72 @@ +specGenerationService = $specGenerationService; + $this->logger = $logger; + } + + public function schedule() + { + if ($this->isSpecGenerationEnabled()) { + $this->daily('generateConfiguredPluginSpecs'); + } + } + + public function generateConfiguredPluginSpecs(): void + { + $pluginNames = require __DIR__ . '/config/plugins.php'; + + foreach ($pluginNames as $pluginName) { + try { + $this->specGenerationService->generateSpecForPlugins( + $pluginName, + 'json', + OpenApiDocs::DEFAULT_SPEC_VERSION, + true, + true + ); + } catch (\Throwable $e) { + $this->logger->error( + 'OpenApiDocs scheduled generation failed for plugin {plugin}: {error}', + [ + 'plugin' => $pluginName, + 'error' => $e->getMessage(), + ] + ); + } + } + } + + private function isSpecGenerationEnabled(): bool + { + return (bool) (Config::getInstance()->OpenApiDocs['enable_spec_generation_task'] ?? 0); + } +} diff --git a/tests/Unit/Generation/SpecGenerationServiceTest.php b/tests/Unit/Generation/SpecGenerationServiceTest.php new file mode 100644 index 0000000..eff5c75 --- /dev/null +++ b/tests/Unit/Generation/SpecGenerationServiceTest.php @@ -0,0 +1,61 @@ +createStub(AnnotationGenerator::class), + $this->createStub(SpecGenerator::class) + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('At least one plugin name is required.'); + + $service->generateSpecForPlugins(' , ', 'json', '1.0.0', false, false); + } + + public function testGenerateSpecForPluginsWorksForValidPluginName(): void + { + $annotationGenerator = $this->createMock(AnnotationGenerator::class); + $specGenerator = $this->getMockBuilder(SpecGenerator::class) + ->disableOriginalConstructor() + ->onlyMethods(['generateSpec']) + ->getMock(); + + $annotationGenerator->expects($this->once()) + ->method('generatePluginApiAnnotations') + ->with('CustomAlerts', true); + $specGenerator->expects($this->once()) + ->method('generateSpec') + ->with(['CustomAlerts'], 'json', '1.0.0', true) + ->willReturn('spec body'); + + $service = new SpecGenerationService($annotationGenerator, $specGenerator); + + $service->generateSpecForPlugins('CustomAlerts', 'json', '1.0.0', true, true); + } +} diff --git a/tests/Unit/TasksTest.php b/tests/Unit/TasksTest.php new file mode 100644 index 0000000..ec5a945 --- /dev/null +++ b/tests/Unit/TasksTest.php @@ -0,0 +1,98 @@ +originalConfig = Config::getInstance()->OpenApiDocs ?? null; + } + + protected function tearDown(): void + { + Config::getInstance()->OpenApiDocs = $this->originalConfig; + + parent::tearDown(); + } + + public function testScheduleDoesNotRegisterTaskWhenDisabled(): void + { + Config::getInstance()->OpenApiDocs = ['enable_spec_generation_task' => 0]; + + $tasks = new Tasks($this->createMock(SpecGenerationService::class), new FakeLogger()); + $tasks->schedule(); + + $this->assertCount(0, $tasks->getScheduledTasks()); + } + + public function testScheduleRegistersDailyTaskWhenEnabled(): void + { + Config::getInstance()->OpenApiDocs = ['enable_spec_generation_task' => 1]; + + $tasks = new Tasks($this->createMock(SpecGenerationService::class), new FakeLogger()); + $tasks->schedule(); + + $scheduledTasks = $tasks->getScheduledTasks(); + + $this->assertCount(1, $scheduledTasks); + $this->assertSame('generateConfiguredPluginSpecs', $scheduledTasks[0]->getMethodName()); + $this->assertInstanceOf(Daily::class, $scheduledTasks[0]->getScheduledTime()); + } + + public function testGenerateConfiguredPluginSpecsLogsPerPluginFailuresAndContinues(): void + { + $calledPlugins = []; + $service = $this->createMock(SpecGenerationService::class); + $service->expects($this->atLeast(2)) + ->method('generateSpecForPlugins') + ->willReturnCallback(function (string $pluginName) use (&$calledPlugins): string { + $calledPlugins[] = $pluginName; + + if ($pluginName === 'RollUpReporting') { + throw new \RuntimeException('Foo failed'); + } + + return 'ok'; + }); + + $logger = new FakeLogger(); + $tasks = new Tasks($service, $logger); + + $tasks->generateConfiguredPluginSpecs(); + + $this->assertSame('RollUpReporting', $calledPlugins[0]); + $this->assertSame('Login', $calledPlugins[1]); + $this->assertStringContainsString('OpenApiDocs scheduled generation failed for plugin RollUpReporting: Foo failed', $logger->output); + } +}