diff --git a/.git-hooks-matomo/pre-push b/.git-hooks-matomo/pre-push new file mode 100755 index 0000000..79c6658 --- /dev/null +++ b/.git-hooks-matomo/pre-push @@ -0,0 +1,106 @@ +#!/bin/bash + +# This hook is called with the following parameters: +# +# $1 -- Name of the remote to which the push is being done +# $2 -- URL to which the push is being done +# +# If pushing without using a named remote those arguments will be equal. +# +# Information about the commits which are being pushed is supplied as lines to +# the standard input in the form: +# +# + + + +### Check we're running in the context of a plugin and get helpful dir variables ### + +REPO_DIR="$(git rev-parse --show-toplevel)" +echo "Running pre-commit hook in repo: $REPO_DIR" + +if [[ "$REPO_DIR" =~ /plugins/(.*) ]]; then + PLUGIN_PATH="plugins/${BASH_REMATCH[1]}/" +else + echo "Not a plugin, not running any further checks" + exit 1 +fi +MATOMO_DIR=$(echo "$REPO_DIR" | sed -E 's|/plugins/.*$||') + + + +### Figure out how to run PHPStan - ddev or not. ### + +COMMAND="" +# Use local PHP if setup +if command -v php >/dev/null 2>&1; then + if [ -f "${MATOMO_DIR}/vendor/bin/phpstan" ]; then + COMMAND="${MATOMO_DIR}/vendor/bin/phpstan" + PLUGIN_PATH='' + fi +# Use ddev if setup (overridding local setup) +elif command -v ddev >/dev/null 2>&1; then + if [ -d "$MATOMO_DIR/.ddev" ]; then + cd "$MATOMO_DIR" || exit 1 + if ddev status 2>&1 > /dev/null; then + COMMAND="ddev exec phpstan" + fi + fi +fi +# If no command, exit +if [[ -z "$COMMAND" ]]; then + echo "No way to run phpstan found." + exit 1 +fi + + + +# Basic setup +cd "$REPO_DIR" +STATUS=0 + + + + +### Run PHPStan on newly created files. ### + +PHPSTAN_CREATED_CONFIG=phpstan/phpstan.created.neon +MAIN_BRANCH='5.x-dev' +if [[ -f "$PHPSTAN_CREATED_CONFIG" ]]; then + CHANGED_FILES=$(git diff --name-only ${MAIN_BRANCH} --diff-filter=A | grep '\.php$' || true) + if [ -z "$CHANGED_FILES" ]; then + echo "No created PHP files" + else + echo "Running PHPstan at a very high level on new files" + CHANGED_FILES=`echo "$CHANGED_FILES" | sed -e 's/^\(.*\)$/"\1"/' | xargs -I{} echo "${PLUGIN_PATH}{}"` + echo "$CHANGED_FILES" | xargs $COMMAND analyse -c ${PLUGIN_PATH}${PHPSTAN_CREATED_CONFIG} || STATUS=1 + fi +fi + + + +### Run PHPStan on modified files. ### +PHPSTAN_MODIFIED_CONFIG=phpstan/phpstan.modified.neon +if [[ -f "$PHPSTAN_MODIFIED_CONFIG" ]]; then + CHANGED_FILES=$(git diff --name-only ${MAIN_BRANCH} --diff-filter=CM | grep '\.php$' || true) + if [ -z "$CHANGED_FILES" ]; then + echo "No changed PHP files" + else + echo "Running PHPstan on modified files" + CHANGED_FILES=`echo "$CHANGED_FILES" | sed -e 's/^\(.*\)$/"\1"/' | xargs -I{} echo "${PLUGIN_PATH}{}"` + echo "$CHANGED_FILES" | xargs $COMMAND analyse -c ${PLUGIN_PATH}${PHPSTAN_MODIFIED_CONFIG} || STATUS=1 + fi +fi + +# Don't bother running the full check, as we check changes files already, and +# can assume that the unchanged files don't need rechecking. +# +# Github will check this anyway. +# +# PHPSTAN_BASE_CONFIG=phpstan.neon +# if [[ -f "$PHPSTAN_BASE_CONFIG" ]]; then +# echo "Running PHPstan at a base level on all plugin files" +# $COMMAND analyse -c ${PLUGIN_PATH}/${PHPSTAN_BASE_CONFIG} || STATUS=1 +# fi + +exit $STATUS diff --git a/.github/workflows/matomo-tests.yml b/.github/workflows/matomo-tests.yml new file mode 100644 index 0000000..6b8dd16 --- /dev/null +++ b/.github/workflows/matomo-tests.yml @@ -0,0 +1,58 @@ +# Action for running tests +# This file has been automatically created. +# To recreate it you can run this command +# ./console generate:test-action --plugin="OpenApiDocs" --php-versions="7.2,8.4" --schedule-cron="0 19 * * 6" + +name: Plugin OpenApiDocs Tests + +on: + pull_request: + types: [opened, synchronize] + push: + branches: + - '**.x-dev' + workflow_dispatch: + schedule: + - cron: "0 19 * * 6" + +permissions: + actions: read + checks: none + contents: read + deployments: none + issues: read + packages: none + pull-requests: read + repository-projects: none + security-events: none + statuses: none + +concurrency: + group: php-${{ github.ref }} + cancel-in-progress: true + +jobs: + PluginTests: + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + php: [ '7.2', '8.4' ] + target: ['minimum_required_matomo', 'maximum_supported_matomo'] + steps: + - uses: actions/checkout@v3 + with: + lfs: true + persist-credentials: false + - name: Install package ripgrep + run: sudo apt-get install ripgrep + - name: Run tests + uses: matomo-org/github-action-tests@main + with: + plugin-name: 'OpenApiDocs' + php-version: ${{ matrix.php }} + test-type: 'PluginTests' + matomo-test-branch: ${{ matrix.target }} + redis-service: true + artifacts-pass: ${{ secrets.ARTIFACTS_PASS }} + upload-artifacts: ${{ matrix.php == '7.2' && matrix.target == 'maximum_supported_matomo' }} \ No newline at end of file diff --git a/.github/workflows/phpcs.yml b/.github/workflows/phpcs.yml new file mode 100644 index 0000000..0eb4bf3 --- /dev/null +++ b/.github/workflows/phpcs.yml @@ -0,0 +1,43 @@ +name: PHPCS check + +on: pull_request + +permissions: + actions: read + checks: read + contents: read + deployments: none + issues: read + packages: none + pull-requests: read + repository-projects: none + security-events: none + statuses: read + +jobs: + phpcs: + name: PHPCS + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + lfs: false + persist-credentials: false + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '7.4' + tools: cs2pr + - name: Install dependencies + run: + composer init --name=matomo/openapidocs --quiet; + composer --no-plugins config allow-plugins.dealerdirect/phpcodesniffer-composer-installer true -n; + composer config repositories.matomo-coding-standards vcs https://github.com/matomo-org/matomo-coding-standards -n; + composer require matomo-org/matomo-coding-standards:dev-master; + composer install --dev --prefer-dist --no-progress --no-suggest + - name: Check PHP code styles + id: phpcs + run: ./vendor/bin/phpcs --report-full --standard=phpcs.xml --report-checkstyle=./phpcs-report.xml + - name: Show PHPCS results in PR + if: ${{ always() && steps.phpcs.outcome == 'failure' }} + run: cs2pr ./phpcs-report.xml --prepend-filename \ No newline at end of file diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 0000000..4ab4683 --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,81 @@ +name: PHPStan check + +on: pull_request + +permissions: + actions: read + checks: read + contents: read + deployments: none + issues: read + packages: none + pull-requests: read + repository-projects: none + security-events: none + statuses: read + +env: + PLUGIN_NAME: OpenApiDocs + +jobs: + phpstan: + name: PHPStan + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + lfs: false + persist-credentials: false + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '7.2' + + - name: Check out github-action-tests repository + uses: actions/checkout@v4 + with: + repository: matomo-org/github-action-tests + ref: main + path: github-action-tests + + - name: checkout matomo for plugin builds + shell: bash + run: ${{ github.workspace }}/github-action-tests/scripts/bash/checkout_matomo.sh + env: + PLUGIN_NAME: ${{ env.PLUGIN_NAME }} + WORKSPACE: ${{ github.workspace }} + ACTION_PATH: ${{ github.workspace }}/github-action-tests + MATOMO_TEST_TARGET: maximum_supported_matomo + + - name: prepare setup + shell: bash + run: | + cd ${{ github.workspace }}/matomo + echo -e "composer install" + composer install --ignore-platform-reqs + - name: checkout additional plugins + if: ${{ env.DEPENDENT_PLUGINS != '' }} + shell: bash + working-directory: ${{ github.workspace }}/matomo + run: ${{ github.workspace }}/github-action-tests/scripts/bash/checkout_dependent_plugins.sh + + env: + GITHUB_USER_TOKEN: ${{ secrets.TESTS_ACCESS_TOKEN || secrets.GITHUB_TOKEN }} + + - name: "Restore result cache" + uses: actions/cache/restore@v4 + with: + path: /tmp/phpstan # same as in phpstan.neon + key: "phpstan-result-cache-${{ github.run_id }}" + restore-keys: | + phpstan-result-cache- + - name: PHPStan whole repo + id: phpstan-all + run: cd ${{ github.workspace }}/matomo && composer run phpstan -- -vvv -c plugins/${{ env.PLUGIN_NAME }}/phpstan.neon + + - name: "Save result cache" + uses: actions/cache/save@v4 + if: ${{ !cancelled() }} + with: + path: /tmp/phpstan # same as in phpstan.neon + key: "phpstan-result-cache-${{ github.run_id }}" \ No newline at end of file diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index 89727a6..da5ec93 100644 --- a/Annotations/AnnotationGenerator.php +++ b/Annotations/AnnotationGenerator.php @@ -11,6 +11,8 @@ namespace Piwik\Plugins\OpenApiDocs\Annotations; +use Piwik\API\DocumentationGenerator; +use Piwik\API\NoDefaultValue; use Piwik\API\Proxy; use Piwik\API\Request; use Piwik\Plugin\Manager; @@ -21,17 +23,26 @@ use PHPStan\PhpDocParser\Parser\TypeParser; use PHPStan\PhpDocParser\Parser\ConstExprParser; use PHPStan\PhpDocParser\Parser\TokenIterator; -use function Symfony\Component\String\s; class AnnotationGenerator { /** - * Use reflection to generate the OpenAPI annotations to be used by php-swagger. + * @var DocumentationGenerator + */ + protected $generator; + + public function __construct(DocumentationGenerator $generator) + { + $this->generator = $generator; + } + + /** + * Use reflection to generate the OpenAPI annotations to be used by swagger-php. * - Tries to use virtual paths and x-runtime to keep paths unique and allow actual path generation * - Uses config.php to set default values. * - Uses config.php from plugin to override default configs. */ - public function generatePluginApiAnnotations(string $pluginName) + public function generatePluginApiAnnotations(string $pluginName, bool $writeToFile = false) { BaseValidator::check('plugin', $pluginName, [ new NotEmpty() ]); Manager::getInstance()->checkIsPluginActivated($pluginName); @@ -39,8 +50,12 @@ public function generatePluginApiAnnotations(string $pluginName) $currentPluginDir = Manager::getInstance()::getPluginDirectory('OpenApiDocs'); $rules = require $currentPluginDir . '/Annotations/config.php'; $pluginDir = Manager::getInstance()::getPluginDirectory($pluginName); - $pluginRules = require $pluginDir . '/OpenApi/Annotations/config.php'; - $rules['plugins'] = [ $pluginName => $pluginRules ]; + $pluginAnnotationDir = $pluginDir . '/OpenApi/Annotations'; + $pluginAnnotationPath = $pluginAnnotationDir . '/GeneratedAnnotations.php'; + // If the directory doesn't exist yet, create it + if ($writeToFile && !is_dir($pluginAnnotationDir)) { + mkdir($pluginAnnotationDir, 0777, true); + } $className = Request::getClassNameAPI($pluginName); @@ -59,39 +74,80 @@ public function generatePluginApiAnnotations(string $pluginName) continue; } - $reflectionMethod = $reflectionClass->getMethod($metadataMethod); - $existing = $reflectionMethod->getDocComment(); - // Skip methods which have been marked as internal or auto annotations disabled - if ($existing !== false && (stripos($existing, 'OA-AUTO:OFF') !== false - || stripos($existing, '@internal') !== false)) { + $methodAnnotations = $this->buildAnnotationForMethod($rules, $pluginName, $reflectionClass->getMethod($metadataMethod)); + if (empty($methodAnnotations)) { continue; } - $methodName = $reflectionMethod->getName(); - $opId = Proxy::getInstance()->buildApiActionName($pluginName, $methodName); - $path = $this->buildVirtualPath( - $rules['virtualPathTemplate'] ?? '/' . $opId, - $pluginName, - $methodName - ); + $annotations[] = $methodAnnotations; + } - $params = $this->determineParameters($rules, $pluginName, $methodName, $reflectionMethod); - $responses = $this->determineResponses($rules, $pluginName, $methodName); + if ($writeToFile) { + $this->writeAnnotationsToFile($annotations, $pluginAnnotationPath, $pluginName); + } - $isPost = !empty($rules['plugins'][$pluginName]['methodsRequiringPost']) - && in_array($methodName, $rules['plugins'][$pluginName]['methodsRequiringPost']); + return $annotations; + } - $annotations[] = $this->compileOperationLines($path, $opId, $pluginName, $methodName, $params, $responses, $isPost); + protected function writeAnnotationsToFile(array $annotations, string $filePath, string $pluginName): void + { + $output = ''; + $lines = [ + 'getDocComment(); + // Skip methods which have been marked as internal or auto annotations disabled + if ( + $existing !== false && (stripos($existing, 'OA-AUTO:OFF') !== false + || stripos($existing, '@internal') !== false) + ) { + return []; } - return $annotations; + $methodName = $reflectionMethod->getName(); + $opId = Proxy::getInstance()->buildApiActionName($pluginName, $methodName); + $path = $this->buildVirtualPath( + $rules['virtualPathTemplate'] ?? '/' . $opId, + $pluginName, + $methodName + ); + + $params = $this->determineParameters($rules, $pluginName, $methodName, $reflectionMethod); + $responses = $this->determineResponses($rules, $pluginName, $methodName); + + $isPost = !empty($rules['plugins'][$pluginName]['methodsRequiringPost']) + && in_array($methodName, $rules['plugins'][$pluginName]['methodsRequiringPost']); + + return $this->compileOperationLines($path, $opId, $pluginName, $methodName, $params, $responses, $isPost); } - function getParamInfoFromDocBlock(string $docBlock): array { + protected function getParamInfoFromDocBlock(string $docBlock): array + { $lexer = new Lexer(); $tokens = $lexer->tokenize($docBlock); $expressionParser = new ConstExprParser(); @@ -111,12 +167,41 @@ function getParamInfoFromDocBlock(string $docBlock): array { return $params; } - function buildVirtualPath(string $virtualPathTemplate, string $plugin, string $method): string + protected function buildVirtualPath(string $virtualPathTemplate, string $plugin, string $method): string { return str_replace([ '{plugin}', '{method}' ], [ $plugin, $method ], $virtualPathTemplate); } - function determineParameters(array $rules, string $plugin, string $method, \ReflectionMethod $reflectionMethod): array + protected function buildParameterAnnotation(string $paramName, array $paramMetadata, array $paramDocInfo): array + { + $docType = strtolower(trim($paramDocInfo['type'] ?? '')); + $metaType = strtolower(trim($paramMetadata['type'] ?? $docType)); + $type = $metaType === 'string' && $docType !== 'string' ? $docType : $metaType; + $typesMap = []; + // Check for pipes and try to list possible types + foreach (explode('|', $type) as $typePart) { + $typePart = trim($typePart, ' ()'); + $normalisedType = $this->getOpenApiTypeFromPhpType($typePart); + // If the type is array, check if there's a subType + $subType = null; + if ($normalisedType === 'array' && $typePart !== 'array' && strpos($typePart, '[]') !== false) { + $subType = substr($typePart, 0, strpos($typePart, '[]')); + } + $typesMap[$normalisedType] = $subType !== null ? $this->getOpenApiTypeFromPhpType($subType) : $subType; + } + + $isRequired = !key_exists('default', $paramMetadata) || $paramMetadata['default'] instanceof NoDefaultValue; + + return [ + 'name' => $paramName, + 'types' => $typesMap, + 'description' => $paramDocInfo['desc'] ?? '', + 'required' => $isRequired ? 'true' : 'false', + 'default' => !$isRequired ? json_encode($paramMetadata['default']) : '', + ]; + } + + protected function determineParameters(array $rules, string $plugin, string $method, \ReflectionMethod $reflectionMethod): array { $refs = []; @@ -140,29 +225,7 @@ function determineParameters(array $rules, string $plugin, string $method, \Refl continue; } - $type = $paramMetadata['type'] ?? $paramInfo['type'] ?? ''; - // TODO - Properly map the internal types to OpenAPI types - switch (strtolower($type)) { - case 'array': - $type = 'array'; - break; - case 'int': - $type = 'integer'; - break; - case 'bool': - case 'boolean': - $type = 'boolean'; - break; - default: - $type = 'string'; - } - - $customParams[] = [ - 'name' => $name, - 'type' => $type, - 'description' => $paramInfo['desc'] ?? '', - 'required' => empty($paramMetadata['allowsNull']) ? 'true' : 'false', - ]; + $customParams[] = $this->buildParameterAnnotation($name, $paramMetadata, $paramInfo); } return [ @@ -171,20 +234,153 @@ function determineParameters(array $rules, string $plugin, string $method, \Refl ]; } - function determineResponses(array $rules, string $plugin, string $method): array + /** + * Map the PHP type to the OpenAPI type. The currently available types for v3.1.1 are the following: “null”, + * “boolean”, “object”, “array”, “number”, “string”, or “integer”. + * + * @link https://spec.openapis.org/oas/v3.1.1.html#data-types + * + * @param string $type The PHP type from the method signature or doc-block + * @return string The normalised Data Type to be used in the swagger-php annotation + */ + public function getOpenApiTypeFromPhpType(string $type): string + { + // TODO - Is there a good way to handle object type or should that always be ref? + // TODO - Eventually handle the Data Type Formats: https://spec.openapis.org/oas/v3.1.1.html#data-type-format + switch (strtolower($type)) { + case 'array': + case '[]': + case 'int[]': + case 'string[]': + case 'bool[]': + case 'float[]': + case 'double[]': + $type = 'array'; + break; + case 'int': + case 'integer': + $type = 'integer'; + break; + case 'bool': + case 'boolean': + $type = 'boolean'; + break; + case 'float': + case 'double': + $type = 'number'; + break; + default: + $type = 'string'; + } + + return $type; + } + + protected function getApplicableDemoExampleUrls(string $pluginName, string $methodName): array + { + // Get the example URLs for the success responses + $parametersToSet = [ + 'idSite' => 1, + 'period' => 'day', + 'date' => 'today' + ]; + $className = Request::getClassNameAPI($pluginName); + $exampleUrl = $this->generator->getExampleUrl($className, $methodName, $parametersToSet); + if (empty($exampleUrl)) { + return []; + } + + $exampleUrl = 'https://demo.matomo.cloud/' . $exampleUrl; + return [ + 'xml' => $exampleUrl . '&filter_limit=2&format=xml&token_auth=anonymous', + 'json' => $exampleUrl . '&filter_limit=2&format=JSON&token_auth=anonymous', + 'tsv' => $exampleUrl . '&filter_limit=2&format=Tsv&token_auth=anonymous', + ]; + } + + protected function getExampleIfAvailable(string $url): array + { + // Simply return the URL for TSV + if (stripos($url, 'format=tsv') !== false) { + return ['externalValue' => $url]; + } + + $ch = curl_init($url); + + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_FOLLOWLOCATION => true, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_TIMEOUT => 5, + ]); + + $body = curl_exec($ch); + $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + // If the example didn't load or is too big, simply include the URL instead of the string value + if ($body === false || $status !== 200 || strlen($body) > 1000 || strpos($body, 'Error: ') === 0) { + return ['externalValue' => $url]; + } + + // Clean up XML formatting a bit + $body = trim($body); + if (stripos($url, 'format=xml') !== false) { + $body = str_replace(['', "\n", "\t", '"'], ['', '', '', '\"'], $body); + } + + // The annotation expects an objects and not arrays + if (stripos($url, 'format=json') !== false && stripos($body, '[') === 0) { + $body = str_replace(['[', ']'], ['{', '}'], $body); + } + + return ['value' => $body]; + } + + protected function determineResponses(array $rules, string $plugin, string $method): array { $responses = []; + // TODO - Try to determine the success response using the return type and/or doc-block return type + $successRef = null; + $successArray = ['code' => 200]; if (isset($rules['plugins'][$plugin]['successResponseByMethod'][$method])) { $successRef = $rules['plugins'][$plugin]['successResponseByMethod'][$method]; } if ($successRef) { - $responses[] = [ 'code' => 200, 'ref' => $successRef ]; - } else { - $responses[] = [ 'code' => 200 ]; + $successArray['ref'] = $successRef; } + $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); + foreach ($exampleUrls as $type => $url) { + $contentType = $type === 'json' ? 'application/json' : ($type === 'xml' ? 'text/xml' : 'application/vnd.ms-excel'); + $exampleProperties = [ + 'example="' . $type . 'DemoLink"', + 'summary="Example ' . $type . '"', + ]; + $exampleValue = $this->getExampleIfAvailable($url); + $valueKey = array_key_first($exampleValue); + $value = '"' . array_pop($exampleValue) . '"'; + // Remove the surrounding quotes for JSON values + if ($valueKey === 'value' && $type === 'json') { + $value = substr($value, 1, -1); + } + $exampleProperties[] = $valueKey . '=' . $value; + $mediaTypes[] = [ + 'mediaType="' . $contentType . '"', + '@OA\Examples' => $exampleProperties, + ]; + } + if (!empty($mediaTypes)) { + $successArray['mediaTypes'] = $mediaTypes; + } + + $responses[] = $successArray; + if (!empty($rules['defaultErrorResponseRefs'])) { foreach ($rules['defaultErrorResponseRefs'] as $errorRef) { $responses[] = $errorRef; @@ -194,45 +390,132 @@ function determineResponses(array $rules, string $plugin, string $method): array return $responses; } - function compileOperationLines(string $path, string $opId, string $plugin, string $method, array $params, array $responses, bool $isPost = false): array + protected function removeTrailingCommaFromLastLine(&$lines): void { - $httpMethod = $isPost ? 'Post' : 'Get'; + if (!empty($lines)) { + $last = array_pop($lines); + $lines[] = rtrim($last, ','); + } + } + + protected function buildLinesForAnnotationObject(string $objectName, array $objectProperties, int $indent = 0): array + { + $indentString = str_repeat(' ', $indent); + $innerIndentString = str_repeat(' ', $indent + 1); $lines = []; - $lines[] = '@OA\\' . $httpMethod . '('; - $lines[] = ' path="' . $path . '",'; - $lines[] = ' operationId="' . $opId . '",'; - $lines[] = ' tags={"' . $plugin . '"},'; + foreach ($objectProperties as $name => $property) { + if (is_string($property)) { + $lines[] = $innerIndentString . $property . (substr($property, -1) !== ',' ? ',' : ''); + continue; + } - foreach ($params['refs'] ?? [] as $ref) { - $lines[] = ' @OA\Parameter(ref="' . $ref . '"),'; + if (is_string($name)) { + $lines = array_merge($lines, $this->buildLinesForAnnotationObject($name, $property, $indent + 1)); + continue; + } + + // If it's not an object, then it's an array of similarly named objects, like parameters + foreach ($property as $subPropIndex => $subProperty) { + $lines = array_merge($lines, $this->buildLinesForAnnotationObject($subPropIndex, $subProperty, $indent + 1)); + } } - foreach ($params['custom'] ?? [] as $param) { - // TODO - Finish implementing this - $lines[] = ' @OA\Parameter('; - $lines[] = ' name="' . $param['name'] . '",'; - $lines[] = ' in="query",'; - $lines[] = " required={$param['required']},"; - $lines[] = ' @OA\Schema('; - $lines[] = ' type="' . $param['type'] . '"'; - $lines[] = ' )'; - $lines[] = ' ),'; + $this->removeTrailingCommaFromLastLine($lines); + + // Default to parenthesis, but override when necessary + $openingCharacter = '('; + $closingCharacter = ')'; + if (substr($objectName, -2) === '={') { + $openingCharacter = ''; + $closingCharacter = '}'; + } + + // Return the compiled lines wrapped with the opening and closing parenthesis/braces + return array_merge([$indentString . $objectName . $openingCharacter], $lines, [$indentString . $closingCharacter . ',']); + } + + protected function buildSchemaObjectArray(string $type, string $subType = '', string $default = ''): array + { + $schemaMap = ['type="' . $type . '"']; + $subTypeString = ''; + if (!empty($subType)) { + $subTypeString = 'type="' . $subType . '"'; + } + if ($type === 'array') { + $schemaMap[] = '@OA\Items(' . $subTypeString . ')'; + if ($default === '[]') { + $default = '{}'; + } + } + + if ($default !== '') { + // TODO - Add some logic to only add default if it matches the type. E.g. false isn't a good default for string + $schemaMap[] = 'default="' . $default . '"'; + } + + return ['@OA\Schema' => $schemaMap]; + } + + protected function buildSchemaObjectArrays(array $typesMap, string $default = ''): array + { + $schemas = []; + foreach ($typesMap as $type => $subType) { + $schemas[] = $this->buildSchemaObjectArray($type, $subType ?? '', $default); + } + + if (count($schemas) === 1) { + return $schemas[0]; } + return ['@OA\Schema' => ['oneOf={' => $schemas]]; + } + + protected function compileOperationLines(string $path, string $opId, string $plugin, string $method, array $params, array $responses, bool $isPost = false): array + { + $operationValuesMap = [ + 'path="' . $path . '"', + 'operationId="' . $opId . '"', + 'tags={"' . $plugin . '"}', + ]; + foreach ($params['refs'] ?? [] as $ref) { + $operationValuesMap[] = '@OA\Parameter(ref="' . $ref . '")'; + } + foreach ($params['custom'] ?? [] as $param) { + $paramMap = [ + 'name="' . $param['name'] . '"', + 'in="query"', + 'required=' . $param['required'], + ]; + if (!empty($param['description'])) { + $paramMap[] = 'description="' . $param['description'] . '"'; + } + $paramMap[] = $this->buildSchemaObjectArrays($param['types'], strval($param['default'])); + $operationValuesMap[] = ['@OA\Parameter' => $paramMap]; + } foreach ($responses as $response) { if (isset($response['ref'])) { $code = $response['code']; $codeFormatted = is_numeric($code) ? (string)$code : '"' . $code . '"'; - $lines[] = ' @OA\Response(response=' . $codeFormatted . ', ref="' . $response['ref'] . '"),'; + $operationValuesMap[] = '@OA\Response(response=' . $codeFormatted . ', ref="' . $response['ref'] . '")'; } else { - $desc = $response['desc'] ?? 'OK'; - $lines[] = ' @OA\Response(response=200, description="' . addcslashes($desc, '"') . '"),'; + $responsePropertyArray = [ + 'response=200', + 'description="' . ($response['desc'] ?? 'OK') . '"', + ]; + if (isset($response['mediaTypes']) && is_array($response['mediaTypes'])) { + foreach ($response['mediaTypes'] as $mediaType) { + $responsePropertyArray[] = ['@OA\MediaType' => $mediaType]; + } + } + $operationValuesMap[] = ['@OA\Response' => $responsePropertyArray]; } } + $operationValuesMap[] = 'x={"runtime"={"entry":"index.php","query":{"module":"API","method":"' . $plugin . '.' . $method . '"}}}'; + + $lines = $this->buildLinesForAnnotationObject('@OA\\' . ($isPost ? 'Post' : 'Get'), $operationValuesMap); - $lines[] = ' x={"runtime"={"entry":"index.php","query":{"module":"API","method":"' . $plugin . '.' . $method . '"}}}'; - $lines[] = ')'; - + // Trim the comma off the very last item at this level and return the array + $this->removeTrailingCommaFromLastLine($lines); return $lines; } } diff --git a/Annotations/GlobalApiComponents.php b/Annotations/GlobalApiComponents.php index ccdf110..af76b57 100644 --- a/Annotations/GlobalApiComponents.php +++ b/Annotations/GlobalApiComponents.php @@ -345,5 +345,4 @@ */ class GlobalApiComponents { - -} \ No newline at end of file +} diff --git a/Annotations/config.php b/Annotations/config.php index fbb730d..db9c090 100644 --- a/Annotations/config.php +++ b/Annotations/config.php @@ -8,7 +8,7 @@ */ return [ - 'virtualPathTemplate' => '/{plugin}.{method}', + 'virtualPathTemplate' => '/index.php?module=API&method={plugin}.{method}', 'defaultParamRefs' => [ '#/components/parameters/formatOptional', diff --git a/Commands/GenerateAnnotations.php b/Commands/GenerateAnnotations.php index b7e4eb7..a600781 100644 --- a/Commands/GenerateAnnotations.php +++ b/Commands/GenerateAnnotations.php @@ -9,6 +9,7 @@ namespace Piwik\Plugins\OpenApiDocs\Commands; +use Piwik\Container\StaticContainer; use Piwik\Plugin\ConsoleCommand; use Piwik\Plugins\OpenApiDocs\Annotations\AnnotationGenerator; @@ -28,7 +29,7 @@ class GenerateAnnotations extends ConsoleCommand protected function configure() { $this->setName('openapidocs:generate-annotations'); - $this->setDescription('Generate the annotations php-swagger uses to generate OpenAPI specs.'); + $this->setDescription('Generate the annotations swagger-php uses to generate OpenAPI specs.'); $this->addRequiredValueOption('plugin', null, 'Name of the plugin to annotate'); $this->addNoValueOption('not-dry-run', null, 'Flag to allow writing to file instead of outputting a dry run.'); } @@ -81,9 +82,13 @@ protected function doExecute(): int $output->writeln(sprintf('Generating annotations for: %s', $plugin)); - // TODO - Add handling for not-dry-run + $result = (StaticContainer::get(AnnotationGenerator::class))->generatePluginApiAnnotations($plugin, $notDryRun); - $result = (new AnnotationGenerator())->generatePluginApiAnnotations($plugin); + if ($notDryRun) { + $output->writeln('Results written to ' . $plugin . ' plugin\'s /OpenApi/Annotations directory.'); + + return $result ? self::SUCCESS : self::FAILURE; + } if (is_array($result)) { foreach ($result as $annotation) { diff --git a/Renderer/Yaml.php b/Renderer/Yaml.php index 394eba7..06c4996 100644 --- a/Renderer/Yaml.php +++ b/Renderer/Yaml.php @@ -11,7 +11,6 @@ use Piwik\API\ApiRenderer; use Piwik\Common; -use Piwik\DataTable\Renderer; use Symfony\Component\Yaml\Yaml as SymfonyYaml; class Yaml extends ApiRenderer @@ -45,4 +44,4 @@ public function renderDataTable($dataTable) { return $dataTable->getFirstRow()->getColumn(0); } -} \ No newline at end of file +} diff --git a/Specs/SpecGenerator.php b/Specs/SpecGenerator.php index 797f13a..bc33cc6 100644 --- a/Specs/SpecGenerator.php +++ b/Specs/SpecGenerator.php @@ -13,7 +13,9 @@ use OpenApi\Generator; use Piwik\Container\StaticContainer; use Piwik\Log\LoggerInterface; +use Piwik\Log\NullLogger; use Piwik\Plugin\Manager; +use Piwik\Plugins\OpenApiDocs\Annotations\AnnotationGenerator; use Piwik\SettingsPiwik; use Piwik\Validators\BaseValidator; use Piwik\Validators\NotEmpty; @@ -23,12 +25,12 @@ class SpecGenerator public function __construct() { // Set the constant for the current instance's URL - if(!defined('LOCAL_MATOMO_SERVER_URL')) { + if (!defined('LOCAL_MATOMO_SERVER_URL')) { define('LOCAL_MATOMO_SERVER_URL', SettingsPiwik::getPiwikUrl()); } } - public function generatePluginDoc(string $pluginName, string $format = 'json'): string + public function generatePluginDoc(string $pluginName, string $format = 'json', bool $writeToFile = false): string { BaseValidator::check('plugin', $pluginName, [new NotEmpty()]); Manager::getInstance()->checkIsPluginActivated($pluginName); @@ -36,13 +38,38 @@ public function generatePluginDoc(string $pluginName, string $format = 'json'): $currentPluginDir = Manager::getInstance()::getPluginDirectory('OpenApiDocs'); $pluginDir = Manager::getInstance()::getPluginDirectory($pluginName); + // 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); + } + } + $generator = new Generator(StaticContainer::get(LoggerInterface::class)); $generator->setVersion(OpenApi::DEFAULT_VERSION); + $openapi = $generator->generate([ $currentPluginDir . '/Annotations/GlobalApiComponents.php', - $pluginDir . '/API.php', + $pluginAnnotationsSource, ]); + // Update title with plugin name + $openapi->info->title .= ' for ' . $pluginName . ' plugin'; + + // 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) { + unset($openapi->servers[0]); + $openapi->servers = array_values($openapi->servers); + } + return strtolower($format) === 'yaml' ? $openapi->toYaml() : $openapi->toJson(); } } diff --git a/phpcs.xml b/phpcs.xml new file mode 100644 index 0000000..74a69ed --- /dev/null +++ b/phpcs.xml @@ -0,0 +1,36 @@ + + + + Matomo Coding Standard for OpenApiDocs plugin + + + + . + + tests/javascript/* + */vendor/* + + + + + + + + tests/* + + + + + Updates/* + + + + + tests/* + + + + + tests/* + + \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..7eeb31a --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,24 @@ +parameters: + level: 5 + phpVersion: 70200 + tmpDir: /tmp/phpstan/OpenApiDocs/main + paths: + - . + excludePaths: + - tests/* + - vendor/* + - github-action-tests + - scoper.inc.php + - Specs/SpecGenerator.php + bootstrapFiles: + - ../../bootstrap-phpstan.php + universalObjectCratesClasses: + - Piwik\Config + - Piwik\View + - Piwik\ViewDataTable\Config + scanDirectories: + # ../../ does not actually seem to give us anything + # that ../plugins/ does not, but including it for + # completeness. It does not seem to slow down performance. + - . + diff --git a/phpstan/phpstan.created.neon b/phpstan/phpstan.created.neon new file mode 100644 index 0000000..01e5495 --- /dev/null +++ b/phpstan/phpstan.created.neon @@ -0,0 +1,5 @@ +includes: + - ../phpstan.neon +parameters: + level: 5 + tmpDir: /tmp/phpstan/OpenApiDocs/created \ No newline at end of file diff --git a/phpstan/phpstan.modified.neon b/phpstan/phpstan.modified.neon new file mode 100644 index 0000000..c9967f1 --- /dev/null +++ b/phpstan/phpstan.modified.neon @@ -0,0 +1,5 @@ +includes: + - ../phpstan.neon +parameters: + level: 5 + tmpDir: /tmp/phpstan/OpenApiDocs/modified \ No newline at end of file diff --git a/tests/Unit/AnnotationGeneratorTest.php b/tests/Unit/AnnotationGeneratorTest.php new file mode 100644 index 0000000..22beaf4 --- /dev/null +++ b/tests/Unit/AnnotationGeneratorTest.php @@ -0,0 +1,70 @@ +annotationGenerator = new AnnotationGenerator(new DocumentationGenerator()); + } + + /** + * @dataProvider getTestDataForGetOpenApiTypeFromPhpType + * + * @param string $type + * @param string $expected + * @return void + */ + public function testGetOpenApiTypeFromPhpType(string $type, string $expected): void + { + $this->assertEquals($expected, $this->annotationGenerator->getOpenApiTypeFromPhpType($type)); + } + + /** + * @return iterable + */ + public function getTestDataForGetOpenApiTypeFromPhpType(): iterable + { + yield 'should be string for empty' => ['', 'string']; + yield 'should be string for unknown' => ['unknown', 'string']; + yield 'should be string for abc123' => ['abc123', 'string']; + yield 'should be array for array' => ['array', 'array']; + yield 'should be array for []' => ['[]', 'array']; + yield 'should be array for int[]' => ['int[]', 'array']; + yield 'should be array for string[]' => ['string[]', 'array']; + yield 'should be array for bool[]' => ['bool[]', 'array']; + yield 'should be array for float[]' => ['float[]', 'array']; + yield 'should be array for double[]' => ['double[]', 'array']; + yield 'should be integer for int' => ['int', 'integer']; + yield 'should be integer for integer' => ['integer', 'integer']; + yield 'should be boolean for bool' => ['bool', 'boolean']; + yield 'should be boolean for boolean' => ['boolean', 'boolean']; + yield 'should be number for float' => ['float', 'number']; + yield 'should be number for double' => ['double', 'number']; + } +}