Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"ext-simplexml": "*",
"adhocore/cli": "^1.7",
"nikic/php-parser": "^5.0",
"phpstan/phpdoc-parser": "^1.28"
"phpstan/phpdoc-parser": "^2.1"
},
"require-dev": {
"nextcloud/coding-standard": "^1.2",
Expand Down
26 changes: 13 additions & 13 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 6 additions & 4 deletions generate-spec.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
use PHPStan\PhpDocParser\Parser\PhpDocParser;
use PHPStan\PhpDocParser\Parser\TokenIterator;
use PHPStan\PhpDocParser\Parser\TypeParser;
use PHPStan\PhpDocParser\ParserConfig;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use stdClass;
Expand Down Expand Up @@ -69,10 +70,11 @@
$astParser = (new ParserFactory())->createForNewestSupportedVersion();
$nodeFinder = new NodeFinder;

$lexer = new Lexer();
$constExprParser = new ConstExprParser();
$typeParser = new TypeParser($constExprParser);
$phpDocParser = new PhpDocParser($typeParser, $constExprParser);
$config = new ParserConfig(usedAttributes: ['lines' => true, 'indexes' => true]);
$lexer = new Lexer($config);
$constExprParser = new ConstExprParser($config);
$typeParser = new TypeParser($config, $constExprParser);
$phpDocParser = new PhpDocParser($config, $typeParser, $constExprParser);

$infoXMLPath = $dir . '/appinfo/info.xml';

Expand Down
104 changes: 66 additions & 38 deletions src/ControllerMethod.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
namespace OpenAPIExtractor;

use PhpParser\Node\Stmt\ClassMethod;
use PHPStan\PhpDocParser\Ast\PhpDoc\DeprecatedTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\GenericTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode;
Expand All @@ -16,6 +18,8 @@
use PHPStan\PhpDocParser\Parser\TokenIterator;

class ControllerMethod {
private const STATUS_CODE_DESCRIPTION_PATTERN = '/^(\d{3}): (.+)$/';

/**
* @param ControllerMethodParameter[] $parameters
* @param list<ControllerMethodResponse|null> $responses
Expand Down Expand Up @@ -55,37 +59,55 @@ public static function parse(string $context,
$docParameters = [];

$doc = $method->getDocComment()?->getText();
if ($doc != null) {
if ($doc !== null) {
$docNodes = $phpDocParser->parse(new TokenIterator($lexer->tokenize($doc)))->children;

foreach ($docNodes as $docNode) {
if ($docNode instanceof PhpDocTextNode) {
$block = Helpers::cleanDocComment($docNode->text);
if ($block === '') {
continue;
}
$pattern = '/(\d{3}): /';
if (preg_match($pattern, $block)) {
$parts = preg_split($pattern, $block, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
$counter = count($parts);
for ($i = 0; $i < $counter; $i += 2) {
$statusCode = intval($parts[$i]);
$responseDescriptions[$statusCode] = trim($parts[$i + 1]);
$nodeDescription = (string)$docNode->text;
} elseif ($docNode->value instanceof GenericTagValueNode) {
$nodeDescription = (string)$docNode->value;
} else {
$nodeDescription = (string)$docNode->value->description;
}

$nodeDescriptionLines = array_filter(explode("\n", $nodeDescription), static fn (string $line) => trim($line) !== '');

// Parse in blocks (separate by double newline) to preserve newlines within a block.
$nodeDescriptionBlocks = preg_split("/\n\s*\n/", $nodeDescription);
foreach ($nodeDescriptionBlocks as $nodeDescriptionBlock) {
$methodDescriptionBlockLines = [];
foreach (array_filter(explode("\n", $nodeDescriptionBlock), static fn (string $line) => trim($line) !== '') as $line) {
if (preg_match(self::STATUS_CODE_DESCRIPTION_PATTERN, $line)) {
$parts = preg_split(self::STATUS_CODE_DESCRIPTION_PATTERN, $line, -1, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY);
$responseDescriptions[(int)$parts[0]] = trim($parts[1]);
} elseif ($docNode instanceof PhpDocTextNode) {
$methodDescriptionBlockLines[] = $line;
} elseif (
$docNode instanceof PhpDocTagNode && (
$docNode->value instanceof ParamTagValueNode ||
$docNode->value instanceof ThrowsTagValueNode ||
$docNode->value instanceof DeprecatedTagValueNode ||
$docNode->name === '@license' ||
$docNode->name === '@since' ||
$docNode->name === '@psalm-suppress' ||
$docNode->name === '@suppress'
)) {
// Only add lines from other node types, as these have special handling (e.g. @param or @throws) or should be ignored entirely (e.g. @deprecated or @license).
continue;
} else {
$methodDescriptionBlockLines[] = $line;
}
} else {
$methodDescription[] = $block;
}
if ($methodDescriptionBlockLines !== []) {
$methodDescription[] = Helpers::cleanDocComment(implode(' ', $methodDescriptionBlockLines));
}
}
}

foreach ($docNodes as $docNode) {
if ($docNode instanceof PhpDocTagNode) {
if ($docNode->value instanceof ParamTagValueNode) {
if (array_key_exists($docNode->name, $docParameters)) {
$docParameters[$docNode->name][] = $docNode->value;
} else {
$docParameters[$docNode->name] = [$docNode->value];
}
$docParameters[$docNode->name] ??= [];
$docParameters[$docNode->name][] = $docNode->value;
}

if ($docNode->value instanceof ReturnTagValueNode) {
Expand All @@ -97,11 +119,12 @@ public static function parse(string $context,
if ($docNode->value instanceof ThrowsTagValueNode) {
$type = $docNode->value->type;
$statusCode = StatusCodes::resolveException($context . ': @throws', $type);
if ($statusCode != null) {
if (!$allowMissingDocs && $docNode->value->description == '' && $statusCode < 500) {
if ($statusCode !== null) {
if (!$allowMissingDocs && $nodeDescriptionLines === [] && $statusCode < 500) {
Logger::error($context, "Missing description for exception '" . $type . "'");
} else {
$responseDescriptions[$statusCode] = $docNode->value->description;
// Only add lines that don't match the status code pattern to the description
$responseDescriptions[$statusCode] = implode("\n", array_filter($nodeDescriptionLines, static fn (string $line) => !preg_match(self::STATUS_CODE_DESCRIPTION_PATTERN, $line)));
}

if (str_starts_with($type->name, 'OCS') && str_ends_with($type->name, 'Exception')) {
Expand Down Expand Up @@ -144,17 +167,19 @@ public static function parse(string $context,
}
}

if ($paramTag instanceof \PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode && $psalmParamTag instanceof \PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode) {
// Use all the type information from @psalm-param because it is more specific,
// but pull the description from @param and @psalm-param because usually only one of them has it.
if ($psalmParamTag->description !== '') {
$description = $psalmParamTag->description;
} elseif ($paramTag->description !== '') {
$description = $paramTag->description;
} else {
$description = '';
}
// Use all the type information from @psalm-param because it is more specific,
// but pull the description from @param and @psalm-param because usually only one of them has it.
if (($psalmParamTag?->description ?? '') !== '') {
$description = $psalmParamTag->description;
} elseif (($paramTag?->description ?? '') !== '') {
$description = $paramTag->description;
} else {
$description = '';
}
// Only keep lines that don't match the status code pattern in the description
$description = implode("\n", array_filter(array_filter(explode("\n", $description), static fn (string $line) => trim($line) !== ''), static fn (string $line) => !preg_match(self::STATUS_CODE_DESCRIPTION_PATTERN, $line)));

if ($paramTag instanceof \PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode && $psalmParamTag instanceof \PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode) {
try {
$type = OpenApiType::resolve(
$context . ': @param: ' . $psalmParamTag->parameterName,
Expand Down Expand Up @@ -183,20 +208,23 @@ public static function parse(string $context,
);
}

$param = new ControllerMethodParameter($context, $definitions, $methodParameterName, $methodParameter, $type);
} elseif ($psalmParamTag instanceof \PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode) {
$type = OpenApiType::resolve($context . ': @param: ' . $methodParameterName, $definitions, $psalmParamTag);
$param = new ControllerMethodParameter($context, $definitions, $methodParameterName, $methodParameter, $type);
} elseif ($paramTag instanceof \PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode) {
$type = OpenApiType::resolve($context . ': @param: ' . $methodParameterName, $definitions, $paramTag);
$param = new ControllerMethodParameter($context, $definitions, $methodParameterName, $methodParameter, $type);
} elseif ($allowMissingDocs) {
$param = new ControllerMethodParameter($context, $definitions, $methodParameterName, $methodParameter, null);
$type = null;
} else {
Logger::error($context, "Missing doc parameter for '" . $methodParameterName . "'");
continue;
}

if ($type !== null) {
$type->description = $description;
}

$param = new ControllerMethodParameter($context, $definitions, $methodParameterName, $methodParameter, $type);

if (!$allowMissingDocs && $param->type->description == '') {
Logger::error($context . ': @param: ' . $methodParameterName, 'Missing description');
continue;
Expand Down
1 change: 1 addition & 0 deletions tests/appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,6 @@
['name' => 'Settings#whitespace', 'url' => '/api/{apiVersion}/whitespace', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'Settings#withCorsAnnotation', 'url' => '/api/{apiVersion}/cors/annotation', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'Settings#withCorsAttribute', 'url' => '/api/{apiVersion}/cors/attribute', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
['name' => 'Settings#docsParsingStatuscode', 'url' => '/api/{apiVersion}/docs-parsing-status-code', 'verb' => 'POST', 'requirements' => ['apiVersion' => '(v2)']],
],
];
39 changes: 39 additions & 0 deletions tests/lib/Controller/SettingsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -596,4 +596,43 @@ public function withCorsAnnotation(): DataResponse {
public function withCorsAttribute(): DataResponse {
return new DataResponse();
}

/**
* Summary.
* More summary.
*
* Description.
* More description.
*
* 200: OK
*
* @NoAdminRequired
*
* 201: CREATED
*
* @param string $param Some param
*
* 202: ACCEPTED
*
* @return DataResponse<Http::STATUS_OK|Http::STATUS_CREATED|Http::STATUS_ACCEPTED|Http::STATUS_NON_AUTHORATIVE_INFORMATION|Http::STATUS_NO_CONTENT, list<empty>, array{}>
*
* 203: STATUS_NON_AUTHORATIVE_INFORMATION
*
* @throws \Exception STATUS_INTERNAL_SERVER_ERROR
*
* 204: STATUS_NO_CONTENT
*
* @license This is not actually a license
*
* @deprecated This is not deprecated
*
* @since Version 1.0.0
*
* @psalm-suppress some issue
*
* @suppress another issue
*/
public function docsParsingStatuscode(string $param): DataResponse {
return new DataResponse();
}
}
Loading
Loading