From a4619c1ec4408901adda3af21c356cb1dcf0e861 Mon Sep 17 00:00:00 2001 From: Lachlan Reynolds Date: Thu, 5 Feb 2026 11:18:11 +1300 Subject: [PATCH 1/7] Tidy docs --- Annotations/AnnotationGenerator.php | 66 ++++++++++++++++++++++++----- Specs/SpecGenerator.php | 9 ++-- 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index 0bc6917..3a952cd 100644 --- a/Annotations/AnnotationGenerator.php +++ b/Annotations/AnnotationGenerator.php @@ -121,15 +121,15 @@ public function generatePluginApiAnnotations(string $pluginName, bool $writeToFi $pluginMetadata = Proxy::getInstance()->getMetadata()[$className] ?? []; $annotations = [[sprintf('@OA\Tag(name="%s")', $pluginName)]]; - // I decided to not include the description in the tag annotation so that it automatically pulls the API class comment as the description. -// if (!empty($pluginMetadata['__documentation'])) { -// $tagLines = $this->buildLinesForAnnotationObject('@OA\Tag', [ -// sprintf('name="%s"', $pluginName), -// sprintf('description="%s"', $this->normaliseDescriptionText($pluginMetadata['__documentation'])), -// ]); -// $this->removeTrailingCommaFromLastLine($tagLines); -// $annotations[] = $tagLines; -// } +// I decided to not include the description in the tag annotation so that it automatically pulls the API class comment as the description. + if (!empty($pluginMetadata['__documentation'])) { + $tagLines = $this->buildLinesForAnnotationObject('@OA\Tag', [ + sprintf('name="%s"', $pluginName), + sprintf('description="%s"', $this->normaliseDescriptionText($pluginMetadata['__documentation'])), + ]); + $this->removeTrailingCommaFromLastLine($tagLines); + $annotations[] = $tagLines; + } foreach (array_keys($pluginMetadata) as $metadataMethod) { if (!$reflectionClass->hasMethod($metadataMethod)) { @@ -254,11 +254,12 @@ protected function buildAnnotationForMethod(array $rules, string $pluginName, \R $params = $this->determineParameters($rules, $pluginName, $methodName, $reflectionMethod); $responses = $this->determineResponses($rules, $pluginName, $methodName, $reflectionMethod, $params); + $description = $this->determineDescription($pluginName, $methodName, $reflectionMethod); $isPost = !empty($rules['plugins'][$pluginName]['methodsRequiringPost']) && in_array($methodName, $rules['plugins'][$pluginName]['methodsRequiringPost']); - return $this->compileOperationLines($path, $opId, $pluginName, $params, $responses, $isPost); + return $this->compileOperationLines($path, $opId, $pluginName, $params, $responses, $description, $isPost); } /** @@ -354,6 +355,24 @@ public function getResponseInfoFromDocBlock(string $docBlock): array } /** + * Extract the description/summary from a given docblock + * + * @param string $docBlock The comment block from a method, which hopefully contains a description. + * + * @return string Description/summary extracted from the docblock + */ + public function getDescriptionFromDocBlock(string $docBlock): string + { + $factory = DocBlockFactory::createInstance(); + $docBlockObject = $factory->create($docBlock); + + return $docBlockObject->getSummary(); + } + + + + + /** * This is a helper method for building the path used for an operation annotation. It takes a path template, like * the one from the config array and populates it with the plugin name and API method name. * @@ -578,6 +597,30 @@ protected function determineParameters(array $rules, string $plugin, string $met ]; } + + + + /** + * Get the description/summary of a given method + * + * @param string $plugin Name of the plugin. E.g. TagManager. + * @param string $method The name of the method being annotated. + * @param \ReflectionMethod $reflectionMethod The reflective representation of the method to provide metadata. + * + * @return string Description of the method + */ + protected function determineDescription(string $plugin, string $method, \ReflectionMethod $reflectionMethod): string + { + $description = ''; + $docBlock = $reflectionMethod->getDocComment(); + if (!empty($docBlock)) { + $description = $this->getDescriptionFromDocBlock($docBlock); + } + + return $description; + } + + /** * Map the PHP type to the OpenAPI type. The currently available types for v3.1.1 are the following: “null”, * “boolean”, “object”, “array”, “number”, “string”, or “integer”. @@ -1717,12 +1760,13 @@ public function buildSchemaObjectArrays(array $typesMap, string $default = '', s * * @return string[] The array of all the lines of the operation annotation object. */ - public function compileOperationLines(string $path, string $opId, string $plugin, array $params, array $responses, bool $isPost = false): array + public function compileOperationLines(string $path, string $opId, string $plugin, array $params, array $responses, string $description, bool $isPost = false): array { $operationValuesMap = [ 'path="' . $path . '"', 'operationId="' . $opId . '"', 'tags={"' . $plugin . '"}', + 'description="' . $description . '"', ]; foreach ($params['refs'] ?? [] as $ref) { $operationValuesMap[] = '@OA\Parameter(ref="' . $ref . '")'; diff --git a/Specs/SpecGenerator.php b/Specs/SpecGenerator.php index 3ba2544..1a5822c 100644 --- a/Specs/SpecGenerator.php +++ b/Specs/SpecGenerator.php @@ -73,7 +73,8 @@ public function generateSpec(array $pluginNames, string $format = 'json', string Manager::getInstance()->checkIsPluginActivated($pluginName); $pluginDir = Manager::getInstance()::getPluginDirectory($pluginName); - $pluginAnnotationsSource = $pluginDir . '/API.php'; +// $pluginAnnotationsSource = $pluginDir . '/API.php'; + $pluginAnnotationsSource = $currentPluginDir . '/tmp/annotations/' . $pluginName . 'GeneratedAnnotations.php'; try { $openapi = (new Generator(StaticContainer::get(NullLogger::class)))->generate([ $pluginAnnotationsSource, @@ -81,9 +82,9 @@ public function generateSpec(array $pluginNames, string $format = 'json', string } catch (\Throwable $e) { throw new \Exception('There was an error testing the API annotations for plugin ' . $pluginName, 0, $e); } - if (trim($openapi->toYaml()) === 'openapi: ' . OpenApi::DEFAULT_VERSION) { - throw new \Exception("The $pluginName plugin's API class does not appear to be annotated yet."); - } +// if (trim($openapi->toYaml()) === 'openapi: ' . OpenApi::DEFAULT_VERSION) { +// throw new \Exception("The $pluginName plugin's API class does not appear to be annotated yet."); +// } $pluginDirs[$pluginName] = $pluginAnnotationsSource; } From 61c3022089333ba5a95c9666135eccfc41ce5cf3 Mon Sep 17 00:00:00 2001 From: Lachlan Reynolds Date: Thu, 5 Feb 2026 11:49:07 +1300 Subject: [PATCH 2/7] Fixed case where void return types had no mapped response --- Annotations/AnnotationGenerator.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index 3a952cd..ffdc735 100644 --- a/Annotations/AnnotationGenerator.php +++ b/Annotations/AnnotationGenerator.php @@ -657,6 +657,9 @@ public function getOpenApiTypeFromPhpType(string $type): string case 'double': $type = 'number'; break; + case 'void': + $type = 'null'; + break; default: $type = 'string'; } @@ -1081,7 +1084,7 @@ protected function determineResponses(array $rules, string $plugin, string $meth } // 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($responseInfo['description'])) { + if (empty($successArray['ref']) && !empty($responseInfo['type']) && empty($responseInfo['description']->getBodyTemplate())) { $ref = ''; switch ($responseInfo['type']) { case 'array': @@ -1096,6 +1099,8 @@ protected function determineResponses(array $rules, string $plugin, string $meth case 'string': $ref = '#/components/responses/GenericString'; break; + case 'null': + $ref = '#/components/responses/GenericSuccess'; } if (!empty($ref)) { From d812a7029281a1c0da2844a433d23179565bbf63 Mon Sep 17 00:00:00 2001 From: Lachlan Reynolds Date: Thu, 5 Feb 2026 13:41:52 +1300 Subject: [PATCH 3/7] fixed issue where string defaults got wrapped in quotes twice --- Annotations/AnnotationGenerator.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index ffdc735..179a2e4 100644 --- a/Annotations/AnnotationGenerator.php +++ b/Annotations/AnnotationGenerator.php @@ -468,12 +468,17 @@ public function buildParameterAnnotationData(string $methodName, string $paramNa // Clean up the descriptions a little more like removing linebreaks and escaping double-quotes $description = $this->normaliseDescriptionText($description); + $default = $paramMetadata['default']; + if (!is_string($default)) { + $default = json_encode($default); + } + return [ 'name' => $paramName, 'types' => $typesMap, 'description' => $description, 'required' => $isRequired ? 'true' : 'false', - 'default' => !$isRequired ? json_encode($paramMetadata['default']) : NoDefaultValue::class, + 'default' => !$isRequired ? $default : NoDefaultValue::class, 'example' => $example, ]; } From 55656ab45824508a8f4dd5788252656181281cce Mon Sep 17 00:00:00 2001 From: Lachlan Reynolds Date: Thu, 5 Feb 2026 14:22:28 +1300 Subject: [PATCH 4/7] Fixed tests --- Annotations/AnnotationGenerator.php | 2 +- tests/Unit/AnnotationGeneratorTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index 179a2e4..7b4d8e2 100644 --- a/Annotations/AnnotationGenerator.php +++ b/Annotations/AnnotationGenerator.php @@ -468,7 +468,7 @@ public function buildParameterAnnotationData(string $methodName, string $paramNa // Clean up the descriptions a little more like removing linebreaks and escaping double-quotes $description = $this->normaliseDescriptionText($description); - $default = $paramMetadata['default']; + $default = $paramMetadata['default'] ?? null; if (!is_string($default)) { $default = json_encode($default); } diff --git a/tests/Unit/AnnotationGeneratorTest.php b/tests/Unit/AnnotationGeneratorTest.php index 75aaa46..5f34842 100644 --- a/tests/Unit/AnnotationGeneratorTest.php +++ b/tests/Unit/AnnotationGeneratorTest.php @@ -435,7 +435,7 @@ public function getTestDataForBuildParameterAnnotationData(): iterable 'types' => ['string' => null], 'description' => '', 'required' => 'false', - 'default' => '"SomeDefaultValue"', + 'default' => 'SomeDefaultValue', 'example' => '', ]]; yield 'should not wrap metadata default value when boolean type' => ['someParam', [ @@ -467,7 +467,7 @@ public function getTestDataForBuildParameterAnnotationData(): iterable 'types' => ['string' => null], 'description' => '', 'required' => 'false', - 'default' => '""', + 'default' => '', 'example' => '', ]]; yield 'should not count the NoDefaultValue class as a default value' => ['someParam', [ From 5a1e8be709794fda60f527e179c2118ed088b5c4 Mon Sep 17 00:00:00 2001 From: Lachlan Reynolds Date: Thu, 5 Feb 2026 14:46:33 +1300 Subject: [PATCH 5/7] Normalised description and fixed case with string description or empty --- Annotations/AnnotationGenerator.php | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index 7b4d8e2..21e4011 100644 --- a/Annotations/AnnotationGenerator.php +++ b/Annotations/AnnotationGenerator.php @@ -11,6 +11,7 @@ namespace Piwik\Plugins\OpenApiDocs\Annotations; +use Matomo\Dependencies\OpenApiDocs\phpDocumentor\Reflection\DocBlock\Description; use Matomo\Dependencies\OpenApiDocs\phpDocumentor\Reflection\DocBlock\Tags\Param; use Matomo\Dependencies\OpenApiDocs\phpDocumentor\Reflection\DocBlock\Tags\TagWithType; use Matomo\Dependencies\OpenApiDocs\phpDocumentor\Reflection\DocBlockFactory; @@ -1088,8 +1089,13 @@ 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(); + } + // 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($responseInfo['description']->getBodyTemplate())) { + if (empty($successArray['ref']) && !empty($responseInfo['type']) && empty($description)) { $ref = ''; switch ($responseInfo['type']) { case 'array': @@ -1776,7 +1782,7 @@ public function compileOperationLines(string $path, string $opId, string $plugin 'path="' . $path . '"', 'operationId="' . $opId . '"', 'tags={"' . $plugin . '"}', - 'description="' . $description . '"', + 'description="' . $this->normaliseDescriptionText($description) . '"', ]; foreach ($params['refs'] ?? [] as $ref) { $operationValuesMap[] = '@OA\Parameter(ref="' . $ref . '")'; From be9e8839e25cdea5a7df3a98bc9d0eac34529e75 Mon Sep 17 00:00:00 2001 From: Lachlan Reynolds Date: Thu, 5 Feb 2026 14:50:13 +1300 Subject: [PATCH 6/7] Cleaned up code --- Annotations/AnnotationGenerator.php | 3 ++- Specs/SpecGenerator.php | 8 +++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index 21e4011..029183f 100644 --- a/Annotations/AnnotationGenerator.php +++ b/Annotations/AnnotationGenerator.php @@ -122,7 +122,7 @@ public function generatePluginApiAnnotations(string $pluginName, bool $writeToFi $pluginMetadata = Proxy::getInstance()->getMetadata()[$className] ?? []; $annotations = [[sprintf('@OA\Tag(name="%s")', $pluginName)]]; -// I decided to not include the description in the tag annotation so that it automatically pulls the API class comment as the description. + if (!empty($pluginMetadata['__documentation'])) { $tagLines = $this->buildLinesForAnnotationObject('@OA\Tag', [ sprintf('name="%s"', $pluginName), @@ -1772,6 +1772,7 @@ public function buildSchemaObjectArrays(array $typesMap, string $default = '', s * @param string $plugin The name of the plugin. E.g. CustomReports * @param array $params The compiled list of method parameters and key information about them, like type. * @param array $responses compiled list of method expected responses and key information about them, like type. + * @param string $description The method level description * @param bool $isPost Indicates whether the operation is a POST. The default is false, meaning it's GET. * * @return string[] The array of all the lines of the operation annotation object. diff --git a/Specs/SpecGenerator.php b/Specs/SpecGenerator.php index 1a5822c..e7a2b78 100644 --- a/Specs/SpecGenerator.php +++ b/Specs/SpecGenerator.php @@ -72,8 +72,6 @@ public function generateSpec(array $pluginNames, string $format = 'json', string BaseValidator::check('pluginName', $pluginName, [new NotEmpty()]); Manager::getInstance()->checkIsPluginActivated($pluginName); - $pluginDir = Manager::getInstance()::getPluginDirectory($pluginName); -// $pluginAnnotationsSource = $pluginDir . '/API.php'; $pluginAnnotationsSource = $currentPluginDir . '/tmp/annotations/' . $pluginName . 'GeneratedAnnotations.php'; try { $openapi = (new Generator(StaticContainer::get(NullLogger::class)))->generate([ @@ -82,9 +80,9 @@ public function generateSpec(array $pluginNames, string $format = 'json', string } catch (\Throwable $e) { throw new \Exception('There was an error testing the API annotations for plugin ' . $pluginName, 0, $e); } -// if (trim($openapi->toYaml()) === 'openapi: ' . OpenApi::DEFAULT_VERSION) { -// throw new \Exception("The $pluginName plugin's API class does not appear to be annotated yet."); -// } + if (trim($openapi->toYaml()) === 'openapi: ' . OpenApi::DEFAULT_VERSION) { + throw new \Exception("The $pluginName plugin's API class does not appear to be annotated yet."); + } $pluginDirs[$pluginName] = $pluginAnnotationsSource; } From c49a949f6d96927db009fd487a6ac690ec8539d1 Mon Sep 17 00:00:00 2001 From: Lachlan Reynolds Date: Thu, 5 Feb 2026 14:58:23 +1300 Subject: [PATCH 7/7] bumped version --- CHANGELOG.md | 4 ++++ plugin.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bf906b..beaea2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,8 @@ ## Changelog + +5.0.1-b1 +- 16-02-2026 - Initial implementation of plugin and POC generating documentation from annotations + 5.0.0-b1 - Initial implementation of plugin and POC generating documentation from annotations diff --git a/plugin.json b/plugin.json index 81eb46f..71c1c66 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "name": "OpenApiDocs", "description": "Generate API documentation for Matomo in the OpenAPI format.", - "version": "5.0.0-b1", + "version": "5.0.1-b1", "theme": false, "keywords": [ "API",