diff --git a/src/JsonSchema/BackwardCompatibleSchemaFactory.php b/src/JsonSchema/BackwardCompatibleSchemaFactory.php new file mode 100644 index 00000000000..35b43168b99 --- /dev/null +++ b/src/JsonSchema/BackwardCompatibleSchemaFactory.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonSchema; + +use ApiPlatform\Metadata\Operation; + +/** + * This factory decorates range integer and number properties to keep Draft 4 backward compatibility. + * + * @see https://github.com/api-platform/core/issues/6041 + * + * @internal + */ +final class BackwardCompatibleSchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareInterface +{ + public const SCHEMA_DRAFT4_VERSION = 'draft_4'; + + public function __construct(private readonly SchemaFactoryInterface $decorated) + { + } + + /** + * {@inheritDoc} + */ + public function buildSchema(string $className, string $format = 'json', string $type = Schema::TYPE_OUTPUT, Operation $operation = null, Schema $schema = null, array $serializerContext = null, bool $forceCollection = false): Schema + { + $schema = $this->decorated->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection); + + if (!($serializerContext[self::SCHEMA_DRAFT4_VERSION] ?? false)) { + return $schema; + } + + foreach ($schema->getDefinitions() as $definition) { + foreach ($definition['properties'] ?? [] as $property) { + if (isset($property['type']) && \in_array($property['type'], ['integer', 'number'], true)) { + if (isset($property['exclusiveMinimum'])) { + $property['minimum'] = $property['exclusiveMinimum']; + $property['exclusiveMinimum'] = true; + } + if (isset($property['exclusiveMaximum'])) { + $property['maximum'] = $property['exclusiveMaximum']; + $property['exclusiveMaximum'] = true; + } + } + } + } + + return $schema; + } + + public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void + { + if ($this->decorated instanceof SchemaFactoryAwareInterface) { + $this->decorated->setSchemaFactory($schemaFactory); + } + } +} diff --git a/src/Symfony/Bundle/Resources/config/json_schema.xml b/src/Symfony/Bundle/Resources/config/json_schema.xml index 2ad6fe11746..0923e155022 100644 --- a/src/Symfony/Bundle/Resources/config/json_schema.xml +++ b/src/Symfony/Bundle/Resources/config/json_schema.xml @@ -36,6 +36,10 @@ + + + + diff --git a/src/Symfony/Bundle/Test/ApiTestAssertionsTrait.php b/src/Symfony/Bundle/Test/ApiTestAssertionsTrait.php index 590372c5cfd..715ab6d99ef 100644 --- a/src/Symfony/Bundle/Test/ApiTestAssertionsTrait.php +++ b/src/Symfony/Bundle/Test/ApiTestAssertionsTrait.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Symfony\Bundle\Test; +use ApiPlatform\JsonSchema\BackwardCompatibleSchemaFactory; use ApiPlatform\JsonSchema\Schema; use ApiPlatform\JsonSchema\SchemaFactoryInterface; use ApiPlatform\Metadata\Get; @@ -118,7 +119,7 @@ public static function assertMatchesResourceCollectionJsonSchema(string $resourc $operation = $operationName ? (new GetCollection())->withName($operationName) : new GetCollection(); } - $schema = $schemaFactory->buildSchema($resourceClass, $format, Schema::TYPE_OUTPUT, $operation, null, $serializationContext); + $schema = $schemaFactory->buildSchema($resourceClass, $format, Schema::TYPE_OUTPUT, $operation, null, ($serializationContext ?? []) + [BackwardCompatibleSchemaFactory::SCHEMA_DRAFT4_VERSION => true]); static::assertMatchesJsonSchema($schema->getArrayCopy()); } @@ -133,7 +134,7 @@ public static function assertMatchesResourceItemJsonSchema(string $resourceClass $operation = $operationName ? (new Get())->withName($operationName) : new Get(); } - $schema = $schemaFactory->buildSchema($resourceClass, $format, Schema::TYPE_OUTPUT, $operation, null, $serializationContext); + $schema = $schemaFactory->buildSchema($resourceClass, $format, Schema::TYPE_OUTPUT, $operation, null, ($serializationContext ?? []) + [BackwardCompatibleSchemaFactory::SCHEMA_DRAFT4_VERSION => true]); static::assertMatchesJsonSchema($schema->getArrayCopy()); } diff --git a/tests/Fixtures/TestBundle/Entity/Issue6041/NumericValidated.php b/tests/Fixtures/TestBundle/Entity/Issue6041/NumericValidated.php new file mode 100644 index 00000000000..5394a8eb43e --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Issue6041/NumericValidated.php @@ -0,0 +1,74 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6041; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource(operations: [ + new Get(uriTemplate: 'numeric-validated/{id}'), + new GetCollection(uriTemplate: 'numeric-validated'), +])] +#[ORM\Entity] +class NumericValidated +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[Assert\Range(min: 1, max: 10)] + #[ORM\Column] + public int $range; + + #[Assert\GreaterThan(value: 10)] + #[ORM\Column] + public int $greaterThanMe; + + #[Assert\GreaterThanOrEqual(value: '10.99')] + #[ORM\Column] + public float $greaterThanOrEqualToMe; + + #[Assert\LessThan(value: 99)] + #[ORM\Column] + public int $lessThanMe; + + #[Assert\LessThanOrEqual(value: '99.33')] + #[ORM\Column] + public float $lessThanOrEqualToMe; + + #[Assert\Positive] + #[ORM\Column] + public int $positive; + + #[Assert\PositiveOrZero] + #[ORM\Column] + public int $positiveOrZero; + + #[Assert\Negative] + #[ORM\Column] + public int $negative; + + #[Assert\NegativeOrZero] + #[ORM\Column] + public int $negativeOrZero; + + public function getId(): ?int + { + return $this->id; + } +} diff --git a/tests/Symfony/Bundle/Test/ApiTestCaseTest.php b/tests/Symfony/Bundle/Test/ApiTestCaseTest.php index 739c9acbc5e..6e6def4da16 100644 --- a/tests/Symfony/Bundle/Test/ApiTestCaseTest.php +++ b/tests/Symfony/Bundle/Test/ApiTestCaseTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoInputOutput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6041\NumericValidated; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\JsonSchemaContextDummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\User; use ApiPlatform\Tests\Fixtures\TestBundle\Model\ResourceInterface; @@ -180,6 +181,33 @@ public function testAssertMatchesResourceItemAndCollectionJsonSchemaOutputWithCo $this->assertMatchesResourceCollectionJsonSchema(User::class, null, 'jsonld', ['groups' => ['api-test-case-group']]); } + public function testAssertMatchesResourceItemAndCollectionJsonSchemaOutputWithRangeAssertions(): void + { + $this->recreateSchema(); + + /** @var EntityManagerInterface $manager */ + $manager = static::getContainer()->get('doctrine')->getManager(); + $numericValidated = new NumericValidated(); + $numericValidated->range = 5; + $numericValidated->greaterThanMe = 11; + $numericValidated->greaterThanOrEqualToMe = 10.99; + $numericValidated->lessThanMe = 11; + $numericValidated->lessThanOrEqualToMe = 99.33; + $numericValidated->positive = 1; + $numericValidated->positiveOrZero = 0; + $numericValidated->negative = -1; + $numericValidated->negativeOrZero = 0; + + $manager->persist($numericValidated); + $manager->flush(); + + self::createClient()->request('GET', "/numeric-validated/{$numericValidated->getId()}"); + $this->assertMatchesResourceItemJsonSchema(NumericValidated::class); + + self::createClient()->request('GET', '/numeric-validated'); + $this->assertMatchesResourceCollectionJsonSchema(NumericValidated::class); + } + // Next tests have been imported from dms/phpunit-arraysubset-asserts, because the original constraint has been deprecated. public function testAssertArraySubsetPassesStrictConfig(): void