From 38c5d61b298e19bd941371d8c06d282de3ae3016 Mon Sep 17 00:00:00 2001 From: Vincent Chalamon <407859+vincentchalamon@users.noreply.github.com> Date: Wed, 23 Aug 2023 11:45:37 +0200 Subject: [PATCH] feat(metadata): improve CreateProvider --- features/main/sub_resource.feature | 20 ++ src/State/CreateProvider.php | 31 +-- src/Symfony/Bundle/Resources/config/state.xml | 3 +- .../ApiResource/PostWithUriVariables.php | 5 +- .../ApiResource/SubresourceBike.php | 67 ++++++ .../ApiResource/SubresourceCategory.php | 48 ++++ tests/State/CreateProviderTest.php | 209 ++++++++++++++++-- 7 files changed, 345 insertions(+), 38 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/SubresourceBike.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/SubresourceCategory.php diff --git a/features/main/sub_resource.feature b/features/main/sub_resource.feature index 33e72f7fed6..597bbb03214 100644 --- a/features/main/sub_resource.feature +++ b/features/main/sub_resource.feature @@ -607,3 +607,23 @@ Feature: Sub-resource support | invalid_uri | collection_uri | item_uri | | /subresource_organizations/invalid/subresource_employees | /subresource_organizations/1/subresource_employees | /subresource_organizations/1/subresource_employees/1 | | /subresource_organizations/invalid/subresource_factories | /subresource_organizations/1/subresource_factories | /subresource_organizations/1/subresource_factories/1 | + + @!mongodb + @createSchema + Scenario: I can POST on a subresource using CreateProvider with parent_uri_template + Given I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/subresource_categories/1/subresource_bikes" with body: + """ + { + "name": "Hello World!" + } + """ + Then the response status code should be 404 + Given I add "Content-Type" header equal to "application/ld+json" + And I send a "POST" request to "/subresource_categories_with_create_provider/1/subresource_bikes" with body: + """ + { + "name": "Hello World!" + } + """ + Then the response status code should be 201 diff --git a/src/State/CreateProvider.php b/src/State/CreateProvider.php index cdd83dc3890..ac3ac9a0442 100644 --- a/src/State/CreateProvider.php +++ b/src/State/CreateProvider.php @@ -14,10 +14,10 @@ namespace ApiPlatform\State; use ApiPlatform\Exception\RuntimeException; -use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\HttpOperation; -use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\State\Exception\ProviderNotFoundException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -30,11 +30,16 @@ * @author Antoine Bluchet * * @experimental + * + * @internal */ final class CreateProvider implements ProviderInterface { - public function __construct(private ProviderInterface $decorated, private ?PropertyAccessorInterface $propertyAccessor = null) - { + public function __construct( + private ProviderInterface $decorated, + private ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + private ?PropertyAccessorInterface $propertyAccessor = null, + ) { $this->propertyAccessor = $propertyAccessor ?: PropertyAccess::createPropertyAccessor(); } @@ -47,18 +52,15 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $operationUriVariables = $operation->getUriVariables(); $relationClass = current($operationUriVariables)->getFromClass(); $key = key($operationUriVariables); - $relationUriVariables = []; - - foreach ($operationUriVariables as $parameterName => $value) { - if ($key === $parameterName) { - $relationUriVariables['id'] = new Link(identifiers: $value->getIdentifiers(), fromClass: $value->getFromClass(), parameterName: $key); - continue; - } - $relationUriVariables[$parameterName] = $value; + $parentOperation = $this->resourceMetadataCollectionFactory + ->create($relationClass) + ->getOperation($operation->getExtraProperties()['parent_uri_template'] ?? null); + try { + $relation = $this->decorated->provide($parentOperation, $uriVariables); + } catch (ProviderNotFoundException) { + $relation = null; } - - $relation = $this->decorated->provide(new Get(uriVariables: $relationUriVariables, class: $relationClass), $uriVariables); if (!$relation) { throw new NotFoundHttpException('Not Found'); } @@ -68,6 +70,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } catch (\Throwable $e) { throw new RuntimeException(sprintf('An error occurred while trying to create an instance of the "%s" resource. Consider writing your own "%s" implementation and setting it as `provider` on your operation instead.', $operation->getClass(), ProviderInterface::class), 0, $e); } + $property = $operationUriVariables[$key]->getToProperty() ?? $key; $this->propertyAccessor->setValue($resource, $property, $relation); diff --git a/src/Symfony/Bundle/Resources/config/state.xml b/src/Symfony/Bundle/Resources/config/state.xml index 1a87aab4573..f215fad3b2d 100644 --- a/src/Symfony/Bundle/Resources/config/state.xml +++ b/src/Symfony/Bundle/Resources/config/state.xml @@ -42,7 +42,8 @@ - + + diff --git a/tests/Fixtures/TestBundle/ApiResource/PostWithUriVariables.php b/tests/Fixtures/TestBundle/ApiResource/PostWithUriVariables.php index 8142768ced2..b2e7ae048cd 100644 --- a/tests/Fixtures/TestBundle/ApiResource/PostWithUriVariables.php +++ b/tests/Fixtures/TestBundle/ApiResource/PostWithUriVariables.php @@ -14,7 +14,6 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; use ApiPlatform\Metadata\NotExposed; -use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Post; use ApiPlatform\Symfony\Validator\Exception\ValidationException as ExceptionValidationException; use Symfony\Component\Validator\ConstraintViolationList; @@ -28,12 +27,12 @@ public function __construct(public readonly ?int $id = null) { } - public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) + public static function process(): self { return new self(id: 1); } - public static function provide(Operation $operation, array $uriVariables = [], array $context = []): void + public static function provide(): void { throw new ExceptionValidationException(new ConstraintViolationList()); } diff --git a/tests/Fixtures/TestBundle/ApiResource/SubresourceBike.php b/tests/Fixtures/TestBundle/ApiResource/SubresourceBike.php new file mode 100644 index 00000000000..c3ae9c67c37 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/SubresourceBike.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\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Post; +use ApiPlatform\State\CreateProvider; +use Symfony\Component\Validator\Constraints as Assert; + +#[Post( + uriTemplate: '/subresource_categories/{id}/subresource_bikes', + uriVariables: [ + 'id' => new Link( + fromClass: SubresourceCategory::class, + toProperty: 'category', + identifiers: ['id'] + ), + ], + provider: CreateProvider::class, + processor: [SubresourceBike::class, 'process'] +)] +#[Post( + uriTemplate: '/subresource_categories_with_create_provider/{id}/subresource_bikes', + uriVariables: [ + 'id' => new Link( + fromClass: SubresourceCategory::class, + toProperty: 'category', + identifiers: ['id'] + ), + ], + provider: CreateProvider::class, + processor: [SubresourceBike::class, 'process'], + extraProperties: ['parent_uri_template' => '/subresource_categories_with_create_provider/{id}'] +)] +/** + * @see SubresourceCategory + */ +class SubresourceBike +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + #[Assert\NotBlank] + public ?string $name = null; + + #[Assert\NotNull] + public ?SubresourceCategory $category = null; + + public static function process(mixed $data): self + { + $data->id = 1; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/SubresourceCategory.php b/tests/Fixtures/TestBundle/ApiResource/SubresourceCategory.php new file mode 100644 index 00000000000..fae59688039 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/SubresourceCategory.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; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; + +#[Get( + uriTemplate: '/subresource_categories/{id}', + provider: [SubresourceCategory::class, 'provideNull'] +)] +#[Get( + uriTemplate: '/subresource_categories_with_create_provider/{id}', + provider: [SubresourceCategory::class, 'provide'] +)] +/** + * @see SubresourceBike + */ +final class SubresourceCategory +{ + public function __construct( + #[ApiProperty(identifier: true)] + public ?int $id = null, + public ?string $name = null + ) { + } + + public static function provideNull() + { + return null; + } + + public static function provide(): self + { + return new self(1, 'Hello World!'); + } +} diff --git a/tests/State/CreateProviderTest.php b/tests/State/CreateProviderTest.php index 8ec43d58682..088539c9d5f 100644 --- a/tests/State/CreateProviderTest.php +++ b/tests/State/CreateProviderTest.php @@ -14,16 +14,23 @@ namespace ApiPlatform\Tests\State; use ApiPlatform\Exception\RuntimeException; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Exception\OperationNotFoundException; +use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\State\CreateProvider; +use ApiPlatform\State\Exception\ProviderNotFoundException; use ApiPlatform\State\ProviderInterface; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Company; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyResourceWithComplexConstructor; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Employee; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class CreateProviderTest extends TestCase { @@ -31,42 +38,204 @@ class CreateProviderTest extends TestCase public function testProvide(): void { + $decorated = $this->prophesize(ProviderInterface::class); + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + + $uriVariables = ['company' => 1]; $link = new Link(identifiers: ['id'], fromClass: Company::class, parameterName: 'company'); + $operation = new Post(class: Employee::class, uriTemplate: '/companies/{company}/employees', uriVariables: ['company' => $link]); + $parentOperation = new Get(uriVariables: ['id' => $link], class: Company::class); + + $resourceMetadataCollectionFactory + ->create(Company::class) + ->shouldBeCalledOnce() + ->willReturn( + new ResourceMetadataCollection(Company::class, [ + new ApiResource(operations: [$parentOperation]), + ]) + ); + $decorated->provide($parentOperation, $uriVariables)->shouldBeCalled()->willReturn(new Company()); + + $createProvider = new CreateProvider($decorated->reveal(), $resourceMetadataCollectionFactory->reveal()); + $createProvider->provide($operation, $uriVariables); + } + + public function testProvideParentNotFound(): void + { $decorated = $this->prophesize(ProviderInterface::class); - $decorated->provide( - new Get(uriVariables: ['id' => $link], class: Company::class), - ['company' => 1] - )->shouldBeCalled()->willReturn(new Company()); - $operation = new Post(class: Employee::class, uriTemplate: '/company/{company}/employees', uriVariables: ['company' => $link]); - - $createProvider = new CreateProvider($decorated->reveal()); - $createProvider->provide($operation, ['company' => 1]); + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + + $uriVariables = ['company' => 1]; + $link = new Link(identifiers: ['id'], fromClass: Company::class, parameterName: 'company'); + $operation = new Post(class: Employee::class, uriTemplate: '/companies/{company}/employees', uriVariables: ['company' => $link]); + $parentOperation = new Get(uriVariables: ['id' => $link], class: Company::class); + + $resourceMetadataCollectionFactory + ->create(Company::class) + ->shouldBeCalledOnce() + ->willReturn( + new ResourceMetadataCollection(Company::class, [ + new ApiResource(operations: [$parentOperation]), + ]) + ); + $decorated->provide($parentOperation, $uriVariables)->shouldBeCalled()->willReturn(null); + + $this->expectException(NotFoundHttpException::class); + + $createProvider = new CreateProvider($decorated->reveal(), $resourceMetadataCollectionFactory->reveal()); + $createProvider->provide($operation, $uriVariables); } - public function testProvideFailsProperlyOnComplexConstructor(): void + public function testProvideParentProviderNotFound(): void { + $decorated = $this->prophesize(ProviderInterface::class); + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + + $uriVariables = ['company' => 1]; $link = new Link(identifiers: ['id'], fromClass: Company::class, parameterName: 'company'); + $operation = new Post(class: Employee::class, uriTemplate: '/companies/{company}/employees', uriVariables: ['company' => $link]); + $parentOperation = new Get(uriVariables: ['id' => $link], class: Company::class); + + $resourceMetadataCollectionFactory + ->create(Company::class) + ->shouldBeCalledOnce() + ->willReturn( + new ResourceMetadataCollection(Company::class, [ + new ApiResource(operations: [$parentOperation]), + ]) + ); + $decorated->provide($parentOperation, $uriVariables)->shouldBeCalled()->willThrow(ProviderNotFoundException::class); + + $this->expectException(NotFoundHttpException::class); + + $createProvider = new CreateProvider($decorated->reveal(), $resourceMetadataCollectionFactory->reveal()); + $createProvider->provide($operation, $uriVariables); + } + + public function testProvideWithInvalidParentResourceClass(): void + { $decorated = $this->prophesize(ProviderInterface::class); - $decorated->provide( - new Get(uriVariables: ['id' => $link], class: Company::class), - ['company' => 1] - )->shouldBeCalled()->willReturn(new Company()); - $operation = new Post(class: DummyResourceWithComplexConstructor::class, uriTemplate: '/company/{company}/employees', uriVariables: ['company' => $link]); + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + + $uriVariables = ['company' => 1]; + $link = new Link(identifiers: ['id'], fromClass: Company::class, parameterName: 'company'); + $operation = new Post( + class: Employee::class, + uriTemplate: '/companies/{company}/employees', + uriVariables: ['company' => $link] + ); + $parentOperation = new Get(uriVariables: ['id' => $link], class: Company::class); + + $resourceMetadataCollectionFactory->create(Company::class)->shouldBeCalledOnce()->willThrow(ResourceClassNotFoundException::class); + $decorated->provide($parentOperation, $uriVariables)->shouldNotBeCalled(); + + $this->expectException(ResourceClassNotFoundException::class); + + $createProvider = new CreateProvider($decorated->reveal(), $resourceMetadataCollectionFactory->reveal()); + $createProvider->provide($operation, $uriVariables); + } + + public function testProvideWithParentEmptyOperations(): void + { + $decorated = $this->prophesize(ProviderInterface::class); + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + + $uriVariables = ['company' => 1]; + $link = new Link(identifiers: ['id'], fromClass: Company::class, parameterName: 'company'); + $operation = new Post( + class: Employee::class, + uriTemplate: '/companies/{company}/employees', + uriVariables: ['company' => $link] + ); + $parentOperation = new Get(uriVariables: ['id' => $link], class: Company::class); + + $resourceMetadataCollectionFactory + ->create(Company::class) + ->shouldBeCalledOnce() + ->willReturn( + new ResourceMetadataCollection(Company::class, [ + new ApiResource(), + ]) + ); + $decorated->provide($parentOperation, $uriVariables)->shouldNotBeCalled(); + + $this->expectException(OperationNotFoundException::class); + + $createProvider = new CreateProvider($decorated->reveal(), $resourceMetadataCollectionFactory->reveal()); + $createProvider->provide($operation, $uriVariables); + } + + public function testProvideWithParentUriTemplate(): void + { + $decorated = $this->prophesize(ProviderInterface::class); + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + + $uriVariables = ['company' => 1]; + $link = new Link(identifiers: ['id'], fromClass: Company::class, parameterName: 'company'); + $operation = new Post( + class: Employee::class, + uriTemplate: '/companies/{company}/employees', + uriVariables: ['company' => $link], + extraProperties: ['parent_uri_template' => '/companies/{id}'] + ); + $parentOperation = new Get(uriTemplate: '/companies/{id}', uriVariables: ['id' => $link], class: Company::class, priority: 1); + + $resourceMetadataCollectionFactory + ->create(Company::class) + ->shouldBeCalledOnce() + ->willReturn( + new ResourceMetadataCollection(Company::class, [ + new ApiResource(operations: [ + new Get(), + $parentOperation, + ]), + ]) + ); + $decorated->provide($parentOperation, $uriVariables)->shouldBeCalled()->willReturn(new Company()); + + $createProvider = new CreateProvider($decorated->reveal(), $resourceMetadataCollectionFactory->reveal()); + $createProvider->provide($operation, $uriVariables); + } + + public function testProvideFailsProperlyOnComplexConstructor(): void + { + $decorated = $this->prophesize(ProviderInterface::class); + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + + $uriVariables = ['company' => 1]; + $link = new Link(identifiers: ['id'], fromClass: Company::class, parameterName: 'company'); + $operation = new Post(class: DummyResourceWithComplexConstructor::class, uriTemplate: '/companies/{company}/employees', uriVariables: ['company' => $link]); + $parentOperation = new Get(uriVariables: ['id' => $link], class: Company::class); + + $resourceMetadataCollectionFactory + ->create(Company::class) + ->shouldBeCalledOnce() + ->willReturn( + new ResourceMetadataCollection(Company::class, [ + new ApiResource(operations: [$parentOperation]), + ]) + ); + $decorated->provide($parentOperation, $uriVariables)->shouldBeCalled()->willReturn(new Company()); $this->expectException(RuntimeException::class); $this->expectExceptionMessage('An error occurred while trying to create an instance of the "ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyResourceWithComplexConstructor" resource. Consider writing your own "ApiPlatform\State\ProviderInterface" implementation and setting it as `provider` on your operation instead.'); - $createProvider = new CreateProvider($decorated->reveal()); - $createProvider->provide($operation, ['company' => 1]); + $createProvider = new CreateProvider($decorated->reveal(), $resourceMetadataCollectionFactory->reveal()); + $createProvider->provide($operation, $uriVariables); } public function testSkipWhenController(): void { $decorated = $this->prophesize(ProviderInterface::class); - $operation = new Post(class: Employee::class, uriTemplate: '/company/{company}/employees', controller: 'test'); + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + + $uriVariables = ['company' => 1]; + $operation = new Post(class: Employee::class, uriTemplate: '/companies/{company}/employees', controller: 'test'); + + $resourceMetadataCollectionFactory->create(Company::class)->shouldNotBeCalled(); + $decorated->provide($operation, $uriVariables, [])->shouldBeCalled()->willReturn(new Employee()); - $decorated->provide($operation, ['company' => 1], [])->shouldBeCalled()->willReturn(new Employee()); - $createProvider = new CreateProvider($decorated->reveal()); - $createProvider->provide($operation, ['company' => 1]); + $createProvider = new CreateProvider($decorated->reveal(), $resourceMetadataCollectionFactory->reveal()); + $createProvider->provide($operation, $uriVariables); } }