Skip to content

refactor(metadata): cascade resource to operation #7246

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
use ApiPlatform\Doctrine\Odm\State\CollectionProvider;
use ApiPlatform\Doctrine\Odm\State\ItemProvider;
use ApiPlatform\Doctrine\Odm\State\Options;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
Expand All @@ -41,12 +40,10 @@ public function create(string $resourceClass): ResourceMetadataCollection
{
$resourceMetadataCollection = $this->decorated->create($resourceClass);

/** @var ApiResource $resourceMetadata */
foreach ($resourceMetadataCollection as $i => $resourceMetadata) {
$operations = $resourceMetadata->getOperations();

if ($operations) {
/** @var Operation $operation */
foreach ($resourceMetadata->getOperations() as $operationName => $operation) {
$documentClass = $this->getStateOptionsClass($operation, $operation->getClass(), Options::class);
if (!$this->managerRegistry->getManagerForClass($documentClass) instanceof DocumentManager) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Operations;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
Expand All @@ -34,7 +34,7 @@ final class DoctrineMongoDbOdmResourceCollectionMetadataFactoryTest extends Test
{
use ProphecyTrait;

private function getResourceMetadataCollectionFactory(Operation $operation)
private function getResourceMetadataCollectionFactory(HttpOperation $operation)
{
if (!class_exists(DocumentManager::class)) {
$this->markTestSkipped('ODM not installed');
Expand Down Expand Up @@ -71,7 +71,7 @@ public function testWithoutManager(): void
}

#[\PHPUnit\Framework\Attributes\DataProvider('operationProvider')]
public function testWithProvider(Operation $operation, ?string $expectedProvider = null, ?string $expectedProcessor = null): void
public function testWithProvider(HttpOperation $operation, ?string $expectedProvider = null, ?string $expectedProcessor = null): void
{
if (!class_exists(DocumentManager::class)) {
$this->markTestSkipped('ODM not installed');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
use ApiPlatform\Doctrine\Orm\State\CollectionProvider;
use ApiPlatform\Doctrine\Orm\State\ItemProvider;
use ApiPlatform\Doctrine\Orm\State\Options;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\DeleteOperationInterface;
use ApiPlatform\Metadata\Operation;
Expand All @@ -41,12 +40,10 @@ public function create(string $resourceClass): ResourceMetadataCollection
{
$resourceMetadataCollection = $this->decorated->create($resourceClass);

/** @var ApiResource $resourceMetadata */
foreach ($resourceMetadataCollection as $i => $resourceMetadata) {
$operations = $resourceMetadata->getOperations();

if ($operations) {
/** @var Operation $operation */
foreach ($resourceMetadata->getOperations() as $operationName => $operation) {
$entityClass = $this->getStateOptionsClass($operation, $operation->getClass(), Options::class);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Operations;
use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;
Expand All @@ -34,7 +34,7 @@ class DoctrineOrmResourceCollectionMetadataFactoryTest extends TestCase
{
use ProphecyTrait;

private function getResourceMetadataCollectionFactory(Operation $operation)
private function getResourceMetadataCollectionFactory(HttpOperation $operation)
{
$resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class);
$resourceMetadataCollectionFactory->create($operation->getClass())->willReturn(new ResourceMetadataCollection($operation->getClass(), [
Expand Down Expand Up @@ -63,7 +63,7 @@ public function testWithoutManager(): void
}

#[\PHPUnit\Framework\Attributes\DataProvider('operationProvider')]
public function testWithProvider(Operation $operation, ?string $expectedProvider = null, ?string $expectedProcessor = null): void
public function testWithProvider(HttpOperation $operation, ?string $expectedProvider = null, ?string $expectedProcessor = null): void
{
$objectManager = $this->prophesize(EntityManagerInterface::class);
$managerRegistry = $this->prophesize(ManagerRegistry::class);
Expand Down
2 changes: 1 addition & 1 deletion src/Hydra/Serializer/EntrypointNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public function normalize(mixed $object, ?string $format = null, array $context
}

try {
$entrypoint[$key] = $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, $operation); // @phpstan-ignore-line phpstan issue as type is CollectionOperationInterface & Operation
$entrypoint[$key] = $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, $operation);
} catch (InvalidArgumentException|OperationNotFoundException) {
// Ignore resources without GET operations
}
Expand Down
2 changes: 1 addition & 1 deletion src/JsonApi/Serializer/EntrypointNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public function normalize(mixed $object, ?string $format = null, array $context
}

try {
$iri = $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_URL, $operation); // @phpstan-ignore-line phpstan issue as type is CollectionOperationInterface & Operation
$iri = $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_URL, $operation);
$entrypoint['links'][lcfirst($resource->getShortName())] = $iri;
} catch (InvalidArgumentException) {
// Ignore resources without GET operations
Expand Down
29 changes: 20 additions & 9 deletions src/Metadata/ApiResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,20 +31,24 @@
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)]
class ApiResource extends Metadata
{
use CascadeToOperationsTrait;
use WithResourceTrait;

/**
* @var Operations<HttpOperation>
*/
protected ?Operations $operations;

/**
* @param array<int, HttpOperation>|array<string, HttpOperation>|Operations|null $operations Operations is a list of HttpOperation
* @param array<string, Link>|array<string, mixed[]>|string[]|string|null $uriVariables
* @param array<string, string> $headers
* @param string|callable|null $provider
* @param string|callable|null $processor
* @param mixed|null $mercure
* @param mixed|null $messenger
* @param mixed|null $input
* @param mixed|null $output
* @param list<HttpOperation>|array<string, HttpOperation>|Operations<HttpOperation>|null $operations Operations is a list of HttpOperation
* @param array<string, Link>|array<string, mixed[]>|string[]|string|null $uriVariables
* @param array<string, string> $headers
* @param string|callable|null $provider
* @param string|callable|null $processor
* @param mixed|null $mercure
* @param mixed|null $messenger
* @param mixed|null $input
* @param mixed|null $output
*/
public function __construct(
/**
Expand Down Expand Up @@ -1012,6 +1016,7 @@ class: $class,
extraProperties: $extraProperties
);

/* @var Operations<HttpOperation> $operations> */
$this->operations = null === $operations ? null : new Operations($operations);
$this->provider = $provider;
$this->processor = $processor;
Expand All @@ -1020,11 +1025,17 @@ class: $class,
}
}

/**
* @return Operations<HttpOperation>
*/
public function getOperations(): ?Operations
{
return $this->operations;
}

/**
* @param Operations<HttpOperation> $operations
*/
public function withOperations(Operations $operations): static
{
$self = clone $this;
Expand Down
29 changes: 29 additions & 0 deletions src/Metadata/CascadeFromResource.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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;

/**
* @internal
*
* @phpstan-require-extends Operation
*/
trait CascadeFromResource
{
use WithResourceTrait;

public function cascadeFromResource(ApiResource $apiResource, array $ignoredOptions = []): static
{
return $this->copyFrom($apiResource, $ignoredOptions);
}
}
70 changes: 70 additions & 0 deletions src/Metadata/CascadeToOperationsTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php

/*
* This file is part of the API Platform project.
*
* (c) Kévin Dunglas <dunglas@gmail.com>
*
* 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;

use ApiPlatform\Metadata\Exception\RuntimeException;
use ApiPlatform\Metadata\GraphQl\Operation as GraphQlOperation;

/**
* @internal
*
* @phpstan-require-extends ApiResource
*/
trait CascadeToOperationsTrait
{
public function cascadeToOperations(): static
{
if (!$this instanceof ApiResource) {
throw new RuntimeException('Not an API resource');
}

if (!($operations = $this->getOperations() ?? [])) {
return $this;
}

return (clone $this)->withOperations(
new Operations($this->getMutatedOperations($operations, $this)),
);
}

public function cascadeToGraphQlOperations(): static
{
if (!$this instanceof ApiResource) {
throw new RuntimeException('Not an API resource');
}

if (!($operations = $this->getGraphQlOperations() ?? [])) {
return $this;
}

return (clone $this)->withGraphQlOperations(
$this->getMutatedOperations($operations, $this),
);
}

/**
* @param Operations<HttpOperation>|list<GraphQlOperation> $operations
*
* @return array[string, HttpOperation]|list<GraphQlOperation>
*/
private function getMutatedOperations(iterable $operations, ApiResource $apiResource): iterable
{
$modifiedOperations = [];
foreach ($operations as $key => $operation) {
$modifiedOperations[$key] = $operation->cascadeFromResource($apiResource);
}

return $modifiedOperations;
}
}
3 changes: 2 additions & 1 deletion src/Metadata/Operation.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
*/
abstract class Operation extends Metadata
{
use CascadeFromResource;
use WithResourceTrait;

/**
Expand Down Expand Up @@ -861,7 +862,7 @@ class: $class,
);
}

public function withOperation($operation)
public function withOperation(self $operation): static
{
return $this->copyFrom($operation);
}
Expand Down
12 changes: 10 additions & 2 deletions src/Metadata/Operations.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,18 @@

/**
* An Operation dictionnary.
*
* @template-covariant T of Operation
*/
final class Operations implements \IteratorAggregate, \Countable
{
/**
* @var list<array{0: string, 1: T}>
*/
private array $operations = [];

/**
* @param array<string|int, Operation> $operations
* @param list<T>|array<string, T> $operations
*/
public function __construct(array $operations = [])
{
Expand All @@ -42,6 +47,9 @@ public function __construct(array $operations = [])
$this->sort();
}

/**
* @return \Iterator<string, T>
*/
public function getIterator(): \Traversable
{
return (function (): \Generator {
Expand Down Expand Up @@ -97,7 +105,7 @@ public function count(): int

public function sort(): self
{
usort($this->operations, fn ($a, $b): int|float => $a[1]->getPriority() - $b[1]->getPriority());
usort($this->operations, fn ($a, $b): int => $a[1]->getPriority() - $b[1]->getPriority());

return $this;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
use ApiPlatform\Metadata\ErrorResource;
use ApiPlatform\Metadata\Exception\InvalidArgumentException;
use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException;
use ApiPlatform\Metadata\HttpOperation;
use ApiPlatform\Metadata\Operations;
use ApiPlatform\Metadata\Resource\ResourceMetadataCollection;

Expand Down Expand Up @@ -67,6 +68,9 @@ public function create(string $resourceClass): ResourceMetadataCollection
return $resourceMetadataCollection;
}

/**
* @param Operations<HttpOperation> $operations
*/
private function normalize(array $resourceInputFormats, array $resourceOutputFormats, Operations $operations): Operations
{
$newOperations = [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,7 @@ public function create(string $resourceClass): ResourceMetadataCollection

// No item operation has been found on all resources for resource class: generate one on the last resource
// Helpful to generate an IRI for a resource without declaring the Get operation
/** @var HttpOperation $operation */
[$key, $operation] = $this->getOperationWithDefaults(resource: $resource, operation: new NotExposed(), generated: true, ignoredOptions: ['uriTemplate', 'uriVariables']); // @phpstan-ignore-line $resource is defined if count > 0
[$key, $operation] = $this->getOperationWithDefaults($resource, new NotExposed(), true, ['uriTemplate', 'uriVariables']); // @phpstan-ignore-line $resource is defined if count > 0

if (!$this->linkFactory->createLinksFromIdentifiers($operation)) {
$operation = $operation->withUriTemplate(self::$skolemUriTemplate);
Expand Down
Loading
Loading