From 125f2cef42e6584c219fd21982a7999d141a2f9d Mon Sep 17 00:00:00 2001 From: a_guilhem Date: Thu, 21 Mar 2024 01:10:28 +1100 Subject: [PATCH] feat: add webhook - openapi (#5873) * feat: add webhook - openapi * cs --------- Co-authored-by: Antoine Bluchet --- features/openapi/docs.feature | 5 ++ src/Metadata/Delete.php | 3 +- src/Metadata/Error.php | 3 +- src/Metadata/Get.php | 3 +- src/Metadata/GetCollection.php | 3 +- src/Metadata/HttpOperation.php | 7 +-- src/Metadata/NotExposed.php | 3 +- src/Metadata/Patch.php | 3 +- src/Metadata/Post.php | 3 +- src/Metadata/Put.php | 3 +- src/OpenApi/Attributes/Webhook.php | 51 ++++++++++++++++++ src/OpenApi/Factory/OpenApiFactory.php | 38 ++++++++++---- .../Tests/Factory/OpenApiFactoryTest.php | 25 ++++++++- .../TestBundle/Entity/DummyWebhook.php | 52 +++++++++++++++++++ 14 files changed, 181 insertions(+), 21 deletions(-) create mode 100644 src/OpenApi/Attributes/Webhook.php create mode 100644 tests/Fixtures/TestBundle/Entity/DummyWebhook.php diff --git a/features/openapi/docs.feature b/features/openapi/docs.feature index accba2beebc..4747db4ef64 100644 --- a/features/openapi/docs.feature +++ b/features/openapi/docs.feature @@ -70,6 +70,7 @@ Feature: Documentation support And the OpenAPI class "UuidIdentifierDummy" exists And the OpenAPI class "ThirdLevel" exists And the OpenAPI class "DummyCar" exists + And the OpenAPI class "DummyWebhook" exists And the OpenAPI class "ParentDummy" doesn't exist And the OpenAPI class "UnknownDummy" doesn't exist And the OpenAPI path "/relation_embedders/{id}/custom" exists @@ -115,6 +116,10 @@ Feature: Documentation support And the JSON node "paths./dummy_cars.get.parameters[8].name" should be equal to "foobar[]" And the JSON node "paths./dummy_cars.get.parameters[8].description" should be equal to "Allows you to reduce the response to contain only the properties you need. If your desired property is nested, you can address it using nested arrays. Example: foobar[]={propertyName}&foobar[]={anotherPropertyName}&foobar[{nestedPropertyParent}][]={nestedProperty}" + # Webhook + And the JSON node "webhooks.webhook[0].get.description" should be equal to "Something else here for example" + And the JSON node "webhooks.webhook[1].post.description" should be equal to "Hi! it's me, I'm the problem, it's me" + # Subcollection - check filter on subResource And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[0].name" should be equal to "id" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[0].in" should be equal to "path" diff --git a/src/Metadata/Delete.php b/src/Metadata/Delete.php index 2ec097568d7..1b816cb6558 100644 --- a/src/Metadata/Delete.php +++ b/src/Metadata/Delete.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Metadata; +use ApiPlatform\OpenApi\Attributes\Webhook; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\State\OptionsInterface; @@ -44,7 +45,7 @@ public function __construct( ?array $paginationViaCursor = null, ?array $hydraContext = null, ?array $openapiContext = null, - bool|OpenApiOperation|null $openapi = null, + bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, ?array $links = null, diff --git a/src/Metadata/Error.php b/src/Metadata/Error.php index c95301b49cb..06f060fa9c6 100644 --- a/src/Metadata/Error.php +++ b/src/Metadata/Error.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Metadata; +use ApiPlatform\OpenApi\Attributes\Webhook; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\State\OptionsInterface; @@ -44,7 +45,7 @@ public function __construct( ?array $paginationViaCursor = null, ?array $hydraContext = null, ?array $openapiContext = null, - bool|OpenApiOperation|null $openapi = null, + bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, ?array $links = null, diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index 0234e87350d..9be1b044d8d 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Metadata; +use ApiPlatform\OpenApi\Attributes\Webhook; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\State\OptionsInterface; @@ -44,7 +45,7 @@ public function __construct( ?array $paginationViaCursor = null, ?array $hydraContext = null, ?array $openapiContext = null, - bool|OpenApiOperation|null $openapi = null, + bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, ?array $links = null, diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index c8fdff644c9..bbc7f23191d 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Metadata; +use ApiPlatform\OpenApi\Attributes\Webhook; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\State\OptionsInterface; @@ -44,7 +45,7 @@ public function __construct( ?array $paginationViaCursor = null, ?array $hydraContext = null, ?array $openapiContext = null, - bool|OpenApiOperation|null $openapi = null, + bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, ?array $links = null, diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index 6c0de768a6c..b98e2095f3f 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Metadata; +use ApiPlatform\OpenApi\Attributes\Webhook; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\State\OptionsInterface; use Symfony\Component\WebLink\Link as WebLink; @@ -150,7 +151,7 @@ public function __construct( protected ?array $paginationViaCursor = null, protected ?array $hydraContext = null, protected ?array $openapiContext = null, // TODO Remove in 4.0 - protected bool|OpenApiOperation|null $openapi = null, + protected bool|OpenApiOperation|Webhook|null $openapi = null, protected ?array $exceptionToStatus = null, protected ?bool $queryParameterValidationEnabled = null, protected ?array $links = null, @@ -578,12 +579,12 @@ public function withOpenapiContext(array $openapiContext): self return $self; } - public function getOpenapi(): bool|OpenApiOperation|null + public function getOpenapi(): bool|OpenApiOperation|Webhook|null { return $this->openapi; } - public function withOpenapi(bool|OpenApiOperation $openapi): self + public function withOpenapi(bool|OpenApiOperation|Webhook $openapi): self { $self = clone $this; $self->openapi = $openapi; diff --git a/src/Metadata/NotExposed.php b/src/Metadata/NotExposed.php index 92d3b262752..a768e5f006a 100644 --- a/src/Metadata/NotExposed.php +++ b/src/Metadata/NotExposed.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Metadata; +use ApiPlatform\OpenApi\Attributes\Webhook; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\State\OptionsInterface; @@ -56,7 +57,7 @@ public function __construct( ?array $hydraContext = null, ?array $openapiContext = null, - bool|OpenApiOperation|null $openapi = false, + bool|OpenApiOperation|Webhook|null $openapi = false, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php index c32700a3ad4..cce0c9adb48 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Metadata; +use ApiPlatform\OpenApi\Attributes\Webhook; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\State\OptionsInterface; @@ -44,7 +45,7 @@ public function __construct( ?array $paginationViaCursor = null, ?array $hydraContext = null, ?array $openapiContext = null, - bool|OpenApiOperation|null $openapi = null, + bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, ?array $links = null, diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index c5a8175812e..7be04523d4d 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Metadata; +use ApiPlatform\OpenApi\Attributes\Webhook; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\State\OptionsInterface; @@ -44,7 +45,7 @@ public function __construct( ?array $paginationViaCursor = null, ?array $hydraContext = null, ?array $openapiContext = null, - bool|OpenApiOperation|null $openapi = null, + bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, ?array $links = null, diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php index 23441145c79..cb78e429c2b 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Metadata; +use ApiPlatform\OpenApi\Attributes\Webhook; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\State\OptionsInterface; @@ -44,7 +45,7 @@ public function __construct( ?array $paginationViaCursor = null, ?array $hydraContext = null, ?array $openapiContext = null, - bool|OpenApiOperation|null $openapi = null, + bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, ?array $links = null, diff --git a/src/OpenApi/Attributes/Webhook.php b/src/OpenApi/Attributes/Webhook.php new file mode 100644 index 00000000000..2bf205a40d3 --- /dev/null +++ b/src/OpenApi/Attributes/Webhook.php @@ -0,0 +1,51 @@ + + * + * 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\OpenApi\Attributes; + +use ApiPlatform\OpenApi\Model\PathItem; + +class Webhook +{ + public function __construct( + protected string $name, + protected ?PathItem $pathItem = null, + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function withName(string $name): self + { + $self = clone $this; + $self->name = $name; + + return $self; + } + + public function getPathItem(): ?PathItem + { + return $this->pathItem; + } + + public function withPathItem(PathItem $pathItem): self + { + $self = clone $this; + $self->pathItem = $pathItem; + + return $self; + } +} diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 12a7571c57d..a89b0b1f3bd 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -26,6 +26,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\OpenApi\Attributes\Webhook; use ApiPlatform\OpenApi\Model; use ApiPlatform\OpenApi\Model\Components; use ApiPlatform\OpenApi\Model\Contact; @@ -90,12 +91,13 @@ public function __invoke(array $context = []): OpenApi $servers = '/' === $baseUrl || '' === $baseUrl ? [new Server('/')] : [new Server($baseUrl)]; $paths = new Paths(); $schemas = new \ArrayObject(); + $webhooks = new \ArrayObject(); foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { $resourceMetadataCollection = $this->resourceMetadataFactory->create($resourceClass); foreach ($resourceMetadataCollection as $resourceMetadata) { - $this->collectPaths($resourceMetadata, $resourceMetadataCollection, $paths, $schemas); + $this->collectPaths($resourceMetadata, $resourceMetadataCollection, $paths, $schemas, $webhooks); } } @@ -119,11 +121,15 @@ public function __invoke(array $context = []): OpenApi new \ArrayObject(), new \ArrayObject($securitySchemes) ), - $securityRequirements + $securityRequirements, + [], + null, + null, + $webhooks ); } - private function collectPaths(ApiResource $resource, ResourceMetadataCollection $resourceMetadataCollection, Paths $paths, \ArrayObject $schemas): void + private function collectPaths(ApiResource $resource, ResourceMetadataCollection $resourceMetadataCollection, Paths $paths, \ArrayObject $schemas, \ArrayObject $webhooks): void { if (0 === $resource->getOperations()->count()) { return; @@ -136,10 +142,10 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection continue; } - $openapiOperation = $operation->getOpenapi(); + $openapiAttribute = $operation->getOpenapi(); // Operation ignored from OpenApi - if ($operation instanceof HttpOperation && false === $openapiOperation) { + if ($operation instanceof HttpOperation && false === $openapiAttribute) { continue; } @@ -163,8 +169,15 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection continue; } - if (!\is_object($openapiOperation)) { + $pathItem = null; + + if ($openapiAttribute instanceof Webhook) { + $pathItem = $openapiAttribute->getPathItem() ?: new PathItem(); + $openapiOperation = $pathItem->{'get'.ucfirst(strtolower($method))}() ?: new Model\Operation(); + } elseif (!\is_object($openapiAttribute)) { $openapiOperation = new Model\Operation(); + } else { + $openapiOperation = $openapiAttribute; } // Complete with defaults @@ -230,7 +243,7 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection if ($path) { $pathItem = $paths->getPath($path) ?: new PathItem(); - } else { + } elseif (!$pathItem) { $pathItem = new PathItem(); } @@ -391,7 +404,14 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection } } - $paths->addPath($path, $pathItem->{'with'.ucfirst($method)}($openapiOperation)); + if ($openapiAttribute instanceof Webhook) { + if (!isset($webhooks[$openapiAttribute->getName()])) { + $webhooks[$openapiAttribute->getName()] = new \ArrayObject(); + } + $webhooks[$openapiAttribute->getName()]->append($pathItem->{'with'.ucfirst($method)}($openapiOperation)); + } else { + $paths->addPath($path, $pathItem->{'with'.ucfirst($method)}($openapiOperation)); + } } } @@ -517,7 +537,7 @@ private function getLinks(ResourceMetadataCollection $resourceMetadataCollection } // Operation ignored from OpenApi - if ($operation instanceof HttpOperation && false === $operation->getOpenapi()) { + if ($operation instanceof HttpOperation && (false === $operation->getOpenapi() || $operation->getOpenapi() instanceof Webhook)) { continue; } diff --git a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php index 5c38748f60a..932bb3f5770 100644 --- a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php +++ b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php @@ -34,6 +34,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\OpenApi\Attributes\Webhook; use ApiPlatform\OpenApi\Factory\OpenApiFactory; use ApiPlatform\OpenApi\Model; use ApiPlatform\OpenApi\Model\Components; @@ -79,6 +80,14 @@ public function testInvoke(): void $baseOperation = (new HttpOperation())->withTypes(['http://schema.example.com/Dummy'])->withInputFormats(self::OPERATION_FORMATS['input_formats'])->withOutputFormats(self::OPERATION_FORMATS['output_formats'])->withClass(Dummy::class)->withOutput([ 'class' => OutputDto::class, ])->withPaginationClientItemsPerPage(true)->withShortName('Dummy')->withDescription('This is a dummy'); + $dummyResourceWebhook = (new ApiResource())->withOperations(new Operations([ + 'dummy webhook' => (new Get())->withUriTemplate('/dummy/{id}')->withShortName('short')->withOpenapi(new Webhook('happy webhook')), + 'an other dummy webhook' => (new Post())->withUriTemplate('/dummies')->withShortName('short something')->withOpenapi(new Webhook('happy webhook', new Model\PathItem(post: new Operation( + summary: 'well...', + description: 'I dont\'t know what to say', + )))), + ])); + $dummyResource = (new ApiResource())->withOperations(new Operations([ 'ignored' => new NotExposed(), 'ignoredWithUriTemplate' => (new NotExposed())->withUriTemplate('/dummies/{id}'), @@ -247,7 +256,7 @@ public function testInvoke(): void $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); $resourceCollectionMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - $resourceCollectionMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Dummy::class, [$dummyResource])); + $resourceCollectionMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Dummy::class, [$dummyResource, $dummyResourceWebhook])); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate', 'enum'])); @@ -482,6 +491,20 @@ public function testInvoke(): void $this->assertEquals($openApi->getInfo(), new Info('Test API', '1.2.3', 'This is a test API.')); $this->assertEquals($openApi->getServers(), [new Server('/app_dev.php/')]); + $webhooks = $openApi->getWebhooks(); + $this->assertCount(1, $webhooks); + + $this->assertNotNull($webhooks['happy webhook']); + $this->assertCount(2, $webhooks['happy webhook']); + + $firstOperationWebhook = $webhooks['happy webhook'][0]; + $secondOperationWebhook = $webhooks['happy webhook'][1]; + + $this->assertSame('dummy webhook', $firstOperationWebhook->getGet()->getOperationId()); + $this->assertSame('an other dummy webhook', $secondOperationWebhook->getPost()->getOperationId()); + $this->assertSame('I dont\'t know what to say', $secondOperationWebhook->getPost()->getDescription()); + $this->assertSame('well...', $secondOperationWebhook->getPost()->getSummary()); + $components = $openApi->getComponents(); $this->assertInstanceOf(Components::class, $components); diff --git a/tests/Fixtures/TestBundle/Entity/DummyWebhook.php b/tests/Fixtures/TestBundle/Entity/DummyWebhook.php new file mode 100644 index 00000000000..fc727726342 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyWebhook.php @@ -0,0 +1,52 @@ + + * + * 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 ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; +use ApiPlatform\OpenApi\Attributes\Webhook; +use ApiPlatform\OpenApi\Model\Operation; +use ApiPlatform\OpenApi\Model\PathItem; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource(operations: [new Get(openapi: new Webhook( + name: 'webhook', + pathItem: new PathItem( + get: new Operation( + summary: 'Something else here', + description: 'Something else here for example', + ), + ) +)), new Post(openapi: new Webhook( + name: 'webhook', + pathItem: new PathItem( + post: new Operation( + summary: 'Something else here', + description: 'Hi! it\'s me, I\'m the problem, it\'s me', + ), + ) +)), +])] +#[ORM\Entity] +class DummyWebhook +{ + /** + * @var int|null The id + */ + #[ORM\Column(type: 'integer', nullable: true)] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private $id; +}