diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index eabfdda8fc0..2b4a2d90d13 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; +use ApiPlatform\OpenApi\Model\PathItem; use ApiPlatform\State\OptionsInterface; /** @@ -326,7 +327,7 @@ public function __construct( protected ?array $denormalizationContext = null, protected ?bool $collectDenormalizationErrors = null, protected ?array $hydraContext = null, - protected bool|OpenApiOperation|null $openapi = null, + protected bool|OpenApiOperation|PathItem|null $openapi = null, /** * The `validationContext` option configures the context of validation for the current ApiResource. * You can, for instance, describe the validation groups that will be used:. @@ -1373,12 +1374,12 @@ public function withHydraContext(array $hydraContext): static return $self; } - public function getOpenapi(): bool|OpenApiOperation|null + public function getOpenapi(): bool|OpenApiOperation|PathItem|null { return $this->openapi; } - public function withOpenapi(bool|OpenApiOperation $openapi): static + public function withOpenapi(bool|OpenApiOperation|PathItem $openapi): static { $self = clone $this; $self->openapi = $openapi; diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index 58d4cf98c7f..1c39b5c1ecf 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -16,6 +16,7 @@ use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; use ApiPlatform\OpenApi\Attributes\Webhook; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; +use ApiPlatform\OpenApi\Model\PathItem; use ApiPlatform\State\OptionsInterface; use Symfony\Component\WebLink\Link as WebLink; @@ -164,7 +165,7 @@ public function __construct( protected ?array $cacheHeaders = null, protected ?array $paginationViaCursor = null, protected ?array $hydraContext = null, - protected bool|OpenApiOperation|Webhook|null $openapi = null, + protected bool|OpenApiOperation|Webhook|PathItem|null $openapi = null, protected ?array $exceptionToStatus = null, protected ?array $links = null, protected ?array $errors = null, @@ -629,12 +630,12 @@ public function withHydraContext(array $hydraContext): static return $self; } - public function getOpenapi(): bool|OpenApiOperation|Webhook|null + public function getOpenapi(): bool|OpenApiOperation|Webhook|PathItem|null { return $this->openapi; } - public function withOpenapi(bool|OpenApiOperation|Webhook $openapi): static + public function withOpenapi(bool|OpenApiOperation|Webhook|PathItem $openapi): static { $self = clone $this; $self->openapi = $openapi; diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 351b9d68193..03a4258149c 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -185,6 +185,10 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $openapiAttribute = $operation->getOpenapi(); + // if (null === $openapiAttribute) { + // $openapiAttribute = $resource->getOpenapi(); + // } + // Operation ignored from OpenApi if (false === $openapiAttribute) { continue; @@ -225,6 +229,9 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection if ($openapiAttribute instanceof Webhook) { $pathItem = $openapiAttribute->getPathItem() ?: new PathItem(); $openapiOperation = $pathItem->{'get'.ucfirst(strtolower($method))}() ?: new Operation(); + } elseif ($openapiAttribute instanceof PathItem) { + $pathItem = $openapiAttribute; + $openapiOperation = $pathItem->{'get'.ucfirst(strtolower($method))}() ?: new Operation(); } elseif (!\is_object($openapiAttribute)) { $openapiOperation = new Operation(); } else { @@ -248,6 +255,50 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection extensionProperties: $openapiOperation->getExtensionProperties(), ); + if ($openapiAttribute instanceof PathItem) { + if ($globalPathSummary = $openapiAttribute->getSummary()) { + $pathItem = $pathItem->withSummary($globalPathSummary); + + foreach (PathItem::$methods as $pathMethod) { + $getMethod = 'get'.ucfirst(strtolower($pathMethod)); + $withMethod = 'with'.ucfirst(strtolower($pathMethod)); + + if (($existingOperation = $pathItem->{$getMethod}()) && $existingOperation instanceof Operation) { + $existingOperationSummary = $existingOperation->getSummary(); + + if ($existingOperationSummary === $this->getPathDescription($resourceShortName, $pathMethod, false) || $existingOperationSummary === $this->getPathDescription($resourceShortName, $pathMethod, true)) { + $pathItem = $pathItem->{$withMethod}($existingOperation->withSummary($globalPathSummary)); + } + } + } + } + + if ($globalPathDescription = $openapiAttribute->getDescription()) { + $pathItem = $pathItem->withDescription($globalPathDescription); + + foreach (PathItem::$methods as $pathMethod) { + $getMethod = 'get'.ucfirst(strtolower($pathMethod)); + $withMethod = 'with'.ucfirst(strtolower($pathMethod)); + + if (($existingOperation = $pathItem->{$getMethod}()) && $existingOperation instanceof Operation) { + $existingOperationDescription = $existingOperation->getDescription(); + + if ($existingOperationDescription === $this->getPathDescription($resourceShortName, $pathMethod, false) || $existingOperationDescription === $this->getPathDescription($resourceShortName, $pathMethod, true)) { + $pathItem = $pathItem->{$withMethod}($existingOperation->withDescription($globalPathDescription)); + } + } + } + } + } + + if ($openapiAttribute instanceof PathItem && $openapiSummary = $openapiAttribute->getSummary()) { + $openapiOperation = $openapiOperation->withSummary($openapiSummary); + } + + if ($openapiAttribute instanceof PathItem && $openapiDescription = $openapiAttribute->getDescription()) { + $openapiOperation = $openapiOperation->withDescription($openapiDescription); + } + foreach ($openapiOperation->getTags() as $v) { $tags[$v] = new Tag(name: $v, description: $resource->getDescription() ?? "Resource '$v' operations."); } @@ -492,7 +543,19 @@ private function collectPaths(ApiResource $resource, ResourceMetadataCollection $openapiOperation = $existingOperation->withResponse(200, $currentResponse->withContent($currentResponseContent)); } - $paths->addPath($path, $pathItem->{'with'.ucfirst($method)}($openapiOperation)); + $finalPathItem = $pathItem->{'with'.ucfirst($method)}($openapiOperation); + + if (null === $operation->getOpenapi() && $resource->getOpenapi() instanceof PathItem) { + $resourcePathItem = $resource->getOpenapi(); + if ($resourcePathItem->getSummary()) { + $finalPathItem = $finalPathItem->withSummary($resourcePathItem->getSummary()); + } + if ($resourcePathItem->getDescription()) { + $finalPathItem = $finalPathItem->withDescription($resourcePathItem->getDescription()); + } + } + + $paths->addPath($path, $finalPathItem); } } diff --git a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php index d8773f8c7a6..555624ebc92 100644 --- a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php +++ b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php @@ -49,6 +49,7 @@ use ApiPlatform\OpenApi\Model\Operation; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\OpenApi\Model\Parameter; +use ApiPlatform\OpenApi\Model\PathItem; use ApiPlatform\OpenApi\Model\RequestBody; use ApiPlatform\OpenApi\Model\Response; use ApiPlatform\OpenApi\Model\Response as OpenApiResponse; @@ -89,7 +90,7 @@ public function testInvoke(): void $dummyResourceWebhook = (new ApiResource())->withOperations(new Operations([ 'dummy webhook' => (new Get())->withUriTemplate('/dummy/{id}')->withShortName('short')->withOpenapi(new Webhook('first webhook')), - 'an other dummy webhook' => (new Post())->withUriTemplate('/dummies')->withShortName('short something')->withOpenapi(new Webhook('happy webhook', new Model\PathItem(post: new Operation( + 'an other dummy webhook' => (new Post())->withUriTemplate('/dummies')->withShortName('short something')->withOpenapi(new Webhook('happy webhook', new PathItem(post: new Operation( summary: 'well...', description: 'I dont\'t know what to say', )))), @@ -1438,4 +1439,134 @@ public function testGetExtensionPropertiesWithFalseValue(): void $openApi = $factory->__invoke(); } + + public function testResourcePathItemWithSummaryAndDescription(): 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'); + + $dummyResource = (new ApiResource())->withOperations( + new Operations([ + 'getDummyCollection' => (new GetCollection())->withUriTemplate('/dummies')->withOperation($baseOperation), + 'postDummy' => (new Post())->withUriTemplate('/dummies')->withOperation($baseOperation), + ]) + )->withOpenapi(new PathItem( + summary: 'Dummy collection endpoint', + description: 'Manage dummy resources' + )); + + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Dummy::class, [$dummyResource])); + $resourceMetadataFactoryProphecy->create(Error::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Error::class, [])); + $resourceMetadataFactoryProphecy->create(ValidationException::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(ValidationException::class, [])); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate', 'enum'])); + $propertyNameCollectionFactoryProphecy->create(OutputDto::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate', 'enum'])); + $propertyNameCollectionFactoryProphecy->create(Error::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['type', 'title', 'status', 'detail', 'instance'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Argument::any(), Argument::any(), Argument::any())->willReturn(new ApiProperty()); + + $definitionNameFactory = new DefinitionNameFactory(); + $schemaFactory = new SchemaFactory( + resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), + propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), + nameConverter: new CamelCaseToSnakeCaseNameConverter(), + definitionNameFactory: $definitionNameFactory, + ); + + $factory = new OpenApiFactory( + $resourceNameCollectionFactoryProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $schemaFactory, + null, + [], + new Options('Test API', 'This is a test API.', '1.2.3'), + new PaginationOptions(), + null, + ['json' => ['application/problem+json']] + ); + + $openApi = $factory->__invoke(); + $paths = $openApi->getPaths(); + $dummyPath = $paths->getPath('/dummies'); + + $this->assertEquals('Dummy collection endpoint', $dummyPath->getSummary()); + $this->assertEquals('Manage dummy resources', $dummyPath->getDescription()); + + $this->assertNotNull($dummyPath->getGet()); + $this->assertNotNull($dummyPath->getPost()); + } + + public function testOperationPathItemOverridesResourcePathItem(): 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'); + + $dummyResource = (new ApiResource())->withOperations( + new Operations([ + 'getDummy' => (new Get())->withUriTemplate('/dummies/{id}')->withOperation($baseOperation)->withOpenapi(new PathItem( + summary: 'Operation-level PathItem', + get: new Operation(summary: 'Operation-level summary') + )), + ]) + )->withOpenapi(new PathItem( + summary: 'Resource-level PathItem', + description: 'This should be overridden' + )); + + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Dummy::class, [$dummyResource])); + $resourceMetadataFactoryProphecy->create(Error::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(Error::class, [])); + $resourceMetadataFactoryProphecy->create(ValidationException::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(ValidationException::class, [])); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Argument::cetera())->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate', 'enum'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Argument::any(), Argument::any(), Argument::any())->willReturn(new ApiProperty()); + + $definitionNameFactory = new DefinitionNameFactory(); + $schemaFactory = new SchemaFactory( + resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), + propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), + propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), + nameConverter: new CamelCaseToSnakeCaseNameConverter(), + definitionNameFactory: $definitionNameFactory, + ); + + $factory = new OpenApiFactory( + $resourceNameCollectionFactoryProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $schemaFactory, + null, + [], + new Options('Test API', 'This is a test API.', '1.2.3'), + new PaginationOptions(), + null, + ['json' => ['application/problem+json']] + ); + + $openApi = $factory->__invoke(); + $paths = $openApi->getPaths(); + $dummyPath = $paths->getPath('/dummies/{id}'); + + $this->assertEquals('Operation-level PathItem', $dummyPath->getSummary()); + + $this->assertEquals('Operation-level summary', $dummyPath->getGet()->getSummary()); + } }