diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index 4e045e4..0bc6917 100644 --- a/Annotations/AnnotationGenerator.php +++ b/Annotations/AnnotationGenerator.php @@ -239,16 +239,8 @@ protected function writeAnnotationsToFile(array $annotations, string $filePath, */ protected function buildAnnotationForMethod(array $rules, string $pluginName, \ReflectionMethod $reflectionMethod): array { - $existing = $reflectionMethod->getDocComment(); // Skip methods which have been marked as internal or auto annotations disabled - if ( - $existing !== false - && ( - stripos($existing, '@internal') !== false - || stripos($existing, '@hide') !== false - || stripos($existing, '@deprecated') !== false - ) - ) { + if (self::shouldApiMethodBeIgnored($reflectionMethod)) { return []; } @@ -269,6 +261,33 @@ protected function buildAnnotationForMethod(array $rules, string $pluginName, \R return $this->compileOperationLines($path, $opId, $pluginName, $params, $responses, $isPost); } + /** + * Check whether the method should be included in public documentation, or it's been marked as internal or similar. + * + * @param \ReflectionMethod $reflectionMethod Reflection method used to check the comment block for annotations. + * + * @return bool Whether the API method should be ignored while generating documentation, like being marked as + * internal, hide, deprecated, etc. + */ + public static function shouldApiMethodBeIgnored(\ReflectionMethod $reflectionMethod): bool + { + $existing = $reflectionMethod->getDocComment(); + // Skip methods which have been marked as internal or hide + if ( + $existing !== false + && ( + stripos($existing, '@internal') !== false + || stripos($existing, '@hide') !== false + || stripos($existing, '@deprecated') !== false + || stripos($existing, '@ignore') !== false + ) + ) { + return true; + } + + return false; + } + /** * Try to extract the list of parameters and key information about them from the method's doc block string. * diff --git a/Annotations/ApiMethodInfoExtractor.php b/Annotations/ApiMethodInfoExtractor.php new file mode 100644 index 0000000..d735a5b --- /dev/null +++ b/Annotations/ApiMethodInfoExtractor.php @@ -0,0 +1,188 @@ +checkIsPluginActivated($plugin); + + $className = Request::getClassNameAPI($plugin); + + $result = $this->buildEndpointInfoArrayForApiClass($className); + if (!empty($result)) { + $methodInfoArray = array_merge($methodInfoArray, $result); + } + } + $methodInfoString = json_encode($methodInfoArray); + + $fileBaseName = 'matomo'; + // If there's only one plugin, name the spec after the plugin + if (count($pluginNames) === 1) { + $fileBaseName = $pluginNames[0]; + } + + if ($writeToFile) { + $pluginSpecPath = $currentPluginDir . OpenApiDocs::GENERATED_ANNOTATIONS_PATH . $fileBaseName . '_api_method_info.json'; + file_put_contents($pluginSpecPath, $methodInfoString); + } + + return $methodInfoString; + } + + /** + * Build the array of info about all the public API endpoints for a specific API class. It uses reflection and the + * metadata from the Proxy class used to build documentation. + * + * @param string $apiClassName Full name of the class which can be used to instantiate a ReflectionClass. + * + * @return array[] Collection of key details about each public API endpoint in the specified class. + */ + public function buildEndpointInfoArrayForApiClass(string $apiClassName): array + { + try { + $reflectionClass = new \ReflectionClass($apiClassName); + } catch (\ReflectionException $e) { + throw new \RuntimeException('Unable to get reflection class for API class: ' . $apiClassName, 0, $e); + } + + Proxy::getInstance()->registerClass($apiClassName); + $pluginMetadata = Proxy::getInstance()->getMetadata()[$apiClassName] ?? []; + + // The proxy has already determined which methods should be ignored, so we just use the list it came up with. + $methodInfoArray = []; + foreach ($pluginMetadata as $methodName => $metadataMethod) { + if ($methodName === '__documentation') { + continue; + } + $result = $this->buildJsonInfoForApiMethod($reflectionClass, $methodName, $metadataMethod); + if (!empty($result)) { + $methodInfoArray[] = $result; + } + } + + return $methodInfoArray; + } + + /** + * Build the array of info about a specific API endpoint. + * + * @param \ReflectionClass $reflectionClass Reflection class used to get the method data defined in the signature. + * @param string $methodName Name of the method being processed. + * @param array $methodMetadata Metadata from the Proxy class which can be used to enhance the reflection data. + * + * @return array Collection of key details about the API endpoint. + * @throws \ReflectionException If the method cannot be found on the class. + */ + public function buildJsonInfoForApiMethod(\ReflectionClass $reflectionClass, string $methodName, array $methodMetadata): array + { + $reflectionMethod = $reflectionClass->getMethod($methodName); + if (AnnotationGenerator::shouldApiMethodBeIgnored($reflectionMethod)) { + return []; + } + + $doc = $reflectionMethod->getDocComment() ?: ''; + + $signatureString = 'public function ' . $methodName . '('; + $paramsString = ''; + $signature = $this->buildMethodSignatureUsingReflection($reflectionMethod); + foreach ($signature['params'] as $param) { + $paramsString .= !empty($param['php_type']) ? $param['php_type'] . ' ' : ''; + $paramsString .= $param['name']; + $paramsString .= $param['default'] !== null ? ' = ' . (!is_string($param['default']) ? json_encode($param['default']) : $param['default']) : ''; + $paramsString .= ', '; + } + $paramsString = rtrim($paramsString, ', '); + $signatureString .= $paramsString . ')'; + if (!empty($signature['return']) && !empty($signature['return']['php_type'])) { + $signatureString .= ': ' . ($signature['return']['nullable'] ? '?' : '') . $signature['return']['php_type']; + } + + $fileName = $reflectionMethod->getFileName(); + $fileName = str_replace(PIWIK_DOCUMENT_ROOT . '/', '', $fileName); + + return [ + 'fqcn' => $reflectionClass->getName(), + 'method' => $methodName, + 'source' => ['file' => $fileName, 'start' => $reflectionMethod->getStartLine(), 'end' => $reflectionMethod->getEndLine()], + 'header' => $signatureString, + 'signature' => $signature, + 'docblock_raw' => $doc, + ]; + } + + /** + * Use reflection to build up information about the method signature, such as parameter and return type details. + * + * @param \ReflectionMethod $reflectionMethod The reflection method which can provide all the necessary info. + * + * @return array[] Collection of parameter and return type details, such as php type and whether they're nullable. + */ + public function buildMethodSignatureUsingReflection(\ReflectionMethod $reflectionMethod) + { + $params = []; + foreach ($reflectionMethod->getParameters() as $reflectionParameter) { + $defaultValue = null; + if ($reflectionParameter->isOptional() && $reflectionParameter->isDefaultValueAvailable()) { + $defaultValue = $reflectionParameter->getDefaultValue(); + $defaultValue = $defaultValue !== null ? $defaultValue : json_encode($reflectionParameter->getDefaultValue()); + } + $param = [ + 'name' => '$' . $reflectionParameter->getName(), + 'php_type' => $reflectionParameter->hasType() ? strval($reflectionParameter->getType()) : null, + 'required' => !$reflectionParameter->isOptional(), + 'nullable' => $reflectionParameter->hasType() && $reflectionParameter->getType() !== null && $reflectionParameter->getType()->allowsNull(), + 'default' => $defaultValue, + 'byRef' => $reflectionParameter->isPassedByReference(), + 'variadic' => $reflectionParameter->isVariadic(), + ]; + + $params[] = $param; + } + + $returnType = $reflectionMethod->getReturnType(); + $returnTypeInfo = [ + 'php_type' => $returnType ? strval($returnType) : null, + 'nullable' => $returnType !== null && $returnType->allowsNull(), + ]; + + return ['params' => $params, 'return' => $returnTypeInfo]; + } +} diff --git a/Commands/ExtractReportingApiMethodInfo.php b/Commands/ExtractReportingApiMethodInfo.php new file mode 100644 index 0000000..5ff52ba --- /dev/null +++ b/Commands/ExtractReportingApiMethodInfo.php @@ -0,0 +1,98 @@ +setName('openapidocs:extract-api-method-info'); + $this->setDescription('Extract the comment block and basic information about methods for the Matomo Reporting API.'); + $this->addRequiredValueOption('plugin', 'p', 'Name of the plugin to inspect'); + $this->addNoValueOption('not-dry-run', null, 'Flag to allow writing to file instead of outputting a dry run.'); + } + + /** + * Interact with the user. + * + * This method is executed before the InputDefinition is validated. + * This means that this is the only place where the command can + * interactively ask for values of missing required arguments. + */ + protected function doInteract(): void + { + } + + /** + * Initializes the command after the input has been bound and before the input + * is validated. + * + * This is mainly useful when a lot of commands extends one main command + * where some things need to be initialized based on the input arguments and options. + */ + protected function doInitialize(): void + { + } + + /** + * The actual task is defined in this method. Here you can access any option or argument that was defined on the + * command line via $this->getInput() and write anything to the console via $this->getOutput(). + * In case anything went wrong during the execution you should throw an exception to make sure the user will get a + * useful error message and to make sure the command does not exit with the status code 0. + * + * Ideally, the actual command is quite short as it acts like a controller. It should only receive the input values, + * execute the task by calling a method of another class and output any useful information. + * + * Execute the command like: ./console openapidocs:extract-api-method-info --plugin=TagManager --not-dry-run + */ + protected function doExecute(): int + { + $input = $this->getInput(); + $output = $this->getOutput(); + + $plugin = $input->getOption('plugin'); + if (empty($plugin)) { + throw new \RuntimeException('Please specify a plugin name.'); + } + $notDryRun = $input->getOption('not-dry-run') ?: false; + + $message = sprintf('Extracting API method info for: %s', $plugin); + + $output->writeln($message); + + $result = (new ApiMethodInfoExtractor())->extractMethodInfo($plugin, $notDryRun); + + if ($notDryRun) { + $output->writeln('Results written to plugins/OpenApiDocs/tmp/annotations directory.'); + + return $result ? self::SUCCESS : self::FAILURE; + } + + $output->writeln($result); + + return $result ? self::SUCCESS : self::FAILURE; + } +}