Skip to content

Commit 6e2968f

Browse files
authored
Merge pull request #5 from matomo-org/PG-4396-generate-oa-annotations
Initial implementation of annotation generation
2 parents 3730d30 + bef76f6 commit 6e2968f

8 files changed

Lines changed: 721 additions & 74 deletions

File tree

API.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
namespace Piwik\Plugins\OpenApiDocs;
1111

1212
use Piwik\Piwik;
13-
use Piwik\Plugins\OpenApiDocs\Generate\MatomoApiDocGenerator;
13+
use Piwik\Plugins\OpenApiDocs\Specs\SpecGenerator;
1414

1515
/**
1616
* API for plugin OpenApiDocs
@@ -44,7 +44,7 @@ public function getGeneratedOpenApiSpec(string $plugin, string $format)
4444
);
4545
}
4646

47-
$docString = (new MatomoApiDocGenerator())->generatePluginDoc($plugin, $format);
47+
$docString = (new SpecGenerator())->generatePluginDoc($plugin, $format);
4848
return strtolower($format) === 'json' ? json_decode($docString, true) : $docString;
4949
}
5050
}
Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
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\Validators\BaseValidator;
18+
use Piwik\Validators\NotEmpty;
19+
use PHPStan\PhpDocParser\Lexer\Lexer;
20+
use PHPStan\PhpDocParser\Parser\PhpDocParser;
21+
use PHPStan\PhpDocParser\Parser\TypeParser;
22+
use PHPStan\PhpDocParser\Parser\ConstExprParser;
23+
use PHPStan\PhpDocParser\Parser\TokenIterator;
24+
use function Symfony\Component\String\s;
25+
26+
class AnnotationGenerator
27+
{
28+
/**
29+
* Use reflection to generate the OpenAPI annotations to be used by php-swagger.
30+
* - Tries to use virtual paths and x-runtime to keep paths unique and allow actual path generation
31+
* - Uses config.php to set default values.
32+
* - Uses config.php from plugin to override default configs.
33+
*/
34+
public function generatePluginApiAnnotations(string $pluginName)
35+
{
36+
BaseValidator::check('plugin', $pluginName, [ new NotEmpty() ]);
37+
Manager::getInstance()->checkIsPluginActivated($pluginName);
38+
39+
$currentPluginDir = Manager::getInstance()::getPluginDirectory('OpenApiDocs');
40+
$rules = require $currentPluginDir . '/Annotations/config.php';
41+
$pluginDir = Manager::getInstance()::getPluginDirectory($pluginName);
42+
$pluginRules = require $pluginDir . '/OpenApi/Annotations/config.php';
43+
$rules['plugins'] = [ $pluginName => $pluginRules ];
44+
45+
$className = Request::getClassNameAPI($pluginName);
46+
47+
try {
48+
$reflectionClass = new \ReflectionClass($className);
49+
} catch (\ReflectionException $e) {
50+
return false;
51+
}
52+
53+
Proxy::getInstance()->registerClass($className);
54+
$pluginMetadata = Proxy::getInstance()->getMetadata()[$className] ?? [];
55+
56+
$annotations = [];
57+
foreach (array_keys($pluginMetadata) as $metadataMethod) {
58+
if (!$reflectionClass->hasMethod($metadataMethod)) {
59+
continue;
60+
}
61+
62+
$reflectionMethod = $reflectionClass->getMethod($metadataMethod);
63+
$existing = $reflectionMethod->getDocComment();
64+
// Skip methods which have been marked as internal or auto annotations disabled
65+
if ($existing !== false && (stripos($existing, 'OA-AUTO:OFF') !== false
66+
|| stripos($existing, '@internal') !== false)) {
67+
continue;
68+
}
69+
70+
$methodName = $reflectionMethod->getName();
71+
$opId = Proxy::getInstance()->buildApiActionName($pluginName, $methodName);
72+
$path = $this->buildVirtualPath(
73+
$rules['virtualPathTemplate'] ?? '/' . $opId,
74+
$pluginName,
75+
$methodName
76+
);
77+
78+
$params = $this->determineParameters($rules, $pluginName, $methodName, $reflectionMethod);
79+
$responses = $this->determineResponses($rules, $pluginName, $methodName);
80+
81+
$isPost = !empty($rules['plugins'][$pluginName]['methodsRequiringPost'])
82+
&& in_array($methodName, $rules['plugins'][$pluginName]['methodsRequiringPost']);
83+
84+
$annotations[] = $this->compileOperationLines($path, $opId, $pluginName, $methodName, $params, $responses, $isPost);
85+
}
86+
87+
if (empty($annotations)) {
88+
return false;
89+
}
90+
91+
return $annotations;
92+
}
93+
94+
function getParamInfoFromDocBlock(string $docBlock): array {
95+
$lexer = new Lexer();
96+
$tokens = $lexer->tokenize($docBlock);
97+
$expressionParser = new ConstExprParser();
98+
$parser = new PhpDocParser(new TypeParser($expressionParser), $expressionParser);
99+
$node = $parser->parse(new TokenIterator($tokens));
100+
101+
$params = [];
102+
foreach ($node->getParamTagValues() as $param) {
103+
$name = ltrim($param->parameterName, '$');
104+
$params[$name] = [
105+
'type' => (string) $param->type,
106+
'desc' => $param->description,
107+
'byRef' => $param->isReference,
108+
'variadic' => $param->isVariadic,
109+
];
110+
}
111+
return $params;
112+
}
113+
114+
function buildVirtualPath(string $virtualPathTemplate, string $plugin, string $method): string
115+
{
116+
return str_replace([ '{plugin}', '{method}' ], [ $plugin, $method ], $virtualPathTemplate);
117+
}
118+
119+
function determineParameters(array $rules, string $plugin, string $method, \ReflectionMethod $reflectionMethod): array
120+
{
121+
$refs = [];
122+
123+
if (!empty($rules['defaultParamRefs'])) {
124+
$refs = array_merge($refs, $rules['defaultParamRefs']);
125+
}
126+
127+
if (isset($rules['plugins'][$plugin]['paramRefsByMethod'][$method])) {
128+
$refs = array_merge($refs, $rules['plugins'][$plugin]['paramRefsByMethod'][$method]);
129+
}
130+
131+
$paramsMetadata = Proxy::getInstance()->getParametersListWithTypes(Request::getClassNameAPI($plugin), $method);
132+
$paramsInfo = $this->getParamInfoFromDocBlock($reflectionMethod->getDocComment());
133+
134+
$customParams = [];
135+
foreach ($paramsMetadata as $name => $paramMetadata) {
136+
$paramInfo = $paramsInfo[$name] ?? [];
137+
// Skip references and variadic for now
138+
// TODO - determine whether these can be handled automatically or if they have to be manual
139+
if (!empty($paramInfo['byRef']) || !empty($paramInfo['variadic'])) {
140+
continue;
141+
}
142+
143+
$type = $paramMetadata['type'] ?? $paramInfo['type'] ?? '';
144+
// TODO - Properly map the internal types to OpenAPI types
145+
switch (strtolower($type)) {
146+
case 'array':
147+
$type = 'array';
148+
break;
149+
case 'int':
150+
$type = 'integer';
151+
break;
152+
case 'bool':
153+
case 'boolean':
154+
$type = 'boolean';
155+
break;
156+
default:
157+
$type = 'string';
158+
}
159+
160+
$customParams[] = [
161+
'name' => $name,
162+
'type' => $type,
163+
'description' => $paramInfo['desc'] ?? '',
164+
'required' => empty($paramMetadata['allowsNull']) ? 'true' : 'false',
165+
];
166+
}
167+
168+
return [
169+
'refs' => array_values(array_unique($refs)),
170+
'custom' => $customParams,
171+
];
172+
}
173+
174+
function determineResponses(array $rules, string $plugin, string $method): array
175+
{
176+
$responses = [];
177+
178+
$successRef = null;
179+
if (isset($rules['plugins'][$plugin]['successResponseByMethod'][$method])) {
180+
$successRef = $rules['plugins'][$plugin]['successResponseByMethod'][$method];
181+
}
182+
if ($successRef) {
183+
$responses[] = [ 'code' => 200, 'ref' => $successRef ];
184+
} else {
185+
$responses[] = [ 'code' => 200 ];
186+
}
187+
188+
if (!empty($rules['defaultErrorResponseRefs'])) {
189+
foreach ($rules['defaultErrorResponseRefs'] as $errorRef) {
190+
$responses[] = $errorRef;
191+
}
192+
}
193+
194+
return $responses;
195+
}
196+
197+
function compileOperationLines(string $path, string $opId, string $plugin, string $method, array $params, array $responses, bool $isPost = false): array
198+
{
199+
$httpMethod = $isPost ? 'Post' : 'Get';
200+
$lines = [];
201+
$lines[] = '@OA\\' . $httpMethod . '(';
202+
$lines[] = ' path="' . $path . '",';
203+
$lines[] = ' operationId="' . $opId . '",';
204+
$lines[] = ' tags={"' . $plugin . '"},';
205+
206+
foreach ($params['refs'] ?? [] as $ref) {
207+
$lines[] = ' @OA\Parameter(ref="' . $ref . '"),';
208+
}
209+
210+
foreach ($params['custom'] ?? [] as $param) {
211+
// TODO - Finish implementing this
212+
$lines[] = ' @OA\Parameter(';
213+
$lines[] = ' name="' . $param['name'] . '",';
214+
$lines[] = ' in="query",';
215+
$lines[] = " required={$param['required']},";
216+
$lines[] = ' @OA\Schema(';
217+
$lines[] = ' type="' . $param['type'] . '"';
218+
$lines[] = ' )';
219+
$lines[] = ' ),';
220+
}
221+
222+
foreach ($responses as $response) {
223+
if (isset($response['ref'])) {
224+
$code = $response['code'];
225+
$codeFormatted = is_numeric($code) ? (string)$code : '"' . $code . '"';
226+
$lines[] = ' @OA\Response(response=' . $codeFormatted . ', ref="' . $response['ref'] . '"),';
227+
} else {
228+
$desc = $response['desc'] ?? 'OK';
229+
$lines[] = ' @OA\Response(response=200, description="' . addcslashes($desc, '"') . '"),';
230+
}
231+
}
232+
233+
$lines[] = ' x={"runtime"={"entry":"index.php","query":{"module":"API","method":"' . $plugin . '.' . $method . '"}}}';
234+
$lines[] = ')';
235+
236+
return $lines;
237+
}
238+
}

0 commit comments

Comments
 (0)