Skip to content

Commit 6f37444

Browse files
authored
Merge pull request #20 from matomo-org/PG-4887-single-swagger
Single file swagger docs generation, #PG-4887
2 parents e4436d1 + 7008df7 commit 6f37444

6 files changed

Lines changed: 175 additions & 17 deletions

File tree

Annotations/AnnotationGenerator.php

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,7 @@ protected function getExampleIfAvailable(string $url, bool $useLocalToken = fals
890890
|| stripos($response['data'], '<result />') !== false
891891
|| trim($response['data']) === '[]'
892892
|| (stripos($url, 'format=tsv') !== false && trim($response['data']) === 'No data available')
893+
|| !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
893894
) {
894895
return '';
895896
}
@@ -900,7 +901,12 @@ protected function getExampleIfAvailable(string $url, bool $useLocalToken = fals
900901

901902
// Convert the XML responses into a JSON object and then encode it into a string. This is helpful for building schemas.
902903
if ($format === 'xml') {
903-
$body = json_encode($this->convertExampleXmlToObject($body));
904+
// Some plugins have invalid XML (e.g <North America>)
905+
try {
906+
$body = json_encode($this->convertExampleXmlToObject($body));
907+
} catch (\Exception $e) {
908+
return '';
909+
}
904910
}
905911

906912
return $body;
@@ -935,7 +941,11 @@ protected function getCachedExampleResponseFile(string $pluginName, string $meth
935941
}
936942

937943
if (!$rawResult && $format === 'xml') {
938-
$exampleContents = json_encode($this->convertExampleXmlToObject($exampleContents));
944+
try {
945+
$exampleContents = json_encode($this->convertExampleXmlToObject($exampleContents));
946+
} catch (\Exception $e) {
947+
return '';
948+
}
939949
}
940950

941951
// 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
10131023
*/
10141024
public function convertExampleXmlToObject(string $xml): array
10151025
{
1016-
$root = new \SimpleXMLElement($xml);
1026+
1027+
$root = new \SimpleXMLElement($xml, LIBXML_NOERROR);
10171028

10181029
$toArray = function (\SimpleXMLElement $node) use (&$toArray) {
10191030
if (!count($node->children()) && !count($node->attributes())) {
@@ -1132,7 +1143,7 @@ protected function determineResponses(array $rules, string $plugin, string $meth
11321143
$exampleUrls = $this->getApplicableDemoExampleUrls($plugin, $method, $paramsData);
11331144
foreach ($exampleUrls as $type => $url) {
11341145
$exampleValue = $this->getExampleIfAvailable($url);
1135-
// If the example lookup failed, try making the same request locally using a temporary token.
1146+
// If the example lookup failed, try making the same request locally using a local token.
11361147
if (empty($exampleValue)) {
11371148
$exampleValue = $this->getExampleIfAvailable($url, true);
11381149
}
@@ -1242,6 +1253,10 @@ protected function buildMediaTypePropertiesArray(string $format, string $example
12421253
$mediaType = array_merge($mediaType, $responseSchema);
12431254
}
12441255
if ($format === 'tsv') {
1256+
// Prevent accidental PHPDoc termination in generated annotation files.
1257+
$exampleValue = preg_replace('~(?<!\\\\)/\\*~', '\\/*', $exampleValue) ?? $exampleValue;
1258+
$exampleValue = str_replace('*/', '*\/', $exampleValue);
1259+
12451260
// Escape quotes differently for the annotation examples
12461261
$exampleValue = str_replace('"', '""', $exampleValue);
12471262
$mediaType[] = 'example="' . $exampleValue . '"';
@@ -1484,6 +1499,18 @@ public function buildPropertyAnnotationFromXmlExample(string $propName, array $v
14841499
{
14851500
$type = 'object';
14861501
$originalValues = $values;
1502+
$isList = !empty($values) && array_keys($values) === range(0, count($values) - 1);
1503+
$treatAsArray = $propName !== 'row' && $isList;
1504+
if ($treatAsArray) {
1505+
$type = 'array';
1506+
$mergedValues = [];
1507+
foreach ($values as $value) {
1508+
if (is_array($value)) {
1509+
$mergedValues = array_merge($mergedValues, $value);
1510+
}
1511+
}
1512+
$values = $mergedValues;
1513+
}
14871514
if ($propName === 'row') {
14881515
$type = 'array';
14891516
// Merge the rows together to get as many properties as possible
@@ -1511,24 +1538,37 @@ public function buildPropertyAnnotationFromXmlExample(string $propName, array $v
15111538
continue;
15121539
}
15131540

1514-
// Special handling for XML attributes
1515-
if ($key === OpenApiDocs::OA_XML_ATTRIBUTES_TEMP_PROPERTY_NAME) {
1516-
$hasAttributes = true;
1517-
$childLines = array_merge($childLines, $this->buildXmlAttributeSchemaLines($value));
1518-
continue;
1519-
}
1520-
15211541
// Handle nested arrays
15221542
if (!is_string($key)) {
15231543
if (!is_array(reset($value))) {
15241544
continue;
15251545
}
15261546

15271547
$keys = array_keys($value);
1528-
$key = reset($keys);
1548+
$key = null;
1549+
foreach ($keys as $candidate) {
1550+
if (
1551+
$candidate !== OpenApiDocs::OA_XML_ATTRIBUTES_TEMP_PROPERTY_NAME
1552+
&& $candidate !== OpenApiDocs::OA_XML_ATTRIBUTES_DEFAULT_KEY_NAME
1553+
) {
1554+
$key = $candidate;
1555+
break;
1556+
}
1557+
}
1558+
$key = $key ?? reset($keys);
15291559
$value = $value[$key];
15301560
}
15311561

1562+
// Special handling for XML attributes (metadata-only)
1563+
if (
1564+
$key === OpenApiDocs::OA_XML_ATTRIBUTES_TEMP_PROPERTY_NAME
1565+
|| $key === OpenApiDocs::OA_XML_ATTRIBUTES_DEFAULT_KEY_NAME
1566+
) {
1567+
$hasAttributes = true;
1568+
$childLines = array_merge($childLines, $this->buildXmlAttributeSchemaLines($value));
1569+
continue;
1570+
}
1571+
15321572
$childLines[] = $this->buildPropertyAnnotationFromXmlExample($key, $value);
15331573
}
15341574

@@ -1548,6 +1588,20 @@ public function buildPropertyAnnotationFromXmlExample(string $propName, array $v
15481588

15491589
$childLines = ['@OA\Items' => array_merge($itemProperties, $childLines)];
15501590
}
1591+
if ($treatAsArray) {
1592+
$itemProperties = [
1593+
'type="object",',
1594+
sprintf('@OA\Xml(name="%s"),', $propName),
1595+
'additionalProperties=true,',
1596+
];
1597+
1598+
$originalKeys = array_keys($originalValues);
1599+
if (!is_string(reset($originalKeys)) && !is_string(reset($values)) && !$hasAttributes) {
1600+
$itemProperties = ['type="string"'];
1601+
}
1602+
1603+
$childLines = ['@OA\Items' => array_merge($itemProperties, $childLines)];
1604+
}
15511605

15521606
return ['@OA\Property' => array_merge($propertyLines, $childLines)];
15531607
}
@@ -1822,12 +1876,12 @@ public function compileOperationLines(string $path, string $opId, string $plugin
18221876
$code = $response['code'];
18231877
$codeFormatted = is_numeric($code) ? (string)$code : '"' . $code . '"';
18241878
$description = !empty($response['description']) && strpos($response['description'], 'Example links: [') !== false
1825-
? ', description="' . $response['description'] . '"' : '';
1879+
? ', description="' . $this->normaliseDescriptionText($response['description']) . '"' : '';
18261880
$operationValuesMap[] = '@OA\Response(response=' . $codeFormatted . $description . ', ref="' . $response['ref'] . '")';
18271881
} else {
18281882
$responsePropertyArray = [
18291883
'response=200',
1830-
'description="' . ($response['description'] ?? 'OK') . '"',
1884+
'description="' . $this->normaliseDescriptionText($response['description'] ?? 'OK') . '"',
18311885
];
18321886
if (!empty($response['schema'])) {
18331887
$responsePropertyArray = array_merge($responsePropertyArray, $response['schema']);

Annotations/GlobalApiComponents.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,10 @@
352352
* description="An in-database subtable ID.", required=false,
353353
* @OA\Schema(type="integer"))
354354
*
355+
* @OA\Parameter(parameter="idSubtableRequired", name="idSubtable", in="query",
356+
* description="An in-database subtable ID.", required=true,
357+
* @OA\Schema(type="integer"))
358+
*
355359
* Parameters specific to DataTables and Views
356360
* @OA\Parameter(parameter="flatOptional", name="flat", in="query",
357361
* description="Flatten subtables into the parent table.", required=false,

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
## Changelog
22

33

4-
5.0.1-b1
5-
- 16-02-2026 - Initial implementation of plugin and POC generating documentation from annotations
4+
5.0.1-b1 - 2026-02-16
5+
- Added class and function level docs
6+
- Updated spec generation command to allow single swagger file generation
67

78
5.0.0-b1
89
- Initial implementation of plugin and POC generating documentation from annotations

Commands/GenerateSpecFile.php

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99

1010
namespace Piwik\Plugins\OpenApiDocs\Commands;
1111

12+
use Piwik\Container\StaticContainer;
1213
use Piwik\Plugin\ConsoleCommand;
14+
use Piwik\Plugins\OpenApiDocs\Annotations\AnnotationGenerator;
1315
use Piwik\Plugins\OpenApiDocs\Specs\SpecGenerator;
1416

1517
/**
@@ -29,10 +31,11 @@ protected function configure()
2931
{
3032
$this->setName('openapidocs:generate-spec-file');
3133
$this->setDescription('Generate the OpenAPI documentation file for the Matomo APIs.');
32-
$this->addRequiredValueOption('plugin', 'p', 'Name of the plugin to document');
34+
$this->addRequiredValueOption('plugin', 'p', 'Name of the plugin to document, use all to process every plugin');
3335
$this->addRequiredValueOption('format', 'f', 'Format of the spec file (JSON or YAML). Default is JSON');
3436
$this->addRequiredValueOption('api-version', null, 'Version of the spec file. Default is 1.0.0');
3537
$this->addNoValueOption('not-dry-run', null, 'Flag to allow writing to file instead of outputting a dry run.');
38+
$this->addNoValueOption('add-annotations', null, 'Flag to also generate annotations that are required to generate OpenAPI documentation');
3639
}
3740

3841
/**
@@ -74,17 +77,33 @@ protected function doExecute(): int
7477
$output = $this->getOutput();
7578

7679
$plugin = $input->getOption('plugin');
80+
81+
7782
if (empty($plugin)) {
7883
throw new \RuntimeException('Please specify a plugin name.');
7984
}
85+
86+
if (strtolower($plugin) == 'all') {
87+
$plugins = require __DIR__ . '/../config/plugins.php';
88+
$plugin = implode(',', $plugins);
89+
}
8090
$format = $input->getOption('format') ?: 'json';
8191
$version = $input->getOption('version') ?: '1.0.0';
8292
$notDryRun = $input->getOption('not-dry-run') ?: false;
93+
$addAnnotations = $input->getOption('add-annotations') ?: false;
8394

8495
$message = sprintf('<info>Generating documentation for: %s</info>', $plugin);
8596

8697
$output->writeln($message);
8798

99+
if ($addAnnotations) {
100+
$pluginsArray = explode(',', $plugin);
101+
foreach ($pluginsArray as $pluginName) {
102+
(StaticContainer::get(AnnotationGenerator::class))->generatePluginApiAnnotations($pluginName, true);
103+
$output->writeln('<info>Created Annotations for ' . $pluginName . ' and wrote results to plugins/OpenApiDocs/tmp/annotations.</info>');
104+
}
105+
}
106+
88107
$result = (new SpecGenerator())->generatePluginDoc($plugin, $format, $version, $notDryRun);
89108

90109
if ($notDryRun) {

OpenApiDocs.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ class OpenApiDocs extends \Piwik\Plugin
1313
{
1414
public const DEFAULT_SPEC_VERSION = '1.0.0';
1515
public const OA_XML_ATTRIBUTES_TEMP_PROPERTY_NAME = 'oaXmlAttributes';
16+
public const OA_XML_ATTRIBUTES_DEFAULT_KEY_NAME = 'defaultKeyName';
1617
public const GENERATED_ANNOTATIONS_PATH = '/tmp/annotations/';
1718
public const EXAMPLE_RESPONSES_PATH = '/tmp/responses/';
1819
public const GENERATED_SPECS_PATH = '/tmp/specs/';

config/plugins.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
return [
4+
'RollUpReporting',
5+
'Login',
6+
'ActivityLog',
7+
'VisitTime',
8+
'CustomAlerts',
9+
'UserLanguage',
10+
'CorePluginsAdmin',
11+
'Referrers',
12+
'AdvertisingConversionExport',
13+
'VisitsSummary',
14+
'PrivacyManager',
15+
'CoreAdminHome',
16+
'MultiChannelConversionAttribution',
17+
'DBStats',
18+
'Funnels',
19+
'SitesManager',
20+
'UsersFlow',
21+
'DevicesDetection',
22+
'LanguagesManager',
23+
'Annotations',
24+
'MultiSites',
25+
'Transitions',
26+
'DevicePlugins',
27+
'Overlay',
28+
'AbTesting',
29+
'ScheduledReports',
30+
'UserId',
31+
'API',
32+
'SearchEngineKeywordsPerformance',
33+
'AIAgents',
34+
'Resolution',
35+
'VisitorInterest',
36+
'CustomVariables',
37+
'SEOWebVitals',
38+
'LogViewer',
39+
'TreemapVisualization',
40+
'Bandwidth',
41+
'BotTracking',
42+
'SEO',
43+
'GithubAnalytics',
44+
'MediaAnalytics',
45+
'ProfessionalServices',
46+
'LoginLdap',
47+
'Cohorts',
48+
'Contents',
49+
'CustomDimensions',
50+
'FormAnalytics',
51+
'Feedback',
52+
'VisitFrequency',
53+
'Provider',
54+
'CrashAnalytics',
55+
'Events',
56+
'SegmentEditor',
57+
'CustomTranslations',
58+
'HeatmapSessionRecording',
59+
'PagePerformance',
60+
'UserCountry',
61+
'TagManager',
62+
'MobileMessaging',
63+
'Goals',
64+
'OpenApiDocs',
65+
'LoginSaml',
66+
'CustomReports',
67+
'TwoFactorAuth',
68+
'Live',
69+
'Tour',
70+
'CustomJsTracker',
71+
'ImageGraph',
72+
'UsersManager',
73+
// 'ConnectAccounts', Requires cloud?
74+
'Marketplace',
75+
'Insights',
76+
'MarketingCampaignsReporting',
77+
'Dashboard',
78+
'Actions',
79+
];

0 commit comments

Comments
 (0)