From bbfe64af0600373a6ecd3402578638b42a45ac94 Mon Sep 17 00:00:00 2001 From: bnowak Date: Thu, 14 Mar 2024 16:10:39 +0100 Subject: [PATCH] Support for range integers + drop support for PHP 7.2 & 7.3 (#2236) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * range integers + test * backward-compatibility for < SF 6.0 which doesn't support positive-int & negative-int * tests fix * added missing description * fix order of expected required fields * changed test fixtures * add min phpdocumentor/type-resolver ver to composer * composer test * test for lowest dependencies with php 7.3 * bump phpdocumentor/type-resolver for test * update composer dependencies * remove php 7.2 backward-compatibility * drop support for PHP 7.3 + changelog updated * bump zircote/swagger-php version to 4.6.1 * adjusted changelog * force hateoas metadata cache to be cleared between tests * Update CHANGELOG.md Co-authored-by: Djordy Koert --------- Co-authored-by: Bartłomiej Nowak Co-authored-by: DjordyKoert Co-authored-by: Djordy Koert --- .github/workflows/continuous-integration.yml | 5 +- CHANGELOG.md | 18 ++ composer.json | 9 +- .../Annotations/PropertyPhpDocReader.php | 46 ++++- tests/Functional/BazingaFunctionalTest.php | 12 ++ .../Functional/Controller/ApiController80.php | 15 ++ .../Functional/Controller/ApiController81.php | 7 + .../Entity/ArrayItems/Dictionary.php | 79 ++++---- tests/Functional/Entity/RangeInteger.php | 52 +++++ tests/Functional/FunctionalTest.php | 179 +++++------------- 10 files changed, 231 insertions(+), 191 deletions(-) create mode 100644 tests/Functional/Entity/RangeInteger.php diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index adee9d452..5db759b07 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -25,12 +25,9 @@ jobs: fail-fast: false matrix: include: - - php-version: 7.2 + - php-version: 7.4 composer-flags: "--prefer-lowest" doctrine-annotations: true - - php-version: 7.3 - symfony-require: "5.4.*" - doctrine-annotations: true - php-version: 7.4 symfony-require: "5.4.*" doctrine-annotations: true diff --git a/CHANGELOG.md b/CHANGELOG.md index c231e1f07..c7a2020a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,24 @@ CHANGELOG ========= +4.24.0 +----- +* Added support for some integer ranges (https://phpstan.org/writing-php-code/phpdoc-types#integer-ranges). + Annotations attached to integer properties like: + ```php + /** + * @var int<6, 11> + * @var int + * @var int<6, max> + * @var positive-int + * @var negative-int + */ + ``` + will be interpreted as appropriate `minimum` and `maximum` properties in the generated OpenAPI specification. + +### Breaking change +Dropped support for PHP 7.2 and PHP 7.3. PHP 7.4 is the minimum required version now. + 4.23.0 ----- * Cache configuration option `nelmio_api_doc.cache.item_id` now automatically gets the area appended. diff --git a/composer.json b/composer.json index 2a56288c8..6b872552f 100644 --- a/composer.json +++ b/composer.json @@ -11,7 +11,7 @@ } ], "require": { - "php": ">=7.2", + "php": ">=7.4", "ext-json": "*", "psr/cache": "^1.0|^2.0|^3.0", "psr/container": "^1.0|^2.0", @@ -23,10 +23,11 @@ "symfony/http-foundation": "^5.4|^6.0|^7.0", "symfony/http-kernel": "^5.4|^6.0|^7.0", "symfony/options-resolver": "^5.4|^6.0|^7.0", - "symfony/property-info": "^5.4|^6.0|^7.0", + "symfony/property-info": "^5.4.10|^6.0|^7.0", "symfony/routing": "^5.4|^6.0|^7.0", - "zircote/swagger-php": "^4.2.15", - "phpdocumentor/reflection-docblock": "^3.1|^4.0|^5.0", + "zircote/swagger-php": "^4.6.1", + "phpdocumentor/reflection-docblock": "^4.3.4|^5.0", + "phpdocumentor/type-resolver": "^1.8.2", "symfony/deprecation-contracts": "^2.1|^3" }, "require-dev": { diff --git a/src/ModelDescriber/Annotations/PropertyPhpDocReader.php b/src/ModelDescriber/Annotations/PropertyPhpDocReader.php index cd3f8deaa..5faf75a4b 100644 --- a/src/ModelDescriber/Annotations/PropertyPhpDocReader.php +++ b/src/ModelDescriber/Annotations/PropertyPhpDocReader.php @@ -15,6 +15,10 @@ use OpenApi\Generator; use phpDocumentor\Reflection\DocBlock\Tags\Var_; use phpDocumentor\Reflection\DocBlockFactory; +use phpDocumentor\Reflection\PseudoTypes\IntegerRange; +use phpDocumentor\Reflection\PseudoTypes\NegativeInteger; +use phpDocumentor\Reflection\PseudoTypes\PositiveInteger; +use phpDocumentor\Reflection\Types\Compound; /** * Extract information about properties of a model from the DocBlock comment. @@ -42,23 +46,49 @@ public function updateProperty($reflection, OA\Property $property): void return; } - if (!$title = $docBlock->getSummary()) { - /** @var Var_ $var */ - foreach ($docBlock->getTagsByName('var') as $var) { - if (!method_exists($var, 'getDescription') || !$description = $var->getDescription()) { - continue; - } + $title = $docBlock->getSummary(); + + /** @var Var_ $var */ + foreach ($docBlock->getTagsByName('var') as $var) { + if (!$title && method_exists($var, 'getDescription') && $description = $var->getDescription()) { $title = $description->render(); - if ($title) { - break; + } + + if ( + (!isset($min) || null !== $min) && (!isset($max) || null !== $max) + && method_exists($var, 'getType') && $type = $var->getType() + ) { + $types = $type instanceof Compound ? $type->getIterator() : [$type]; + + foreach ($types as $type) { + if ($type instanceof IntegerRange) { + $min = is_numeric($type->getMinValue()) ? (int) $type->getMinValue() : null; + $max = is_numeric($type->getMaxValue()) ? (int) $type->getMaxValue() : null; + break; + } elseif ($type instanceof PositiveInteger) { + $min = 1; + $max = null; + break; + } elseif ($type instanceof NegativeInteger) { + $min = null; + $max = -1; + break; + } } } } + if (Generator::UNDEFINED === $property->title && $title) { $property->title = $title; } if (Generator::UNDEFINED === $property->description && $docBlock->getDescription() && $docBlock->getDescription()->render()) { $property->description = $docBlock->getDescription()->render(); } + if (Generator::UNDEFINED === $property->minimum && isset($min)) { + $property->minimum = $min; + } + if (Generator::UNDEFINED === $property->maximum && isset($max)) { + $property->maximum = $max; + } } } diff --git a/tests/Functional/BazingaFunctionalTest.php b/tests/Functional/BazingaFunctionalTest.php index e09e08424..64fa15921 100644 --- a/tests/Functional/BazingaFunctionalTest.php +++ b/tests/Functional/BazingaFunctionalTest.php @@ -12,8 +12,11 @@ namespace Nelmio\ApiDocBundle\Tests\Functional; use Hateoas\Configuration\Embedded; +use Metadata\Cache\PsrCacheAdapter; +use Metadata\MetadataFactory; use ReflectionException; use ReflectionMethod; +use Symfony\Component\Cache\Adapter\ArrayAdapter; use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\HttpKernel\KernelInterface; @@ -28,6 +31,15 @@ protected function setUp(): void parent::setUp(); static::createClient([], ['HTTP_HOST' => 'api.example.com']); + + $metaDataFactory = self::getContainer()->get('hateoas.configuration.metadata_factory'); + + if (!$metaDataFactory instanceof MetadataFactory) { + $this->fail('The hateoas.metadata_factory service is not an instance of MetadataFactory'); + } + + // Reusing the cache from previous tests causes relations metadata to be lost, so we need to clear it + $metaDataFactory->setCache(new PsrCacheAdapter('BazingaFunctionalTest', new ArrayAdapter())); } public function testModelComplexDocumentationBazinga() diff --git a/tests/Functional/Controller/ApiController80.php b/tests/Functional/Controller/ApiController80.php index 184cb1055..ee4d05ff0 100644 --- a/tests/Functional/Controller/ApiController80.php +++ b/tests/Functional/Controller/ApiController80.php @@ -26,6 +26,7 @@ use Nelmio\ApiDocBundle\Tests\Functional\Entity\EntityWithNullableSchemaSet; use Nelmio\ApiDocBundle\Tests\Functional\Entity\EntityWithObjectType; use Nelmio\ApiDocBundle\Tests\Functional\Entity\EntityWithRef; +use Nelmio\ApiDocBundle\Tests\Functional\Entity\RangeInteger; use Nelmio\ApiDocBundle\Tests\Functional\Entity\SymfonyConstraints80; use Nelmio\ApiDocBundle\Tests\Functional\Entity\SymfonyConstraintsWithValidationGroups; use Nelmio\ApiDocBundle\Tests\Functional\Entity\SymfonyDiscriminator80; @@ -511,6 +512,20 @@ public function entityWithFalsyDefaults() { } + /** + * @Route("/range_integer", methods={"GET"}) + * + * @OA\Response( + * response="200", + * description="", + * + * @Model(type=RangeInteger::class) + * ) + */ + public function rangeInteger() + { + } + /** * @OA\Response( * response="200", diff --git a/tests/Functional/Controller/ApiController81.php b/tests/Functional/Controller/ApiController81.php index 0658ad6fe..3b80bce89 100644 --- a/tests/Functional/Controller/ApiController81.php +++ b/tests/Functional/Controller/ApiController81.php @@ -31,6 +31,7 @@ use Nelmio\ApiDocBundle\Tests\Functional\Entity\QueryModel\FilterQueryModel; use Nelmio\ApiDocBundle\Tests\Functional\Entity\QueryModel\PaginationQueryModel; use Nelmio\ApiDocBundle\Tests\Functional\Entity\QueryModel\SortQueryModel; +use Nelmio\ApiDocBundle\Tests\Functional\Entity\RangeInteger; use Nelmio\ApiDocBundle\Tests\Functional\Entity\SymfonyConstraints81; use Nelmio\ApiDocBundle\Tests\Functional\Entity\SymfonyConstraintsWithValidationGroups; use Nelmio\ApiDocBundle\Tests\Functional\Entity\SymfonyDiscriminator81; @@ -468,6 +469,12 @@ public function enum() { } + #[Route('/range_integer', methods: ['GET'])] + #[OA\Response(response: '200', description: '', attachables: [new Model(type: RangeInteger::class)])] + public function rangeInteger() + { + } + #[Route('/serializename', methods: ['GET'])] #[OA\Response(response: 200, description: 'success', content: new Model(type: SerializedNameEntity::class))] public function serializedNameAction() diff --git a/tests/Functional/Entity/ArrayItems/Dictionary.php b/tests/Functional/Entity/ArrayItems/Dictionary.php index 23c992573..872601ede 100644 --- a/tests/Functional/Entity/ArrayItems/Dictionary.php +++ b/tests/Functional/Entity/ArrayItems/Dictionary.php @@ -4,47 +4,40 @@ namespace Nelmio\ApiDocBundle\Tests\Functional\Entity\ArrayItems; -// PHP 7.2 is not able to guess these types -if (PHP_VERSION_ID < 70300) { - class Dictionary - { - } -} else { - class Dictionary - { - /** - * @var array - */ - public $options; - - /** - * @var array - */ - public $compoundOptions; - - /** - * @var array> - */ - public $nestedCompoundOptions; - - /** - * @var array - */ - public $modelOptions; - - /** - * @var array - */ - public $listOptions; - - /** - * @var array|array - */ - public $arrayOrDictOptions; - - /** - * @var array - */ - public $integerOptions; - } +class Dictionary +{ + /** + * @var array + */ + public $options; + + /** + * @var array + */ + public $compoundOptions; + + /** + * @var array> + */ + public $nestedCompoundOptions; + + /** + * @var array + */ + public $modelOptions; + + /** + * @var array + */ + public $listOptions; + + /** + * @var array|array + */ + public $arrayOrDictOptions; + + /** + * @var array + */ + public $integerOptions; } diff --git a/tests/Functional/Entity/RangeInteger.php b/tests/Functional/Entity/RangeInteger.php new file mode 100644 index 000000000..001c18ecf --- /dev/null +++ b/tests/Functional/Entity/RangeInteger.php @@ -0,0 +1,52 @@ + + */ + public $rangeInt; + + /** + * @var int<1, max> + */ + public $minRangeInt; + + /** + * @var int + */ + public $maxRangeInt; + + /** + * @var int<1, 99>|null + */ + public $nullableRangeInt; +} + +if (version_compare(Kernel::VERSION, '6.1', '>=')) { + class RangeInteger + { + use RangeIntegerTrait; + + /** + * @var positive-int + */ + public $positiveInt; + + /** + * @var negative-int + */ + public $negativeInt; + } +} else { + class RangeInteger + { + use RangeIntegerTrait; + } +} diff --git a/tests/Functional/FunctionalTest.php b/tests/Functional/FunctionalTest.php index 991c080a3..7bd63a605 100644 --- a/tests/Functional/FunctionalTest.php +++ b/tests/Functional/FunctionalTest.php @@ -17,6 +17,7 @@ use OpenApi\Annotations as OAAnnotations; use OpenApi\Attributes as OAAttributes; use OpenApi\Generator; +use Symfony\Component\HttpKernel\Kernel; use Symfony\Component\Serializer\Annotation\SerializedName; use const PHP_VERSION_ID; @@ -622,135 +623,6 @@ public function testSerializedNameAction() public function testCompoundEntityAction() { - if (PHP_VERSION_ID < 70300) { - self::assertEquals([ - 'schema' => 'CompoundEntity', - 'type' => 'object', - 'required' => ['complex', 'arrayOfArrayComplex'], - 'properties' => [ - 'complex' => [ - 'oneOf' => [ - [ - 'type' => 'integer', - ], - [ - 'type' => 'array', - 'items' => [ - '$ref' => '#/components/schemas/CompoundEntity', - ], - ], - ], - ], - 'nullableComplex' => [ - 'nullable' => true, - 'oneOf' => [ - [ - 'type' => 'integer', - 'nullable' => true, - ], - [ - 'type' => 'array', - 'items' => [ - '$ref' => '#/components/schemas/CompoundEntity', - ], - 'nullable' => true, // For some reason, this only exists on PHP < 7.3, which should not be the case. Assuming this to be a bug in PHP. - ], - ], - ], - 'complexNested' => [ - 'nullable' => true, - 'oneOf' => [ - [ - 'type' => 'array', - 'items' => [ - '$ref' => '#/components/schemas/CompoundEntityNested', - ], - 'nullable' => true, - ], - [ - 'type' => 'string', - 'nullable' => true, // For some reason, this only exists on PHP < 7.3, which should not be the case. Assuming this to be a bug in PHP. - ], - ], - ], - 'arrayOfArrayComplex' => [ - 'oneOf' => [ - [ - 'type' => 'array', - 'items' => [ - '$ref' => '#/components/schemas/CompoundEntityNested', - ], - ], - [ - 'type' => 'array', - 'items' => [ - 'type' => 'array', - 'items' => [ - '$ref' => '#/components/schemas/CompoundEntityNested', - ], - ], - ], - ], - ], - ], - ], json_decode($this->getModel('CompoundEntity')->toJson(), true)); - - self::assertEquals([ - 'schema' => 'CompoundEntityNested', - 'type' => 'object', - 'required' => ['complex'], - 'properties' => [ - 'complex' => [ - 'oneOf' => [ - [ - 'type' => 'integer', - ], - [ - 'type' => 'array', - 'items' => [ - '$ref' => '#/components/schemas/CompoundEntity', - ], - ], - ], - ], - 'nullableComplex' => [ - 'nullable' => true, - 'oneOf' => [ - [ - 'type' => 'integer', - 'nullable' => true, - ], - [ - 'type' => 'array', - 'items' => [ - '$ref' => '#/components/schemas/CompoundEntity', - ], - 'nullable' => true, // For some reason, this only exists on PHP < 7.4, which should not be the case. Assuming this to be a bug in PHP. - ], - ], - ], - 'complexNested' => [ - 'nullable' => true, - 'oneOf' => [ - [ - 'type' => 'array', - 'items' => [ - '$ref' => '#/components/schemas/CompoundEntityNested', - ], - 'nullable' => true, - ], - [ - 'type' => 'string', - 'nullable' => true, // For some reason, this only exists on PHP < 7.4, which should not be the case. Assuming this to be a bug in PHP. - ], - ], - ], - ], - ], json_decode($this->getModel('CompoundEntityNested')->toJson(), true)); - - return; - } - self::assertEquals([ 'schema' => 'CompoundEntity', 'type' => 'object', @@ -1234,9 +1106,6 @@ public function testArbitraryArrayModel() ], json_decode($this->getModel('Bar')->toJson(), true)); } - /** - * @requires PHP >= 7.3 - */ public function testDictionaryModel() { $this->getOperation('/api/dictionary', 'get'); @@ -1359,4 +1228,50 @@ public function testEntityWithFalsyDefaults() ], ], json_decode($model->toJson(), true)); } + + public function testRangeIntegers() + { + $expected = [ + 'schema' => 'RangeInteger', + 'required' => ['rangeInt', 'minRangeInt', 'maxRangeInt'], + 'properties' => [ + 'rangeInt' => [ + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 99, + ], + 'minRangeInt' => [ + 'type' => 'integer', + 'minimum' => 1, + ], + 'maxRangeInt' => [ + 'type' => 'integer', + 'maximum' => 99, + ], + 'nullableRangeInt' => [ + 'type' => 'integer', + 'nullable' => true, + 'minimum' => 1, + 'maximum' => 99, + ], + ], + 'type' => 'object', + ]; + + if (version_compare(Kernel::VERSION, '6.1', '>=')) { + array_unshift($expected['required'], 'positiveInt', 'negativeInt'); + $expected['properties'] += [ + 'positiveInt' => [ + 'type' => 'integer', + 'minimum' => 1, + ], + 'negativeInt' => [ + 'type' => 'integer', + 'maximum' => -1, + ], + ]; + } + + self::assertEquals($expected, json_decode($this->getModel('RangeInteger')->toJson(), true)); + } }