diff --git a/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php b/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php index 523168f10bd..33409aeb11d 100644 --- a/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php @@ -59,15 +59,11 @@ public function create(string $resourceClass, string $property, array $options = return $this->handleNotFound($parentPropertyMetadata, $resourceClass, $property); } - if ($reflectionEnum) { - if ($reflectionEnum->hasCase($property)) { - $reflectionCase = $reflectionEnum->getCase($property); - if ($attributes = $reflectionCase->getAttributes(ApiProperty::class)) { - return $this->createMetadata($attributes[0]->newInstance(), $parentPropertyMetadata); - } + if ($reflectionEnum && $reflectionEnum->hasCase($property)) { + $reflectionCase = $reflectionEnum->getCase($property); + if ($attributes = $reflectionCase->getAttributes(ApiProperty::class)) { + return $this->createMetadata($attributes[0]->newInstance(), $parentPropertyMetadata); } - - return $this->handleNotFound($parentPropertyMetadata, $resourceClass, $property); } if ($reflectionClass->hasProperty($property)) { @@ -79,11 +75,11 @@ public function create(string $resourceClass, string $property, array $options = foreach (array_merge(Reflection::ACCESSOR_PREFIXES, Reflection::MUTATOR_PREFIXES) as $prefix) { $methodName = $prefix.ucfirst($property); - if (!$reflectionClass->hasMethod($methodName)) { + if (!$reflectionClass->hasMethod($methodName) && !$reflectionEnum?->hasMethod($methodName)) { continue; } - $reflectionMethod = $reflectionClass->getMethod($methodName); + $reflectionMethod = $reflectionClass->hasMethod($methodName) ? $reflectionClass->getMethod($methodName) : $reflectionEnum?->getMethod($methodName); if (!$reflectionMethod->isPublic()) { continue; } diff --git a/src/Metadata/Resource/Factory/BackedEnumResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/BackedEnumResourceMetadataCollectionFactory.php new file mode 100644 index 00000000000..9f7411b1c3b --- /dev/null +++ b/src/Metadata/Resource/Factory/BackedEnumResourceMetadataCollectionFactory.php @@ -0,0 +1,67 @@ + + * + * 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\Metadata\Resource\Factory; + +use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; + +/** + * Triggers resource deprecations. + * + * @internal + */ +final class BackedEnumResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface +{ + public const PROVIDER = 'api_platform.state_provider.backed_enum'; + + public function __construct(private readonly ResourceMetadataCollectionFactoryInterface $decorated) + { + } + + public function create(string $resourceClass): ResourceMetadataCollection + { + $resourceMetadataCollection = $this->decorated->create($resourceClass); + if (!is_a($resourceClass, \BackedEnum::class, true)) { + return $resourceMetadataCollection; + } + + foreach ($resourceMetadataCollection as $i => $resourceMetadata) { + $newOperations = []; + foreach ($resourceMetadata->getOperations() as $operationName => $operation) { + $newOperations[$operationName] = $operation; + + if (null !== $operation->getProvider()) { + continue; + } + + $newOperations[$operationName] = $operation->withProvider(self::PROVIDER); + } + + $newGraphQlOperations = []; + foreach ($resourceMetadata->getGraphQlOperations() as $operationName => $operation) { + $newGraphQlOperations[$operationName] = $operation; + + if (null !== $operation->getProvider()) { + continue; + } + + $newGraphQlOperations[$operationName] = $operation->withProvider(self::PROVIDER); + } + + $resourceMetadataCollection[$i] = $resourceMetadata->withOperations(new Operations($newOperations))->withGraphQlOperations($newGraphQlOperations); + } + + return $resourceMetadataCollection; + } +} diff --git a/src/Metadata/Resource/Factory/LinkFactory.php b/src/Metadata/Resource/Factory/LinkFactory.php index 3420aa4647c..95fecdd95b2 100644 --- a/src/Metadata/Resource/Factory/LinkFactory.php +++ b/src/Metadata/Resource/Factory/LinkFactory.php @@ -59,6 +59,9 @@ public function createLinksFromIdentifiers(Metadata $operation): array $link = (new Link())->withFromClass($resourceClass)->withIdentifiers($identifiers); $parameterName = $identifiers[0]; + if ('value' === $parameterName && enum_exists($resourceClass)) { + $parameterName = 'id'; + } if (1 < \count($identifiers)) { $parameterName = 'id'; @@ -155,6 +158,10 @@ private function getIdentifiersFromResourceClass(string $resourceClass): array return ['id']; } + if (!$hasIdProperty && !$identifiers && enum_exists($resourceClass)) { + return ['value']; + } + return $identifiers; } diff --git a/src/Metadata/Resource/Factory/OperationDefaultsTrait.php b/src/Metadata/Resource/Factory/OperationDefaultsTrait.php index 8814c5b9214..c21f71ea81a 100644 --- a/src/Metadata/Resource/Factory/OperationDefaultsTrait.php +++ b/src/Metadata/Resource/Factory/OperationDefaultsTrait.php @@ -88,6 +88,10 @@ private function getResourceWithDefaults(string $resourceClass, string $shortNam private function getDefaultHttpOperations($resource): iterable { + if (enum_exists($resource->getClass())) { + return new Operations([new GetCollection(paginationEnabled: false), new Get()]); + } + if (($defaultOperations = $this->defaults['operations'] ?? null) && null === $resource->getOperations()) { $operations = []; @@ -108,8 +112,9 @@ private function getDefaultHttpOperations($resource): iterable private function addDefaultGraphQlOperations(ApiResource $resource): ApiResource { + $operations = enum_exists($resource->getClass()) ? [new QueryCollection(paginationEnabled: false), new Query()] : [new QueryCollection(), new Query(), (new Mutation())->withName('update'), (new DeleteMutation())->withName('delete'), (new Mutation())->withName('create')]; $graphQlOperations = []; - foreach ([new QueryCollection(), new Query(), (new Mutation())->withName('update'), (new DeleteMutation())->withName('delete'), (new Mutation())->withName('create')] as $operation) { + foreach ($operations as $operation) { [$key, $operation] = $this->getOperationWithDefaults($resource, $operation); $graphQlOperations[$key] = $operation; } diff --git a/src/State/Provider/BackedEnumProvider.php b/src/State/Provider/BackedEnumProvider.php new file mode 100644 index 00000000000..d5641fb2719 --- /dev/null +++ b/src/State/Provider/BackedEnumProvider.php @@ -0,0 +1,69 @@ + + * + * 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\State\Provider; + +use ApiPlatform\Metadata\CollectionOperationInterface; +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\ProviderInterface; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +final class BackedEnumProvider implements ProviderInterface +{ + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $resourceClass = $operation->getClass(); + if (!$resourceClass || !is_a($resourceClass, \BackedEnum::class, true)) { + throw new RuntimeException('This resource is not an enum'); + } + + if ($operation instanceof CollectionOperationInterface) { + return $resourceClass::cases(); + } + + $id = $uriVariables['id'] ?? null; + if (null === $id) { + throw new NotFoundHttpException('Not Found'); + } + + if ($enum = $this->resolveEnum($resourceClass, $id)) { + return $enum; + } + + throw new NotFoundHttpException('Not Found'); + } + + /** + * @param class-string $resourceClass + */ + private function resolveEnum(string $resourceClass, string|int $id): ?\BackedEnum + { + $reflectEnum = new \ReflectionEnum($resourceClass); + $type = (string) $reflectEnum->getBackingType(); + + if ('int' === $type) { + if (!is_numeric($id)) { + return null; + } + $enum = $resourceClass::tryFrom((int) $id); + } else { + $enum = $resourceClass::tryFrom($id); + } + + // @deprecated enums will be indexable only by value in 4.0 + $enum ??= array_reduce($resourceClass::cases(), static fn ($c, \BackedEnum $case) => $id === $case->name ? $case : $c, null); + + return $enum; + } +} diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource.xml b/src/Symfony/Bundle/Resources/config/metadata/resource.xml index 04d8fa2016f..2fd6edfdca3 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource.xml +++ b/src/Symfony/Bundle/Resources/config/metadata/resource.xml @@ -29,6 +29,10 @@ + + + + diff --git a/src/Symfony/Bundle/Resources/config/state/provider.xml b/src/Symfony/Bundle/Resources/config/state/provider.xml index 56a42f70f87..47f56f4aea1 100644 --- a/src/Symfony/Bundle/Resources/config/state/provider.xml +++ b/src/Symfony/Bundle/Resources/config/state/provider.xml @@ -25,6 +25,11 @@ + + + + + api_platform.symfony.main_controller diff --git a/tests/Fixtures/TestBundle/ApiResource/BackedEnumIntegerResource.php b/tests/Fixtures/TestBundle/ApiResource/BackedEnumIntegerResource.php new file mode 100644 index 00000000000..7a1a15757ff --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/BackedEnumIntegerResource.php @@ -0,0 +1,33 @@ + + * + * 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\Metadata\ApiResource; + +#[ApiResource] +enum BackedEnumIntegerResource: int +{ + case Yes = 1; + case No = 2; + case Maybe = 3; + + public function getDescription(): string + { + return match ($this) { + self::Yes => 'We say yes', + self::No => 'Computer says no', + self::Maybe => 'Let me think about it', + }; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/BackedEnumStringResource.php b/tests/Fixtures/TestBundle/ApiResource/BackedEnumStringResource.php new file mode 100644 index 00000000000..2066187ed63 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/BackedEnumStringResource.php @@ -0,0 +1,33 @@ + + * + * 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\Metadata\ApiResource; + +#[ApiResource] +enum BackedEnumStringResource: string +{ + case Yes = 'yes'; + case No = 'no'; + case Maybe = 'maybe'; + + public function getDescription(): string + { + return match ($this) { + self::Yes => 'We say yes', + self::No => 'Computer says no', + self::Maybe => 'Let me think about it', + }; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue6264/Availability.php b/tests/Fixtures/TestBundle/ApiResource/Issue6264/Availability.php index a714307a13c..fecf4cdabfe 100644 --- a/tests/Fixtures/TestBundle/ApiResource/Issue6264/Availability.php +++ b/tests/Fixtures/TestBundle/ApiResource/Issue6264/Availability.php @@ -16,10 +16,18 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\GraphQl\Query; -#[ApiResource(normalizationContext: ['groups' => ['get']])] -#[GetCollection(provider: Availability::class.'::getCases')] -#[Get(provider: Availability::class.'::getCase')] +#[ApiResource( + normalizationContext: ['groups' => ['get']], + operations: [ + new GetCollection(provider: Availability::class.'::getCases'), + new Get(provider: Availability::class.'::getCase'), + ], + graphQlOperations: [ + new Query(provider: Availability::class.'getCase'), + ] +)] enum Availability: int { use BackedEnumTrait; diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue6264/AvailabilityStatus.php b/tests/Fixtures/TestBundle/ApiResource/Issue6264/AvailabilityStatus.php index a6825d72e8d..ef406b2a4ce 100644 --- a/tests/Fixtures/TestBundle/ApiResource/Issue6264/AvailabilityStatus.php +++ b/tests/Fixtures/TestBundle/ApiResource/Issue6264/AvailabilityStatus.php @@ -22,7 +22,7 @@ #[Get(provider: AvailabilityStatus::class.'::getCase')] enum AvailabilityStatus: string { - use BackedEnumTrait; + use BackedEnumStringTrait; case Pending = 'pending'; case Reviewed = 'reviewed'; diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue6264/BackedEnumStringTrait.php b/tests/Fixtures/TestBundle/ApiResource/Issue6264/BackedEnumStringTrait.php new file mode 100644 index 00000000000..f6f90be4cc7 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue6264/BackedEnumStringTrait.php @@ -0,0 +1,49 @@ + + * + * 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\Issue6264; + +use ApiPlatform\Metadata\Operation; +use Symfony\Component\Serializer\Attribute\Groups; + +trait BackedEnumStringTrait +{ + public static function values(): array + { + return array_map(static fn (\BackedEnum $feature) => $feature->value, self::cases()); + } + + public function getId(): string + { + return $this->value; + } + + #[Groups(['get'])] + public function getValue(): string + { + return $this->value; + } + + public static function getCases(): array + { + return self::cases(); + } + + /** + * @param array $uriVariables + */ + public static function getCase(Operation $operation, array $uriVariables): ?self + { + return array_reduce(self::cases(), static fn ($c, \BackedEnum $case) => $case->value == $uriVariables['id'] ? $case : $c, null); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue6264/BackedEnumTrait.php b/tests/Fixtures/TestBundle/ApiResource/Issue6264/BackedEnumTrait.php index 20b2d60eee2..3bd48c3ebd5 100644 --- a/tests/Fixtures/TestBundle/ApiResource/Issue6264/BackedEnumTrait.php +++ b/tests/Fixtures/TestBundle/ApiResource/Issue6264/BackedEnumTrait.php @@ -23,13 +23,13 @@ public static function values(): array return array_map(static fn (\BackedEnum $feature) => $feature->value, self::cases()); } - public function getId(): string|int + public function getId(): int { return $this->value; } #[Groups(['get'])] - public function getValue(): string|int + public function getValue(): int { return $this->value; } @@ -39,6 +39,9 @@ public static function getCases(): array return self::cases(); } + /** + * @param array $uriVariables + */ public static function getCase(Operation $operation, array $uriVariables): ?self { return array_reduce(self::cases(), static fn ($c, \BackedEnum $case) => $case->value == $uriVariables['id'] ? $case : $c, null); diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue6317/Issue6317.php b/tests/Fixtures/TestBundle/ApiResource/Issue6317/Issue6317.php new file mode 100644 index 00000000000..3b98bb283bc --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue6317/Issue6317.php @@ -0,0 +1,48 @@ + + * + * 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\Issue6317; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; + +#[ApiResource] +enum Issue6317: int +{ + case First = 1; + case Second = 2; + + #[ApiProperty(identifier: true, example: 'An example of an ID')] + public function getId(): int + { + return $this->value; + } + + #[ApiProperty(jsonSchemaContext: ['example' => '/lisa/mary'])] + public function getName(): string + { + return $this->name; + } + + #[ApiProperty(jsonldContext: ['example' => '24'])] + public function getOrdinal(): string + { + return 1 === $this->value ? '1st' : '2nd'; + } + + #[ApiProperty(openapiContext: ['example' => '42'])] + public function getCardinal(): int + { + return $this->value; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/ResourceWithEnumProperty.php b/tests/Fixtures/TestBundle/ApiResource/ResourceWithEnumProperty.php new file mode 100644 index 00000000000..f72bda258a2 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/ResourceWithEnumProperty.php @@ -0,0 +1,58 @@ + + * + * 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\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GenderTypeEnum; + +#[ApiResource()] +#[Get( + provider: self::class.'::providerItem', +)] +#[GetCollection( + provider: self::class.'::providerCollection', +)] +class ResourceWithEnumProperty +{ + public int $id = 1; + + public ?BackedEnumIntegerResource $intEnum = null; + + /** @var BackedEnumStringResource[] */ + public array $stringEnum = []; + + public ?GenderTypeEnum $gender = null; + + /** @var GenderTypeEnum[] */ + public array $genders = []; + + public static function providerItem(Operation $operation, array $uriVariables): self + { + $self = new self(); + $self->intEnum = BackedEnumIntegerResource::Yes; + $self->stringEnum = [BackedEnumStringResource::Maybe, BackedEnumStringResource::No]; + $self->gender = GenderTypeEnum::FEMALE; + $self->genders = [GenderTypeEnum::FEMALE, GenderTypeEnum::MALE]; + + return $self; + } + + public static function providerCollection(Operation $operation, array $uriVariables): array + { + return [self::providerItem($operation, $uriVariables)]; + } +} diff --git a/tests/Functional/BackedEnumResourceTest.php b/tests/Functional/BackedEnumResourceTest.php index e8f78d5d3bc..6bbd2b0f448 100644 --- a/tests/Functional/BackedEnumResourceTest.php +++ b/tests/Functional/BackedEnumResourceTest.php @@ -13,7 +13,12 @@ namespace ApiPlatform\Tests\Functional; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\BackedEnumIntegerResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\BackedEnumStringResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6264\Availability; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6264\AvailabilityStatus; use Symfony\Component\HttpClient\HttpOptions; @@ -94,49 +99,6 @@ public function testItemJson(string $uri, string $mimeType, array $expected): vo $this->assertJsonEquals($expected); } - public static function providerEnumItemsGraphQl(): iterable - { - // Integer cases - $query = <<<'GRAPHQL' -query GetAvailability($identifier: ID!) { - availability(id: $identifier) { - value - } -} -GRAPHQL; - foreach (Availability::cases() as $case) { - yield [$query, ['identifier' => '/availabilities/'.$case->value], ['data' => ['availability' => ['value' => $case->value]]]]; - } - - // String cases - $query = <<<'GRAPHQL' -query GetAvailabilityStatus($identifier: ID!) { - availabilityStatus(id: $identifier) { - value - } -} -GRAPHQL; - foreach (AvailabilityStatus::cases() as $case) { - yield [$query, ['identifier' => '/availability_statuses/'.$case->value], ['data' => ['availability_status' => ['value' => $case->value]]]]; - } - } - - /** - * @dataProvider providerEnumItemsGraphQl - * - * @group legacy - */ - public function testItemGraphql(string $query, array $variables, array $expected): void - { - $options = (new HttpOptions()) - ->setJson(['query' => $query, 'variables' => $variables]) - ->setHeaders(['Content-Type' => 'application/json']); - self::createClient()->request('POST', '/graphql', $options->toArray()); - - $this->assertResponseIsSuccessful(); - $this->assertJsonEquals($expected); - } - public function testCollectionJson(): void { self::createClient()->request('GET', '/availabilities', ['headers' => ['Accept' => 'application/json']]); @@ -258,4 +220,357 @@ public function testCollectionJsonLd(): void ], ]); } + + public static function providerEnums(): iterable + { + yield 'Int enum collection' => [BackedEnumIntegerResource::class, GetCollection::class, '_api_/backed_enum_integer_resources{._format}_get_collection']; + yield 'Int enum item' => [BackedEnumIntegerResource::class, Get::class, '_api_/backed_enum_integer_resources/{id}{._format}_get']; + + yield 'String enum collection' => [BackedEnumStringResource::class, GetCollection::class, '_api_/backed_enum_string_resources{._format}_get_collection']; + yield 'String enum item' => [BackedEnumStringResource::class, Get::class, '_api_/backed_enum_string_resources/{id}{._format}_get']; + } + + /** @dataProvider providerEnums */ + public function testOnlyGetOperationsAddedWhenNonSpecified(string $resourceClass, string $operationClass, string $operationName): void + { + $factory = self::getContainer()->get('api_platform.metadata.resource.metadata_collection_factory'); + $resourceMetadata = $factory->create($resourceClass); + + $this->assertCount(1, $resourceMetadata); + $resource = $resourceMetadata[0]; + $operations = iterator_to_array($resource->getOperations()); + $this->assertCount(2, $operations); + + $this->assertInstanceOf($operationClass, $operations[$operationName]); + } + + public function testEnumsAreAssignedValuePropertyAsIdentifierByDefault(): void + { + $linkFactory = self::getContainer()->get('api_platform.metadata.resource.link_factory'); + $result = $linkFactory->completeLink(new Link(fromClass: BackedEnumIntegerResource::class)); + $identifiers = $result->getIdentifiers(); + + $this->assertCount(1, $identifiers); + $this->assertNotContains('id', $identifiers); + $this->assertContains('value', $identifiers); + } + + public static function providerCollection(): iterable + { + yield 'JSON' => ['application/json', [ + [ + 'name' => 'Yes', + 'value' => 1, + 'description' => 'We say yes', + ], + [ + 'name' => 'No', + 'value' => 2, + 'description' => 'Computer says no', + ], + [ + 'name' => 'Maybe', + 'value' => 3, + 'description' => 'Let me think about it', + ], + ]]; + + yield 'JSON:API' => ['application/vnd.api+json', [ + 'links' => [ + 'self' => '/backed_enum_integer_resources', + ], + 'meta' => [ + 'totalItems' => 3, + ], + 'data' => [ + [ + 'id' => '/backed_enum_integer_resources/1', + 'type' => 'BackedEnumIntegerResource', + 'attributes' => [ + 'name' => 'Yes', + 'value' => 1, + 'description' => 'We say yes', + ], + ], + [ + 'id' => '/backed_enum_integer_resources/2', + 'type' => 'BackedEnumIntegerResource', + 'attributes' => [ + 'name' => 'No', + 'value' => 2, + 'description' => 'Computer says no', + ], + ], + [ + 'id' => '/backed_enum_integer_resources/3', + 'type' => 'BackedEnumIntegerResource', + 'attributes' => [ + 'name' => 'Maybe', + 'value' => 3, + 'description' => 'Let me think about it', + ], + ], + ], + ]]; + + yield 'LD+JSON' => ['application/ld+json', [ + '@context' => '/contexts/BackedEnumIntegerResource', + '@id' => '/backed_enum_integer_resources', + '@type' => 'hydra:Collection', + 'hydra:totalItems' => 3, + 'hydra:member' => [ + [ + '@id' => '/backed_enum_integer_resources/1', + '@type' => 'BackedEnumIntegerResource', + 'name' => 'Yes', + 'value' => 1, + 'description' => 'We say yes', + ], + [ + '@id' => '/backed_enum_integer_resources/2', + '@type' => 'BackedEnumIntegerResource', + 'name' => 'No', + 'value' => 2, + 'description' => 'Computer says no', + ], + [ + '@id' => '/backed_enum_integer_resources/3', + '@type' => 'BackedEnumIntegerResource', + 'name' => 'Maybe', + 'value' => 3, + 'description' => 'Let me think about it', + ], + ], + ]]; + + yield 'HAL+JSON' => ['application/hal+json', [ + '_links' => [ + 'self' => [ + 'href' => '/backed_enum_integer_resources', + ], + 'item' => [ + [ + 'href' => '/backed_enum_integer_resources/1', + ], + [ + 'href' => '/backed_enum_integer_resources/2', + ], + [ + 'href' => '/backed_enum_integer_resources/3', + ], + ], + ], + 'totalItems' => 3, + '_embedded' => [ + 'item' => [ + [ + '_links' => [ + 'self' => [ + 'href' => '/backed_enum_integer_resources/1', + ], + ], + 'name' => 'Yes', + 'value' => 1, + 'description' => 'We say yes', + ], + [ + '_links' => [ + 'self' => [ + 'href' => '/backed_enum_integer_resources/2', + ], + ], + 'name' => 'No', + 'value' => 2, + 'description' => 'Computer says no', + ], + [ + '_links' => [ + 'self' => [ + 'href' => '/backed_enum_integer_resources/3', + ], + ], + 'name' => 'Maybe', + 'value' => 3, + 'description' => 'Let me think about it', + ], + ], + ], + ]]; + } + + /** @dataProvider providerCollection */ + public function testCollection(string $mimeType, array $expected): void + { + self::createClient()->request('GET', '/backed_enum_integer_resources', ['headers' => ['Accept' => $mimeType]]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonEquals($expected); + } + + public static function providerItem(): iterable + { + yield 'JSON' => ['application/json', [ + 'name' => 'Yes', + 'value' => 1, + 'description' => 'We say yes', + ]]; + + yield 'JSON:API' => ['application/vnd.api+json', [ + 'data' => [ + 'id' => '/backed_enum_integer_resources/1', + 'type' => 'BackedEnumIntegerResource', + 'attributes' => [ + 'name' => 'Yes', + 'value' => 1, + 'description' => 'We say yes', + ], + ], + ]]; + + yield 'JSON:HAL' => ['application/hal+json', [ + '_links' => [ + 'self' => [ + 'href' => '/backed_enum_integer_resources/1', + ], + ], + 'name' => 'Yes', + 'value' => 1, + 'description' => 'We say yes', + ]]; + + yield 'LD+JSON' => ['application/ld+json', [ + '@context' => '/contexts/BackedEnumIntegerResource', + '@id' => '/backed_enum_integer_resources/1', + '@type' => 'BackedEnumIntegerResource', + 'name' => 'Yes', + 'value' => 1, + 'description' => 'We say yes', + ]]; + } + + /** @dataProvider providerItem */ + public function testItem(string $mimeType, array $expected): void + { + self::createClient()->request('GET', '/backed_enum_integer_resources/1', ['headers' => ['Accept' => $mimeType]]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonEquals($expected); + } + + public static function provider404s(): iterable + { + yield ['/backed_enum_integer_resources/42']; + yield ['/backed_enum_integer_resources/fortytwo']; + } + + /** @dataProvider provider404s */ + public function testItem404(string $uri): void + { + self::createClient()->request('GET', $uri); + + $this->assertResponseStatusCodeSame(404); + } + + public static function providerEnumItemsGraphQl(): iterable + { + // Integer cases + $query = <<<'GRAPHQL' +query GetAvailability($identifier: ID!) { + availability(id: $identifier) { + value + } +} +GRAPHQL; + foreach (Availability::cases() as $case) { + yield [$query, ['identifier' => '/availabilities/'.$case->value], ['data' => ['availability' => ['value' => $case->value]]]]; + } + + // String cases + $query = <<<'GRAPHQL' +query GetAvailabilityStatus($identifier: ID!) { + availabilityStatus(id: $identifier) { + value + } +} +GRAPHQL; + foreach (AvailabilityStatus::cases() as $case) { + yield [$query, ['identifier' => '/availability_statuses/'.$case->value], ['data' => ['availabilityStatus' => ['value' => $case->value]]]]; + } + } + + /** + * @dataProvider providerEnumItemsGraphQl + * + * @group legacy + */ + public function testItemGraphql(string $query, array $variables, array $expected): void + { + $options = (new HttpOptions()) + ->setJson(['query' => $query, 'variables' => $variables]) + ->setHeaders(['Content-Type' => 'application/json']); + self::createClient()->request('POST', '/graphql', $options->toArray()); + + $this->assertResponseIsSuccessful(); + $this->assertJsonEquals($expected); + } + + /** + * @group legacy + */ + public function testCollectionGraphQl(): void + { + $query = <<<'GRAPHQL' +query { + backedEnumIntegerResources { + value + } +} +GRAPHQL; + $options = (new HttpOptions()) + ->setJson(['query' => $query, 'variables' => []]) + ->setHeaders(['Content-Type' => 'application/json']); + self::createClient()->request('POST', '/graphql', $options->toArray()); + + $this->assertResponseIsSuccessful(); + $this->assertJsonEquals([ + 'data' => [ + 'backedEnumIntegerResources' => [ + ['value' => 1], + ['value' => 2], + ['value' => 3], + ], + ], + ]); + } + + /** + * @group legacy + */ + public function testItemGraphQlInteger(): void + { + $query = <<<'GRAPHQL' +query GetBackedEnumIntegerResource($identifier: ID!) { + backedEnumIntegerResource(id: $identifier) { + name + value + description + } +} +GRAPHQL; + $options = (new HttpOptions()) + ->setJson(['query' => $query, 'variables' => ['identifier' => '/backed_enum_integer_resources/1']]) + ->setHeaders(['Content-Type' => 'application/json']); + self::createClient()->request('POST', '/graphql', $options->toArray()); + + $this->assertResponseIsSuccessful(); + $this->assertJsonEquals([ + 'data' => [ + 'backedEnumIntegerResource' => [ + 'description' => 'We say yes', + 'name' => 'Yes', + 'value' => 1, + ], + ], + ]); + } } diff --git a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php index 3ef06851574..67248042f5c 100644 --- a/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php +++ b/tests/JsonSchema/Command/JsonSchemaGenerateCommandTest.php @@ -269,4 +269,67 @@ public function testJsonApiIncludesSchema(): void $this->assertArrayHasKey('kingdom', $properties['attributes']['properties']); $this->assertArrayHasKey('phylum', $properties['attributes']['properties']); } + + /** + * Test issue #6317. + */ + public function testBackedEnumExamplesAreNotLost(): void + { + $this->tester->run(['command' => 'api:json-schema:generate', 'resource' => 'ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6317\Issue6317', '--type' => 'output', '--format' => 'jsonld']); + $result = $this->tester->getDisplay(); + $json = json_decode($result, associative: true); + $properties = $json['definitions']['Issue6317.jsonld']['properties']; + + $this->assertArrayHasKey('example', $properties['id']); + $this->assertArrayHasKey('example', $properties['name']); + // jsonldContext + $this->assertArrayNotHasKey('example', $properties['ordinal']); + // openapiContext + $this->assertArrayNotHasKey('example', $properties['cardinal']); + } + + public function testResourceWithEnumPropertiesSchema(): void + { + $this->tester->run(['command' => 'api:json-schema:generate', 'resource' => 'ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ResourceWithEnumProperty', '--type' => 'output', '--format' => 'jsonld']); + $result = $this->tester->getDisplay(); + $json = json_decode($result, associative: true); + $properties = $json['definitions']['ResourceWithEnumProperty.jsonld']['properties']; + + $this->assertSame( + [ + 'type' => ['string', 'null'], + 'format' => 'iri-reference', + 'example' => 'https://example.com/', + ], + $properties['intEnum'] + ); + $this->assertSame( + [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'format' => 'iri-reference', + 'example' => 'https://example.com/', + ], + ], + $properties['stringEnum'] + ); + $this->assertSame( + [ + 'type' => ['string', 'null'], + 'enum' => ['male', 'female', null], + ], + $properties['gender'] + ); + $this->assertSame( + [ + 'type' => 'array', + 'items' => [ + 'type' => 'string', + 'enum' => ['male', 'female'], + ], + ], + $properties['genders'] + ); + } } diff --git a/tests/State/Provider/BackedEnumProviderTest.php b/tests/State/Provider/BackedEnumProviderTest.php new file mode 100644 index 00000000000..934744681cb --- /dev/null +++ b/tests/State/Provider/BackedEnumProviderTest.php @@ -0,0 +1,67 @@ + + * + * 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\State\Provider; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Provider\BackedEnumProvider; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\BackedEnumIntegerResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\BackedEnumStringResource; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; + +final class BackedEnumProviderTest extends TestCase +{ + use ProphecyTrait; + + public static function provideCollection(): iterable + { + yield 'Integer case enum' => [BackedEnumIntegerResource::class, BackedEnumIntegerResource::cases()]; + yield 'String case enum' => [BackedEnumStringResource::class, BackedEnumStringResource::cases()]; + } + + /** @dataProvider provideCollection */ + public function testProvideCollection(string $class, array $expected): void + { + $operation = new GetCollection(class: $class); + + $this->testProvide($expected, $operation); + } + + public static function provideItem(): iterable + { + yield 'Integer case enum' => [BackedEnumIntegerResource::class, 1, BackedEnumIntegerResource::Yes]; + yield 'String case enum' => [BackedEnumStringResource::class, 'yes', BackedEnumStringResource::Yes]; + } + + /** @dataProvider provideItem */ + public function testProvideItem(string $class, string|int $id, \BackedEnum $expected): void + { + $operation = new Get(class: $class); + + $this->testProvide($expected, $operation, ['id' => $id]); + } + + private function testProvide($expected, Operation $operation, array $uriVariables = [], array $context = []): void + { + $decorated = $this->prophesize(ProviderInterface::class); + $decorated->provide(Argument::any())->shouldNotBeCalled(); + $provider = new BackedEnumProvider(); + + $this->assertSame($expected, $provider->provide($operation, $uriVariables, $context)); + } +} diff --git a/tests/Symfony/Bundle/Command/OpenApiCommandTest.php b/tests/Symfony/Bundle/Command/OpenApiCommandTest.php index f012a8bcc52..cf68f461997 100644 --- a/tests/Symfony/Bundle/Command/OpenApiCommandTest.php +++ b/tests/Symfony/Bundle/Command/OpenApiCommandTest.php @@ -117,6 +117,28 @@ public function testWriteToFile(): void @unlink($tmpFile); } + /** + * Test issue #6317. + */ + public function testBackedEnumExamplesAreNotLost(): void + { + $this->tester->run(['command' => 'api:openapi:export']); + $result = $this->tester->getDisplay(); + $json = json_decode($result, true, 512, \JSON_THROW_ON_ERROR); + + $assertExample = function (array $properties, string $id): void { + $this->assertArrayHasKey('example', $properties[$id]); // default + $this->assertArrayHasKey('example', $properties['cardinal']); // openapiContext + $this->assertArrayNotHasKey('example', $properties['name']); // jsonSchemaContext + $this->assertArrayNotHasKey('example', $properties['ordinal']); // jsonldContext + }; + + $assertExample($json['components']['schemas']['Issue6317']['properties'], 'id'); + $assertExample($json['components']['schemas']['Issue6317.jsonld']['properties'], 'id'); + $assertExample($json['components']['schemas']['Issue6317.jsonapi']['properties']['data']['properties']['attributes']['properties'], '_id'); + $assertExample($json['components']['schemas']['Issue6317.jsonhal']['properties'], 'id'); + } + private function assertYaml(string $data): void { try {