From 11cc1e57613f1c7e3f87a023a991966c379d6e2c Mon Sep 17 00:00:00 2001 From: Vincent Amstoutz Date: Tue, 15 Oct 2024 16:19:43 +0200 Subject: [PATCH] fix(metadata): try to improve handling of stateOptions --- ...meterResourceMetadataCollectionFactory.php | 15 +- src/State/OptionsInterface.php | 6 + .../TestBundle/ApiResource/AgentApi.php | 89 +++++++++++ .../TestBundle/Document/AgentDocument.php | 95 ++++++++++++ tests/Fixtures/TestBundle/Entity/Agent.php | 97 ++++++++++++ .../QueryParameterStateOptionsTest.php | 139 ++++++++++++++++++ 6 files changed, 440 insertions(+), 1 deletion(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/AgentApi.php create mode 100644 tests/Fixtures/TestBundle/Document/AgentDocument.php create mode 100644 tests/Fixtures/TestBundle/Entity/Agent.php create mode 100644 tests/Functional/Parameters/QueryParameterStateOptionsTest.php diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index c9f8500847d..0f4658a1290 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -22,6 +22,7 @@ use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use ApiPlatform\Serializer\Filter\FilterInterface as SerializerFilterInterface; +use ApiPlatform\State\OptionsInterface; use Psr\Container\ContainerInterface; use Symfony\Component\Validator\Constraints\Choice; use Symfony\Component\Validator\Constraints\Count; @@ -54,6 +55,17 @@ public function create(string $resourceClass): ResourceMetadataCollection $resourceMetadataCollection = $this->decorated?->create($resourceClass) ?? new ResourceMetadataCollection($resourceClass); foreach ($resourceMetadataCollection as $i => $resource) { + $stateOptions = $resource->getStateOptions(); + if ($stateOptions instanceof OptionsInterface) { + if ($stateOptions instanceof \ApiPlatform\Doctrine\Orm\State\Options) { + $resourceClass = $resource->getStateOptions()->getEntityClass(); + } + + if ($stateOptions instanceof \ApiPlatform\Doctrine\Odm\State\Options) { + $resourceClass = $resource->getStateOptions()->getDocumentClass(); + } + } + $operations = $resource->getOperations(); $internalPriority = -1; @@ -118,6 +130,7 @@ private function setDefaults(string $key, Parameter $parameter, string $resource // Read filter description to populate the Parameter $description = $filter instanceof FilterInterface ? $filter->getDescription($resourceClass) : []; + if (($schema = $description[$key]['schema'] ?? null) && null === $parameter->getSchema()) { $parameter = $parameter->withSchema($schema); } @@ -286,7 +299,7 @@ private function addFilterValidation(HttpOperation $operation): Parameters $parameters->add($key, $this->addSchemaValidation( // we disable openapi and hydra on purpose as their docs comes from filters see the condition for addFilterValidation above - new QueryParameter(key: $key, property: $definition['property'] ?? null, priority: $internalPriority--, schema: $schema, openApi: false, hydra: false), + new QueryParameter(key: $key, schema: $schema, openApi: false, property: $definition['property'] ?? null, priority: $internalPriority--, hydra: false), $schema, $required, $openApi diff --git a/src/State/OptionsInterface.php b/src/State/OptionsInterface.php index 1656a4d6451..9a85e9068a4 100644 --- a/src/State/OptionsInterface.php +++ b/src/State/OptionsInterface.php @@ -13,6 +13,12 @@ namespace ApiPlatform\State; +/** + * Interface for state options used in API Platform. + * + * @method string getEntityClass() Gets the fully qualified class name of the entity associated with the state options. + * @method string getDocumentClass() Gets the fully qualified class name of the document associated with the state options. + */ interface OptionsInterface { } diff --git a/tests/Fixtures/TestBundle/ApiResource/AgentApi.php b/tests/Fixtures/TestBundle/ApiResource/AgentApi.php new file mode 100644 index 00000000000..d4ed6bb9ba9 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/AgentApi.php @@ -0,0 +1,89 @@ + + * + * 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\ApiResource; + +use ApiPlatform\Doctrine\Orm\Filter\DateFilter; +use ApiPlatform\Doctrine\Orm\State\Options; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Agent; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiFilter(DateFilter::class, properties: ['birthday'], alias: 'app_filter_date')] +#[ApiResource( + shortName: 'Agent', + operations: [ + new GetCollection(parameters: [ + 'bla[:property]' => new QueryParameter(filter: 'app_filter_date'), + ]), + ], + stateOptions: new Options(entityClass: Agent::class) +)] +#[ApiFilter(DateFilter::class, properties: ['birthday'])] +#[ApiResource( + shortName: 'AgentSimple', + operations: [ + new GetCollection(), + ], + stateOptions: new Options(entityClass: Agent::class) +)] +class AgentApi +{ + #[Groups(['agent:read'])] + private ?int $id = null; + + #[Groups(['agent:read', 'agent:write'])] + private ?string $name = null; + + #[Groups(['agent:read', 'agent:write'])] + private ?\DateTimeInterface $birthday = null; + + public function getId(): ?int + { + return $this->id; + } + + public function setId(?int $id): self + { + $this->id = $id; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(?string $name): self + { + $this->name = $name; + + return $this; + } + + public function getBirthday(): ?\DateTimeInterface + { + return $this->birthday; + } + + public function setBirthday(?\DateTimeInterface $birthday): self + { + $this->birthday = $birthday; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Document/AgentDocument.php b/tests/Fixtures/TestBundle/Document/AgentDocument.php new file mode 100644 index 00000000000..6605e9cac7c --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/AgentDocument.php @@ -0,0 +1,95 @@ + + * + * 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\Document; + +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +#[ODM\Document] +class AgentDocument +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + public ?int $id = null; + + #[ODM\Field] + public ?string $name = null; + + #[ODM\Field] + public ?string $apiKey = null; + + #[ODM\Field] + public ?\DateTimeImmutable $createdAt = null; + + #[ODM\Field] + public ?\DateTimeImmutable $birthday = null; + + public function getId(): ?int + { + return $this->id; + } + + public function setId(int $id): static + { + $this->id = $id; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getApiKey(): ?string + { + return $this->apiKey; + } + + public function setApiKey(string $apiKey): static + { + $this->apiKey = $apiKey; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getBirthday(): ?\DateTimeImmutable + { + return $this->birthday; + } + + public function setBirthday(\DateTimeImmutable $birthday): static + { + $this->birthday = $birthday; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Agent.php b/tests/Fixtures/TestBundle/Entity/Agent.php new file mode 100644 index 00000000000..7ef40f5489a --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Agent.php @@ -0,0 +1,97 @@ + + * + * 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; + +use Doctrine\ORM\Mapping as ORM; + +#[ORM\Entity] +class Agent +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + public ?int $id = null; + + #[ORM\Column(length: 255)] + public ?string $name = null; + + #[ORM\Column(length: 255)] + public ?string $apiKey = null; + + #[ORM\Column] + public ?\DateTimeImmutable $createdAt = null; + + #[ORM\Column] + public ?\DateTimeImmutable $birthday = null; + + public function getId(): ?int + { + return $this->id; + } + + public function setId(int $id): static + { + $this->id = $id; + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): static + { + $this->name = $name; + + return $this; + } + + public function getApiKey(): ?string + { + return $this->apiKey; + } + + public function setApiKey(string $apiKey): static + { + $this->apiKey = $apiKey; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeImmutable $createdAt): static + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getBirthday(): ?\DateTimeImmutable + { + return $this->birthday; + } + + public function setBirthday(\DateTimeImmutable $birthday): static + { + $this->birthday = $birthday; + + return $this; + } +} diff --git a/tests/Functional/Parameters/QueryParameterStateOptionsTest.php b/tests/Functional/Parameters/QueryParameterStateOptionsTest.php new file mode 100644 index 00000000000..62825fc882a --- /dev/null +++ b/tests/Functional/Parameters/QueryParameterStateOptionsTest.php @@ -0,0 +1,139 @@ + + * + * 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\Functional\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\AgentDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Agent; +use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\ODM\MongoDB\MongoDBException; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Tools\SchemaTool; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\DecodingExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface; +use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +final class QueryParameterStateOptionsTest extends ApiTestCase +{ + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + public function testWithBirthdayDateFilter(): void + { + $this->recreateSchema(); + $response = self::createClient()->request('GET', 'agent_simples?birthday[before]=2000-01-01&birthday[after]=1990-01-01'); + $this->assertResponseIsSuccessful(); + + $agents = $this->getResponseData($response); + $this->assertCount(1, $agents); + + $validBirthdays = array_column($agents, 'birthday'); + $this->assertValidBirthdayRange($validBirthdays); + } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + public function testQueryParameterStateOptions(): void + { + $this->recreateSchema(); + $response = self::createClient()->request('GET', 'agents?bla[birthday][before]=2000-01-01&bla[birthday][after]=1990-01-01'); + $this->assertResponseIsSuccessful(); + + $agents = $this->getResponseData($response); + $this->assertCount(1, $agents); + + $validBirthdays = array_column($agents, 'birthday'); + $this->assertValidBirthdayRange($validBirthdays); + } + + /** + * @throws TransportExceptionInterface + * @throws ServerExceptionInterface + * @throws RedirectionExceptionInterface + * @throws DecodingExceptionInterface + * @throws ClientExceptionInterface + */ + private function getResponseData(ResponseInterface $response): array + { + $data = $response->toArray(); + $this->assertArrayHasKey('hydra:member', $data, '"hydra:member" key does not contain an array'); + + return $data['hydra:member']; + } + + /** + * @param array $birthdays + */ + private function assertValidBirthdayRange(array $birthdays): void + { + foreach ($birthdays as $birthday) { + $this->assertLessThanOrEqual('2000-01-01T00:00:00+00:00', $birthday, "The birthday date {$birthday} exceeds the upper limit."); + $this->assertGreaterThanOrEqual('1990-01-01T00:00:00+00:00', $birthday, "The birthday date {$birthday} is below the lower limit."); + } + } + + /** + * @param array $options kernel options + */ + private function recreateSchema(array $options = []): void + { + self::bootKernel($options); + $container = static::getContainer(); + + $isMongoDb = 'mongodb' === $container->getParameter('kernel.environment'); + $registry = $this->getContainer()->get($isMongoDb ? 'doctrine_mongodb' : 'doctrine'); + $resourceClass = $isMongoDb ? AgentDocument::class : Agent::class; + + $manager = $registry->getManager(); + if ($manager instanceof EntityManagerInterface) { + $classes = $manager->getClassMetadata($resourceClass); + $schemaTool = new SchemaTool($manager); + + @$schemaTool->dropSchema([$classes]); + @$schemaTool->createSchema([$classes]); + } + if ($manager instanceof DocumentManager) { + @$manager->getSchemaManager()->dropCollections(); + } + + $birthdays = [new \DateTimeImmutable('2002-01-01'), new \DateTimeImmutable(), new \DateTimeImmutable('1990-12-31')]; + foreach ($birthdays as $birthday) { + $agent = (new $resourceClass()) + ->setName('Agent '.$birthday->format('Y')) + ->setApiKey('api_key_'.$birthday->format('Y')) + ->setBirthday($birthday) + ->setCreatedAt(new \DateTimeImmutable()); + + $manager->persist($agent); + } + + try { + $manager->flush(); + } catch (MongoDBException|\Throwable $e) { + throw new \RuntimeException($e->getMessage(), $e->getCode(), $e); + } + } +}