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 @@
+