Skip to content

Commit 15d4b99

Browse files
committed
Initial implementation of API endpoint info export
1 parent 624235a commit 15d4b99

3 files changed

Lines changed: 311 additions & 9 deletions

File tree

Annotations/AnnotationGenerator.php

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -239,16 +239,8 @@ protected function writeAnnotationsToFile(array $annotations, string $filePath,
239239
*/
240240
protected function buildAnnotationForMethod(array $rules, string $pluginName, \ReflectionMethod $reflectionMethod): array
241241
{
242-
$existing = $reflectionMethod->getDocComment();
243242
// Skip methods which have been marked as internal or auto annotations disabled
244-
if (
245-
$existing !== false
246-
&& (
247-
stripos($existing, '@internal') !== false
248-
|| stripos($existing, '@hide') !== false
249-
|| stripos($existing, '@deprecated') !== false
250-
)
251-
) {
243+
if (self::shouldApiMethodBeIgnored($reflectionMethod)) {
252244
return [];
253245
}
254246

@@ -269,6 +261,33 @@ protected function buildAnnotationForMethod(array $rules, string $pluginName, \R
269261
return $this->compileOperationLines($path, $opId, $pluginName, $params, $responses, $isPost);
270262
}
271263

264+
/**
265+
* Check whether the method should be included in public documentation, or it's been marked as internal or similar.
266+
*
267+
* @param \ReflectionMethod $reflectionMethod Reflection method used to check the comment block for annotations.
268+
*
269+
* @return bool Whether the API method should be ignored while generating documentation, like being marked as
270+
* internal, hide, deprecated, etc.
271+
*/
272+
public static function shouldApiMethodBeIgnored(\ReflectionMethod $reflectionMethod): bool
273+
{
274+
$existing = $reflectionMethod->getDocComment();
275+
// Skip methods which have been marked as internal or hide
276+
if (
277+
$existing !== false
278+
&& (
279+
stripos($existing, '@internal') !== false
280+
|| stripos($existing, '@hide') !== false
281+
|| stripos($existing, '@deprecated') !== false
282+
|| stripos($existing, '@ignore') !== false
283+
)
284+
) {
285+
return true;
286+
}
287+
288+
return false;
289+
}
290+
272291
/**
273292
* Try to extract the list of parameters and key information about them from the method's doc block string.
274293
*
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
<?php
2+
3+
/**
4+
* Matomo - free/libre analytics platform
5+
*
6+
* @link https://matomo.org
7+
* @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
8+
*/
9+
10+
namespace Piwik\Plugins\OpenApiDocs\Annotations;
11+
12+
use Piwik\API\Proxy;
13+
use Piwik\API\Request;
14+
use Piwik\Plugin\Manager;
15+
use Piwik\Plugins\OpenApiDocs\OpenApiDocs;
16+
use Piwik\Validators\BaseValidator;
17+
use Piwik\Validators\NotEmpty;
18+
19+
class ApiMethodInfoExtractor
20+
{
21+
/**
22+
* Look up the Matomo Reporting API methods for the specified plugin(s) and output the basic information for each.
23+
* This includes the comment block, parameter information, and things like that. This can then be fed to a secure
24+
* AI model which can come up with suggestions on how to improve the descriptions, type hinting, etc.
25+
*
26+
* @param string $pluginName Name or comma-separated list of names of the plugins to extract API endpoint info for.
27+
* @param bool $writeToFile Whether to output the result to console or write it to tmp file.
28+
*
29+
* @return string String result of the extracted info.
30+
* @throws \Exception
31+
*/
32+
public function extractMethodInfo(string $pluginName, bool $writeToFile = false): string
33+
{
34+
BaseValidator::check('plugin', $pluginName, [new NotEmpty()]);
35+
$pluginNames = explode(',', $pluginName);
36+
37+
BaseValidator::check('pluginNames', $pluginNames, [new NotEmpty()]);
38+
$currentPluginDir = Manager::getInstance()::getPluginDirectory('OpenApiDocs');
39+
40+
$methodInfoArray = [];
41+
foreach ($pluginNames as $plugin) {
42+
BaseValidator::check('pluginName', $plugin, [new NotEmpty()]);
43+
Manager::getInstance()->checkIsPluginActivated($plugin);
44+
45+
$className = Request::getClassNameAPI($plugin);
46+
47+
$result = $this->buildEndpointInfoArrayForApiClass($className);
48+
if (!empty($result)) {
49+
$methodInfoArray = array_merge($methodInfoArray, $result);
50+
}
51+
}
52+
$methodInfoString = json_encode($methodInfoArray);
53+
54+
$fileBaseName = 'matomo';
55+
// If there's only one plugin, name the spec after the plugin
56+
if (count($pluginNames) === 1) {
57+
$fileBaseName = $pluginNames[0];
58+
}
59+
60+
if ($writeToFile) {
61+
$pluginSpecPath = $currentPluginDir . OpenApiDocs::GENERATED_ANNOTATIONS_PATH . $fileBaseName . '_api_method_info.json';
62+
file_put_contents($pluginSpecPath, $methodInfoString);
63+
}
64+
65+
return $methodInfoString;
66+
}
67+
68+
/**
69+
* Build the array of info about all the public API endpoints for a specific API class. It uses reflection and the
70+
* metadata from the Proxy class used to build documentation.
71+
*
72+
* @param string $apiClassName Full name of the class which can be used to instantiate a ReflectionClass.
73+
*
74+
* @return array[] Collection of key details about each public API endpoint in the specified class.
75+
*/
76+
public function buildEndpointInfoArrayForApiClass(string $apiClassName): array
77+
{
78+
try {
79+
$reflectionClass = new \ReflectionClass($apiClassName);
80+
} catch (\ReflectionException $e) {
81+
throw new \RuntimeException('Unable to get reflection class for API class: ' . $apiClassName, 0, $e);
82+
}
83+
84+
Proxy::getInstance()->registerClass($apiClassName);
85+
$pluginMetadata = Proxy::getInstance()->getMetadata()[$apiClassName] ?? [];
86+
87+
// The proxy has already determined which methods should be ignored, so we just use the list it came up with.
88+
$methodInfoArray = [];
89+
foreach ($pluginMetadata as $methodName => $metadataMethod) {
90+
if ($methodName === '__documentation') {
91+
continue;
92+
}
93+
$result = $this->buildJsonInfoForApiMethod($reflectionClass, $methodName, $metadataMethod);
94+
if (!empty($result)) {
95+
$methodInfoArray[] = $result;
96+
}
97+
}
98+
99+
return $methodInfoArray;
100+
}
101+
102+
/**
103+
* Build the array of info about a specific API endpoint.
104+
*
105+
* @param \ReflectionClass $reflectionClass Reflection class used to get the method data defined in the signature.
106+
* @param string $methodName Name of the method being processed.
107+
* @param array $methodMetadata Metadata from the Proxy class which can be used to enhance the reflection data.
108+
*
109+
* @return array Collection of key details about the API endpoint.
110+
* @throws \ReflectionException If the method cannot be found on the class.
111+
*/
112+
public function buildJsonInfoForApiMethod(\ReflectionClass $reflectionClass, string $methodName, array $methodMetadata): array
113+
{
114+
$reflectionMethod = $reflectionClass->getMethod($methodName);
115+
if (AnnotationGenerator::shouldApiMethodBeIgnored($reflectionMethod)) {
116+
return [];
117+
}
118+
119+
$doc = $reflectionMethod->getDocComment() ?: '';
120+
121+
$signatureString = 'public function ' . $methodName . '(';
122+
$paramsString = '';
123+
$signature = $this->buildMethodSignatureUsingReflection($reflectionMethod);
124+
foreach ($signature['params'] as $param) {
125+
$paramsString .= !empty($param['php_type']) ? $param['php_type'] . ' ' : '';
126+
$paramsString .= $param['name'];
127+
$paramsString .= $param['default'] !== null ? ' = ' . (!is_string($param['default']) ? json_encode($param['default']) : $param['default']) : '';
128+
$paramsString .= ', ';
129+
}
130+
$paramsString = rtrim($paramsString, ', ');
131+
$signatureString .= $paramsString . ')';
132+
if (!empty($signature['return']) && !empty($signature['return']['php_type'])) {
133+
$signatureString .= ': ' . ($signature['return']['nullable'] ? '?' : '') . $signature['return']['php_type'];
134+
}
135+
136+
$fileName = $reflectionMethod->getFileName();
137+
$fileName = str_replace(PIWIK_DOCUMENT_ROOT . '/', '', $fileName);
138+
139+
return [
140+
'fqcn' => $reflectionClass->getName(),
141+
'method' => $methodName,
142+
'source' => ['file' => $fileName, 'start' => $reflectionMethod->getStartLine(), 'end' => $reflectionMethod->getEndLine()],
143+
'header' => $signatureString,
144+
'signature' => $signature,
145+
'docblock_raw' => $doc,
146+
];
147+
}
148+
149+
/**
150+
* Use reflection to build up information about the method signature, such as parameter and return type details.
151+
*
152+
* @param \ReflectionMethod $reflectionMethod The reflection method which can provide all the necessary info.
153+
*
154+
* @return array[] Collection of parameter and return type details, such as php type and whether they're nullable.
155+
*/
156+
public function buildMethodSignatureUsingReflection(\ReflectionMethod $reflectionMethod)
157+
{
158+
$params = [];
159+
foreach ($reflectionMethod->getParameters() as $reflectionParameter) {
160+
$defaultValue = null;
161+
if ($reflectionParameter->isOptional() && $reflectionParameter->isDefaultValueAvailable()) {
162+
$defaultValue = $reflectionParameter->getDefaultValue();
163+
$defaultValue = $defaultValue !== null ? $defaultValue : json_encode($reflectionParameter->getDefaultValue());
164+
}
165+
$param = [
166+
'name' => '$' . $reflectionParameter->getName(),
167+
'php_type' => $reflectionParameter->hasType() ? strval($reflectionParameter->getType()) : null,
168+
'required' => !$reflectionParameter->isOptional(),
169+
'nullable' => $reflectionParameter->hasType() && $reflectionParameter->getType() !== null && $reflectionParameter->getType()->allowsNull(),
170+
'default' => $defaultValue,
171+
'byRef' => $reflectionParameter->isPassedByReference(),
172+
'variadic' => $reflectionParameter->isVariadic(),
173+
];
174+
175+
$params[] = $param;
176+
}
177+
178+
$returnType = $reflectionMethod->getReturnType();
179+
$returnTypeInfo = [
180+
'php_type' => $returnType ? strval($returnType) : null,
181+
'nullable' => $returnType !== null && $returnType->allowsNull(),
182+
];
183+
184+
return ['params' => $params, 'return' => $returnTypeInfo];
185+
}
186+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php
2+
3+
/**
4+
* Matomo - free/libre analytics platform
5+
*
6+
* @link https://matomo.org
7+
* @license https://www.gnu.org/licenses/gpl-3.0.html GPL v3 or later
8+
*/
9+
10+
namespace Piwik\Plugins\OpenApiDocs\Commands;
11+
12+
use Piwik\Plugin\ConsoleCommand;
13+
use Piwik\Plugins\OpenApiDocs\Annotations\ApiMethodInfoExtractor;
14+
use Piwik\Plugins\OpenApiDocs\Specs\SpecGenerator;
15+
16+
/**
17+
* This class lets you define a new command. To read more about commands have a look at our Matomo Console guide on
18+
* https://developer.matomo.org/guides/piwik-on-the-command-line
19+
*
20+
* As Matomo Console is based on the Symfony Console you might also want to have a look at
21+
* https://symfony.com/doc/current/components/console/index.html
22+
*/
23+
class ExtractReportingApiMethodInfo extends ConsoleCommand
24+
{
25+
/**
26+
* This method allows you to configure your command. Here you can define the name and description of your command
27+
* as well as all options and arguments you expect when executing it.
28+
*/
29+
protected function configure()
30+
{
31+
$this->setName('openapidocs:extract-api-method-info');
32+
$this->setDescription('Extract the comment block and basic information about methods for the Matomo Reporting API.');
33+
$this->addRequiredValueOption('plugin', 'p', 'Name of the plugin to inspect');
34+
$this->addNoValueOption('not-dry-run', null, 'Flag to allow writing to file instead of outputting a dry run.');
35+
}
36+
37+
/**
38+
* Interact with the user.
39+
*
40+
* This method is executed before the InputDefinition is validated.
41+
* This means that this is the only place where the command can
42+
* interactively ask for values of missing required arguments.
43+
*/
44+
protected function doInteract(): void
45+
{
46+
}
47+
48+
/**
49+
* Initializes the command after the input has been bound and before the input
50+
* is validated.
51+
*
52+
* This is mainly useful when a lot of commands extends one main command
53+
* where some things need to be initialized based on the input arguments and options.
54+
*/
55+
protected function doInitialize(): void
56+
{
57+
}
58+
59+
/**
60+
* The actual task is defined in this method. Here you can access any option or argument that was defined on the
61+
* command line via $this->getInput() and write anything to the console via $this->getOutput().
62+
* In case anything went wrong during the execution you should throw an exception to make sure the user will get a
63+
* useful error message and to make sure the command does not exit with the status code 0.
64+
*
65+
* Ideally, the actual command is quite short as it acts like a controller. It should only receive the input values,
66+
* execute the task by calling a method of another class and output any useful information.
67+
*
68+
* Execute the command like: ./console openapidocs:extract-api-method-info --plugin=TagManager --not-dry-run
69+
*/
70+
protected function doExecute(): int
71+
{
72+
$input = $this->getInput();
73+
$output = $this->getOutput();
74+
75+
$plugin = $input->getOption('plugin');
76+
if (empty($plugin)) {
77+
throw new \RuntimeException('Please specify a plugin name.');
78+
}
79+
$notDryRun = $input->getOption('not-dry-run') ?: false;
80+
81+
$message = sprintf('<info>Extracting API method info for: %s</info>', $plugin);
82+
83+
$output->writeln($message);
84+
85+
$result = (new ApiMethodInfoExtractor())->extractMethodInfo($plugin, $notDryRun);
86+
87+
if ($notDryRun) {
88+
$output->writeln('<info>Results written to plugins/OpenApiDocs/tmp/annotations directory.</info>');
89+
90+
return $result ? self::SUCCESS : self::FAILURE;
91+
}
92+
93+
$output->writeln($result);
94+
95+
return $result ? self::SUCCESS : self::FAILURE;
96+
}
97+
}

0 commit comments

Comments
 (0)