Skip to content

Commit a290e78

Browse files
authored
Merge pull request #8 from matomo-org/PG-4544-improve-globals
Get spec generation ready for initial plugin API specs
2 parents 2bfe31a + a8b8dd0 commit a290e78

4 files changed

Lines changed: 244 additions & 76 deletions

File tree

Annotations/AnnotationGenerator.php

Lines changed: 120 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ protected function buildAnnotationForMethod(array $rules, string $pluginName, \R
138138
);
139139

140140
$params = $this->determineParameters($rules, $pluginName, $methodName, $reflectionMethod);
141-
$responses = $this->determineResponses($rules, $pluginName, $methodName);
141+
$responses = $this->determineResponses($rules, $pluginName, $methodName, $reflectionMethod);
142142

143143
$isPost = !empty($rules['plugins'][$pluginName]['methodsRequiringPost'])
144144
&& in_array($methodName, $rules['plugins'][$pluginName]['methodsRequiringPost']);
@@ -159,24 +159,57 @@ protected function getParamInfoFromDocBlock(string $docBlock): array
159159
$name = ltrim($param->parameterName, '$');
160160
$params[$name] = [
161161
'type' => (string) $param->type,
162-
'desc' => $param->description,
162+
// Normalise the description. E.g. remove linebreaks and indentation
163+
'desc' => trim(preg_replace(['/^\h+/m', '/\R+/u',], ['', ' '], $param->description)),
163164
'byRef' => $param->isReference,
164165
'variadic' => $param->isVariadic,
165166
];
166167
}
167168
return $params;
168169
}
169170

171+
protected function getResponseInfoFromDocBlock(string $docBlock): array
172+
{
173+
$lexer = new Lexer();
174+
$tokens = $lexer->tokenize($docBlock);
175+
$expressionParser = new ConstExprParser();
176+
$parser = new PhpDocParser(new TypeParser($expressionParser), $expressionParser);
177+
$node = $parser->parse(new TokenIterator($tokens));
178+
179+
$responseInfo = ['type' => null];
180+
$returnTags = $node->getReturnTagValues();
181+
if (empty($returnTags)) {
182+
return $responseInfo;
183+
}
184+
185+
$returnTag = $returnTags[0];
186+
$tagValue = strval($returnTag->type);
187+
$responseInfo['type'] = $this->getOpenApiTypeFromPhpType($tagValue);
188+
if ($responseInfo['type'] === 'string' && !empty($tagValue) && strtolower($tagValue) !== 'string') {
189+
$responseInfo['type'] = '';
190+
$responseInfo['description'] = 'Response of unknown type';
191+
}
192+
if (!empty($returnTag->description)) {
193+
$responseInfo['description'] = $returnTag->description;
194+
}
195+
196+
return $responseInfo;
197+
}
198+
170199
protected function buildVirtualPath(string $virtualPathTemplate, string $plugin, string $method): string
171200
{
172-
return str_replace([ '{plugin}', '{method}' ], [ $plugin, $method ], $virtualPathTemplate);
201+
return str_replace(['{plugin}', '{method}'], [$plugin, $method], $virtualPathTemplate);
173202
}
174203

175204
protected function buildParameterAnnotation(string $paramName, array $paramMetadata, array $paramDocInfo): array
176205
{
177206
$docType = strtolower(trim($paramDocInfo['type'] ?? ''));
178207
$metaType = strtolower(trim($paramMetadata['type'] ?? $docType));
179208
$type = $metaType === 'string' && $docType !== 'string' ? $docType : $metaType;
209+
// If the signature type is array, but the type hinting provides more, use that instead
210+
if ($type === 'array' && strpos($docType, '[]') !== false && strpos($docType, '|') === false) {
211+
$type = $docType;
212+
}
180213
$typesMap = [];
181214
// Check for pipes and try to list possible types
182215
foreach (explode('|', $type) as $typePart) {
@@ -197,7 +230,7 @@ protected function buildParameterAnnotation(string $paramName, array $paramMetad
197230
'types' => $typesMap,
198231
'description' => $paramDocInfo['desc'] ?? '',
199232
'required' => $isRequired ? 'true' : 'false',
200-
'default' => !$isRequired ? json_encode($paramMetadata['default']) : '',
233+
'default' => !$isRequired ? json_encode($paramMetadata['default']) : NoDefaultValue::class,
201234
];
202235
}
203236

@@ -300,8 +333,8 @@ protected function getApplicableDemoExampleUrls(string $pluginName, string $meth
300333

301334
protected function getExampleIfAvailable(string $url): array
302335
{
303-
// Simply return the URL for TSV
304-
if (stripos($url, 'format=tsv') !== false) {
336+
// Simply return the URL for anything other than JSON until we figure out how to better format those examples
337+
if (stripos($url, 'format=json') === false) {
305338
return ['externalValue' => $url];
306339
}
307340

@@ -319,16 +352,10 @@ protected function getExampleIfAvailable(string $url): array
319352
curl_close($ch);
320353

321354
// If the example didn't load or is too big, simply include the URL instead of the string value
322-
if ($body === false || $status !== 200 || strlen($body) > 1000 || strpos($body, 'Error: ') === 0) {
355+
if ($body === false || $status !== 200 || strlen($body) > 2000 || strpos($body, 'Error: ') === 0) {
323356
return ['externalValue' => $url];
324357
}
325358

326-
// Clean up XML formatting a bit
327-
$body = trim($body);
328-
if (stripos($url, 'format=xml') !== false) {
329-
$body = str_replace(['<?xml version="1.0" encoding="utf-8" ?>', "\n", "\t", '"'], ['', '', '', '\"'], $body);
330-
}
331-
332359
// The annotation expects an objects and not arrays
333360
if (stripos($url, 'format=json') !== false && stripos($body, '[') === 0) {
334361
$body = str_replace(['[', ']'], ['{', '}'], $body);
@@ -337,21 +364,61 @@ protected function getExampleIfAvailable(string $url): array
337364
return ['value' => $body];
338365
}
339366

340-
protected function determineResponses(array $rules, string $plugin, string $method): array
367+
protected function determineResponses(array $rules, string $plugin, string $method, \ReflectionMethod $reflectionMethod): array
341368
{
342369
$responses = [];
343370

344-
// TODO - Try to determine the success response using the return type and/or doc-block return type
371+
// Try to determine the success response using the return type and/or doc-block return type
372+
$returnType = $reflectionMethod->getReturnType();
373+
$responseInfo = $this->getResponseInfoFromDocBlock($reflectionMethod->getDocComment());
374+
if (!empty($returnType) && $returnType->isBuiltin()) {
375+
$responseInfo['type'] = $this->getOpenApiTypeFromPhpType(strval($returnType));
376+
}
345377

346378
$successRef = null;
347379
$successArray = ['code' => 200];
348380
if (isset($rules['plugins'][$plugin]['successResponseByMethod'][$method])) {
349381
$successRef = $rules['plugins'][$plugin]['successResponseByMethod'][$method];
350382
}
383+
// TODO - See if there's a way to auto-handle custom objects, especially common stuff like DataTable\DataTableInterface
351384
if ($successRef) {
352385
$successArray['ref'] = $successRef;
353386
}
354387

388+
// If the return type is void, use the generic response type
389+
if (empty($successArray['ref']) && !empty($returnType) && strval($returnType) === 'void') {
390+
$successArray['ref'] = '#/components/responses/GenericSuccessNoBody';
391+
}
392+
393+
// If it's a generic type and there's no custom description, use one of the global generic responses
394+
if (empty($successArray['ref']) && !empty($responseInfo['type']) && empty($responseInfo['description'])) {
395+
$ref = '';
396+
switch ($responseInfo['type']) {
397+
case 'array':
398+
$ref = '#/components/responses/GenericArray';
399+
break;
400+
case 'integer':
401+
$ref = '#/components/responses/GenericInteger';
402+
break;
403+
case 'boolean':
404+
$ref = '#/components/responses/GenericBoolean';
405+
break;
406+
case 'string':
407+
$ref = '#/components/responses/GenericString';
408+
break;
409+
}
410+
411+
if (!empty($ref)) {
412+
$successArray['ref'] = $ref;
413+
}
414+
}
415+
416+
if (!empty($responseInfo['description'])) {
417+
$successArray['desc'] = $responseInfo['description'];
418+
}
419+
420+
$responseSchema = !empty($responseInfo['type']) ? $this->buildSchemaObjectArray($responseInfo['type']) : [];
421+
355422
$mediaTypes = [];
356423
// This simply reuses the example URLs used by the current documentation, but some endpoints don't work because authentication is required
357424
// 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...
@@ -370,13 +437,21 @@ protected function determineResponses(array $rules, string $plugin, string $meth
370437
$value = substr($value, 1, -1);
371438
}
372439
$exampleProperties[] = $valueKey . '=' . $value;
373-
$mediaTypes[] = [
440+
$mediaType = [
374441
'mediaType="' . $contentType . '"',
375442
'@OA\Examples' => $exampleProperties,
376443
];
444+
// If a type was found, add it as a schema to the media type
445+
if (!empty($responseSchema)) {
446+
$mediaType = array_merge($mediaType, $responseSchema);
447+
}
448+
$mediaTypes[] = $mediaType;
377449
}
378450
if (!empty($mediaTypes)) {
379451
$successArray['mediaTypes'] = $mediaTypes;
452+
} else {
453+
// Make sure the schema is included in there are no examples
454+
$successArray['schema'] = $responseSchema;
380455
}
381456

382457
$responses[] = $successArray;
@@ -434,7 +509,7 @@ protected function buildLinesForAnnotationObject(string $objectName, array $obje
434509
return array_merge([$indentString . $objectName . $openingCharacter], $lines, [$indentString . $closingCharacter . ',']);
435510
}
436511

437-
protected function buildSchemaObjectArray(string $type, string $subType = '', string $default = ''): array
512+
protected function buildSchemaObjectArray(string $type, string $subType = '', string $default = NoDefaultValue::class): array
438513
{
439514
$schemaMap = ['type="' . $type . '"'];
440515
$subTypeString = '';
@@ -448,14 +523,32 @@ protected function buildSchemaObjectArray(string $type, string $subType = '', st
448523
}
449524
}
450525

451-
if ($default !== '') {
452-
// TODO - Add some logic to only add default if it matches the type. E.g. false isn't a good default for string
453-
$schemaMap[] = 'default="' . $default . '"';
526+
if ($this->shouldIncludeDefault($type, $default)) {
527+
$doubleQuote = '"';
528+
// Don't wrap with quotes for certain values
529+
if (in_array($default, ['{}', 'false', 'true', "{$doubleQuote}{$doubleQuote}"])) {
530+
$doubleQuote = '';
531+
}
532+
$schemaMap[] = "default={$doubleQuote}{$default}{$doubleQuote}";
454533
}
455534

456535
return ['@OA\Schema' => $schemaMap];
457536
}
458537

538+
protected function shouldIncludeDefault(string $type, string $default = NoDefaultValue::class): bool
539+
{
540+
if ($default === NoDefaultValue::class) {
541+
return false;
542+
}
543+
544+
// Don't use true or false for default if it's not a boolean type
545+
if ($type !== 'boolean' && in_array(strtolower($default), ['false', 'true'])) {
546+
return false;
547+
}
548+
549+
return true;
550+
}
551+
459552
protected function buildSchemaObjectArrays(array $typesMap, string $default = ''): array
460553
{
461554
$schemas = [];
@@ -493,7 +586,8 @@ protected function compileOperationLines(string $path, string $opId, string $plu
493586
$operationValuesMap[] = ['@OA\Parameter' => $paramMap];
494587
}
495588
foreach ($responses as $response) {
496-
if (isset($response['ref'])) {
589+
// Don't use the reference if there are media type examples
590+
if (isset($response['ref']) && empty($response['mediaTypes'])) {
497591
$code = $response['code'];
498592
$codeFormatted = is_numeric($code) ? (string)$code : '"' . $code . '"';
499593
$operationValuesMap[] = '@OA\Response(response=' . $codeFormatted . ', ref="' . $response['ref'] . '")';
@@ -502,6 +596,9 @@ protected function compileOperationLines(string $path, string $opId, string $plu
502596
'response=200',
503597
'description="' . ($response['desc'] ?? 'OK') . '"',
504598
];
599+
if (!empty($response['schema'])) {
600+
$responsePropertyArray = array_merge($responsePropertyArray, $response['schema']);
601+
}
505602
if (isset($response['mediaTypes']) && is_array($response['mediaTypes'])) {
506603
foreach ($response['mediaTypes'] as $mediaType) {
507604
$responsePropertyArray[] = ['@OA\MediaType' => $mediaType];
@@ -510,7 +607,8 @@ protected function compileOperationLines(string $path, string $opId, string $plu
510607
$operationValuesMap[] = ['@OA\Response' => $responsePropertyArray];
511608
}
512609
}
513-
$operationValuesMap[] = 'x={"runtime"={"entry":"index.php","query":{"module":"API","method":"' . $plugin . '.' . $method . '"}}}';
610+
// TODO - Remove this if it's determined that we won't ever use it
611+
//$operationValuesMap[] = 'x={"runtime"={"entry":"index.php","query":{"module":"API","method":"' . $plugin . '.' . $method . '"}}}';
514612

515613
$lines = $this->buildLinesForAnnotationObject('@OA\\' . ($isPost ? 'Post' : 'Get'), $operationValuesMap);
516614

0 commit comments

Comments
 (0)