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);
+ }
+}