diff --git a/.gitignore b/.gitignore
index 41a8347..4c8a4e9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,9 @@ vendor/**/composer.lock
/vue/dist/*.common.js
/vue/dist/*.map
/vue/dist/*.development.*
+/tmp/specs/*
+!/tmp/specs/.gitkeep
+/tmp/annotations/*
+!/tmp/annotations/.gitkeep
+/tmp/responses/*
+!/tmp/responses/.gitkeep
diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php
index 007ce3e..4eaf71c 100644
--- a/Annotations/AnnotationGenerator.php
+++ b/Annotations/AnnotationGenerator.php
@@ -18,7 +18,10 @@
use Piwik\Http;
use Piwik\Piwik;
use Piwik\Plugin\Manager;
+use Piwik\Plugins\OpenApiDocs\OpenApiDocs;
use Piwik\SettingsPiwik;
+use Piwik\Url;
+use Piwik\UrlHelper;
use Piwik\Validators\BaseValidator;
use Piwik\Validators\NotEmpty;
use PHPStan\PhpDocParser\Lexer\Lexer;
@@ -59,6 +62,11 @@ class AnnotationGenerator
'idGoal',
];
+ /**
+ * @var string
+ */
+ protected $currentPluginDir;
+
/**
* @var DocumentationGenerator
*/
@@ -78,6 +86,7 @@ public function __construct(DocumentationGenerator $generator)
{
$this->generator = $generator;
$this->missingImportantDataWarnings = [];
+ $this->currentPluginDir = Manager::getInstance()::getPluginDirectory('OpenApiDocs');
}
/**
@@ -87,30 +96,20 @@ public function __construct(DocumentationGenerator $generator)
* @param string $pluginName The name of the plugin. E.g. TagManager
* @param bool $writeToFile Indicate whether the results should be written to file. Default is false so that a dry
* run won't affect the file-system.
- * @param bool $useTmpDir Indicate whether the file should be written in Matomo's tmp/ directory. The default is
- * false, meaning that it will be written in the OpenApi/Annotations/ directory of the plugin, creating the
- * directory if it doesn't already exist. This is useful if we just want a temp file for comparison. like during
- * testing.
*
* @return string[]|array[] The collection of all the lines which make up the generated annotations for the public API
* endpoints defined by the plugin.
* @throws \Piwik\Exception\PluginDeactivatedException If the plugin is not activated. It should be loaded.
* @throws \Throwable
*/
- public function generatePluginApiAnnotations(string $pluginName, bool $writeToFile = false, bool $useTmpDir = false): array
+ public function generatePluginApiAnnotations(string $pluginName, bool $writeToFile = false): array
{
BaseValidator::check('plugin', $pluginName, [new NotEmpty()]);
Manager::getInstance()->checkIsPluginActivated($pluginName);
- $currentPluginDir = Manager::getInstance()::getPluginDirectory('OpenApiDocs');
- $rules = require $currentPluginDir . '/Annotations/config.php';
- $pluginDir = Manager::getInstance()::getPluginDirectory($pluginName);
- $pluginAnnotationDir = !$useTmpDir ? $pluginDir . '/OpenApi/Annotations' : PIWIK_INCLUDE_PATH . '/tmp/OpenApi/Annotations';
- $pluginAnnotationPath = $pluginAnnotationDir . '/GeneratedAnnotations.php';
- // If the directory doesn't exist yet, create it
- if ($writeToFile && !is_dir($pluginAnnotationDir)) {
- mkdir($pluginAnnotationDir, 0777, true);
- }
+ $rules = require $this->currentPluginDir . '/Annotations/config.php';
+ $pluginAnnotationDir = $this->currentPluginDir . OpenApiDocs::GENERATED_ANNOTATIONS_PATH;
+ $pluginAnnotationPath = $pluginAnnotationDir . "/{$pluginName}GeneratedAnnotations.php";
$className = Request::getClassNameAPI($pluginName);
@@ -123,7 +122,17 @@ public function generatePluginApiAnnotations(string $pluginName, bool $writeToFi
Proxy::getInstance()->registerClass($className);
$pluginMetadata = Proxy::getInstance()->getMetadata()[$className] ?? [];
- $annotations = [];
+ $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;
+// }
+
foreach (array_keys($pluginMetadata) as $metadataMethod) {
if (!$reflectionClass->hasMethod($metadataMethod)) {
continue;
@@ -179,7 +188,7 @@ public function getContentForGeneratedAnnotationsFile(array $annotations, string
$lines = [
' 'idSite',
- * 'types' => ['integer', 'string'],
+ * 'types' => ['integer' => null, 'string' => null],
* 'description' => 'The ID of the site.',
* 'required' => 'true', // It's a string here, but gets converted to boolean in the annotation.
* 'default' => '\Piwik\API\NoDefaultValue', // This class name indicates no default value since falsy values might be valid.
@@ -375,14 +384,23 @@ public function buildParameterAnnotationData(string $methodName, string $paramNa
$this->addMissingImportantDataWarning($methodName, $paramName, 'Type is not specified in comment block.');
}
$metaType = strtolower(trim($paramMetadata['type'] ?? $docType));
- $type = $metaType === 'string' && $docType !== 'string' ? $docType : $metaType;
+ $type = in_array($metaType, ['string', 'bool']) && !empty($docType) && $docType !== $metaType ? $docType : $metaType;
+ // Sometimes, doc-block can wrap type hinting with parenthesis. Remove them.
+ $type = trim($type, '()');
// If the signature type is array, but the type hinting provides more, use that instead
if ($type === 'array' && strpos($docType, '[]') !== false && strpos($docType, '|') === false) {
$type = $docType;
}
$typesMap = [];
// Check for pipes and try to list possible types
- foreach (explode('|', $type) as $typePart) {
+ $typeHints = array_map(function ($typeHint) {
+ return trim($typeHint);
+ }, explode('|', $type));
+ // If there's more than 1 type hinted and one is bool, remove bool. This is because many params default to false regardless of expected type
+ if (count($typeHints) > 1 && in_array('bool', $typeHints)) {
+ $typeHints = array_diff($typeHints, ['bool']);
+ }
+ foreach ($typeHints as $typePart) {
$typePart = trim($typePart, ' ()');
$normalisedType = $this->getOpenApiTypeFromPhpType($typePart);
// If the type is array, check if there's a subType
@@ -412,8 +430,7 @@ public function buildParameterAnnotationData(string $methodName, string $paramNa
}
// Clean up the descriptions a little more like removing linebreaks and escaping double-quotes
- $description = str_replace("\n", ' ', $description);
- $description = str_replace('"', '""', $description);
+ $description = $this->normaliseDescriptionText($description);
return [
'name' => $paramName,
@@ -425,6 +442,20 @@ public function buildParameterAnnotationData(string $methodName, string $paramNa
];
}
+ /**
+ * Take description text and normalise it. This includes trimming surrounding whitespace, removing newlines and
+ * escaping double-quote characters.
+ *
+ * @param string $description
+ *
+ * @return string
+ */
+ protected function normaliseDescriptionText(string $description): string
+ {
+ $description = str_replace("\n", ' ', trim($description));
+ return str_replace('"', '""', $description);
+ }
+
/**
* Add an entry to the map of warnings about missing important information, like type and description of parameters
* and returns.
@@ -511,8 +542,13 @@ protected function determineParameters(array $rules, string $plugin, string $met
$customParamData = $this->buildParameterAnnotationData($method, $name, $paramMetadata, $paramInfo);
if (empty($customParamData['description']) && in_array($name, self::GLOBAL_PARAMETER_NAMES)) {
$globalParamSuffix = $customParamData['required'] === 'true' ? 'Required' : 'Optional';
- $refs[] = '#/components/parameters/' . $name . $globalParamSuffix;
+ $paramRef = '#/components/parameters/' . $name . $globalParamSuffix;
+ $customParams[] = $paramRef;
$this->removeMissingImportantDataWarning($method, $name);
+ // Remove any duplicates from the global references array.
+ if (count($refs) > 0 && in_array($paramRef, $refs)) {
+ $refs = array_diff($refs, [$paramRef]);
+ }
continue;
}
@@ -606,6 +642,11 @@ protected function getApplicableDemoExampleUrls(string $pluginName, string $meth
$parametersToReplace = [];
if (!empty($paramsData['custom'])) {
foreach ($paramsData['custom'] as $customParam) {
+ // Skip any which might be references.
+ if (!is_array($customParam)) {
+ continue;
+ }
+
$paramName = strval($customParam['name']);
if (isset($customParam['example']) && $customParam['example'] !== '') {
$example = $customParam['example'];
@@ -712,13 +753,38 @@ protected function getDemoReportMetadata(): array
* https://demo.matomo.cloud/?module=API&method=CustomReports.getConfiguredReports&idSite=1&format=xml&token_auth=anonymous
* @param bool $useLocalToken A boolean indicating whether to get a temporary token and try the request against the
* currently running Matomo instance.
+ * @param bool $ignoreCached A boolean indicating whether the cached response file should be ignored. Default is
+ * false. This is simply in case we want to replace the existing responses with new ones.
*
* @return string The response received from the API endpoint if no error was received or the response wasn't empty.
* An empty string is returned by default.
* @throws \Throwable
*/
- protected function getExampleIfAvailable(string $url, bool $useLocalToken = false): string
+ protected function getExampleIfAvailable(string $url, bool $useLocalToken = false, bool $ignoreCached = false): string
{
+ $queryString = Url::getQueryStringFromUrl($url);
+ $queryParams = UrlHelper::getArrayFromQueryString($queryString);
+ if (empty($queryParams['method']) || empty($queryParams['format'])) {
+ throw new \Exception('Missing method or format in URL: ' . $url);
+ }
+ $method = $queryParams['method'];
+ $format = strtolower($queryParams['format']);
+ $exampleFilePath = $this->currentPluginDir . OpenApiDocs::EXAMPLE_RESPONSES_PATH . $method . '.' . $format;
+ // If there's already a file, use that instead of making a new server call. Ignore the file when the flag is set.
+ if (!$ignoreCached) {
+ // If an example file is found, return its contents instead of making the server call.
+ [$pluginName, $methodName] = explode('.', $method);
+ $exampleContents = $this->getCachedExampleResponseFile($pluginName, $methodName, $format);
+ if (!empty($exampleContents)) {
+ return $exampleContents;
+ }
+ }
+
+ // Include a specific parameter for the TSV requests.
+ if ($format === 'tsv') {
+ $url .= '&convertToUnicode=0';
+ }
+
// If the flag to use a temp token is set, get a token and update the request URL
$tempUrl = $url . '&hideIdSubDatable=1';
if ($useLocalToken) {
@@ -760,14 +826,57 @@ protected function getExampleIfAvailable(string $url, bool $useLocalToken = fals
}
$body = $response['data'];
+ // Write the example response to file as a cache and reference.
+ file_put_contents($exampleFilePath, $body);
+
// Convert the XML responses into a JSON object and then encode it into a string. This is helpful for building schemas.
- if (stripos($url, 'format=xml') !== false) {
+ if ($format === 'xml') {
$body = json_encode($this->convertExampleXmlToObject($body));
}
return $body;
}
+ /**
+ * Try looking up the cached example response file for a specific plugin and method. If not found, it returns an
+ * empty string.
+ *
+ * @param string $pluginName The name of the plugin. E.g. TagManager.
+ * @param string $methodName The name of the plugin specific API method. E.g. getCustomReport.
+ * @param string $format The format of the file. E.g. json, xml, or tsv
+ * @param bool $rawResult Optional flag to indicate whether to return the raw file contents or do some processing.
+ * The default is false. If false and XML format, the content will be converted into a JSON string.
+ * @param bool $applyMaxLength Optional flag to indicate whether to truncate the example if it exceeds the max
+ * characters allowed. The default is true. It only applies if rawResult is false.
+ *
+ * @return string The contents of the example file or empty if it wasn't found.
+ * @throws \Exception
+ */
+ protected function getCachedExampleResponseFile(string $pluginName, string $methodName, string $format, bool $rawResult = false, bool $applyMaxLength = true): string
+ {
+ $exampleFilePath = $this->currentPluginDir . OpenApiDocs::EXAMPLE_RESPONSES_PATH . $pluginName . '.' . $methodName . '.' . $format;
+ // Simply return an empty string if the file doesn't exist yet.
+ if (!file_exists($exampleFilePath)) {
+ return '';
+ }
+
+ $exampleContents = file_get_contents($exampleFilePath);
+ if (!$exampleContents) {
+ throw new \Exception('Error reading example file: ' . $exampleFilePath);
+ }
+
+ if (!$rawResult && $format === 'xml') {
+ $exampleContents = json_encode($this->convertExampleXmlToObject($exampleContents));
+ }
+
+ // Unless set otherwise, make sure that the example is around the max allowed characters. If raw, don't bother.
+ if (!$rawResult && $applyMaxLength && strlen($exampleContents) > self::EXAMPLE_CHAR_LIMIT) {
+ $exampleContents = $this->cutExampleCloseToCharLimit($exampleContents, $format);
+ }
+
+ return $exampleContents;
+ }
+
/**
* Try to build an example URL for a specific API method using report metadata. This queries the demo server for
* report metadata to get examples of existing reports which can be used as example URLS. If no metadata matches the
@@ -811,8 +920,8 @@ protected function getReportExampleUrlFromMetadata(string $pluginName, string $m
$metadata['imageGraphUrl']
);
- // If we get a valid response, return the URL
- if (!empty($this->getExampleIfAvailable('https://demo.matomo.cloud/' . $url))) {
+ // Use the JSON format for the test. If we get a valid response, return the URL without format.
+ if (!empty($this->getExampleIfAvailable('https://demo.matomo.cloud/' . $url . '&format=JSON'))) {
return $url;
}
}
@@ -938,63 +1047,41 @@ protected function determineResponses(array $rules, string $plugin, string $meth
$mediaTypes = [];
// This simply reuses the example URLs used by the current documentation, but some endpoints don't work because authentication is required
- // TODO - Come up with a way to demo examples for endpoints which require authentication. E.g. hit a live endpoint server-side and replace any potentially sensitive data...
$exampleUrls = $this->getApplicableDemoExampleUrls($plugin, $method, $paramsData);
foreach ($exampleUrls as $type => $url) {
- $contentType = $type === 'json' ? 'application/json' : ($type === 'xml' ? 'text/xml' : 'application/vnd.ms-excel');
- if ($type === 'tsv') {
- $url .= '&convertToUnicode=0';
- }
$exampleValue = $this->getExampleIfAvailable($url);
- // If the example lookup failed, try making the same request locally
- $isLocalExample = false;
+ // If the example lookup failed, try making the same request locally using a temporary token.
if (empty($exampleValue)) {
$exampleValue = $this->getExampleIfAvailable($url, true);
- $isLocalExample = true;
}
if (strlen($exampleValue) > self::EXAMPLE_CHAR_LIMIT) {
$exampleValue = $this->cutExampleCloseToCharLimit($exampleValue, $type);
}
- $jsonSchema = $type === 'json' ? $this->buildSchemaAnnotationFromJsonExample(json_decode($exampleValue, true) ?? []) : [];
- $xmlSchema = $type === 'xml' ? $this->buildSchemaAnnotationFromXmlExample(json_decode($exampleValue, true) ?? []) : [];
- // Make sure that the local example doesn't have anything bad in it
- if ($isLocalExample) {
- // TODO - Obfuscate any potentially sensitive data
- }
-
- if (in_array($type, ['json', 'xml'])) {
- // The annotation expects objects and not arrays, so replace [] with {}
- $exampleValue = str_replace(['[', ']'], ['{', '}'], $exampleValue);
- // Escape quotes differently for the annotation examples
- $exampleValue = str_replace('\"', '""', $exampleValue);
- }
// Skip if there was no example response
if (empty($exampleValue)) {
continue;
}
- $mediaType = [
- 'mediaType="' . $contentType . '"',
- ];
- if ($type !== 'tsv') {
- $mediaType[] = 'example=' . $exampleValue;
- }
- // If a type was found, add it as a schema to the media type
- if ($type === 'json') {
- $responseSchema = !empty($jsonSchema) ? $jsonSchema : ($responseSchema ?: []);
- $mediaType = array_merge($mediaType, $responseSchema);
- }
- if ($type === 'tsv') {
- // Escape quotes differently for the annotation examples
- $exampleValue = str_replace('"', '""', $exampleValue);
- $mediaType[] = 'example="' . $exampleValue . '"';
+ $mediaTypes[] = $this->buildMediaTypePropertiesArray($type, $exampleValue, $responseSchema);
+ }
+
+ // Check if any example files exist even though there aren't any example URLs
+ if (empty($mediaTypes)) {
+ $jsonExample = $this->getCachedExampleResponseFile($plugin, $method, 'json');
+ $xmlExample = $this->getCachedExampleResponseFile($plugin, $method, 'xml');
+ $jsonType = $this->buildMediaTypePropertiesArray('json', $jsonExample, $responseSchema);
+ $xmlType = $this->buildMediaTypePropertiesArray('xml', $xmlExample, $responseSchema);
+
+ // Check and add XML first since it's added first everywhere else
+ if (!empty($xmlExample) && !empty($xmlType)) {
+ $mediaTypes[] = $xmlType;
}
- if ($type === 'xml') {
- $mediaType = array_merge($mediaType, $xmlSchema);
+ if (!empty($jsonExample) && !empty($jsonType)) {
+ $mediaTypes[] = $jsonType;
}
- $mediaTypes[] = $mediaType;
}
+
if (!empty($mediaTypes)) {
$successArray['mediaTypes'] = $mediaTypes;
@@ -1032,6 +1119,53 @@ protected function determineResponses(array $rules, string $plugin, string $meth
return $responses;
}
+ /**
+ * Build the array of properties making up a media type annotation object to be included in a response annotation
+ * object. The is for when we can provide examples for specific formats, like XML, JSON, and TSV.
+ *
+ * @param string $format The format of the example. E.g. xml, json, or tsv.
+ * @param string $exampleValue The example value, which can be a JSON string.
+ * @param array $responseSchema The default schema, like GenericArray or GenericInteger responses.
+ *
+ * @return string[]
+ */
+ protected function buildMediaTypePropertiesArray(string $format, string $exampleValue, array $responseSchema = []): array
+ {
+ $contentType = $format === 'json' ? 'application/json' : ($format === 'xml' ? 'text/xml' : 'application/vnd.ms-excel');
+
+ $jsonSchema = $format === 'json' ? $this->buildSchemaAnnotationFromJsonExample(json_decode($exampleValue, true) ?? []) : [];
+ $xmlSchema = $format === 'xml' ? $this->buildSchemaAnnotationFromXmlExample(json_decode($exampleValue, true) ?? []) : [];
+
+ if (in_array($format, ['json', 'xml'])) {
+ // The annotation expects objects and not arrays, so replace [] with {}
+ $exampleValue = str_replace(['[', ']'], ['{', '}'], $exampleValue);
+ // Escape quotes differently for the annotation examples
+ $exampleValue = str_replace('\"', '""', $exampleValue);
+ }
+
+ $mediaType = [
+ 'mediaType="' . $contentType . '"',
+ ];
+ if ($format !== 'tsv') {
+ $mediaType[] = 'example=' . $exampleValue;
+ }
+ // If a type was found, add it as a schema to the media type
+ if ($format === 'json') {
+ $responseSchema = !empty($jsonSchema) ? $jsonSchema : ($responseSchema ?: []);
+ $mediaType = array_merge($mediaType, $responseSchema);
+ }
+ if ($format === 'tsv') {
+ // Escape quotes differently for the annotation examples
+ $exampleValue = str_replace('"', '""', $exampleValue);
+ $mediaType[] = 'example="' . $exampleValue . '"';
+ }
+ if ($format === 'xml') {
+ $mediaType = array_merge($mediaType, $xmlSchema);
+ }
+
+ return $mediaType;
+ }
+
/**
* Take a string example and make sure that it is close to the max char limit. There's a little wiggle room due to
* wrapping elements and whitespace characters, but it should be within 100 characters of the limit. To do this, we
@@ -1275,7 +1409,7 @@ public function buildPropertyAnnotationFromXmlExample(string $propName, array $v
// Handle arrays of strings which don't have named properties
$keys = array_keys($values);
- if (!is_string(reset($keys))) {
+ if (!is_string(reset($keys)) && count($values) === 1) {
$itemProperties = ['type="string"'];
}
@@ -1479,6 +1613,15 @@ public function compileOperationLines(string $path, string $opId, string $plugin
$operationValuesMap[] = '@OA\Parameter(ref="' . $ref . '")';
}
foreach ($params['custom'] ?? [] as $param) {
+ if (!is_array($param)) {
+ if (!is_string($param) || stripos($param, '#/components/parameters/') === false) {
+ throw new \Exception('Invalid custom param: ' . strval($param));
+ }
+
+ $operationValuesMap[] = '@OA\Parameter(ref="' . $param . '")';
+ continue;
+ }
+
$paramMap = [
'name="' . $param['name'] . '"',
'in="query"',
diff --git a/OpenApiDocs.php b/OpenApiDocs.php
index 641e24b..445699a 100644
--- a/OpenApiDocs.php
+++ b/OpenApiDocs.php
@@ -11,6 +11,11 @@
class OpenApiDocs extends \Piwik\Plugin
{
+ public const DEFAULT_SPEC_VERSION = '1.0.0';
+ public const GENERATED_ANNOTATIONS_PATH = '/tmp/annotations/';
+ public const EXAMPLE_RESPONSES_PATH = '/tmp/responses/';
+ public const GENERATED_SPECS_PATH = '/tmp/specs/';
+
public function registerEvents()
{
return [];
diff --git a/Specs/SpecGenerator.php b/Specs/SpecGenerator.php
index 0229699..7cf66a7 100644
--- a/Specs/SpecGenerator.php
+++ b/Specs/SpecGenerator.php
@@ -15,7 +15,7 @@
use Piwik\Log\LoggerInterface;
use Piwik\Log\NullLogger;
use Piwik\Plugin\Manager;
-use Piwik\Plugins\OpenApiDocs\Annotations\AnnotationGenerator;
+use Piwik\Plugins\OpenApiDocs\OpenApiDocs;
use Piwik\SettingsPiwik;
use Piwik\Validators\BaseValidator;
use Piwik\Validators\NotEmpty;
@@ -30,46 +30,72 @@ public function __construct()
}
}
- public function generatePluginDoc(string $pluginName, string $format = 'json', string $version = '1.0.0', bool $writeToFile = false): string
+ /**
+ * Generate an OpenAPI spec for a single plugin.
+ *
+ * @param string $pluginName
+ * @param string $format
+ * @param string $version
+ * @param bool $writeToFile
+ *
+ * @return string
+ * @throws \Exception
+ */
+ public function generatePluginDoc(string $pluginName, string $format = 'json', string $version = OpenApiDocs::DEFAULT_SPEC_VERSION, bool $writeToFile = false): string
{
BaseValidator::check('plugin', $pluginName, [new NotEmpty()]);
- Manager::getInstance()->checkIsPluginActivated($pluginName);
+ return $this->generateSpec(explode(',', $pluginName), $format, $version, $writeToFile);
+ }
+
+ /**
+ * Generate an OpenAPI spec for one or more plugins.
+ *
+ * @param array $pluginNames
+ * @param string $format
+ * @param string $version
+ * @param bool $writeToFile
+ *
+ * @return string
+ * @throws \Piwik\Exception\DI\DependencyException
+ * @throws \Piwik\Exception\DI\NotFoundException
+ * @throws \Piwik\Exception\PluginDeactivatedException
+ */
+ public function generateSpec(array $pluginNames, string $format = 'json', string $version = OpenApiDocs::DEFAULT_SPEC_VERSION, bool $writeToFile = false): string
+ {
+ BaseValidator::check('pluginNames', $pluginNames, [new NotEmpty()]);
$currentPluginDir = Manager::getInstance()::getPluginDirectory('OpenApiDocs');
- $pluginDir = Manager::getInstance()::getPluginDirectory($pluginName);
- $pluginSpecDir = $pluginDir . '/OpenApi/Specs';
- $pluginSpecPath = $pluginSpecDir . '/' . $pluginName . '_v' . $version . '.' . strtolower($format);
- // If the directory doesn't exist yet, create it
- if ($writeToFile && !is_dir($pluginSpecDir)) {
- mkdir($pluginSpecDir, 0777, true);
- }
- // Check if the API class has been annotated and use the generated annotations file if it hasn't
- $pluginAnnotationsSource = $pluginDir . '/API.php';
- $openapi = (new Generator(StaticContainer::get(NullLogger::class)))->generate([
- $pluginAnnotationsSource,
- ]);
- if (trim($openapi->toYaml()) === 'openapi: ' . OpenApi::DEFAULT_VERSION) {
- $pluginAnnotationDir = $pluginDir . '/OpenApi/Annotations';
- $pluginAnnotationPath = $pluginAnnotationDir . '/GeneratedAnnotations.php';
- $pluginAnnotationsSource = $pluginAnnotationPath;
- // If the generated file doesn't exist yet, generate one
- if (!is_dir($pluginAnnotationDir) || !file_exists($pluginAnnotationPath)) {
- (StaticContainer::get(AnnotationGenerator::class))->generatePluginApiAnnotations($pluginName, true);
+ $pluginDirs = [];
+ foreach ($pluginNames as $pluginName) {
+ BaseValidator::check('pluginName', $pluginName, [new NotEmpty()]);
+ Manager::getInstance()->checkIsPluginActivated($pluginName);
+
+ $pluginDir = Manager::getInstance()::getPluginDirectory($pluginName);
+ $pluginAnnotationsSource = $pluginDir . '/API.php';
+ $openapi = (new Generator(StaticContainer::get(NullLogger::class)))->generate([
+ $pluginAnnotationsSource,
+ ]);
+ 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;
}
$generator = new Generator(StaticContainer::get(LoggerInterface::class));
-
- $openapi = $generator->generate([
+ $openapi = $generator->generate(array_merge([
$currentPluginDir . '/Annotations/GlobalApiComponents.php',
- $pluginAnnotationsSource,
- ]);
+ ], $pluginDirs));
- // Update title with plugin name
- $openapi->info->title .= ' for ' . $pluginName . ' plugin';
+ $specFileBaseName = 'matomo';
+ // If there's only one plugin, name the spec after the plugin
+ if (count($pluginNames) === 1) {
+ // Update title with plugin name
+ $openapi->info->title .= ' for ' . $pluginNames[0] . ' plugin';
+ $specFileBaseName = $pluginNames[0];
+ }
- $openapi->info->version = $version ?: '1.0.0';
+ $openapi->info->version = $version ?: OpenApiDocs::DEFAULT_SPEC_VERSION;
// Remove the current server so that it isn't used when saving the spec file. It should only leave demo
if ($writeToFile && is_array($openapi->servers) && count($openapi->servers) > 1) {
@@ -77,8 +103,10 @@ public function generatePluginDoc(string $pluginName, string $format = 'json', s
$openapi->servers = array_values($openapi->servers);
}
- $specContents = strtolower($format) === 'yaml' ? $openapi->toYaml() : $openapi->toJson();
+ $lowercaseFormat = strtolower($format);
+ $specContents = $lowercaseFormat === 'yaml' ? $openapi->toYaml() : $openapi->toJson();
if ($writeToFile) {
+ $pluginSpecPath = $currentPluginDir . OpenApiDocs::GENERATED_SPECS_PATH . $specFileBaseName . '_openapi_spec_v' . $version . '.' . $lowercaseFormat;
file_put_contents($pluginSpecPath, $specContents);
}
diff --git a/phpcs.xml b/phpcs.xml
index 74a69ed..155e118 100644
--- a/phpcs.xml
+++ b/phpcs.xml
@@ -9,6 +9,7 @@
tests/javascript/*
*/vendor/*
+ */tmp/*
diff --git a/tests/Resources/ExampleResponsesNormalised/ExamplesSchemasByType.json b/tests/Resources/ExampleResponsesNormalised/ExamplesSchemasByType.json
index 9ebf4fe..ed7ffea 100644
--- a/tests/Resources/ExampleResponsesNormalised/ExamplesSchemasByType.json
+++ b/tests/Resources/ExampleResponsesNormalised/ExamplesSchemasByType.json
@@ -40,7 +40,7 @@
"json": "{\"@OA\\\\Schema\":{\"0\":\"type=\\\"array\\\",\",\"@OA\\\\Items\":{\"0\":\"type=\\\"object\\\",\",\"1\":\"additionalProperties=true,\",\"@OA\\\\Property\":[\"type=\\\"object\\\",\",\"@OA\\\\Property(property=\\\"severity\\\", type=\\\"string\\\")\",\"@OA\\\\Property(property=\\\"tag\\\", type=\\\"string\\\")\",\"@OA\\\\Property(property=\\\"datetime\\\", type=\\\"string\\\")\",\"@OA\\\\Property(property=\\\"requestId\\\", type=\\\"string\\\")\",\"@OA\\\\Property(property=\\\"message\\\", type=\\\"string\\\")\"]}}}"
},
"LogViewer.getAvailableLogReaders": {
- "xml": "{\"@OA\\\\Schema\":[\"type=\\\"object\\\",\",\"@OA\\\\Xml(name=\\\"result\\\"),\",{\"@OA\\\\Property\":{\"0\":\"property=\\\"row\\\",\",\"1\":\"type=\\\"array\\\",\",\"@OA\\\\Items\":[\"type=\\\"string\\\"\"]}}]}",
+ "xml": "{\"@OA\\\\Schema\":[\"type=\\\"object\\\",\",\"@OA\\\\Xml(name=\\\"result\\\"),\",{\"@OA\\\\Property\":{\"0\":\"property=\\\"row\\\",\",\"1\":\"type=\\\"array\\\",\",\"@OA\\\\Items\":[\"type=\\\"object\\\",\",\"@OA\\\\Xml(name=\\\"row\\\"),\",\"additionalProperties=true,\"]}}]}",
"json": "{\"@OA\\\\Schema\":[\"type=\\\"array\\\",\",\"@OA\\\\Items()\"]}"
},
"LogViewer.getConfiguredLogReaders": {
@@ -48,11 +48,11 @@
"json": "{\"@OA\\\\Schema\":[\"type=\\\"array\\\",\",\"@OA\\\\Items()\"]}"
},
"LogViewer.getLogConfig": {
- "xml": "{\"@OA\\\\Schema\":[\"type=\\\"object\\\",\",\"@OA\\\\Xml(name=\\\"result\\\"),\",{\"@OA\\\\Property\":[\"property=\\\"log_writers\\\",\",\"type=\\\"object\\\",\",{\"@OA\\\\Property\":{\"0\":\"property=\\\"row\\\",\",\"1\":\"type=\\\"array\\\",\",\"@OA\\\\Items\":[\"type=\\\"string\\\"\"]}}]}]}",
+ "xml": "{\"@OA\\\\Schema\":[\"type=\\\"object\\\",\",\"@OA\\\\Xml(name=\\\"result\\\"),\",{\"@OA\\\\Property\":[\"property=\\\"log_writers\\\",\",\"type=\\\"object\\\",\",{\"@OA\\\\Property\":{\"0\":\"property=\\\"row\\\",\",\"1\":\"type=\\\"array\\\",\",\"@OA\\\\Items\":[\"type=\\\"object\\\",\",\"@OA\\\\Xml(name=\\\"row\\\"),\",\"additionalProperties=true,\"]}}]}]}",
"json": "{\"@OA\\\\Schema\":{\"0\":\"type=\\\"object\\\",\",\"@OA\\\\Property\":[\"property=\\\"log_writers\\\",\",\"type=\\\"array\\\",\",\"@OA\\\\Items()\"],\"1\":\"@OA\\\\Property(property=\\\"log_level\\\", type=\\\"string\\\")\",\"2\":\"@OA\\\\Property(property=\\\"logger_file_path\\\", type=\\\"string\\\")\",\"3\":\"@OA\\\\Property(property=\\\"logger_syslog_ident\\\", type=\\\"string\\\")\"}}"
},
"CustomAlerts.getAlert": {
- "xml": "{\"@OA\\\\Schema\":[\"type=\\\"object\\\",\",\"@OA\\\\Xml(name=\\\"result\\\"),\"]}",
+ "xml": "{\"@OA\\\\Schema\":[\"type=\\\"object\\\",\",\"@OA\\\\Xml(name=\\\"result\\\"),\",{\"@OA\\\\Property\":[\"property=\\\"report_mediums\\\",\",\"type=\\\"object\\\",\"]},{\"@OA\\\\Property\":[\"property=\\\"id_sites\\\",\",\"type=\\\"object\\\",\"]}]}",
"json": "{\"@OA\\\\Schema\":[\"type=\\\"array\\\",\",\"@OA\\\\Items()\"]}"
},
"CustomAlerts.getAlerts": {
@@ -68,7 +68,7 @@
"json": "{\"@OA\\\\Schema\":{\"0\":\"type=\\\"array\\\",\",\"@OA\\\\Items\":{\"0\":\"type=\\\"object\\\",\",\"1\":\"additionalProperties=true,\",\"@OA\\\\Property\":{\"0\":\"type=\\\"object\\\",\",\"1\":\"@OA\\\\Property(property=\\\"idtriggered\\\", type=\\\"integer\\\")\",\"2\":\"@OA\\\\Property(property=\\\"idalert\\\", type=\\\"integer\\\")\",\"3\":\"@OA\\\\Property(property=\\\"idsite\\\", type=\\\"integer\\\")\",\"4\":\"@OA\\\\Property(property=\\\"ts_triggered\\\", type=\\\"string\\\")\",\"5\":\"@OA\\\\Property(property=\\\"ts_last_sent\\\", type=\\\"string\\\")\",\"6\":\"@OA\\\\Property(property=\\\"value_old\\\", type=\\\"string\\\")\",\"7\":\"@OA\\\\Property(property=\\\"value_new\\\", type=\\\"string\\\")\",\"8\":\"@OA\\\\Property(property=\\\"name\\\", type=\\\"string\\\")\",\"9\":\"@OA\\\\Property(property=\\\"login\\\", type=\\\"string\\\")\",\"10\":\"@OA\\\\Property(property=\\\"period\\\", type=\\\"string\\\")\",\"11\":\"@OA\\\\Property(property=\\\"report\\\", type=\\\"string\\\")\",\"12\":\"@OA\\\\Property(property=\\\"report_condition\\\", type={\\\"string\\\", \\\"number\\\", \\\"integer\\\", \\\"boolean\\\", \\\"array\\\", \\\"object\\\", \\\"null\\\"})\",\"13\":\"@OA\\\\Property(property=\\\"report_matched\\\", type={\\\"string\\\", \\\"number\\\", \\\"integer\\\", \\\"boolean\\\", \\\"array\\\", \\\"object\\\", \\\"null\\\"})\",\"@OA\\\\Property\":[\"property=\\\"id_sites\\\",\",\"type=\\\"array\\\",\",\"@OA\\\\Items()\"],\"14\":\"@OA\\\\Property(property=\\\"metric\\\", type=\\\"string\\\")\",\"15\":\"@OA\\\\Property(property=\\\"metric_condition\\\", type=\\\"string\\\")\",\"16\":\"@OA\\\\Property(property=\\\"metric_matched\\\", type=\\\"integer\\\")\",\"17\":\"@OA\\\\Property(property=\\\"compared_to\\\", type=\\\"integer\\\")\",\"18\":\"@OA\\\\Property(property=\\\"email_me\\\", type=\\\"integer\\\")\",\"19\":\"@OA\\\\Property(property=\\\"slack_channel_id\\\", type={\\\"string\\\", \\\"number\\\", \\\"integer\\\", \\\"boolean\\\", \\\"array\\\", \\\"object\\\", \\\"null\\\"})\"}}}}"
},
"CustomDimensions.getCustomDimension": {
- "xml": "{\"@OA\\\\Schema\":[\"type=\\\"object\\\",\",\"@OA\\\\Xml(name=\\\"result\\\"),\",{\"@OA\\\\Property\":{\"0\":\"property=\\\"row\\\",\",\"1\":\"type=\\\"array\\\",\",\"@OA\\\\Items\":[\"type=\\\"string\\\"\"]}}]}",
+ "xml": "{\"@OA\\\\Schema\":[\"type=\\\"object\\\",\",\"@OA\\\\Xml(name=\\\"result\\\"),\",{\"@OA\\\\Property\":{\"0\":\"property=\\\"row\\\",\",\"1\":\"type=\\\"array\\\",\",\"@OA\\\\Items\":[\"type=\\\"object\\\",\",\"@OA\\\\Xml(name=\\\"row\\\"),\",\"additionalProperties=true,\"]}}]}",
"json": "{\"@OA\\\\Schema\":{\"0\":\"type=\\\"array\\\",\",\"@OA\\\\Items\":{\"0\":\"type=\\\"object\\\",\",\"1\":\"additionalProperties=true,\",\"@OA\\\\Property\":{\"0\":\"type=\\\"object\\\",\",\"1\":\"@OA\\\\Property(property=\\\"label\\\", type=\\\"string\\\")\",\"2\":\"@OA\\\\Property(property=\\\"nb_uniq_visitors\\\", type=\\\"string\\\")\",\"3\":\"@OA\\\\Property(property=\\\"nb_visits\\\", type=\\\"string\\\")\",\"4\":\"@OA\\\\Property(property=\\\"nb_actions\\\", type=\\\"string\\\")\",\"5\":\"@OA\\\\Property(property=\\\"max_actions\\\", type=\\\"integer\\\")\",\"6\":\"@OA\\\\Property(property=\\\"sum_visit_length\\\", type=\\\"string\\\")\",\"7\":\"@OA\\\\Property(property=\\\"bounce_count\\\", type=\\\"string\\\")\",\"8\":\"@OA\\\\Property(property=\\\"nb_visits_converted\\\", type=\\\"string\\\")\",\"@OA\\\\Property\":{\"0\":\"property=\\\"goals\\\",\",\"1\":\"type=\\\"object\\\",\",\"@OA\\\\Property\":[\"property=\\\"idgoal=8\\\",\",\"type=\\\"object\\\",\",\"@OA\\\\Property(property=\\\"nb_conversions\\\", type=\\\"integer\\\")\",\"@OA\\\\Property(property=\\\"nb_visits_converted\\\", type=\\\"integer\\\")\",\"@OA\\\\Property(property=\\\"revenue\\\", type=\\\"integer\\\")\"]},\"9\":\"@OA\\\\Property(property=\\\"nb_conversions\\\", type=\\\"integer\\\")\",\"10\":\"@OA\\\\Property(property=\\\"revenue\\\", type=\\\"integer\\\")\",\"11\":\"@OA\\\\Property(property=\\\"avg_time_on_site\\\", type=\\\"integer\\\")\",\"12\":\"@OA\\\\Property(property=\\\"bounce_rate\\\", type=\\\"string\\\")\",\"13\":\"@OA\\\\Property(property=\\\"nb_actions_per_visit\\\", type={\\\"string\\\", \\\"number\\\", \\\"integer\\\", \\\"boolean\\\", \\\"array\\\", \\\"object\\\", \\\"null\\\"})\",\"14\":\"@OA\\\\Property(property=\\\"segment\\\", type=\\\"string\\\")\"}}}}"
},
"CustomDimensions.getConfiguredCustomDimensions": {
diff --git a/tests/Unit/AnnotationGeneratorTest.php b/tests/Unit/AnnotationGeneratorTest.php
index 854b906..401d7dc 100644
--- a/tests/Unit/AnnotationGeneratorTest.php
+++ b/tests/Unit/AnnotationGeneratorTest.php
@@ -442,6 +442,56 @@ public function getTestDataForBuildParameterAnnotationData(): iterable
'default' => 'Piwik\API\NoDefaultValue',
'example' => '',
]];
+ yield 'should show one type when docInfo has two types and one is bool' => ['someParam', [], [
+ 'type' => 'string|bool',
+ ], [
+ 'name' => 'someParam',
+ 'types' => ['string' => null],
+ 'description' => '',
+ 'required' => 'true',
+ 'default' => 'Piwik\API\NoDefaultValue',
+ 'example' => '',
+ ]];
+ yield 'should remove bool type when docInfo has more than 2 types and one is bool' => ['someParam', [], [
+ 'type' => 'string|int|bool',
+ ], [
+ 'name' => 'someParam',
+ 'types' => ['string' => null, 'integer' => null],
+ 'description' => '',
+ 'required' => 'true',
+ 'default' => 'Piwik\API\NoDefaultValue',
+ 'example' => '',
+ ]];
+ yield 'should remove bool type regardless of spacing around pipe' => ['someParam', [], [
+ 'type' => 'string | int | bool',
+ ], [
+ 'name' => 'someParam',
+ 'types' => ['string' => null, 'integer' => null],
+ 'description' => '',
+ 'required' => 'true',
+ 'default' => 'Piwik\API\NoDefaultValue',
+ 'example' => '',
+ ]];
+ yield 'should remove bool type regardless of spacing and order' => ['someParam', [], [
+ 'type' => 'bool | string',
+ ], [
+ 'name' => 'someParam',
+ 'types' => ['string' => null],
+ 'description' => '',
+ 'required' => 'true',
+ 'default' => 'Piwik\API\NoDefaultValue',
+ 'example' => '',
+ ]];
+ yield 'should remove bool type even when type hints are wrapped by parenthesis' => ['someParam', [], [
+ 'type' => '(bool | string)',
+ ], [
+ 'name' => 'someParam',
+ 'types' => ['string' => null],
+ 'description' => '',
+ 'required' => 'true',
+ 'default' => 'Piwik\API\NoDefaultValue',
+ 'example' => '',
+ ]];
yield 'should allow multiple types when metadata type is string' => ['someParam', [
'type' => 'string',
], [
@@ -454,6 +504,31 @@ public function getTestDataForBuildParameterAnnotationData(): iterable
'default' => 'Piwik\API\NoDefaultValue',
'example' => '',
]];
+ yield 'should allow multiple types when metadata type is bool and doc type is piped' => ['someParam', [
+ 'type' => 'bool',
+ ], [
+ 'type' => 'string|int|bool',
+ ], [
+ 'name' => 'someParam',
+ 'types' => ['string' => null, 'integer' => null],
+ 'description' => '',
+ 'required' => 'true',
+ 'default' => 'Piwik\API\NoDefaultValue',
+ 'example' => '',
+ ]];
+ yield 'should allow multiple types when metadata type is bool and doc type is piped even if default is bool' => ['someParam', [
+ 'type' => 'bool',
+ 'default' => false,
+ ], [
+ 'type' => 'string|int|bool',
+ ], [
+ 'name' => 'someParam',
+ 'types' => ['string' => null, 'integer' => null],
+ 'description' => '',
+ 'required' => 'false',
+ 'default' => 'false',
+ 'example' => '',
+ ]];
yield 'should not allow multiple types when metadata type is specified' => ['someParam', [
'type' => 'integer',
], [
@@ -661,8 +736,16 @@ public function testBuildPropertyAnnotationFromJsonExample(): void
public function testBuildSchemaAnnotationFromXmlExample(): void
{
- // TODO - buildSchemaAnnotationFromXmlExample method
- $this->expectNotToPerformAssertions();
+ $normalisedMap = $this->getExampleResponsesMap();
+ $schemasMap = $this->getExampleResponsesMap(true);
+ foreach (self::EXAMPLE_API_ENDPOINTS as $endpoint) {
+ $normalisedString = $normalisedMap[$endpoint]['xml'] ?? '';
+ $this->assertNotEmpty($normalisedString, 'The normalised example response should not be empty for endpoint: ' . $endpoint);
+ $normalisedObject = json_decode($normalisedString, true) ?? [];
+ $this->assertNotEmpty($normalisedObject, 'The decoded example response should not be empty for endpoint: ' . $endpoint);
+ $expected = json_decode($schemasMap[$endpoint]['xml'] ?? '', true) ?? [];
+ $this->assertEquals($expected, $this->annotationGenerator->buildSchemaAnnotationFromXmlExample($normalisedObject), "The XML schema was not as expected for endpoint $endpoint.");
+ }
}
public function testBuildPropertyAnnotationFromXmlExample(): void
diff --git a/tmp/.gitkeep b/tmp/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/tmp/annotations/.gitkeep b/tmp/annotations/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/tmp/responses/.gitkeep b/tmp/responses/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/tmp/specs/.gitkeep b/tmp/specs/.gitkeep
new file mode 100644
index 0000000..e69de29