Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 68 additions & 14 deletions Annotations/AnnotationGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,7 @@ protected function getExampleIfAvailable(string $url, bool $useLocalToken = fals
|| stripos($response['data'], '<result />') !== 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 '';
}
Expand All @@ -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 <North America>)
try {
$body = json_encode($this->convertExampleXmlToObject($body));
} catch (\Exception $e) {
return '';
}
}

return $body;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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())) {
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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('~(?<!\\\\)/\\*~', '\\/*', $exampleValue) ?? $exampleValue;
$exampleValue = str_replace('*/', '*\/', $exampleValue);

// Escape quotes differently for the annotation examples
$exampleValue = str_replace('"', '""', $exampleValue);
$mediaType[] = 'example="' . $exampleValue . '"';
Expand Down Expand Up @@ -1484,6 +1499,18 @@ public function buildPropertyAnnotationFromXmlExample(string $propName, array $v
{
$type = 'object';
$originalValues = $values;
$isList = !empty($values) && array_keys($values) === range(0, count($values) - 1);
$treatAsArray = $propName !== 'row' && $isList;
if ($treatAsArray) {
$type = 'array';
$mergedValues = [];
foreach ($values as $value) {
if (is_array($value)) {
$mergedValues = array_merge($mergedValues, $value);
}
}
$values = $mergedValues;
}
if ($propName === 'row') {
$type = 'array';
// Merge the rows together to get as many properties as possible
Expand Down Expand Up @@ -1511,24 +1538,37 @@ public function buildPropertyAnnotationFromXmlExample(string $propName, array $v
continue;
}

// Special handling for XML attributes
if ($key === OpenApiDocs::OA_XML_ATTRIBUTES_TEMP_PROPERTY_NAME) {
$hasAttributes = true;
$childLines = array_merge($childLines, $this->buildXmlAttributeSchemaLines($value));
continue;
}

// Handle nested arrays
if (!is_string($key)) {
if (!is_array(reset($value))) {
continue;
}

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

Expand All @@ -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)];
}
Expand Down Expand Up @@ -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']);
Expand Down
4 changes: 4 additions & 0 deletions Annotations/GlobalApiComponents.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 3 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
21 changes: 20 additions & 1 deletion Commands/GenerateSpecFile.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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');
}

/**
Expand Down Expand Up @@ -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';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lachiebol Is this something specific to ddev ? I don't see any such files.
I think below is the correct way to get all Plugins.

$pluginNames = PluginManager::getAllPluginsNames();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see below, you have added a list, can't we use above method and have a blockList ?

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer the allowlist - safer that we have to manually add new plugins.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Assuming we keep this as an internal tool only)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@lachiebol Is this something specific to ddev ? I don't see any such files. I think below is the correct way to get all Plugins.

$pluginNames = PluginManager::getAllPluginsNames();

The new list is only for plugins with an API.php file. I could go this route but I'll need to check how the existing commands handle plugins with no API

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am okay with the current approach also 👍

$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('<info>Generating documentation for: %s</info>', $plugin);

$output->writeln($message);

if ($addAnnotations) {
$pluginsArray = explode(',', $plugin);
foreach ($pluginsArray as $pluginName) {
(StaticContainer::get(AnnotationGenerator::class))->generatePluginApiAnnotations($pluginName, true);
$output->writeln('<info>Created Annotations for ' . $pluginName . ' and wrote results to plugins/OpenApiDocs/tmp/annotations.</info>');
}
}

$result = (new SpecGenerator())->generatePluginDoc($plugin, $format, $version, $notDryRun);

if ($notDryRun) {
Expand Down
1 change: 1 addition & 0 deletions OpenApiDocs.php
Original file line number Diff line number Diff line change
Expand Up @@ -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/';
Expand Down
79 changes: 79 additions & 0 deletions config/plugins.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
<?php

return [
'RollUpReporting',
'Login',
'ActivityLog',
'VisitTime',
'CustomAlerts',
'UserLanguage',
'CorePluginsAdmin',
'Referrers',
'AdvertisingConversionExport',
'VisitsSummary',
'PrivacyManager',
'CoreAdminHome',
'MultiChannelConversionAttribution',
'DBStats',
'Funnels',
'SitesManager',
'UsersFlow',
'DevicesDetection',
'LanguagesManager',
'Annotations',
'MultiSites',
'Transitions',
'DevicePlugins',
'Overlay',
'AbTesting',
'ScheduledReports',
'UserId',
'API',
'SearchEngineKeywordsPerformance',
'AIAgents',
'Resolution',
'VisitorInterest',
'CustomVariables',
'SEOWebVitals',
'LogViewer',
'TreemapVisualization',
'Bandwidth',
'BotTracking',
'SEO',
'GithubAnalytics',
'MediaAnalytics',
'ProfessionalServices',
'LoginLdap',
'Cohorts',
'Contents',
'CustomDimensions',
'FormAnalytics',
'Feedback',
'VisitFrequency',
'Provider',
'CrashAnalytics',
'Events',
'SegmentEditor',
'CustomTranslations',
'HeatmapSessionRecording',
'PagePerformance',
'UserCountry',
'TagManager',
'MobileMessaging',
'Goals',
'OpenApiDocs',
'LoginSaml',
'CustomReports',
'TwoFactorAuth',
'Live',
'Tour',
'CustomJsTracker',
'ImageGraph',
'UsersManager',
// 'ConnectAccounts', Requires cloud?
'Marketplace',
'Insights',
'MarketingCampaignsReporting',
'Dashboard',
'Actions',
];