diff --git a/CHANGELOG.md b/CHANGELOG.md index ffd5d418438..89d2dba5096 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## v4.0.9 + +### Bug fixes + +* [417fef5da](https://github.com/api-platform/core/commit/417fef5da9b1f3f16e323a193dec141f13b1ebc5) fix(laravel): overlaping route format (#6782) +* [81099065e](https://github.com/api-platform/core/commit/81099065e30eb0356881907bd52095b85b9cae3d) fix(laravel): declare normalizer list as a service (#6786) +* [dc8c09b1e](https://github.com/api-platform/core/commit/dc8c09b1e1ac15a7bcd4961fc3e80b06bec82e77) fix(laravel) graphQl Relationship loading (#6792) + +Also contains [v3.4.6 changes](#v346). + +### Features + +## v4.0.8 + +### Bug fixes + +* [dddb97075](https://github.com/api-platform/core/commit/dddb97075af9c6e2517e1881b803c9d31a1913cf) fix(symfony): default formats order (#6780) + +### Features + ## v4.0.7 ### Bug fixes @@ -179,6 +199,20 @@ Notes: * [0d5f35683](https://github.com/api-platform/core/commit/0d5f356839eb6aa9f536044abe4affa736553e76) feat(laravel): laravel component (#5882) +## v3.4.6 + +### Bug fixes + +* [17c916c3a](https://github.com/api-platform/core/commit/17c916c3a1bcc837c9bc842dc48390dbeb043450) fix(symfony): service typo fix BackedEnumProvider for autowiring (#6769) +* [216d9ccaa](https://github.com/api-platform/core/commit/216d9ccaacf7845daaaeab30f3a58bb5567430fe) fix(serializer): fetch type on normalization error when possible (#6761) +* [2f967d934](https://github.com/api-platform/core/commit/2f967d9345004779f409b9ce1b5d0cbba84c7132) fix(doctrine): throw an exception when a filter is not found in a parameter (#6767) +* [736ca045e](https://github.com/api-platform/core/commit/736ca045e6832f04aaa002ddd7b85c55df4696bb) fix(validator): allow to pass both a ConstraintViolationList and a previous exception (#6762) +* [a98332d99](https://github.com/api-platform/core/commit/a98332d99a43338fa3bc0fd6b20f82ac58d1c397) fix(metadata): name convert parameter property (#6766) +* [aa1667de1](https://github.com/api-platform/core/commit/aa1667de116fa9a40842f1480fc90ab49c7c2784) fix(state): empty result when the array paginator is out of bound (#6785) +* [ab88353a3](https://github.com/api-platform/core/commit/ab88353a32f94146b01c34bae377ec5a735846db) fix(hal): detecting and handling circular reference (#6752) +* [bba030614](https://github.com/api-platform/core/commit/bba030614b96887fea4f5c177e3137378ccae8a5) fix: properly support phpstan/phpdoc-parser 2 (#6789) +* [bec147b91](https://github.com/api-platform/core/commit/bec147b916c29e346a698b28ddd4493bf305d9a0) fix(state): do not check content type if no input (#6794) + ## v3.4.5 ### Bug fixes diff --git a/composer.json b/composer.json index 39314dc5890..290828ac782 100644 --- a/composer.json +++ b/composer.json @@ -145,7 +145,7 @@ "orchestra/testbench": "^9.1", "phpspec/prophecy-phpunit": "^2.2", "phpstan/extension-installer": "^1.1", - "phpstan/phpdoc-parser": "^1.13", + "phpstan/phpdoc-parser": "^1.13|^2.0", "phpstan/phpstan": "^1.10", "phpstan/phpstan-doctrine": "^1.0", "phpstan/phpstan-phpunit": "^1.0", diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php index f128e150909..24748804177 100644 --- a/src/Hal/Serializer/ItemNormalizer.php +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -13,14 +13,26 @@ namespace ApiPlatform\Hal\Serializer; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceAccessCheckerInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\ClassInfoTrait; use ApiPlatform\Serializer\AbstractItemNormalizer; use ApiPlatform\Serializer\CacheKeyTrait; use ApiPlatform\Serializer\ContextTrait; +use ApiPlatform\Serializer\TagCollectorInterface; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\Exception\CircularReferenceException; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; /** * Converts between objects and array including HAL metadata. @@ -35,9 +47,25 @@ final class ItemNormalizer extends AbstractItemNormalizer public const FORMAT = 'jsonhal'; + protected const HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS = 'hal_circular_reference_limit_counters'; + private array $componentsCache = []; private array $attributesMetadataCache = []; + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, ?TagCollectorInterface $tagCollector = null) + { + $defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER] = function ($object): ?array { + $iri = $this->iriConverter->getIriFromResource($object); + if (null === $iri) { + return null; + } + + return ['_links' => ['self' => ['href' => $iri]]]; + }; + + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector); + } + /** * {@inheritdoc} */ @@ -216,6 +244,10 @@ private function populateRelation(array $data, object $object, ?string $format, { $class = $this->getObjectClass($object); + if ($this->isHalCircularReference($object, $context)) { + return $this->handleHalCircularReference($object, $format, $context); + } + $attributesMetadata = \array_key_exists($class, $this->attributesMetadataCache) ? $this->attributesMetadataCache[$class] : $this->attributesMetadataCache[$class] = $this->classMetadataFactory ? $this->classMetadataFactory->getMetadataFor($class)->getAttributesMetadata() : null; @@ -319,4 +351,49 @@ private function isMaxDepthReached(array $attributesMetadata, string $class, str return false; } + + /** + * Detects if the configured circular reference limit is reached. + * + * @throws CircularReferenceException + */ + protected function isHalCircularReference(object $object, array &$context): bool + { + $objectHash = spl_object_hash($object); + + $circularReferenceLimit = $context[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT] ?? $this->defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT]; + if (isset($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash])) { + if ($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash] >= $circularReferenceLimit) { + unset($context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash]); + + return true; + } + + ++$context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash]; + } else { + $context[self::HAL_CIRCULAR_REFERENCE_LIMIT_COUNTERS][$objectHash] = 1; + } + + return false; + } + + /** + * Handles a circular reference. + * + * If a circular reference handler is set, it will be called. Otherwise, a + * {@class CircularReferenceException} will be thrown. + * + * @final + * + * @throws CircularReferenceException + */ + protected function handleHalCircularReference(object $object, ?string $format = null, array $context = []): mixed + { + $circularReferenceHandler = $context[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER] ?? $this->defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_HANDLER]; + if ($circularReferenceHandler) { + return $circularReferenceHandler($object, $format, $context); + } + + throw new CircularReferenceException(\sprintf('A circular reference has been detected when serializing the object of class "%s" (configured limit: %d).', get_debug_type($object), $context[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT] ?? $this->defaultContext[AbstractNormalizer::CIRCULAR_REFERENCE_LIMIT])); + } } diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index d30598bfe53..04534fee036 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -432,12 +432,12 @@ public function register(): void $this->app->singleton(ItemProvider::class, function (Application $app) { $tagged = iterator_to_array($app->tagged(LinksHandlerInterface::class)); - return new ItemProvider(new LinksHandler($app), new ServiceLocator($tagged)); + return new ItemProvider(new LinksHandler($app, $app->make(ResourceMetadataCollectionFactoryInterface::class)), new ServiceLocator($tagged)); }); $this->app->singleton(CollectionProvider::class, function (Application $app) { $tagged = iterator_to_array($app->tagged(LinksHandlerInterface::class)); - return new CollectionProvider($app->make(Pagination::class), new LinksHandler($app), $app->tagged(QueryExtensionInterface::class), new ServiceLocator($tagged)); + return new CollectionProvider($app->make(Pagination::class), new LinksHandler($app, $app->make(ResourceMetadataCollectionFactoryInterface::class)), $app->tagged(QueryExtensionInterface::class), new ServiceLocator($tagged)); }); $this->app->tag([ItemProvider::class, CollectionProvider::class], ProviderInterface::class); @@ -973,11 +973,7 @@ public function register(): void ); }); - $this->app->bind(SerializerInterface::class, Serializer::class); - $this->app->bind(NormalizerInterface::class, Serializer::class); - $this->app->singleton(Serializer::class, function (Application $app) { - /** @var ConfigRepository */ - $config = $app['config']; + $this->app->singleton('api_platform_normalizer_list', function (Application $app) { $list = new \SplPriorityQueue(); $list->insert($app->make(HydraEntrypointNormalizer::class), -800); $list->insert($app->make(HydraPartialCollectionViewNormalizer::class), -800); @@ -1011,6 +1007,12 @@ public function register(): void $list->insert($app->make(GraphQlRuntimeExceptionNormalizer::class), -780); } + return $list; + }); + + $this->app->bind(SerializerInterface::class, Serializer::class); + $this->app->bind(NormalizerInterface::class, Serializer::class); + $this->app->singleton(Serializer::class, function (Application $app) { // TODO: unused + implement hal/jsonapi ? // $list->insert($dataUriNormalizer, -920); // $list->insert($unwrappingDenormalizer, 1000); @@ -1018,7 +1020,7 @@ public function register(): void // $list->insert($uuidDenormalizer, -895); //Todo ramsey uuid support ? return new Serializer( - iterator_to_array($list), + iterator_to_array($app->make('api_platform_normalizer_list')), [ new JsonEncoder('json'), $app->make(JsonEncoder::class), @@ -1342,6 +1344,7 @@ public function boot(ResourceNameCollectionFactoryInterface $resourceNameCollect // _format is read by the middleware $uriTemplate = $operation->getRoutePrefix().str_replace('{._format}', '{_format?}', $uriTemplate); $route = (new Route([$operation->getMethod()], $uriTemplate, [ApiPlatformController::class, '__invoke'])) + ->where('_format', '^\.[a-zA-Z]+') ->name($operation->getName()) ->setDefaults(['_api_operation_name' => $operation->getName(), '_api_resource_class' => $operation->getClass()]); diff --git a/src/Laravel/Eloquent/State/LinksHandler.php b/src/Laravel/Eloquent/State/LinksHandler.php index 32256ab717c..559a506a5e7 100644 --- a/src/Laravel/Eloquent/State/LinksHandler.php +++ b/src/Laravel/Eloquent/State/LinksHandler.php @@ -13,7 +13,12 @@ namespace ApiPlatform\Laravel\Eloquent\State; +use ApiPlatform\Metadata\Exception\OperationNotFoundException; +use ApiPlatform\Metadata\GraphQl\Operation; +use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use Illuminate\Contracts\Foundation\Application; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; @@ -25,6 +30,7 @@ final class LinksHandler implements LinksHandlerInterface { public function __construct( private readonly Application $application, + private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, ) { } @@ -34,27 +40,72 @@ public function handleLinks(Builder $builder, array $uriVariables, array $contex if ($operation instanceof HttpOperation) { foreach (array_reverse($operation->getUriVariables() ?? []) as $uriVariable => $link) { - $identifier = $uriVariables[$uriVariable]; + $builder = $this->buildQuery($builder, $link, $uriVariables[$uriVariable]); + } - if ($to = $link->getToProperty()) { - $builder = $builder->where($builder->getModel()->{$to}()->getQualifiedForeignKeyName(), $identifier); + return $builder; + } - continue; - } + if (!($linkClass = $context['linkClass'] ?? false)) { + return $builder; + } - if ($from = $link->getFromProperty()) { - $relation = $this->application->make($link->getFromClass()); - $builder = $builder->getModel()->where($relation->{$from}()->getQualifiedForeignKeyName(), $identifier); + $newLink = null; + $linkedOperation = null; + $linkProperty = $context['linkProperty'] ?? null; - continue; + try { + $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($linkClass); + $linkedOperation = $resourceMetadataCollection->getOperation($operation->getName()); + } catch (OperationNotFoundException) { + // Instead, we'll look for the first Query available. + foreach ($resourceMetadataCollection as $resourceMetadata) { + foreach ($resourceMetadata->getGraphQlOperations() as $op) { + if ($op instanceof Query) { + $linkedOperation = $op; + } } + } + } + + if (!$linkedOperation instanceof Operation) { + return $builder; + } - $builder->where($builder->getModel()->qualifyColumn($link->getIdentifiers()[0]), $identifier); + $resourceClass = $builder->getModel()::class; + foreach ($linkedOperation->getLinks() ?? [] as $link) { + if ($resourceClass === $link->getToClass() && $linkProperty === $link->getFromProperty()) { + $newLink = $link; + break; } + } + if (!$newLink) { return $builder; } - return $builder; + return $this->buildQuery($builder, $newLink, $uriVariables[$newLink->getIdentifiers()[0]]); + } + + /** + * @param Builder $builder + * + * @throws \Illuminate\Contracts\Container\BindingResolutionException + * + * @return Builder $builder + */ + private function buildQuery(Builder $builder, Link $link, mixed $identifier): Builder + { + if ($to = $link->getToProperty()) { + return $builder->where($builder->getModel()->{$to}()->getQualifiedForeignKeyName(), $identifier); + } + + if ($from = $link->getFromProperty()) { + $relation = $this->application->make($link->getFromClass()); + + return $builder->getModel()->where($relation->{$from}()->getQualifiedForeignKeyName(), $identifier); + } + + return $builder->where($builder->getModel()->qualifyColumn($link->getIdentifiers()[0]), $identifier); } } diff --git a/src/Laravel/Tests/JsonLdTest.php b/src/Laravel/Tests/JsonLdTest.php index 46039ebe68e..46e7c806334 100644 --- a/src/Laravel/Tests/JsonLdTest.php +++ b/src/Laravel/Tests/JsonLdTest.php @@ -41,6 +41,7 @@ protected function defineEnvironment($app): void { tap($app['config'], function (Repository $config): void { $config->set('app.debug', true); + $config->set('api-platform.resources', [app_path('Models'), app_path('ApiResource')]); }); } @@ -337,4 +338,14 @@ public function testRelationWithGroups(): void $this->assertArrayHasKey('relation', $content); $this->assertArrayHasKey('name', $content['relation']); } + + /** + * @see https://github.com/api-platform/core/issues/6779 + */ + public function testSimilarRoutesWithFormat(): void + { + $response = $this->get('/api/staff_position_histories?page=1', ['accept' => 'application/ld+json']); + $response->assertStatus(200); + $this->assertSame('/api/staff_position_histories', $response->json()['@id']); + } } diff --git a/src/Laravel/workbench/app/ApiResource/Staff.php b/src/Laravel/workbench/app/ApiResource/Staff.php new file mode 100644 index 00000000000..5376f1863da --- /dev/null +++ b/src/Laravel/workbench/app/ApiResource/Staff.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Workbench\App\ApiResource; + +use ApiPlatform\Metadata\ApiResource; + +#[ApiResource(provider: [self::class, 'provide'])] +class Staff +{ + public static function provide() + { + return []; + } +} diff --git a/src/Laravel/workbench/app/ApiResource/StaffPositionHistory.php b/src/Laravel/workbench/app/ApiResource/StaffPositionHistory.php new file mode 100644 index 00000000000..df0f243a253 --- /dev/null +++ b/src/Laravel/workbench/app/ApiResource/StaffPositionHistory.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Workbench\App\ApiResource; + +use ApiPlatform\Metadata\ApiResource; + +#[ApiResource(provider: [self::class, 'provide'])] +class StaffPositionHistory +{ + public static function provide() + { + return []; + } +} diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 066244428d1..f0eda744432 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -200,6 +200,10 @@ private function setDefaults(string $key, Parameter $parameter, string $resource $currentKey = $nameConvertedKey; } + if ($this->nameConverter && $property = $parameter->getProperty()) { + $parameter = $parameter->withProperty($this->nameConverter->normalize($property)); + } + if (isset($properties[$currentKey]) && ($eloquentRelation = ($properties[$currentKey]->getExtraProperties()['eloquent_relation'] ?? null)) && isset($eloquentRelation['foreign_key'])) { $parameter = $parameter->withExtraProperties(['_query_property' => $eloquentRelation['foreign_key']] + $parameter->getExtraProperties()); } diff --git a/src/Metadata/Resource/Factory/PhpDocResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/PhpDocResourceMetadataCollectionFactory.php index 2a5163f424c..b7c1378b57b 100644 --- a/src/Metadata/Resource/Factory/PhpDocResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/PhpDocResourceMetadataCollectionFactory.php @@ -25,6 +25,7 @@ use PHPStan\PhpDocParser\Parser\PhpDocParser; use PHPStan\PhpDocParser\Parser\TokenIterator; use PHPStan\PhpDocParser\Parser\TypeParser; +use PHPStan\PhpDocParser\ParserConfig; /** * Extracts descriptions from PHPDoc. @@ -58,9 +59,13 @@ public function __construct(private readonly ResourceMetadataCollectionFactoryIn } $phpDocParser = null; $lexer = null; - if (class_exists(PhpDocParser::class)) { - $phpDocParser = new PhpDocParser(new TypeParser(new ConstExprParser()), new ConstExprParser()); - $lexer = new Lexer(); + if (class_exists(PhpDocParser::class) && class_exists(ParserConfig::class)) { + $config = new ParserConfig([]); + $phpDocParser = new PhpDocParser($config, new TypeParser($config, new ConstExprParser($config)), new ConstExprParser($config)); + $lexer = new Lexer($config); + } elseif (class_exists(PhpDocParser::class)) { + $phpDocParser = new PhpDocParser(new TypeParser(new ConstExprParser()), new ConstExprParser()); // @phpstan-ignore-line + $lexer = new Lexer(); // @phpstan-ignore-line } $this->phpDocParser = $phpDocParser; $this->lexer = $lexer; diff --git a/src/MongoDB/Extension/PaginationExtension.php b/src/MongoDB/Extension/PaginationExtension.php new file mode 100644 index 00000000000..96da266a640 --- /dev/null +++ b/src/MongoDB/Extension/PaginationExtension.php @@ -0,0 +1,128 @@ + + * + * 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\MongoDB\Extension; + +use ApiPlatform\Doctrine\Odm\Extension\AggregationResultCollectionExtensionInterface; +use ApiPlatform\Doctrine\Odm\Paginator; +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\Pagination; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\ODM\MongoDB\Repository\DocumentRepository; +use Doctrine\Persistence\ManagerRegistry; + +/** + * Applies pagination on the Doctrine aggregation for resource collection when enabled. + * + * @author Kévin Dunglas + * @author Samuel ROZE + * @author Alan Poulain + */ +final class PaginationExtension implements AggregationResultCollectionExtensionInterface +{ + public function __construct(private readonly ManagerRegistry $managerRegistry, private readonly Pagination $pagination) + { + } + + /** + * {@inheritdoc} + * + * @throws RuntimeException + */ + public function applyToCollection(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + if (!$this->pagination->isEnabled($operation, $context)) { + return; + } + + if (($context['graphql_operation_name'] ?? false) && !$this->pagination->isGraphQlEnabled($operation, $context)) { + return; + } + + $context = $this->addCountToContext(clone $aggregationBuilder, $context); + + [, $offset, $limit] = $this->pagination->getPagination($operation, $context); + + $manager = $this->managerRegistry->getManagerForClass($resourceClass); + if (!$manager instanceof DocumentManager) { + throw new RuntimeException(\sprintf('The manager for "%s" must be an instance of "%s".', $resourceClass, DocumentManager::class)); + } + + /** + * @var DocumentRepository + */ + $repository = $manager->getRepository($resourceClass); + $resultsAggregationBuilder = $repository->createAggregationBuilder()->skip($offset); + if ($limit > 0) { + $resultsAggregationBuilder->limit($limit); + } else { + // Results have to be 0 but MongoDB does not support a limit equal to 0. + $resultsAggregationBuilder->match()->field(Paginator::LIMIT_ZERO_MARKER_FIELD)->equals(Paginator::LIMIT_ZERO_MARKER); + } + + $aggregationBuilder + ->facet() + ->field('results')->pipeline( + $resultsAggregationBuilder + ) + ->field('count')->pipeline( + $repository->createAggregationBuilder() + ->count('count') + ); + } + + /** + * {@inheritdoc} + */ + public function supportsResult(string $resourceClass, ?Operation $operation = null, array $context = []): bool + { + if ($context['graphql_operation_name'] ?? false) { + return $this->pagination->isGraphQlEnabled($operation, $context); + } + + return $this->pagination->isEnabled($operation, $context); + } + + /** + * {@inheritdoc} + * + * @throws RuntimeException + */ + public function getResult(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array $context = []): iterable + { + $manager = $this->managerRegistry->getManagerForClass($resourceClass); + if (!$manager instanceof DocumentManager) { + throw new RuntimeException(\sprintf('The manager for "%s" must be an instance of "%s".', $resourceClass, DocumentManager::class)); + } + + $attribute = $operation?->getExtraProperties()['doctrine_mongodb'] ?? []; + $executeOptions = $attribute['execute_options'] ?? []; + + return new Paginator($aggregationBuilder->execute($executeOptions), $manager->getUnitOfWork(), $resourceClass, $aggregationBuilder->getPipeline()); + } + + private function addCountToContext(Builder $aggregationBuilder, array $context): array + { + if (!($context['graphql_operation_name'] ?? false)) { + return $context; + } + + if (isset($context['filters']['last']) && !isset($context['filters']['before'])) { + $context['count'] = $aggregationBuilder->count('count')->execute()->toArray()[0]['count']; + } + + return $context; + } +} diff --git a/src/MongoDB/State/CollectionProvider.php b/src/MongoDB/State/CollectionProvider.php new file mode 100644 index 00000000000..85789725650 --- /dev/null +++ b/src/MongoDB/State/CollectionProvider.php @@ -0,0 +1,87 @@ +getClass(); + + // @todo support collection extensions + $filter = []; + + $limit = $this->pagination->getLimit($operation, $context); + $offset = $this->pagination->getOffset($operation, $context); + + $pipeline = [ + ['$match' => $filter], + // Use $facet to get total count and data in a single query + [ + '$facet' => [ + 'count' => [['$count' => 'total']], + 'data' => [ + ['$skip' => $limit], + ['$limit' => $limit], + ] + ] + ], + [ + '$project' => [ + 'total' => ['$arrayElemAt' => ['$count.total', 0]], + 'data' => 1, + ] + ] + ]; + + $options = [ + 'typeMap' => ['root' => 'array', 'document' => 'array', 'array' => 'array'], + ]; + + $documents = $this->getCollection($operation)->aggregate($pipeline, $options); + + if ($documents instanceof CursorInterface) { + $documents = $documents->toArray(); + } + + return new Paginator( + $this->denormalizer, + $documents, + $resourceClass, + $limit, + $offset, + $context + ); + } + + private function getCollection(Operation $operation): Collection + { + $name = $this->inflector->tableize($operation->getShortName()); + + return $this->database->selectCollection($name); + } +} diff --git a/src/MongoDB/State/ItemProvider.php b/src/MongoDB/State/ItemProvider.php new file mode 100644 index 00000000000..f0abc5683ca --- /dev/null +++ b/src/MongoDB/State/ItemProvider.php @@ -0,0 +1,69 @@ + + */ +class ItemProvider implements ProviderInterface +{ + public function __construct( + private Database $database, + private readonly ?DenormalizerInterface $denormalizer = null, + private readonly ?InflectorInterface $inflector = new Inflector() + ) { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $resourceClass = $operation->getClass(); + $options = $operation->getStateOptions() instanceof Options ? $operation->getStateOptions() : new Options(index: $this->getIndex($operation)); + if (!$options instanceof Options) { + throw new RuntimeException(\sprintf('The "%s" provider was called without "%s".', self::class, Options::class)); + } + + try { + // @todo check type of "_id" field + $filter = ['_id' => new ObjectId(reset($uriVariables))]; + } catch (InvalidArgumentException) { + return null; + } + + $options = [ + 'typeMap' => ['root' => 'array', 'document' => 'array', 'array' => 'array'], + // @todo add projection + ]; + + $document = $this->getCollection($operation)->findOne($filter, $options); + + $item = $this->denormalizer->denormalize($document, $resourceClass, DocumentNormalizer::FORMAT, [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => true]); + if (!\is_object($item) && null !== $item) { + throw new \UnexpectedValueException('Expected item to be an object or null.'); + } + + return $item; + } + + private function getCollection(Operation $operation): Collection + { + $name = $this->inflector->tableize($operation->getShortName()); + + return $this->database->selectCollection($name); + } +} diff --git a/src/MongoDB/Tests/State/CollectionProviderTest.php b/src/MongoDB/Tests/State/CollectionProviderTest.php new file mode 100644 index 00000000000..7084ee5800f --- /dev/null +++ b/src/MongoDB/Tests/State/CollectionProviderTest.php @@ -0,0 +1,167 @@ + + * + * 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\MongoDB\Tests\State; + +use ApiPlatform\Doctrine\Odm\Extension\AggregationCollectionExtensionInterface; +use ApiPlatform\Doctrine\Odm\Extension\AggregationResultCollectionExtensionInterface; +use ApiPlatform\Doctrine\Odm\State\CollectionProvider; +use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\ProviderDocument; +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\ODM\MongoDB\Iterator\Iterator; +use Doctrine\ODM\MongoDB\Repository\DocumentRepository; +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\ObjectRepository; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Prophecy\Prophecy\ObjectProphecy; + +class CollectionProviderTest extends TestCase +{ + use ProphecyTrait; + + private ObjectProphecy $managerRegistryProphecy; + private ObjectProphecy $resourceMetadataFactoryProphecy; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); + $this->resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + } + + public function testGetCollection(): void + { + $iterator = $this->prophesize(Iterator::class)->reveal(); + + $aggregationBuilderProphecy = $this->prophesize(Builder::class); + $aggregationBuilderProphecy->hydrate(ProviderDocument::class)->willReturn($aggregationBuilderProphecy)->shouldBeCalled(); + $aggregationBuilderProphecy->execute([])->willReturn($iterator)->shouldBeCalled(); + $aggregationBuilder = $aggregationBuilderProphecy->reveal(); + + $repositoryProphecy = $this->prophesize(DocumentRepository::class); + $repositoryProphecy->createAggregationBuilder()->willReturn($aggregationBuilder)->shouldBeCalled(); + + $managerProphecy = $this->prophesize(DocumentManager::class); + $managerProphecy->getRepository(ProviderDocument::class)->willReturn($repositoryProphecy->reveal())->shouldBeCalled(); + + $this->managerRegistryProphecy->getManagerForClass(ProviderDocument::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); + + $operation = (new GetCollection())->withName('foo')->withClass(ProviderDocument::class); + + $extensionProphecy = $this->prophesize(AggregationCollectionExtensionInterface::class); + $extensionProphecy->applyToCollection($aggregationBuilder, ProviderDocument::class, $operation, [])->shouldBeCalled(); + + $dataProvider = new CollectionProvider($this->resourceMetadataFactoryProphecy->reveal(), $this->managerRegistryProphecy->reveal(), [$extensionProphecy->reveal()]); + $this->assertSame($iterator, $dataProvider->provide($operation, [])); + } + + public function testGetCollectionWithExecuteOptions(): void + { + $iterator = $this->prophesize(Iterator::class)->reveal(); + + $aggregationBuilderProphecy = $this->prophesize(Builder::class); + $aggregationBuilderProphecy->hydrate(ProviderDocument::class)->willReturn($aggregationBuilderProphecy)->shouldBeCalled(); + $aggregationBuilderProphecy->execute(['allowDiskUse' => true])->willReturn($iterator)->shouldBeCalled(); + $aggregationBuilder = $aggregationBuilderProphecy->reveal(); + + $repositoryProphecy = $this->prophesize(DocumentRepository::class); + $repositoryProphecy->createAggregationBuilder()->willReturn($aggregationBuilder)->shouldBeCalled(); + + $managerProphecy = $this->prophesize(DocumentManager::class); + $managerProphecy->getRepository(ProviderDocument::class)->willReturn($repositoryProphecy->reveal())->shouldBeCalled(); + + $this->managerRegistryProphecy->getManagerForClass(ProviderDocument::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); + + $operation = (new GetCollection())->withExtraProperties(['doctrine_mongodb' => ['execute_options' => ['allowDiskUse' => true]]])->withName('foo')->withClass(ProviderDocument::class); + + $extensionProphecy = $this->prophesize(AggregationCollectionExtensionInterface::class); + $extensionProphecy->applyToCollection($aggregationBuilder, ProviderDocument::class, $operation, [])->shouldBeCalled(); + + $dataProvider = new CollectionProvider($this->resourceMetadataFactoryProphecy->reveal(), $this->managerRegistryProphecy->reveal(), [$extensionProphecy->reveal()]); + $this->assertSame($iterator, $dataProvider->provide($operation, [])); + } + + public function testAggregationResultExtension(): void + { + $aggregationBuilderProphecy = $this->prophesize(Builder::class); + $aggregationBuilder = $aggregationBuilderProphecy->reveal(); + + $repositoryProphecy = $this->prophesize(DocumentRepository::class); + $repositoryProphecy->createAggregationBuilder()->willReturn($aggregationBuilder)->shouldBeCalled(); + + $managerProphecy = $this->prophesize(DocumentManager::class); + $managerProphecy->getRepository(ProviderDocument::class)->willReturn($repositoryProphecy->reveal())->shouldBeCalled(); + + $this->managerRegistryProphecy->getManagerForClass(ProviderDocument::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); + + $operation = (new GetCollection())->withName('foo')->withClass(ProviderDocument::class); + + $extensionProphecy = $this->prophesize(AggregationResultCollectionExtensionInterface::class); + $extensionProphecy->applyToCollection($aggregationBuilder, ProviderDocument::class, $operation, [])->shouldBeCalled(); + $extensionProphecy->supportsResult(ProviderDocument::class, $operation, [])->willReturn(true)->shouldBeCalled(); + $extensionProphecy->getResult($aggregationBuilder, ProviderDocument::class, $operation, [])->willReturn([])->shouldBeCalled(); + + $dataProvider = new CollectionProvider($this->resourceMetadataFactoryProphecy->reveal(), $this->managerRegistryProphecy->reveal(), [$extensionProphecy->reveal()]); + $this->assertEquals([], $dataProvider->provide($operation, [])); + } + + public function testCannotCreateAggregationBuilder(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The repository for "ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\ProviderDocument" must be an instance of "Doctrine\ODM\MongoDB\Repository\DocumentRepository".'); + + $repositoryProphecy = $this->prophesize(ObjectRepository::class); + + $managerProphecy = $this->prophesize(DocumentManager::class); + $managerProphecy->getRepository(ProviderDocument::class)->willReturn($repositoryProphecy->reveal())->shouldBeCalled(); + + $this->managerRegistryProphecy->getManagerForClass(ProviderDocument::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); + + $dataProvider = new CollectionProvider($this->resourceMetadataFactoryProphecy->reveal(), $this->managerRegistryProphecy->reveal()); + $operation = (new GetCollection())->withName('foo')->withClass(ProviderDocument::class); + $this->assertEquals([], $dataProvider->provide($operation, [])); + } + + public function testOperationNotFound(): void + { + $iterator = $this->prophesize(Iterator::class)->reveal(); + + $aggregationBuilderProphecy = $this->prophesize(Builder::class); + $aggregationBuilderProphecy->hydrate(ProviderDocument::class)->willReturn($aggregationBuilderProphecy)->shouldBeCalled(); + $aggregationBuilderProphecy->execute([])->willReturn($iterator)->shouldBeCalled(); + $aggregationBuilder = $aggregationBuilderProphecy->reveal(); + + $repositoryProphecy = $this->prophesize(DocumentRepository::class); + $repositoryProphecy->createAggregationBuilder()->willReturn($aggregationBuilder)->shouldBeCalled(); + + $managerProphecy = $this->prophesize(DocumentManager::class); + $managerProphecy->getRepository(ProviderDocument::class)->willReturn($repositoryProphecy->reveal())->shouldBeCalled(); + + $this->managerRegistryProphecy->getManagerForClass(ProviderDocument::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); + + $operation = new GetCollection(name: 'bar', class: ProviderDocument::class); + + $extensionProphecy = $this->prophesize(AggregationCollectionExtensionInterface::class); + $extensionProphecy->applyToCollection($aggregationBuilder, ProviderDocument::class, $operation, [])->shouldBeCalled(); + + $dataProvider = new CollectionProvider($this->resourceMetadataFactoryProphecy->reveal(), $this->managerRegistryProphecy->reveal(), [$extensionProphecy->reveal()]); + $this->assertSame($iterator, $dataProvider->provide($operation, [])); + } +} diff --git a/src/MongoDB/Tests/State/ItemProviderTest.php b/src/MongoDB/Tests/State/ItemProviderTest.php new file mode 100644 index 00000000000..ef40b91df1b --- /dev/null +++ b/src/MongoDB/Tests/State/ItemProviderTest.php @@ -0,0 +1,213 @@ + + * + * 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\MongoDB\Tests\State; + +use ApiPlatform\Doctrine\Odm\Extension\AggregationItemExtensionInterface; +use ApiPlatform\Doctrine\Odm\Extension\AggregationResultItemExtensionInterface; +use ApiPlatform\Doctrine\Odm\State\ItemProvider; +use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\ProviderDocument; +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Doctrine\ODM\MongoDB\Aggregation\Stage\MatchStage as AggregationMatch; +use Doctrine\ODM\MongoDB\DocumentManager; +use Doctrine\ODM\MongoDB\Iterator\Iterator; +use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; +use Doctrine\ODM\MongoDB\Repository\DocumentRepository; +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\ObjectManager; +use Doctrine\Persistence\ObjectRepository; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; + +class ItemProviderTest extends TestCase +{ + use ProphecyTrait; + + public function testGetItemSingleIdentifier(): void + { + $context = ['foo' => 'bar', 'fetch_data' => true]; + + $matchProphecy = $this->prophesize(AggregationMatch::class); + $matchProphecy->field('id')->willReturn($matchProphecy)->shouldBeCalled(); + $matchProphecy->equals(1)->shouldBeCalled()->willReturn($matchProphecy); + + $iterator = $this->prophesize(Iterator::class); + $result = new \stdClass(); + $iterator->current()->willReturn($result)->shouldBeCalled(); + + $aggregationBuilderProphecy = $this->prophesize(Builder::class); + $aggregationBuilderProphecy->match()->willReturn($matchProphecy->reveal())->shouldBeCalled(); + $aggregationBuilderProphecy->hydrate(ProviderDocument::class)->willReturn($aggregationBuilderProphecy)->shouldBeCalled(); + $aggregationBuilderProphecy->execute([])->willReturn($iterator->reveal())->shouldBeCalled(); + $aggregationBuilder = $aggregationBuilderProphecy->reveal(); + + $managerRegistry = $this->getManagerRegistry(ProviderDocument::class, $aggregationBuilder); + + $operation = (new Get()) + ->withUriVariables([(new Link())->withFromClass(ProviderDocument::class)->withIdentifiers(['id'])]) + ->withClass(ProviderDocument::class) + ->withName('foo'); + + $extensionProphecy = $this->prophesize(AggregationItemExtensionInterface::class); + $extensionProphecy->applyToItem($aggregationBuilder, ProviderDocument::class, ['id' => 1], $operation, $context)->shouldBeCalled(); + + $dataProvider = new ItemProvider($this->prophesize(ResourceMetadataCollectionFactoryInterface::class)->reveal(), $managerRegistry, [$extensionProphecy->reveal()]); + + $this->assertSame($result, $dataProvider->provide($operation, ['id' => 1], $context)); + } + + public function testGetItemWithExecuteOptions(): void + { + $context = ['foo' => 'bar', 'fetch_data' => true]; + + $matchProphecy = $this->prophesize(AggregationMatch::class); + $matchProphecy->field('id')->willReturn($matchProphecy->reveal())->shouldBeCalled(); + $matchProphecy->equals(1)->shouldBeCalled()->willReturn($matchProphecy->reveal()); + + $iterator = $this->prophesize(Iterator::class); + $result = new \stdClass(); + $iterator->current()->willReturn($result)->shouldBeCalled(); + + $aggregationBuilderProphecy = $this->prophesize(Builder::class); + $aggregationBuilderProphecy->match()->willReturn($matchProphecy->reveal())->shouldBeCalled(); + $aggregationBuilderProphecy->hydrate(ProviderDocument::class)->willReturn($aggregationBuilderProphecy)->shouldBeCalled(); + $aggregationBuilderProphecy->execute(['allowDiskUse' => true])->willReturn($iterator->reveal())->shouldBeCalled(); + $aggregationBuilder = $aggregationBuilderProphecy->reveal(); + + $managerRegistry = $this->getManagerRegistry(ProviderDocument::class, $aggregationBuilder); + + $operation = (new Get()) + ->withUriVariables([(new Link())->withFromClass(ProviderDocument::class)->withIdentifiers(['id'])]) + ->withClass(ProviderDocument::class) + ->withName('foo') + ->withExtraProperties(['doctrine_mongodb' => ['execute_options' => ['allowDiskUse' => true]]]); + + $extensionProphecy = $this->prophesize(AggregationItemExtensionInterface::class); + $extensionProphecy->applyToItem($aggregationBuilder, ProviderDocument::class, ['id' => 1], $operation, $context)->shouldBeCalled(); + + $dataProvider = new ItemProvider($this->prophesize(ResourceMetadataCollectionFactoryInterface::class)->reveal(), $managerRegistry, [$extensionProphecy->reveal()]); + + $this->assertSame($result, $dataProvider->provide($operation, ['id' => 1], $context)); + } + + public function testGetItemDoubleIdentifier(): void + { + $matchProphecy = $this->prophesize(AggregationMatch::class); + $matchProphecy->field('ida')->willReturn($matchProphecy)->shouldBeCalled(); + $matchProphecy->field('idb')->willReturn($matchProphecy)->shouldBeCalled(); + $matchProphecy->equals(1)->shouldBeCalled()->willReturn($matchProphecy); + $matchProphecy->equals(2)->shouldBeCalled()->willReturn($matchProphecy); + + $iterator = $this->prophesize(Iterator::class); + $result = new \stdClass(); + $iterator->current()->willReturn($result)->shouldBeCalled(); + + $aggregationBuilderProphecy = $this->prophesize(Builder::class); + $aggregationBuilderProphecy->match()->willReturn($matchProphecy->reveal())->shouldBeCalled(); + $aggregationBuilderProphecy->hydrate(ProviderDocument::class)->willReturn($aggregationBuilderProphecy)->shouldBeCalled(); + $aggregationBuilderProphecy->execute([])->willReturn($iterator->reveal())->shouldBeCalled(); + $aggregationBuilder = $aggregationBuilderProphecy->reveal(); + + $managerRegistry = $this->getManagerRegistry(ProviderDocument::class, $aggregationBuilder); + + $operation = (new Get()) + ->withUriVariables([(new Link())->withFromClass(ProviderDocument::class)->withIdentifiers(['ida', 'idb'])]) + ->withClass(ProviderDocument::class) + ->withName('foo'); + + $context = []; + $extensionProphecy = $this->prophesize(AggregationItemExtensionInterface::class); + $extensionProphecy->applyToItem($aggregationBuilder, ProviderDocument::class, ['ida' => 1, 'idb' => 2], $operation, $context)->shouldBeCalled(); + + $dataProvider = new ItemProvider($this->prophesize(ResourceMetadataCollectionFactoryInterface::class)->reveal(), $managerRegistry, [$extensionProphecy->reveal()]); + + $this->assertSame($result, $dataProvider->provide($operation, ['ida' => 1, 'idb' => 2], $context)); + } + + public function testAggregationResultExtension(): void + { + $returnObject = new \stdClass(); + + $matchProphecy = $this->prophesize(AggregationMatch::class); + $matchProphecy->field('id')->willReturn($matchProphecy)->shouldBeCalled(); + $matchProphecy->equals(1)->shouldBeCalled()->willReturn($matchProphecy); + + $aggregationBuilderProphecy = $this->prophesize(Builder::class); + $aggregationBuilderProphecy->match()->willReturn($matchProphecy->reveal())->shouldBeCalled(); + $aggregationBuilder = $aggregationBuilderProphecy->reveal(); + + $managerRegistry = $this->getManagerRegistry(ProviderDocument::class, $aggregationBuilder); + + $operation = (new Get()) + ->withUriVariables([(new Link())->withFromClass(ProviderDocument::class)->withIdentifiers(['id'])]) + ->withClass(ProviderDocument::class) + ->withName('foo'); + + $context = []; + $extensionProphecy = $this->prophesize(AggregationResultItemExtensionInterface::class); + $extensionProphecy->applyToItem($aggregationBuilder, ProviderDocument::class, ['id' => 1], $operation, $context)->shouldBeCalled(); + $extensionProphecy->supportsResult(ProviderDocument::class, $operation, $context)->willReturn(true)->shouldBeCalled(); + $extensionProphecy->getResult($aggregationBuilder, ProviderDocument::class, $operation, $context)->willReturn($returnObject)->shouldBeCalled(); + + $dataProvider = new ItemProvider($this->prophesize(ResourceMetadataCollectionFactoryInterface::class)->reveal(), $managerRegistry, [$extensionProphecy->reveal()]); + + $this->assertEquals($returnObject, $dataProvider->provide($operation, ['id' => 1], $context)); + } + + public function testCannotCreateAggregationBuilder(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The repository for "ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\ProviderDocument" must be an instance of "Doctrine\ODM\MongoDB\Repository\DocumentRepository".'); + + $repositoryProphecy = $this->prophesize(ObjectRepository::class); + + $managerProphecy = $this->prophesize(ObjectManager::class); + $managerProphecy->getRepository(ProviderDocument::class)->willReturn($repositoryProphecy->reveal()); + + $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); + $managerRegistryProphecy->getManagerForClass(ProviderDocument::class)->willReturn($managerProphecy->reveal()); + + $extensionProphecy = $this->prophesize(AggregationItemExtensionInterface::class); + + (new ItemProvider($this->prophesize(ResourceMetadataCollectionFactoryInterface::class)->reveal(), $managerRegistryProphecy->reveal(), [$extensionProphecy->reveal()]))->provide((new Get())->withClass(ProviderDocument::class), [], []); + } + + /** + * Gets a mocked manager registry. + */ + private function getManagerRegistry(string $resourceClass, Builder $aggregationBuilder, array $identifierFields = []): ManagerRegistry + { + $classMetadataProphecy = $this->prophesize(ClassMetadata::class); + $classMetadataProphecy->getIdentifier()->willReturn(array_keys($identifierFields)); + + foreach ($identifierFields as $name => $field) { + $classMetadataProphecy->getTypeOfField($name)->willReturn($field['type']); + } + + $repositoryProphecy = $this->prophesize(DocumentRepository::class); + $repositoryProphecy->createAggregationBuilder()->willReturn($aggregationBuilder); + + $managerProphecy = $this->prophesize(DocumentManager::class); + $managerProphecy->getRepository($resourceClass)->willReturn($repositoryProphecy->reveal()); + $managerProphecy->getClassMetadata($resourceClass)->willReturn($classMetadataProphecy->reveal()); + + $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); + $managerRegistryProphecy->getManagerForClass(ProviderDocument::class)->willReturn($managerProphecy->reveal()); + + return $managerRegistryProphecy->reveal(); + } +} diff --git a/src/State/Pagination/ArrayPaginator.php b/src/State/Pagination/ArrayPaginator.php index 3de9ab6e8f1..8c50186eb62 100644 --- a/src/State/Pagination/ArrayPaginator.php +++ b/src/State/Pagination/ArrayPaginator.php @@ -27,14 +27,15 @@ final class ArrayPaginator implements \IteratorAggregate, PaginatorInterface, Ha public function __construct(array $results, int $firstResult, int $maxResults) { - if ($maxResults > 0) { + $this->firstResult = $firstResult; + $this->maxResults = $maxResults; + $this->totalItems = \count($results); + + if ($maxResults > 0 && $firstResult < $this->totalItems) { $this->iterator = new \LimitIterator(new \ArrayIterator($results), $firstResult, $maxResults); } else { $this->iterator = new \EmptyIterator(); } - $this->firstResult = $firstResult; - $this->maxResults = $maxResults; - $this->totalItems = \count($results); } /** diff --git a/src/State/Provider/ContentNegotiationProvider.php b/src/State/Provider/ContentNegotiationProvider.php index f01b3e268d0..02f28f30121 100644 --- a/src/State/Provider/ContentNegotiationProvider.php +++ b/src/State/Provider/ContentNegotiationProvider.php @@ -92,6 +92,14 @@ private function flattenMimeTypes(array $formats): array */ private function getInputFormat(HttpOperation $operation, Request $request): ?string { + if ( + false === ($input = $operation->getInput()) + || (\is_array($input) && null === $input['class']) + || false === $operation->canDeserialize() + ) { + return null; + } + $contentType = $request->headers->get('CONTENT_TYPE'); if (null === $contentType || '' === $contentType) { return null; @@ -103,14 +111,14 @@ private function getInputFormat(HttpOperation $operation, Request $request): ?st return $format; } - $supportedMimeTypes = []; - foreach ($formats as $mimeTypes) { - foreach ($mimeTypes as $mimeType) { - $supportedMimeTypes[] = $mimeType; + if (!$request->isMethodSafe() && 'DELETE' !== $request->getMethod()) { + $supportedMimeTypes = []; + foreach ($formats as $mimeTypes) { + foreach ($mimeTypes as $mimeType) { + $supportedMimeTypes[] = $mimeType; + } } - } - if (!$request->isMethodSafe() && 'DELETE' !== $request->getMethod()) { throw new UnsupportedMediaTypeHttpException(\sprintf('The content-type "%s" is not supported. Supported MIME types are "%s".', $contentType, implode('", "', $supportedMimeTypes))); } diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource.xml b/src/Symfony/Bundle/Resources/config/metadata/resource.xml index 59b9422a9df..ea88caba300 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource.xml +++ b/src/Symfony/Bundle/Resources/config/metadata/resource.xml @@ -83,7 +83,8 @@ - + + diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceA.php b/tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceA.php new file mode 100644 index 00000000000..a86482bc2ea --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceA.php @@ -0,0 +1,64 @@ + + * + * 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\Issue4358; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Annotation\MaxDepth; + +#[Get(uriTemplate: 'resource_a', + formats: ['jsonhal'], + outputFormats: ['jsonhal'], + normalizationContext: ['groups' => ['ResourceA:read'], 'enable_max_depth' => true], + provider: [self::class, 'provide'])] +final class ResourceA +{ + private static ?ResourceA $resourceA = null; + + #[ApiProperty(readableLink: true)] + #[Groups(['ResourceA:read', 'ResourceB:read'])] + #[MaxDepth(6)] + public ResourceB $b; + + public function __construct(?ResourceB $b = null) + { + if (null !== $b) { + $this->b = $b; + } + } + + public static function provide(): self + { + return self::provideWithResource(); + } + + public static function provideWithResource(?ResourceB $b = null): self + { + if (!isset(self::$resourceA)) { + self::$resourceA = new self($b); + + if (null === ResourceB::getInstance()) { + self::$resourceA->b = ResourceB::provideWithResource(self::$resourceA); + } + } + + return self::$resourceA; + } + + public static function getInstance(): ?self + { + return self::$resourceA; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceB.php b/tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceB.php new file mode 100644 index 00000000000..cd5ba29d3c5 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue4358/ResourceB.php @@ -0,0 +1,64 @@ + + * + * 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\Issue4358; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Annotation\MaxDepth; + +#[Get(uriTemplate: 'resource_b', + formats: ['jsonhal'], + outputFormats: ['jsonhal'], + normalizationContext: ['groups' => ['ResourceB:read'], 'enable_max_depth' => true], + provider: [self::class, 'provide'])] +final class ResourceB +{ + private static ?ResourceB $resourceB = null; + + #[ApiProperty(readableLink: true)] + #[Groups(['ResourceA:read', 'ResourceB:read'])] + #[MaxDepth(6)] + public ResourceA $a; + + public function __construct(?ResourceA $a = null) + { + if (null !== $a) { + $this->a = $a; + } + } + + public static function provide(): self + { + return self::provideWithResource(); + } + + public static function provideWithResource(?ResourceA $a = null): self + { + if (!isset(self::$resourceB)) { + self::$resourceB = new self($a); + + if (null === ResourceA::getInstance()) { + self::$resourceB->a = ResourceA::provideWithResource(self::$resourceB); + } + } + + return self::$resourceB; + } + + public static function getInstance(): ?self + { + return self::$resourceB; + } +} diff --git a/tests/Fixtures/TestBundle/Document/DummyProduct.php b/tests/Fixtures/TestBundle/Document/DummyProduct.php index 0887236029b..cdd001ca68f 100644 --- a/tests/Fixtures/TestBundle/Document/DummyProduct.php +++ b/tests/Fixtures/TestBundle/Document/DummyProduct.php @@ -23,7 +23,7 @@ /** * Dummy Product. * - * https://github.com/api-platform/core/issues/1107. + * @see https://github.com/api-platform/core/issues/1107 * * @author Antoine Bluchet */ diff --git a/tests/Fixtures/TestBundle/Entity/DummyProduct.php b/tests/Fixtures/TestBundle/Entity/DummyProduct.php index 0f34fe8174d..d6e428f8a02 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyProduct.php +++ b/tests/Fixtures/TestBundle/Entity/DummyProduct.php @@ -23,7 +23,7 @@ /** * Dummy Product. * - * https://github.com/api-platform/core/issues/1107. + * @see https://github.com/api-platform/core/issues/1107 * * @author Antoine Bluchet */ diff --git a/tests/Functional/HALCircularReference.php b/tests/Functional/HALCircularReference.php new file mode 100644 index 00000000000..eda2afffaa2 --- /dev/null +++ b/tests/Functional/HALCircularReference.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\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue4358\ResourceA; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue4358\ResourceB; + +class HALCircularReference extends ApiTestCase +{ + public function testIssue4358(): void + { + $r1 = self::createClient()->request('GET', '/resource_a', ['headers' => ['Accept' => 'application/hal+json']]); + self::assertResponseIsSuccessful(); + self::assertEquals('{"_links":{"self":{"href":"\/resource_a"},"b":{"href":"\/resource_b"}},"_embedded":{"b":{"_links":{"self":{"href":"\/resource_b"},"a":{"href":"\/resource_a"}},"_embedded":{"a":{"_links":{"self":{"href":"\/resource_a"}}}}}}}', $r1->getContent()); + } + + public static function getResources(): array + { + return [ResourceA::class, ResourceB::class]; + } +} diff --git a/tests/State/Pagination/ArrayPaginatorTest.php b/tests/State/Pagination/ArrayPaginatorTest.php index 317ad6055e0..ba96f7bcbd3 100644 --- a/tests/State/Pagination/ArrayPaginatorTest.php +++ b/tests/State/Pagination/ArrayPaginatorTest.php @@ -41,6 +41,7 @@ public static function initializeProvider(): array 'Second of two pages of 3 items for the first page and 2 for the second' => [[0, 1, 2, 3, 4], 3, 3, 2, 5, 2, 2, false], 'Empty results' => [[], 0, 2, 0, 0, 1, 1, false], '0 for max results' => [[0, 1, 2, 3], 2, 0, 0, 4, 1, 1, false], + 'First result greater than total items' => [[0, 1], 2, 1, 0, 2, 3, 2, false], ]; } } diff --git a/tests/State/Provider/ContentNegotiationProviderTest.php b/tests/State/Provider/ContentNegotiationProviderTest.php index e4eb99ba904..dd610bd4b8d 100644 --- a/tests/State/Provider/ContentNegotiationProviderTest.php +++ b/tests/State/Provider/ContentNegotiationProviderTest.php @@ -21,6 +21,7 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; class ContentNegotiationProviderTest extends TestCase { @@ -60,4 +61,63 @@ public function testRequestWithEmptyContentType(): void $this->assertSame($expectedResult, $result); } + + public function testRequestWhenNoInput(): void + { + $expectedResult = new \stdClass(); + + $decorated = $this->prophesize(ProviderInterface::class); + $decorated->provide(Argument::cetera())->willReturn($expectedResult); + + $negotiator = new Negotiator(); + $formats = ['jsonld' => ['application/ld+json']]; + $errorFormats = ['jsonld' => ['application/ld+json']]; + + $provider = new ContentNegotiationProvider($decorated->reveal(), $negotiator, $formats, $errorFormats); + + $request = new Request( + server: [ + 'REQUEST_METHOD' => 'POST', + 'REQUEST_URI' => '/', + 'CONTENT_TYPE' => 'some-not-supported/content-type', + ], + content: '' + ); + + $operation = new Post(); + $operation = $operation->withDeserialize(false); + $context = ['request' => $request]; + + $result = $provider->provide($operation, [], $context); + + $this->assertSame($expectedResult, $result); + } + + public function testRequestWithInput(): void + { + $this->expectException(UnsupportedMediaTypeHttpException::class); + + $decorated = $this->prophesize(ProviderInterface::class); + + $negotiator = new Negotiator(); + $formats = ['jsonld' => ['application/ld+json']]; + $errorFormats = ['jsonld' => ['application/ld+json']]; + + $provider = new ContentNegotiationProvider($decorated->reveal(), $negotiator, $formats, $errorFormats); + + $request = new Request( + server: [ + 'REQUEST_METHOD' => 'POST', + 'REQUEST_URI' => '/', + 'CONTENT_TYPE' => 'some-not-supported/content-type', + ], + content: '' + ); + + $operation = new Post(); + $operation = $operation->withDeserialize(); + $context = ['request' => $request]; + + $provider->provide($operation, [], $context); + } }