diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index 029183f..84c85c5 100644 --- a/Annotations/AnnotationGenerator.php +++ b/Annotations/AnnotationGenerator.php @@ -890,6 +890,7 @@ protected function getExampleIfAvailable(string $url, bool $useLocalToken = fals || stripos($response['data'], '') !== false || trim($response['data']) === '[]' || (stripos($url, 'format=tsv') !== false && trim($response['data']) === 'No data available') + || !preg_match("/(json|xml|vnd.ms-excel)/", $response['headers']['content-type'] ?? $response['headers']['Content-Type'] ?? '') // Some ask for xml/json/tsv but return image/png, shouldn't be treated as xml ) { return ''; } @@ -900,7 +901,12 @@ protected function getExampleIfAvailable(string $url, bool $useLocalToken = fals // Convert the XML responses into a JSON object and then encode it into a string. This is helpful for building schemas. if ($format === 'xml') { - $body = json_encode($this->convertExampleXmlToObject($body)); + // Some plugins have invalid XML (e.g ) + try { + $body = json_encode($this->convertExampleXmlToObject($body)); + } catch (\Exception $e) { + return ''; + } } return $body; @@ -935,7 +941,11 @@ protected function getCachedExampleResponseFile(string $pluginName, string $meth } if (!$rawResult && $format === 'xml') { - $exampleContents = json_encode($this->convertExampleXmlToObject($exampleContents)); + try { + $exampleContents = json_encode($this->convertExampleXmlToObject($exampleContents)); + } catch (\Exception $e) { + return ''; + } } // Unless set otherwise, make sure that the example is around the max allowed characters. If raw, don't bother. @@ -1013,7 +1023,8 @@ protected function getReportExampleUrlFromMetadata(string $pluginName, string $m */ public function convertExampleXmlToObject(string $xml): array { - $root = new \SimpleXMLElement($xml); + + $root = new \SimpleXMLElement($xml, LIBXML_NOERROR); $toArray = function (\SimpleXMLElement $node) use (&$toArray) { if (!count($node->children()) && !count($node->attributes())) { @@ -1132,7 +1143,7 @@ protected function determineResponses(array $rules, string $plugin, string $meth $exampleUrls = $this->getApplicableDemoExampleUrls($plugin, $method, $paramsData); foreach ($exampleUrls as $type => $url) { $exampleValue = $this->getExampleIfAvailable($url); - // If the example lookup failed, try making the same request locally using a temporary token. + // If the example lookup failed, try making the same request locally using a local token. if (empty($exampleValue)) { $exampleValue = $this->getExampleIfAvailable($url, true); } @@ -1242,6 +1253,10 @@ protected function buildMediaTypePropertiesArray(string $format, string $example $mediaType = array_merge($mediaType, $responseSchema); } if ($format === 'tsv') { + // Prevent accidental PHPDoc termination in generated annotation files. + $exampleValue = preg_replace('~(?buildXmlAttributeSchemaLines($value)); - continue; - } - // Handle nested arrays if (!is_string($key)) { if (!is_array(reset($value))) { @@ -1525,10 +1545,30 @@ public function buildPropertyAnnotationFromXmlExample(string $propName, array $v } $keys = array_keys($value); - $key = reset($keys); + $key = null; + foreach ($keys as $candidate) { + if ( + $candidate !== OpenApiDocs::OA_XML_ATTRIBUTES_TEMP_PROPERTY_NAME + && $candidate !== OpenApiDocs::OA_XML_ATTRIBUTES_DEFAULT_KEY_NAME + ) { + $key = $candidate; + break; + } + } + $key = $key ?? reset($keys); $value = $value[$key]; } + // Special handling for XML attributes (metadata-only) + if ( + $key === OpenApiDocs::OA_XML_ATTRIBUTES_TEMP_PROPERTY_NAME + || $key === OpenApiDocs::OA_XML_ATTRIBUTES_DEFAULT_KEY_NAME + ) { + $hasAttributes = true; + $childLines = array_merge($childLines, $this->buildXmlAttributeSchemaLines($value)); + continue; + } + $childLines[] = $this->buildPropertyAnnotationFromXmlExample($key, $value); } @@ -1548,6 +1588,20 @@ public function buildPropertyAnnotationFromXmlExample(string $propName, array $v $childLines = ['@OA\Items' => array_merge($itemProperties, $childLines)]; } + if ($treatAsArray) { + $itemProperties = [ + 'type="object",', + sprintf('@OA\Xml(name="%s"),', $propName), + 'additionalProperties=true,', + ]; + + $originalKeys = array_keys($originalValues); + if (!is_string(reset($originalKeys)) && !is_string(reset($values)) && !$hasAttributes) { + $itemProperties = ['type="string"']; + } + + $childLines = ['@OA\Items' => array_merge($itemProperties, $childLines)]; + } return ['@OA\Property' => array_merge($propertyLines, $childLines)]; } @@ -1822,12 +1876,12 @@ public function compileOperationLines(string $path, string $opId, string $plugin $code = $response['code']; $codeFormatted = is_numeric($code) ? (string)$code : '"' . $code . '"'; $description = !empty($response['description']) && strpos($response['description'], 'Example links: [') !== false - ? ', description="' . $response['description'] . '"' : ''; + ? ', description="' . $this->normaliseDescriptionText($response['description']) . '"' : ''; $operationValuesMap[] = '@OA\Response(response=' . $codeFormatted . $description . ', ref="' . $response['ref'] . '")'; } else { $responsePropertyArray = [ 'response=200', - 'description="' . ($response['description'] ?? 'OK') . '"', + 'description="' . $this->normaliseDescriptionText($response['description'] ?? 'OK') . '"', ]; if (!empty($response['schema'])) { $responsePropertyArray = array_merge($responsePropertyArray, $response['schema']); diff --git a/Annotations/GlobalApiComponents.php b/Annotations/GlobalApiComponents.php index c2e886e..4841ece 100644 --- a/Annotations/GlobalApiComponents.php +++ b/Annotations/GlobalApiComponents.php @@ -352,6 +352,10 @@ * description="An in-database subtable ID.", required=false, * @OA\Schema(type="integer")) * + * @OA\Parameter(parameter="idSubtableRequired", name="idSubtable", in="query", + * description="An in-database subtable ID.", required=true, + * @OA\Schema(type="integer")) + * * Parameters specific to DataTables and Views * @OA\Parameter(parameter="flatOptional", name="flat", in="query", * description="Flatten subtables into the parent table.", required=false, diff --git a/CHANGELOG.md b/CHANGELOG.md index beaea2a..cf8b2a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,9 @@ ## Changelog -5.0.1-b1 -- 16-02-2026 - Initial implementation of plugin and POC generating documentation from annotations +5.0.1-b1 - 2026-02-16 +- Added class and function level docs +- Updated spec generation command to allow single swagger file generation 5.0.0-b1 - Initial implementation of plugin and POC generating documentation from annotations diff --git a/Commands/GenerateSpecFile.php b/Commands/GenerateSpecFile.php index f24aac4..cda0062 100644 --- a/Commands/GenerateSpecFile.php +++ b/Commands/GenerateSpecFile.php @@ -9,7 +9,9 @@ namespace Piwik\Plugins\OpenApiDocs\Commands; +use Piwik\Container\StaticContainer; use Piwik\Plugin\ConsoleCommand; +use Piwik\Plugins\OpenApiDocs\Annotations\AnnotationGenerator; use Piwik\Plugins\OpenApiDocs\Specs\SpecGenerator; /** @@ -29,10 +31,11 @@ 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'); + $this->addRequiredValueOption('plugin', 'p', 'Name of the plugin to document, use all to process every plugin'); $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.'); + $this->addNoValueOption('add-annotations', null, 'Flag to also generate annotations that are required to generate OpenAPI documentation'); } /** @@ -74,17 +77,33 @@ protected function doExecute(): int $output = $this->getOutput(); $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); + } $format = $input->getOption('format') ?: 'json'; $version = $input->getOption('version') ?: '1.0.0'; $notDryRun = $input->getOption('not-dry-run') ?: false; + $addAnnotations = $input->getOption('add-annotations') ?: false; $message = sprintf('Generating documentation for: %s', $plugin); $output->writeln($message); + if ($addAnnotations) { + $pluginsArray = explode(',', $plugin); + foreach ($pluginsArray as $pluginName) { + (StaticContainer::get(AnnotationGenerator::class))->generatePluginApiAnnotations($pluginName, true); + $output->writeln('Created Annotations for ' . $pluginName . ' and wrote results to plugins/OpenApiDocs/tmp/annotations.'); + } + } + $result = (new SpecGenerator())->generatePluginDoc($plugin, $format, $version, $notDryRun); if ($notDryRun) { diff --git a/OpenApiDocs.php b/OpenApiDocs.php index 551dc9b..33b028e 100644 --- a/OpenApiDocs.php +++ b/OpenApiDocs.php @@ -13,6 +13,7 @@ class OpenApiDocs extends \Piwik\Plugin { public const DEFAULT_SPEC_VERSION = '1.0.0'; public const OA_XML_ATTRIBUTES_TEMP_PROPERTY_NAME = 'oaXmlAttributes'; + public const OA_XML_ATTRIBUTES_DEFAULT_KEY_NAME = 'defaultKeyName'; public const GENERATED_ANNOTATIONS_PATH = '/tmp/annotations/'; public const EXAMPLE_RESPONSES_PATH = '/tmp/responses/'; public const GENERATED_SPECS_PATH = '/tmp/specs/'; diff --git a/config/plugins.php b/config/plugins.php new file mode 100644 index 0000000..80f6220 --- /dev/null +++ b/config/plugins.php @@ -0,0 +1,79 @@ +