From 29b30e6d2bfdaa3b3adf6c850a27d147cd3892ae Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 22 Feb 2025 03:13:01 +0000 Subject: [PATCH 1/2] build(deps): bump phpstan/phpdoc-parser from 1.33.0 to 2.1.0 Bumps [phpstan/phpdoc-parser](https://github.com/phpstan/phpdoc-parser) from 1.33.0 to 2.1.0. - [Release notes](https://github.com/phpstan/phpdoc-parser/releases) - [Commits](https://github.com/phpstan/phpdoc-parser/compare/1.33.0...2.1.0) --- updated-dependencies: - dependency-name: phpstan/phpdoc-parser dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- composer.json | 2 +- composer.lock | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/composer.json b/composer.json index 5743482..232481c 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 53475b0..1d9802a 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "34540febc1cc5c5b13d307c549dc6800", + "content-hash": "c517214335ad71b01eab7a94da7bb502", "packages": [ { "name": "adhocore/cli", @@ -139,30 +139,30 @@ }, { "name": "phpstan/phpdoc-parser", - "version": "1.33.0", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "82a311fd3690fb2bf7b64d5c98f912b3dd746140" + "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/82a311fd3690fb2bf7b64d5c98f912b3dd746140", - "reference": "82a311fd3690fb2bf7b64d5c98f912b3dd746140", + "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", + "reference": "9b30d6fd026b2c132b3985ce6b23bec09ab3aa68", "shasum": "" }, "require": { - "php": "^7.2 || ^8.0" + "php": "^7.4 || ^8.0" }, "require-dev": { "doctrine/annotations": "^2.0", - "nikic/php-parser": "^4.15", + "nikic/php-parser": "^5.3.0", "php-parallel-lint/php-parallel-lint": "^1.2", "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^1.5", - "phpstan/phpstan-phpunit": "^1.1", - "phpstan/phpstan-strict-rules": "^1.0", - "phpunit/phpunit": "^9.5", + "phpstan/phpstan": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^9.6", "symfony/process": "^5.2" }, "type": "library", @@ -180,9 +180,9 @@ "description": "PHPDoc parser with support for nullable, intersection and generic types", "support": { "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/1.33.0" + "source": "https://github.com/phpstan/phpdoc-parser/tree/2.1.0" }, - "time": "2024-10-13T11:25:22+00:00" + "time": "2025-02-19T13:28:12+00:00" } ], "packages-dev": [ From f7984a10ee0f5d35e26bb64b02cef6c66476543c Mon Sep 17 00:00:00 2001 From: provokateurin Date: Mon, 3 Mar 2025 15:05:16 +0100 Subject: [PATCH 2/2] fix: Adjust docs and status code parsing for phpdoc-parser v2 Signed-off-by: provokateurin --- generate-spec.php | 10 +- src/ControllerMethod.php | 104 +++++++---- tests/appinfo/routes.php | 1 + tests/lib/Controller/SettingsController.php | 39 ++++ tests/openapi-full.json | 189 ++++++++++++++++++++ tests/openapi.json | 189 ++++++++++++++++++++ 6 files changed, 490 insertions(+), 42 deletions(-) diff --git a/generate-spec.php b/generate-spec.php index ec839b6..754d1dc 100755 --- a/generate-spec.php +++ b/generate-spec.php @@ -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; @@ -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'; diff --git a/src/ControllerMethod.php b/src/ControllerMethod.php index 8955ae8..cdf4147 100644 --- a/src/ControllerMethod.php +++ b/src/ControllerMethod.php @@ -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; @@ -16,6 +18,8 @@ use PHPStan\PhpDocParser\Parser\TokenIterator; class ControllerMethod { + private const STATUS_CODE_DESCRIPTION_PATTERN = '/^(\d{3}): (.+)$/'; + /** * @param ControllerMethodParameter[] $parameters * @param list $responses @@ -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) { @@ -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')) { @@ -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, @@ -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; diff --git a/tests/appinfo/routes.php b/tests/appinfo/routes.php index 5bf7eb9..8a39f96 100644 --- a/tests/appinfo/routes.php +++ b/tests/appinfo/routes.php @@ -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)']], ], ]; diff --git a/tests/lib/Controller/SettingsController.php b/tests/lib/Controller/SettingsController.php index 4634ac0..db4d73b 100644 --- a/tests/lib/Controller/SettingsController.php +++ b/tests/lib/Controller/SettingsController.php @@ -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, 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(); + } } diff --git a/tests/openapi-full.json b/tests/openapi-full.json index 09b560b..8ca9f22 100644 --- a/tests/openapi-full.json +++ b/tests/openapi-full.json @@ -5623,6 +5623,195 @@ } } }, + "/ocs/v2.php/apps/notifications/api/{apiVersion}/docs-parsing-status-code": { + "post": { + "operationId": "settings-docs-parsing-statuscode", + "summary": "Summary. More summary.", + "description": "Description. More description.", + "deprecated": true, + "tags": [ + "settings" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "param" + ], + "properties": { + "param": { + "type": "string", + "description": "Some param" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v2" + ], + "default": "v2" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "201": { + "description": "CREATED", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "202": { + "description": "ACCEPTED", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "203": { + "description": "STATUS_NON_AUTHORATIVE_INFORMATION", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "204": { + "description": "STATUS_NO_CONTENT" + }, + "500": { + "description": "STATUS_INTERNAL_SERVER_ERROR", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/index.php/apps/notifications/plain/with-scope": { "get": { "operationId": "plain-with-scope", diff --git a/tests/openapi.json b/tests/openapi.json index 6173bb9..8e369de 100644 --- a/tests/openapi.json +++ b/tests/openapi.json @@ -798,6 +798,195 @@ } } }, + "/ocs/v2.php/apps/notifications/api/{apiVersion}/docs-parsing-status-code": { + "post": { + "operationId": "settings-docs-parsing-statuscode", + "summary": "Summary. More summary.", + "description": "Description. More description.", + "deprecated": true, + "tags": [ + "settings" + ], + "security": [ + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "param" + ], + "properties": { + "param": { + "type": "string", + "description": "Some param" + } + } + } + } + } + }, + "parameters": [ + { + "name": "apiVersion", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "v2" + ], + "default": "v2" + } + }, + { + "name": "OCS-APIRequest", + "in": "header", + "description": "Required to be true for the API request to pass", + "required": true, + "schema": { + "type": "boolean", + "default": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "201": { + "description": "CREATED", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "202": { + "description": "ACCEPTED", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "203": { + "description": "STATUS_NON_AUTHORATIVE_INFORMATION", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "ocs" + ], + "properties": { + "ocs": { + "type": "object", + "required": [ + "meta", + "data" + ], + "properties": { + "meta": { + "$ref": "#/components/schemas/OCSMeta" + }, + "data": {} + } + } + } + } + } + } + }, + "204": { + "description": "STATUS_NO_CONTENT" + }, + "500": { + "description": "STATUS_INTERNAL_SERVER_ERROR", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + } + } + } + } + }, "/index.php/apps/notifications/plain/with-scope": { "get": { "operationId": "plain-with-scope",