Skip to content

Commit b00031f

Browse files
authored
Initial implementation of API endpoint info export (#17)
* Initial implementation of API endpoint info export * Fixed PHP style issue * Add strict types to new files
1 parent 624235a commit b00031f

3 files changed

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

0 commit comments

Comments
 (0)