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