diff --git a/features/bootstrap/FeatureContext.php b/features/bootstrap/FeatureContext.php index f55fb76b5e0..1790ddead56 100644 --- a/features/bootstrap/FeatureContext.php +++ b/features/bootstrap/FeatureContext.php @@ -609,9 +609,11 @@ public function thereIsAnAnswerToTheQuestion(string $a, string $q) $question = new Question(); $question->setContent($q); $question->setAnswer($answer); + $answer->addRelatedQuestion($question); $this->manager->persist($answer); $this->manager->persist($question); + $this->manager->flush(); } diff --git a/features/main/subresource.feature b/features/main/subresource.feature index ab6ccba3c6e..66ac56ffd5d 100644 --- a/features/main/subresource.feature +++ b/features/main/subresource.feature @@ -12,12 +12,39 @@ Feature: Subresource support And the JSON should be equal to: """ { - "@context": "\/contexts\/Answer", - "@id": "\/answers\/1", - "@type": "Answer", - "id": 1, - "content": "42", - "question": "\/questions\/1" + "@context": "/contexts/Answer", + "@id": "/answers/1", + "@type": "Answer", + "id": 1, + "content": "42", + "question": "/questions/1", + "relatedQuestions": [ + "/questions/1" + ] + } + """ + + Scenario: Get subresource one to one relation + When I send a "GET" request to "/questions/1/answer/related_questions" + And print last JSON response + And the response status code should be 200 + And the response should be in JSON + And the JSON should be equal to: + """ + { + "@context": "/contexts/Question", + "@id": "/questions/1/answer/related_questions", + "@type": "hydra:Collection", + "hydra:member": [ + { + "@id": "/questions/1", + "@type": "Question", + "content": "What's the answer to the Ultimate Question of Life, the Universe and Everything?", + "id": 1, + "answer": "/answers/1" + } + ], + "hydra:totalItems": 1 } """ diff --git a/src/Bridge/Doctrine/Orm/SubresourceDataProvider.php b/src/Bridge/Doctrine/Orm/SubresourceDataProvider.php index 50b95d0f69b..df99468f832 100644 --- a/src/Bridge/Doctrine/Orm/SubresourceDataProvider.php +++ b/src/Bridge/Doctrine/Orm/SubresourceDataProvider.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Bridge\Doctrine\Orm; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\FilterEagerLoadingExtension; use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryCollectionExtensionInterface; use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryItemExtensionInterface; use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryResultCollectionExtensionInterface; @@ -105,7 +106,7 @@ public function getSubresource(string $resourceClass, array $identifiers, array $qb = $manager->createQueryBuilder(); $alias = $queryNameGenerator->generateJoinAlias($identifier); $relationType = $classMetadata->getAssociationMapping($previousAssociationProperty)['type']; - $normalizedIdentifiers = $this->normalizeIdentifiers($identifiers[$identifier], $manager, $identifierResourceClass); + $normalizedIdentifiers = isset($identifiers[$identifier]) ? $this->normalizeIdentifiers($identifiers[$identifier], $manager, $identifierResourceClass) : []; switch ($relationType) { //MANY_TO_MANY relations need an explicit join so that the identifier part can be retrieved @@ -169,6 +170,11 @@ public function getSubresource(string $resourceClass, array $identifiers, array if (true === $context['collection']) { foreach ($this->collectionExtensions as $extension) { + // We don't need this anymore because we already made sub queries to ensure correct results + if ($extension instanceof FilterEagerLoadingExtension) { + continue; + } + $extension->applyToCollection($queryBuilder, $queryNameGenerator, $resourceClass, $operationName); if ($extension instanceof QueryResultCollectionExtensionInterface && $extension->supportsResult($resourceClass, $operationName)) { diff --git a/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php b/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php index a4f9ca17fc2..df7d5bfeac0 100644 --- a/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php +++ b/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php @@ -117,6 +117,8 @@ private function getContext(Request $request, Documentation $documentation): arr $swaggerData['operationId'] = sprintf('%s%sCollection', $collectionOperationName, $swaggerData['shortName']); } elseif (null !== $itemOperationName = $request->attributes->get('_api_item_operation_name')) { $swaggerData['operationId'] = sprintf('%s%sItem', $itemOperationName, $swaggerData['shortName']); + } elseif (null !== $subresourceOperationContext = $request->attributes->get('_api_subresource_context')) { + $swaggerData['operationId'] = $subresourceOperationContext['operationId']; } list($swaggerData['path'], $swaggerData['method']) = $this->getPathAndMethod($swaggerData); diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index 95641d5dfaa..2b4837337dd 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -43,8 +43,15 @@ public function getConfigTreeBuilder() ->scalarNode('title')->defaultValue('')->info('The title of the API.')->end() ->scalarNode('description')->defaultValue('')->info('The description of the API.')->end() ->scalarNode('version')->defaultValue('0.0.0')->info('The version of the API.')->end() - ->scalarNode('default_operation_path_resolver')->defaultValue('api_platform.operation_path_resolver.underscore')->info('Specify the default operation path resolver to use for generating resources operations path.')->end() + ->scalarNode('default_operation_path_resolver') + ->beforeNormalization()->always(function ($v) { + if (isset($v['default_operation_path_resolver'])) { + @trigger_error('The use of the `default_operation_path_resolver` has been deprecated in 2.1 and will be removed in 3.0. Use `path_segment_name_generator` instead.', E_USER_DEPRECATED); + } + })->end() + ->defaultValue('api_platform.operation_path_resolver.underscore')->info('Specify the default operation path resolver to use for generating resources operations path.')->end() ->scalarNode('name_converter')->defaultNull()->info('Specify a name converter to use.')->end() + ->scalarNode('path_segment_name_generator')->defaultValue('api_platform.path_segment_name_generator.underscore')->info('Specify a path name generator to use.')->end() ->scalarNode('api_resources_directory')->defaultValue('Entity')->info('The name of the directory within the bundles that contains the api resources.')->end() ->arrayNode('eager_loading') ->canBeDisabled() diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index 43fde27e12d..528e7747211 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -36,8 +36,7 @@ %api_platform.formats% %api_platform.resource_class_directories% - - + @@ -91,14 +90,31 @@ + + - - + + + + + + The "%service_id%" service is deprecated since ApiPlatform 2.1 and will be removed in 3.0. Use PathSegmentNameGenerator instead. + + + + The "%service_id%" service is deprecated since ApiPlatform 2.1 and will be removed in 3.0. Use PathSegmentNameGenerator instead. + + + + + + + @@ -194,6 +210,20 @@ + + + + + + + + + + + + + + @@ -203,6 +233,10 @@ + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml b/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml index a8e188f2079..914d7661ad7 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml @@ -22,6 +22,7 @@ %api_platform.oauth.tokenUrl% %api_platform.oauth.authorizationUrl% %api_platform.oauth.scopes% + diff --git a/src/Bridge/Symfony/Routing/ApiLoader.php b/src/Bridge/Symfony/Routing/ApiLoader.php index 7c1c86b3a3d..856f1934ab6 100644 --- a/src/Bridge/Symfony/Routing/ApiLoader.php +++ b/src/Bridge/Symfony/Routing/ApiLoader.php @@ -16,10 +16,9 @@ use ApiPlatform\Core\Api\OperationType; use ApiPlatform\Core\Exception\InvalidResourceException; use ApiPlatform\Core\Exception\RuntimeException; -use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; -use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactoryInterface; use ApiPlatform\Core\PathResolver\OperationPathResolverInterface; use Symfony\Component\Config\FileLocator; use Symfony\Component\Config\Loader\Loader; @@ -42,19 +41,17 @@ final class ApiLoader extends Loader */ const ROUTE_NAME_PREFIX = 'api_'; const DEFAULT_ACTION_PATTERN = 'api_platform.action.'; - const SUBRESOURCE_SUFFIX = '_get_subresource'; private $fileLoader; - private $propertyNameCollectionFactory; - private $propertyMetadataFactory; private $resourceNameCollectionFactory; private $resourceMetadataFactory; private $operationPathResolver; private $container; private $formats; private $resourceClassDirectories; + private $subresourceOperationFactory; - public function __construct(KernelInterface $kernel, ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, OperationPathResolverInterface $operationPathResolver, ContainerInterface $container, array $formats, array $resourceClassDirectories = [], PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory = null, PropertyMetadataFactoryInterface $propertyMetadataFactory = null) + public function __construct(KernelInterface $kernel, ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, OperationPathResolverInterface $operationPathResolver, ContainerInterface $container, array $formats, array $resourceClassDirectories = [], SubresourceOperationFactoryInterface $subresourceOperationFactory = null) { $this->fileLoader = new XmlFileLoader(new FileLocator($kernel->locateResource('@ApiPlatformBundle/Resources/config/routing'))); $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; @@ -63,8 +60,7 @@ public function __construct(KernelInterface $kernel, ResourceNameCollectionFacto $this->container = $container; $this->formats = $formats; $this->resourceClassDirectories = $resourceClassDirectories; - $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; - $this->propertyMetadataFactory = $propertyMetadataFactory; + $this->subresourceOperationFactory = $subresourceOperationFactory; } /** @@ -99,90 +95,35 @@ public function load($data, $type = null): RouteCollection } } - $this->computeSubresourceOperations($routeCollection, $resourceClass); - } - - return $routeCollection; - } - - /** - * Handles subresource operations recursively and declare their corresponding routes. - * - * @param RouteCollection $routeCollection - * @param string $resourceClass - * @param string $rootResourceClass null on the first iteration, it then keeps track of the origin resource class - * @param array $parentOperation the previous call operation - */ - private function computeSubresourceOperations(RouteCollection $routeCollection, string $resourceClass, string $rootResourceClass = null, array $parentOperation = null, array $visited = []) - { - if (null === $this->propertyNameCollectionFactory || null === $this->propertyMetadataFactory) { - return; - } - - if (null === $rootResourceClass) { - $rootResourceClass = $resourceClass; - } - - foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $property) { - $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property); - - if (!$propertyMetadata->hasSubresource()) { - continue; - } - - $subresource = $propertyMetadata->getSubresource(); - - $operation = [ - 'property' => $property, - 'collection' => $subresource->isCollection(), - ]; - - $visiting = "$rootResourceClass $resourceClass $property {$subresource->isCollection()} {$subresource->getResourceClass()}"; - - if (in_array($visiting, $visited, true)) { + if (null === $this->subresourceOperationFactory) { continue; } - $visited[] = $visiting; - - if (null === $parentOperation) { - $rootResourceMetadata = $this->resourceMetadataFactory->create($rootResourceClass); - $rootShortname = $rootResourceMetadata->getShortName(); - - $operation['identifiers'] = [['id', $rootResourceClass]]; - $operation['route_name'] = RouteNameGenerator::generate('get', $rootShortname, OperationType::SUBRESOURCE, $operation); - $operation['path'] = $this->operationPathResolver->resolveOperationPath($rootShortname, $operation, OperationType::SUBRESOURCE, $operation['route_name']); - } else { - $operation['identifiers'] = $parentOperation['identifiers']; - $operation['identifiers'][] = [$parentOperation['property'], $resourceClass]; - $operation['route_name'] = str_replace('get'.RouteNameGenerator::SUBRESOURCE_SUFFIX, RouteNameGenerator::routeNameResolver($property, $operation['collection']).'_get'.RouteNameGenerator::SUBRESOURCE_SUFFIX, $parentOperation['route_name']); - $operation['path'] = $this->operationPathResolver->resolveOperationPath($parentOperation['path'], $operation, OperationType::SUBRESOURCE, $operation['route_name']); - } - - $route = new Route( - $operation['path'], - [ - '_controller' => self::DEFAULT_ACTION_PATTERN.'get_subresource', - '_format' => null, - '_api_resource_class' => $subresource->getResourceClass(), - '_api_subresource_operation_name' => $operation['route_name'], - '_api_subresource_context' => [ - 'property' => $operation['property'], - 'identifiers' => $operation['identifiers'], - 'collection' => $subresource->isCollection(), + foreach ($this->subresourceOperationFactory->create($resourceClass) as $operationId => $operation) { + $routeCollection->add($operation['route_name'], new Route( + $operation['path'], + [ + '_controller' => self::DEFAULT_ACTION_PATTERN.'get_subresource', + '_format' => null, + '_api_resource_class' => $operation['resource_class'], + '_api_subresource_operation_name' => $operation['route_name'], + '_api_subresource_context' => [ + 'property' => $operation['property'], + 'identifiers' => $operation['identifiers'], + 'collection' => $operation['collection'], + 'operationId' => $operationId, + ], ], - ], - [], - [], - '', - [], - ['GET'] - ); - - $routeCollection->add($operation['route_name'], $route); - - $this->computeSubresourceOperations($routeCollection, $subresource->getResourceClass(), $rootResourceClass, $operation, $visited); + [], + [], + '', + [], + ['GET'] + )); + } } + + return $routeCollection; } /** diff --git a/src/Bridge/Symfony/Routing/RouteNameGenerator.php b/src/Bridge/Symfony/Routing/RouteNameGenerator.php index 3f596c9b7ce..e325b6174ca 100644 --- a/src/Bridge/Symfony/Routing/RouteNameGenerator.php +++ b/src/Bridge/Symfony/Routing/RouteNameGenerator.php @@ -25,10 +25,9 @@ * * @author Baptiste Meyer */ -class RouteNameGenerator +final class RouteNameGenerator { const ROUTE_NAME_PREFIX = 'api_'; - const SUBRESOURCE_SUFFIX = '_subresource'; private function __construct() { @@ -40,33 +39,21 @@ private function __construct() * @param string $operationName * @param string $resourceShortName * @param string|bool $operationType - * @param array $subresourceContext * * @throws InvalidArgumentException * * @return string */ - public static function generate(string $operationName, string $resourceShortName, $operationType, array $subresourceContext = []): string + public static function generate(string $operationName, string $resourceShortName, $operationType): string { if (OperationType::SUBRESOURCE === $operationType = OperationTypeDeprecationHelper::getOperationType($operationType)) { - if (!isset($subresourceContext['property'])) { - throw new InvalidArgumentException('Missing "property" to generate a route name from a subresource'); - } - - return sprintf( - '%s%s_%s_%s%s', - static::ROUTE_NAME_PREFIX, - self::routeNameResolver($resourceShortName), - self::routeNameResolver($subresourceContext['property'], $subresourceContext['collection'] ?? false), - $operationName, - self::SUBRESOURCE_SUFFIX - ); + throw new InvalidArgumentException('Subresource operations are not supported by the RouteNameGenerator.'); } return sprintf( '%s%s_%s_%s', static::ROUTE_NAME_PREFIX, - self::routeNameResolver($resourceShortName), + self::inflector($resourceShortName), $operationName, $operationType ); @@ -79,7 +66,7 @@ public static function generate(string $operationName, string $resourceShortName * * @return string A string that is a part of the route name */ - public static function routeNameResolver(string $name, bool $pluralize = true): string + public static function inflector(string $name, bool $pluralize = true): string { $name = Inflector::tableize($name); diff --git a/src/Bridge/Symfony/Routing/RouterOperationPathResolver.php b/src/Bridge/Symfony/Routing/RouterOperationPathResolver.php index 77618b8644f..d760b1e172e 100644 --- a/src/Bridge/Symfony/Routing/RouterOperationPathResolver.php +++ b/src/Bridge/Symfony/Routing/RouterOperationPathResolver.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Bridge\Symfony\Routing; +use ApiPlatform\Core\Api\OperationType; use ApiPlatform\Core\Api\OperationTypeDeprecationHelper; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\PathResolver\OperationPathResolverInterface; @@ -51,10 +52,14 @@ public function resolveOperationPath(string $resourceShortName, array $operation if (isset($operation['route_name'])) { $routeName = $operation['route_name']; - } elseif (null !== $operationName) { - $routeName = RouteNameGenerator::generate($operationName, $resourceShortName, $operationType, $operation); + } elseif (OperationType::SUBRESOURCE === $operationType) { + throw new InvalidArgumentException('Subresource operations are not supported by the RouterOperationPathResolver without a route name.'); } else { - return $this->deferred->resolveOperationPath($resourceShortName, $operation, OperationTypeDeprecationHelper::getOperationType($operationType), $operationName); + if (null !== $operationName) { + $routeName = RouteNameGenerator::generate($operationName, $resourceShortName, $operationType); + } else { + return $this->deferred->resolveOperationPath($resourceShortName, $operation, OperationTypeDeprecationHelper::getOperationType($operationType), $operationName); + } } if (!$route = $this->router->getRouteCollection()->get($routeName)) { diff --git a/src/EventListener/ReadListener.php b/src/EventListener/ReadListener.php index 590345bc5c7..4bc3ba0e741 100644 --- a/src/EventListener/ReadListener.php +++ b/src/EventListener/ReadListener.php @@ -133,8 +133,10 @@ private function getSubresourceData(Request $request, array $attributes) } $identifiers = []; - foreach ($attributes['subresource_context']['identifiers'] as $key => list($id)) { - $identifiers[$id] = $request->attributes->get($id); + foreach ($attributes['subresource_context']['identifiers'] as $key => list($id, $class, $hasIdentifier)) { + if (true === $hasIdentifier) { + $identifiers[$id] = $request->attributes->get($id); + } } $data = $this->subresourceDataProvider->getSubresource($attributes['resource_class'], $identifiers, $attributes['subresource_context'], $attributes['subresource_operation_name']); diff --git a/src/Operation/DashPathSegmentNameGenerator.php b/src/Operation/DashPathSegmentNameGenerator.php new file mode 100644 index 00000000000..91042d9a396 --- /dev/null +++ b/src/Operation/DashPathSegmentNameGenerator.php @@ -0,0 +1,39 @@ + + * + * 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\Core\Operation; + +use Doctrine\Common\Util\Inflector; + +/** + * Generate a path name with a dash separator according to a string and whether it's a collection or not. + * + * @author Antoine Bluchet + */ +final class DashPathSegmentNameGenerator implements PathSegmentNameGeneratorInterface +{ + /** + * {@inheritdoc} + */ + public function getSegmentName(string $name, bool $collection = true): string + { + $name = $this->dashize($name); + + return $collection ? Inflector::pluralize($name) : $name; + } + + private function dashize(string $string): string + { + return strtolower(preg_replace('~(?<=\\w)([A-Z])~', '-$1', $string)); + } +} diff --git a/src/Operation/Factory/CachedSubresourceOperationFactory.php b/src/Operation/Factory/CachedSubresourceOperationFactory.php new file mode 100644 index 00000000000..7c615066444 --- /dev/null +++ b/src/Operation/Factory/CachedSubresourceOperationFactory.php @@ -0,0 +1,59 @@ + + * + * 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\Core\Operation\Factory; + +use Psr\Cache\CacheException; +use Psr\Cache\CacheItemPoolInterface; + +/** + * @internal + */ +final class CachedSubresourceOperationFactory implements SubresourceOperationFactoryInterface +{ + const CACHE_KEY_PREFIX = 'subresource_operations_'; + + private $cacheItemPool; + private $decorated; + + public function __construct(CacheItemPoolInterface $cacheItemPool, SubresourceOperationFactoryInterface $decorated) + { + $this->cacheItemPool = $cacheItemPool; + $this->decorated = $decorated; + } + + /** + * {@inheritdoc} + */ + public function create(string $resourceClass): array + { + $cacheKey = self::CACHE_KEY_PREFIX.str_replace('\\', '', $resourceClass); + + try { + $cacheItem = $this->cacheItemPool->getItem($cacheKey); + + if ($cacheItem->isHit()) { + return $cacheItem->get(); + } + } catch (CacheException $e) { + return $this->decorated->create($resourceClass); + } + + $subresourceOperations = $this->decorated->create($resourceClass); + + $cacheItem->set($subresourceOperations); + $this->cacheItemPool->save($cacheItem); + + return $subresourceOperations; + } +} diff --git a/src/Operation/Factory/SubresourceOperationFactory.php b/src/Operation/Factory/SubresourceOperationFactory.php new file mode 100644 index 00000000000..71f032bc799 --- /dev/null +++ b/src/Operation/Factory/SubresourceOperationFactory.php @@ -0,0 +1,144 @@ + + * + * 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\Core\Operation\Factory; + +use ApiPlatform\Core\Bridge\Symfony\Routing\RouteNameGenerator; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Operation\PathSegmentNameGeneratorInterface; + +/** + * @internal + */ +final class SubresourceOperationFactory implements SubresourceOperationFactoryInterface +{ + const SUBRESOURCE_SUFFIX = '_subresource'; + const FORMAT_SUFFIX = '.{_format}'; + + private $resourceMetadataFactory; + private $propertyNameCollectionFactory; + private $propertyMetadataFactory; + private $pathSegmentNameGenerator; + + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, PathSegmentNameGeneratorInterface $pathSegmentNameGenerator) + { + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; + $this->propertyMetadataFactory = $propertyMetadataFactory; + $this->pathSegmentNameGenerator = $pathSegmentNameGenerator; + } + + /** + * {@inheritdoc} + */ + public function create(string $resourceClass): array + { + $tree = []; + $this->computeSubresourceOperations($resourceClass, $tree); + + return $tree; + } + + /** + * Handles subresource operations recursively and declare their corresponding routes. + * + * @param string $resourceClass + * @param array $tree + * @param string $rootResourceClass null on the first iteration, it then keeps track of the origin resource class + * @param array $parentOperation the previous call operation + */ + private function computeSubresourceOperations(string $resourceClass, array &$tree, string $rootResourceClass = null, array $parentOperation = null) + { + if (null === $rootResourceClass) { + $rootResourceClass = $resourceClass; + } + + foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $property) { + $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property); + + if (!$propertyMetadata->hasSubresource()) { + continue; + } + + $subresource = $propertyMetadata->getSubresource(); + $subresourceClass = $subresource->getResourceClass(); + $subresourceMetadata = $this->resourceMetadataFactory->create($subresourceClass); + + if (null === $parentOperation) { + $visiting = "$rootResourceClass-$property-$subresourceClass"; + } else { + $prefix = ''; + $visiting = "{$parentOperation['property']}-{$parentOperation['resource_class']}-$property-$subresourceClass"; + + foreach ($parentOperation['identifiers'] as $key => list($param, $class)) { + $prefix .= 0 === $key ? $class : "-$param-$class"; + } + + if (false !== strpos($prefix, $visiting)) { + continue; + } + + $visiting = $prefix.'-'.$visiting; + } + + $operationName = 'get'; + $operation = [ + 'property' => $property, + 'collection' => $subresource->isCollection(), + 'resource_class' => $subresourceClass, + 'shortNames' => [$subresourceMetadata->getShortName()], + ]; + + if (null === $parentOperation) { + $rootResourceMetadata = $this->resourceMetadataFactory->create($rootResourceClass); + $rootShortname = $rootResourceMetadata->getShortName(); + $operation['identifiers'] = [['id', $rootResourceClass, true]]; + $operation['route_name'] = sprintf( + '%s%s_%s_%s%s', + RouteNameGenerator::ROUTE_NAME_PREFIX, + RouteNameGenerator::inflector($rootShortname), + RouteNameGenerator::inflector($operation['property'], $operation['collection'] ?? false), + $operationName, + self::SUBRESOURCE_SUFFIX + ); + + $operation['path'] = sprintf( + '/%s/{id}/%s%s', + $this->pathSegmentNameGenerator->getSegmentName($rootShortname, true), + $this->pathSegmentNameGenerator->getSegmentName($operation['property'], $operation['collection']), + self::FORMAT_SUFFIX + ); + + $operation['shortNames'][] = $rootShortname; + } else { + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + $operation['identifiers'] = $parentOperation['identifiers']; + $operation['identifiers'][] = [$parentOperation['property'], $resourceClass, $parentOperation['collection']]; + $operation['route_name'] = str_replace('get'.self::SUBRESOURCE_SUFFIX, RouteNameGenerator::inflector($property, $operation['collection']).'_get'.self::SUBRESOURCE_SUFFIX, $parentOperation['route_name']); + $operation['shortNames'][] = $resourceMetadata->getShortName(); + + $operation['path'] = str_replace(self::FORMAT_SUFFIX, '', $parentOperation['path']); + if ($parentOperation['collection']) { + list($key) = end($operation['identifiers']); + $operation['path'] .= sprintf('/{%s}', $key); + } + $operation['path'] .= sprintf('/%s%s', $this->pathSegmentNameGenerator->getSegmentName($property, $operation['collection']), self::FORMAT_SUFFIX); + } + + $tree[$visiting] = $operation; + $this->computeSubresourceOperations($subresourceClass, $tree, $rootResourceClass, $operation); + } + } +} diff --git a/src/Operation/Factory/SubresourceOperationFactoryInterface.php b/src/Operation/Factory/SubresourceOperationFactoryInterface.php new file mode 100644 index 00000000000..5a3460cb866 --- /dev/null +++ b/src/Operation/Factory/SubresourceOperationFactoryInterface.php @@ -0,0 +1,31 @@ + + * + * 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\Core\Operation\Factory; + +/** + * Computes subresource operation for a given resource. + * + * @author Antoine Bluchet + */ +interface SubresourceOperationFactoryInterface +{ + /** + * Creates subresource operations. + * + * @param string $resourceClass + * + * @return array + */ + public function create(string $resourceClass): array; +} diff --git a/src/Operation/PathSegmentNameGeneratorInterface.php b/src/Operation/PathSegmentNameGeneratorInterface.php new file mode 100644 index 00000000000..d777cee7e67 --- /dev/null +++ b/src/Operation/PathSegmentNameGeneratorInterface.php @@ -0,0 +1,32 @@ + + * + * 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\Core\Operation; + +/** + * Generates a path name according to a string and whether it's a collection or not. + * + * @author Antoine Bluchet + */ +interface PathSegmentNameGeneratorInterface +{ + /** + * Transforms a given string to a valid path name which can be pluralized (eg. for collections). + * + * @param string $name usually a ResourceMetadata shortname + * @param bool $collection + * + * @return string A string that is a part of the route name + */ + public function getSegmentName(string $name, bool $collection = true): string; +} diff --git a/src/Operation/UnderscorePathSegmentNameGenerator.php b/src/Operation/UnderscorePathSegmentNameGenerator.php new file mode 100644 index 00000000000..9e39329b66c --- /dev/null +++ b/src/Operation/UnderscorePathSegmentNameGenerator.php @@ -0,0 +1,34 @@ + + * + * 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\Core\Operation; + +use Doctrine\Common\Util\Inflector; + +/** + * Generate a path name with an underscore separator according to a string and whether it's a collection or not. + * + * @author Antoine Bluchet + */ +final class UnderscorePathSegmentNameGenerator implements PathSegmentNameGeneratorInterface +{ + /** + * {@inheritdoc} + */ + public function getSegmentName(string $name, bool $collection = true): string + { + $name = Inflector::tableize($name); + + return $collection ? Inflector::pluralize($name) : $name; + } +} diff --git a/src/PathResolver/DashOperationPathResolver.php b/src/PathResolver/DashOperationPathResolver.php index 28df5dd1572..b9d13414770 100644 --- a/src/PathResolver/DashOperationPathResolver.php +++ b/src/PathResolver/DashOperationPathResolver.php @@ -13,12 +13,10 @@ namespace ApiPlatform\Core\PathResolver; -use ApiPlatform\Core\Api\OperationType; -use ApiPlatform\Core\Api\OperationTypeDeprecationHelper; -use Doctrine\Common\Inflector\Inflector; +use ApiPlatform\Core\Operation\DashPathSegmentNameGenerator; /** - * Generates a path with words separated by dashes. + * Generates a path with words separated by underscores. * * @author Paul Le Corre */ @@ -29,35 +27,14 @@ final class DashOperationPathResolver implements OperationPathResolverInterface */ public function resolveOperationPath(string $resourceShortName, array $operation, $operationType/*, string $operationName = null*/): string { - if (func_num_args() < 4) { - @trigger_error(sprintf('Method %s() will have a 4th `string $operationName` argument in version 3.0. Not defining it is deprecated since 2.1.', __METHOD__), E_USER_DEPRECATED); - } - - $operationType = OperationTypeDeprecationHelper::getOperationType($operationType); + @trigger_error(sprintf('The use of %s is deprecated since 2.1. Please use PathSegmentNameGenerator instead.', __CLASS__), E_USER_DEPRECATED); - if ($operationType === OperationType::SUBRESOURCE && 1 < count($operation['identifiers'])) { - $path = str_replace('.{_format}', '', $resourceShortName); + if (func_num_args() >= 4) { + $operationName = func_get_arg(3); } else { - $path = '/'.Inflector::pluralize($this->dashize($resourceShortName)); - } - - if ($operationType === OperationType::ITEM) { - $path .= '/{id}'; - } - - if ($operationType === OperationType::SUBRESOURCE) { - list($key) = end($operation['identifiers']); - $property = true === $operation['collection'] ? Inflector::pluralize($this->dashize($operation['property'])) : $this->dashize($operation['property']); - $path .= sprintf('/{%s}/%s', $key, $property); + $operationName = null; } - $path .= '.{_format}'; - - return $path; - } - - private function dashize(string $string): string - { - return strtolower(preg_replace('~(?<=\\w)([A-Z])~', '-$1', $string)); + return (new OperationPathResolver(new DashPathSegmentNameGenerator()))->resolveOperationPath($resourceShortName, $operation, $operationType, $operationName); } } diff --git a/src/PathResolver/OperationPathResolver.php b/src/PathResolver/OperationPathResolver.php new file mode 100644 index 00000000000..668be472c6a --- /dev/null +++ b/src/PathResolver/OperationPathResolver.php @@ -0,0 +1,60 @@ + + * + * 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\Core\PathResolver; + +use ApiPlatform\Core\Api\OperationType; +use ApiPlatform\Core\Api\OperationTypeDeprecationHelper; +use ApiPlatform\Core\Exception\InvalidArgumentException; +use ApiPlatform\Core\Operation\PathSegmentNameGeneratorInterface; + +/** + * Generates an operation path. + * + * @author Antoine Bluchet + */ +final class OperationPathResolver implements OperationPathResolverInterface +{ + private $pathSegmentNameGenerator; + + public function __construct(PathSegmentNameGeneratorInterface $pathSegmentNameGenerator) + { + $this->pathSegmentNameGenerator = $pathSegmentNameGenerator; + } + + /** + * {@inheritdoc} + */ + public function resolveOperationPath(string $resourceShortName, array $operation, $operationType/*, string $operationName = null*/): string + { + if (func_num_args() < 4) { + @trigger_error(sprintf('Method %s() will have a 4th `string $operationName` argument in version 3.0. Not defining it is deprecated since 2.1.', __METHOD__), E_USER_DEPRECATED); + } + + $operationType = OperationTypeDeprecationHelper::getOperationType($operationType); + + if (OperationType::SUBRESOURCE === $operationType) { + throw new InvalidArgumentException('Subresource operations are not supported by the OperationPathResolver.'); + } + + $path = '/'.$this->pathSegmentNameGenerator->getSegmentName($resourceShortName, true); + + if ($operationType === OperationType::ITEM) { + $path .= '/{id}'; + } + + $path .= '.{_format}'; + + return $path; + } +} diff --git a/src/PathResolver/UnderscoreOperationPathResolver.php b/src/PathResolver/UnderscoreOperationPathResolver.php index c676cbdf1ef..712be0dc8c6 100644 --- a/src/PathResolver/UnderscoreOperationPathResolver.php +++ b/src/PathResolver/UnderscoreOperationPathResolver.php @@ -13,9 +13,7 @@ namespace ApiPlatform\Core\PathResolver; -use ApiPlatform\Core\Api\OperationType; -use ApiPlatform\Core\Api\OperationTypeDeprecationHelper; -use ApiPlatform\Core\Bridge\Symfony\Routing\RouteNameGenerator; +use ApiPlatform\Core\Operation\UnderscorePathSegmentNameGenerator; /** * Generates a path with words separated by underscores. @@ -29,30 +27,14 @@ final class UnderscoreOperationPathResolver implements OperationPathResolverInte */ public function resolveOperationPath(string $resourceShortName, array $operation, $operationType/*, string $operationName = null*/): string { - if (func_num_args() < 4) { - @trigger_error(sprintf('Method %s() will have a 4th `string $operationName` argument in version 3.0. Not defining it is deprecated since 2.1.', __METHOD__), E_USER_DEPRECATED); - } - - $operationType = OperationTypeDeprecationHelper::getOperationType($operationType); + @trigger_error(sprintf('The use of %s is deprecated since 2.1. Please use PathSegmentNameGenerator instead.', __CLASS__), E_USER_DEPRECATED); - if ($operationType === OperationType::SUBRESOURCE && 1 < count($operation['identifiers'])) { - $path = str_replace('.{_format}', '', $resourceShortName); + if (func_num_args() >= 4) { + $operationName = func_get_arg(3); } else { - $path = '/'.RouteNameGenerator::routeNameResolver($resourceShortName, true); - } - - if ($operationType === OperationType::ITEM) { - $path .= '/{id}'; + $operationName = null; } - if ($operationType === OperationType::SUBRESOURCE) { - list($key) = end($operation['identifiers']); - $property = RouteNameGenerator::routeNameResolver($operation['property'], $operation['collection']); - $path .= sprintf('/{%s}/%s', $key, $property); - } - - $path .= '.{_format}'; - - return $path; + return (new OperationPathResolver(new UnderscorePathSegmentNameGenerator()))->resolveOperationPath($resourceShortName, $operation, $operationType, $operationName); } } diff --git a/src/Swagger/Serializer/DocumentationNormalizer.php b/src/Swagger/Serializer/DocumentationNormalizer.php index 9d1133765db..c0f4f1d5093 100644 --- a/src/Swagger/Serializer/DocumentationNormalizer.php +++ b/src/Swagger/Serializer/DocumentationNormalizer.php @@ -25,6 +25,7 @@ use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactoryInterface; use ApiPlatform\Core\PathResolver\OperationPathResolverInterface; use Psr\Container\ContainerInterface; use Symfony\Component\PropertyInfo\Type; @@ -58,11 +59,12 @@ final class DocumentationNormalizer implements NormalizerInterface private $oauthTokenUrl; private $oauthAuthorizationUrl; private $oauthScopes; + private $subresourceOperationFactory; /** * @param ContainerInterface|FilterCollection|null $filterLocator The new filter locator or the deprecated filter collection */ - public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, OperationMethodResolverInterface $operationMethodResolver, OperationPathResolverInterface $operationPathResolver, UrlGeneratorInterface $urlGenerator = null, $filterLocator = null, NameConverterInterface $nameConverter = null, $oauthEnabled = false, $oauthType = '', $oauthFlow = '', $oauthTokenUrl = '', $oauthAuthorizationUrl = '', $oauthScopes = []) + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceClassResolverInterface $resourceClassResolver, OperationMethodResolverInterface $operationMethodResolver, OperationPathResolverInterface $operationPathResolver, UrlGeneratorInterface $urlGenerator = null, $filterLocator = null, NameConverterInterface $nameConverter = null, $oauthEnabled = false, $oauthType = '', $oauthFlow = '', $oauthTokenUrl = '', $oauthAuthorizationUrl = '', $oauthScopes = [], SubresourceOperationFactoryInterface $subresourceOperationFactory = null) { if ($urlGenerator) { @trigger_error(sprintf('Passing an instance of %s to %s() is deprecated since version 2.1 and will be removed in 3.0.', UrlGeneratorInterface::class, __METHOD__), E_USER_DEPRECATED); @@ -83,6 +85,7 @@ public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFa $this->oauthTokenUrl = $oauthTokenUrl; $this->oauthAuthorizationUrl = $oauthAuthorizationUrl; $this->oauthScopes = $oauthScopes; + $this->subresourceOperationFactory = $subresourceOperationFactory; } /** @@ -100,6 +103,44 @@ public function normalize($object, $format = null, array $context = []) $this->addPaths($paths, $definitions, $resourceClass, $resourceShortName, $resourceMetadata, $mimeTypes, OperationType::COLLECTION); $this->addPaths($paths, $definitions, $resourceClass, $resourceShortName, $resourceMetadata, $mimeTypes, OperationType::ITEM); + + if (null === $this->subresourceOperationFactory) { + continue; + } + + foreach ($this->subresourceOperationFactory->create($resourceClass) as $operationId => $subresourceOperation) { + $operationName = 'get'; + $serializerContext = $this->getSerializerContext(OperationType::SUBRESOURCE, false, $resourceMetadata, $operationName); + $responseDefinitionKey = $this->getDefinition($definitions, $this->resourceMetadataFactory->create($subresourceOperation['resource_class']), $subresourceOperation['resource_class'], $serializerContext); + + $pathOperation = new \ArrayObject([]); + $pathOperation['tags'] = $subresourceOperation['shortNames']; + $pathOperation['operationId'] = $operationId; + $pathOperation['produces'] = $mimeTypes; + $pathOperation['summary'] = sprintf('Retrieves %s%s resource%s.', $subresourceOperation['collection'] ? 'the collection of ' : 'a ', $subresourceOperation['shortNames'][0], $subresourceOperation['collection'] ? 's' : ''); + $pathOperation['responses'] = [ + '200' => $subresourceOperation['collection'] ? [ + 'description' => sprintf('%s colletion response', $subresourceOperation['shortNames'][0]), + 'schema' => ['type' => 'array', 'items' => ['$ref' => sprintf('#/definitions/%s', $responseDefinitionKey)]], + ] : [ + 'description' => sprintf('%s resource response', $subresourceOperation['shortNames'][0]), + 'schema' => ['$ref' => sprintf('#/definitions/%s', $responseDefinitionKey)], + ], + '404' => ['description' => 'Resource not found'], + ]; + + if ($parameters = $this->getFiltersParameters($resourceClass, $operationName, $resourceMetadata, $definitions, $serializerContext)) { + $pathOperation['parameters'] = $parameters; + } + + foreach ($subresourceOperation['identifiers'] as list($identifier, $class, $hasIdentifier)) { + if (true === $hasIdentifier) { + $pathOperation['parameters'][] = ['name' => $identifier, 'in' => 'path', 'required' => true, 'type' => 'string']; + } + } + + $paths[$this->getPath($subresourceOperation['shortNames'][0], $subresourceOperation['route_name'], $subresourceOperation, OperationType::SUBRESOURCE)] = new \ArrayObject(['get' => $pathOperation]); + } } $definitions->ksort(); diff --git a/tests/Bridge/Symfony/Bundle/Command/SwaggerCommandTest.php b/tests/Bridge/Symfony/Bundle/Command/SwaggerCommandTest.php index f0383f24ac4..fccc7f9244e 100644 --- a/tests/Bridge/Symfony/Bundle/Command/SwaggerCommandTest.php +++ b/tests/Bridge/Symfony/Bundle/Command/SwaggerCommandTest.php @@ -19,6 +19,7 @@ /** * @author Amrouche Hamza + * @group legacy */ class SwaggerCommandTest extends KernelTestCase { diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index d7932a1b09d..82fa70c9625 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -341,6 +341,7 @@ private function getContainerBuilderProphecy() 'api_platform.cache.identifiers_extractor', 'api_platform.cache.metadata.resource', 'api_platform.cache.route_name_resolver', + 'api_platform.cache.subresource_operation_factory', 'api_platform.collection_data_provider', 'api_platform.doctrine.listener.view.write', 'api_platform.doctrine.metadata_factory', @@ -438,7 +439,10 @@ private function getContainerBuilderProphecy() 'api_platform.operation_path_resolver.custom', 'api_platform.operation_path_resolver.dash', 'api_platform.operation_path_resolver.router', + 'api_platform.operation_path_resolver.generator', 'api_platform.operation_path_resolver.underscore', + 'api_platform.path_segment_name_generator.underscore', + 'api_platform.path_segment_name_generator.dash', 'api_platform.problem.encoder', 'api_platform.problem.normalizer.constraint_violation_list', 'api_platform.problem.normalizer.error', @@ -453,6 +457,8 @@ private function getContainerBuilderProphecy() 'api_platform.serializer.group_filter', 'api_platform.serializer.normalizer.item', 'api_platform.subresource_data_provider', + 'api_platform.subresource_operation_factory', + 'api_platform.subresource_operation_factory.cached', 'api_platform.swagger.action.ui', 'api_platform.swagger.command.swagger_command', 'api_platform.swagger.normalizer.documentation', @@ -480,6 +486,7 @@ private function getContainerBuilderProphecy() 'api_platform.metadata.resource.name_collection_factory' => 'api_platform.metadata.resource.name_collection_factory.xml', 'api_platform.operation_path_resolver' => 'api_platform.operation_path_resolver.router', 'api_platform.operation_path_resolver.default' => 'api_platform.operation_path_resolver.underscore', + 'api_platform.path_segment_name_generator' => 'api_platform.path_segment_name_generator.underscore', 'api_platform.property_accessor' => 'property_accessor', 'api_platform.property_info' => 'property_info', 'api_platform.serializer' => 'serializer', diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 798f3e37de0..443d541a7d9 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -68,6 +68,7 @@ public function testDefaultConfig() InvalidArgumentException::class => Response::HTTP_BAD_REQUEST, ], 'default_operation_path_resolver' => 'api_platform.operation_path_resolver.underscore', + 'path_segment_name_generator' => 'api_platform.path_segment_name_generator.underscore', 'name_converter' => null, 'enable_fos_user' => false, 'enable_nelmio_api_doc' => false, diff --git a/tests/Bridge/Symfony/Routing/ApiLoaderTest.php b/tests/Bridge/Symfony/Routing/ApiLoaderTest.php index d3d7e136895..d4781fcb338 100644 --- a/tests/Bridge/Symfony/Routing/ApiLoaderTest.php +++ b/tests/Bridge/Symfony/Routing/ApiLoaderTest.php @@ -23,8 +23,10 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactory; +use ApiPlatform\Core\Operation\UnderscorePathSegmentNameGenerator; use ApiPlatform\Core\PathResolver\CustomOperationPathResolver; -use ApiPlatform\Core\PathResolver\UnderscoreOperationPathResolver; +use ApiPlatform\Core\PathResolver\OperationPathResolver; use ApiPlatform\Core\Tests\Fixtures\DummyEntity; use ApiPlatform\Core\Tests\Fixtures\RelatedDummyEntity; use Prophecy\Argument; @@ -89,7 +91,7 @@ public function testApiLoader() ); $this->assertEquals( - $this->getSubresourceRoute('/dummies/{id}/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_dummies_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => [['id', DummyEntity::class]], 'collection' => true]), + $this->getSubresourceRoute('/dummies/{id}/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_dummies_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => [['id', DummyEntity::class, true]], 'collection' => true, 'operationId' => 'ApiPlatform\Core\Tests\Fixtures\DummyEntity-subresource-ApiPlatform\Core\Tests\Fixtures\RelatedDummyEntity']), $routeCollection->get('api_dummies_subresources_get_subresource') ); } @@ -158,32 +160,32 @@ public function testRecursiveSubresource() $routeCollection = $this->getApiLoaderWithResourceMetadata($resourceMetadata, true)->load(null); $this->assertEquals( - $this->getSubresourceRoute('/dummies/{id}/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_dummies_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => [['id', DummyEntity::class]], 'collection' => true]), + $this->getSubresourceRoute('/dummies/{id}/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_dummies_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => [['id', DummyEntity::class, true]], 'collection' => true, 'operationId' => 'ApiPlatform\Core\Tests\Fixtures\DummyEntity-subresource-ApiPlatform\Core\Tests\Fixtures\RelatedDummyEntity']), $routeCollection->get('api_dummies_subresources_get_subresource') ); $this->assertEquals( - $this->getSubresourceRoute('/related_dummies/{id}/recursivesubresource/{recursivesubresource}/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_related_dummies_recursivesubresource_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => [['id', RelatedDummyEntity::class], ['recursivesubresource', DummyEntity::class]], 'collection' => true]), + $this->getSubresourceRoute('/related_dummies/{id}/recursivesubresource/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_related_dummies_recursivesubresource_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => [['id', RelatedDummyEntity::class, true], ['recursivesubresource', DummyEntity::class, false]], 'collection' => true, 'operationId' => 'ApiPlatform\Core\Tests\Fixtures\RelatedDummyEntity-recursivesubresource-ApiPlatform\Core\Tests\Fixtures\DummyEntity-subresource-ApiPlatform\Core\Tests\Fixtures\RelatedDummyEntity']), $routeCollection->get('api_related_dummies_recursivesubresource_subresources_get_subresource') ); $this->assertEquals( - $this->getSubresourceRoute('/related_dummies/{id}/recursivesubresource.{_format}', 'api_platform.action.get_subresource', DummyEntity::class, 'api_related_dummies_recursivesubresource_get_subresource', ['property' => 'recursivesubresource', 'identifiers' => [['id', RelatedDummyEntity::class]], 'collection' => false]), + $this->getSubresourceRoute('/related_dummies/{id}/recursivesubresource.{_format}', 'api_platform.action.get_subresource', DummyEntity::class, 'api_related_dummies_recursivesubresource_get_subresource', ['property' => 'recursivesubresource', 'identifiers' => [['id', RelatedDummyEntity::class, true]], 'collection' => false, 'operationId' => 'ApiPlatform\Core\Tests\Fixtures\RelatedDummyEntity-recursivesubresource-ApiPlatform\Core\Tests\Fixtures\DummyEntity']), $routeCollection->get('api_related_dummies_recursivesubresource_get_subresource') ); $this->assertEquals( - $this->getSubresourceRoute('/dummies/{id}/subresources/{subresource}/recursivesubresource.{_format}', 'api_platform.action.get_subresource', DummyEntity::class, 'api_dummies_subresources_recursivesubresource_get_subresource', ['property' => 'recursivesubresource', 'identifiers' => [['id', DummyEntity::class], ['subresource', RelatedDummyEntity::class]], 'collection' => false]), + $this->getSubresourceRoute('/dummies/{id}/subresources/{subresource}/recursivesubresource.{_format}', 'api_platform.action.get_subresource', DummyEntity::class, 'api_dummies_subresources_recursivesubresource_get_subresource', ['property' => 'recursivesubresource', 'identifiers' => [['id', DummyEntity::class, true], ['subresource', RelatedDummyEntity::class, true]], 'collection' => false, 'operationId' => 'ApiPlatform\Core\Tests\Fixtures\DummyEntity-subresource-ApiPlatform\Core\Tests\Fixtures\RelatedDummyEntity-recursivesubresource-ApiPlatform\Core\Tests\Fixtures\DummyEntity']), $routeCollection->get('api_dummies_subresources_recursivesubresource_get_subresource') ); $this->assertEquals( - $this->getSubresourceRoute('/related_dummies/{id}/secondrecursivesubresource/{secondrecursivesubresource}/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_related_dummies_secondrecursivesubresource_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => [['id', RelatedDummyEntity::class], ['secondrecursivesubresource', DummyEntity::class]], 'collection' => true]), + $this->getSubresourceRoute('/related_dummies/{id}/secondrecursivesubresource/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_related_dummies_secondrecursivesubresource_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => [['id', RelatedDummyEntity::class, true], ['secondrecursivesubresource', DummyEntity::class, false]], 'collection' => true, 'operationId' => 'ApiPlatform\Core\Tests\Fixtures\RelatedDummyEntity-secondrecursivesubresource-ApiPlatform\Core\Tests\Fixtures\DummyEntity-subresource-ApiPlatform\Core\Tests\Fixtures\RelatedDummyEntity']), $routeCollection->get('api_related_dummies_secondrecursivesubresource_subresources_get_subresource') ); $this->assertEquals( - $this->getSubresourceRoute('/related_dummies/{id}/secondrecursivesubresource.{_format}', 'api_platform.action.get_subresource', DummyEntity::class, 'api_related_dummies_secondrecursivesubresource_get_subresource', ['property' => 'secondrecursivesubresource', 'identifiers' => [['id', RelatedDummyEntity::class]], 'collection' => false]), + $this->getSubresourceRoute('/related_dummies/{id}/secondrecursivesubresource.{_format}', 'api_platform.action.get_subresource', DummyEntity::class, 'api_related_dummies_secondrecursivesubresource_get_subresource', ['property' => 'secondrecursivesubresource', 'identifiers' => [['id', RelatedDummyEntity::class, true]], 'collection' => false, 'operationId' => 'ApiPlatform\Core\Tests\Fixtures\RelatedDummyEntity-secondrecursivesubresource-ApiPlatform\Core\Tests\Fixtures\DummyEntity']), $routeCollection->get('api_related_dummies_secondrecursivesubresource_get_subresource') ); } @@ -248,9 +250,13 @@ private function getApiLoaderWithResourceMetadata(ResourceMetadata $resourceMeta $propertyMetadataFactoryProphecy->create(DummyEntity::class, 'subresource')->willReturn($subResourcePropertyMetadata); - $operationPathResolver = new CustomOperationPathResolver(new UnderscoreOperationPathResolver()); + $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); - $apiLoader = new ApiLoader($kernelProphecy->reveal(), $resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataFactoryProphecy->reveal(), $operationPathResolver, $containerProphecy->reveal(), ['jsonld' => ['application/ld+json']], [], $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal()); + $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + + $subresourceOperationFactory = new SubresourceOperationFactory($resourceMetadataFactory, $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), new UnderscorePathSegmentNameGenerator()); + + $apiLoader = new ApiLoader($kernelProphecy->reveal(), $resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataFactory, $operationPathResolver, $containerProphecy->reveal(), ['jsonld' => ['application/ld+json']], [], $subresourceOperationFactory); return $apiLoader; } diff --git a/tests/Bridge/Symfony/Routing/RouteNameGeneratorTest.php b/tests/Bridge/Symfony/Routing/RouteNameGeneratorTest.php index 81ac0a1280a..3b866de99b4 100644 --- a/tests/Bridge/Symfony/Routing/RouteNameGeneratorTest.php +++ b/tests/Bridge/Symfony/Routing/RouteNameGeneratorTest.php @@ -36,13 +36,12 @@ public function testLegacyGenerate() $this->assertEquals('api_foos_get_collection', RouteNameGenerator::generate('get', 'Foo', true)); } + /** + * @expectedException \ApiPlatform\Core\Exception\InvalidArgumentException + * @expectedMessage Subresource operations are not supported by the RouteNameGenerator. + */ public function testGenerateWithSubresource() { - $this->assertEquals('api_foos_bar_get_subresource', RouteNameGenerator::generate('get', 'Foo', OperationType::SUBRESOURCE, ['property' => 'bar', 'collection' => false])); - } - - public function testGenerateWithSubresourceCollection() - { - $this->assertEquals('api_foos_bars_get_subresource', RouteNameGenerator::generate('get', 'Foo', OperationType::SUBRESOURCE, ['property' => 'bar', 'collection' => true])); + $this->assertEquals('api_foos_bar_get_subresource', RouteNameGenerator::generate('get', 'Foo', OperationType::SUBRESOURCE)); } } diff --git a/tests/Bridge/Symfony/Routing/RouterOperationPathResolverTest.php b/tests/Bridge/Symfony/Routing/RouterOperationPathResolverTest.php index d687b92ae76..60e5f8999b8 100644 --- a/tests/Bridge/Symfony/Routing/RouterOperationPathResolverTest.php +++ b/tests/Bridge/Symfony/Routing/RouterOperationPathResolverTest.php @@ -40,17 +40,17 @@ public function testResolveOperationPath() $this->assertEquals('/foos', $operationPathResolver->resolveOperationPath('Foo', ['route_name' => 'foos'], OperationType::COLLECTION, 'get')); } + /** + * @expectedException \ApiPlatform\Core\Exception\InvalidArgumentException + * @expectedMessage Subresource operations are not supported by the RouterOperationPathResolver. + */ public function testResolveOperationPathWithSubresource() { - $routeCollection = new RouteCollection(); - $routeCollection->add('api_foos_bars_get_subresource', new Route('/foos/{id}/bars')); - $routerProphecy = $this->prophesize(RouterInterface::class); - $routerProphecy->getRouteCollection()->willReturn($routeCollection)->shouldBeCalled(); $operationPathResolver = new RouterOperationPathResolver($routerProphecy->reveal(), $this->prophesize(OperationPathResolverInterface::class)->reveal()); - $this->assertEquals('/foos/{id}/bars', $operationPathResolver->resolveOperationPath('Foo', ['property' => 'bar', 'collection' => true], OperationType::SUBRESOURCE, 'get')); + $operationPathResolver->resolveOperationPath('Foo', ['property' => 'bar', 'collection' => true, 'resource_class' => 'Foo'], OperationType::SUBRESOURCE, 'get'); } public function testResolveOperationPathWithRouteNameGeneration() diff --git a/tests/EventListener/ReadListenerTest.php b/tests/EventListener/ReadListenerTest.php index e38abf3b9b0..e647db165c8 100644 --- a/tests/EventListener/ReadListenerTest.php +++ b/tests/EventListener/ReadListenerTest.php @@ -145,9 +145,9 @@ public function testRetrieveSubresource() $data = [new \stdClass()]; $subresourceDataProvider = $this->prophesize(SubresourceDataProviderInterface::class); - $subresourceDataProvider->getSubresource('Foo', ['id' => 1], ['identifiers' => [['id', 'Bar']], 'property' => 'bar'], 'get')->willReturn($data)->shouldBeCalled(); + $subresourceDataProvider->getSubresource('Foo', ['id' => 1], ['identifiers' => [['id', 'Bar', true]], 'property' => 'bar'], 'get')->willReturn($data)->shouldBeCalled(); - $request = new Request([], [], ['id' => 1, '_api_resource_class' => 'Foo', '_api_subresource_operation_name' => 'get', '_api_format' => 'json', '_api_mime_type' => 'application/json', '_api_subresource_context' => ['identifiers' => [['id', 'Bar']], 'property' => 'bar']]); + $request = new Request([], [], ['id' => 1, '_api_resource_class' => 'Foo', '_api_subresource_operation_name' => 'get', '_api_format' => 'json', '_api_mime_type' => 'application/json', '_api_subresource_context' => ['identifiers' => [['id', 'Bar', true]], 'property' => 'bar']]); $request->setMethod(Request::METHOD_GET); $event = $this->prophesize(GetResponseEvent::class); diff --git a/tests/Fixtures/TestBundle/Entity/Answer.php b/tests/Fixtures/TestBundle/Entity/Answer.php index f850310bc74..b68da8ff8f7 100644 --- a/tests/Fixtures/TestBundle/Entity/Answer.php +++ b/tests/Fixtures/TestBundle/Entity/Answer.php @@ -14,6 +14,8 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Annotation\ApiSubresource; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Annotation as Serializer; @@ -47,6 +49,18 @@ class Answer */ private $question; + /** + * @ORM\OneToMany(targetEntity="Question", mappedBy="answer") + * @Serializer\Groups({"foobar"}) + * @ApiSubresource + */ + private $relatedQuestions; + + public function __construct() + { + $this->relatedQuestions = new ArrayCollection(); + } + /** * Get id. * @@ -104,4 +118,19 @@ public function getQuestion() { return $this->question; } + + /** + * Get related question. + * + * @return ArrayCollection + */ + public function getRelatedQuestions() + { + return $this->relatedQuestions; + } + + public function addRelatedQuestion(Question $question) + { + $this->relatedQuestions->add($question); + } } diff --git a/tests/Operation/Factory/CachedSubresourceOperationFactoryTest.php b/tests/Operation/Factory/CachedSubresourceOperationFactoryTest.php new file mode 100644 index 00000000000..c69ebfff902 --- /dev/null +++ b/tests/Operation/Factory/CachedSubresourceOperationFactoryTest.php @@ -0,0 +1,86 @@ + + * + * 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\Core\Tests\Operation\Factory; + +use ApiPlatform\Core\Operation\Factory\CachedSubresourceOperationFactory; +use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactoryInterface; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use Psr\Cache\CacheException; +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; + +/** + * @author Antoine Bluchet + */ +class CachedSubresourceOperationFactoryTest extends \PHPUnit_Framework_TestCase +{ + public function testCreateWithItemHit() + { + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit()->willReturn(true)->shouldBeCalled(); + $cacheItem->get()->willReturn(['foo' => 'bar'])->shouldBeCalled(); + + $cacheItemPool = $this->prophesize(CacheItemPoolInterface::class); + $cacheItemPool->getItem($this->generateCacheKey())->willReturn($cacheItem->reveal())->shouldBeCalled(); + + $decoratedSubresourceOperationFactory = $this->prophesize(SubresourceOperationFactoryInterface::class); + $decoratedSubresourceOperationFactory->create()->shouldNotBeCalled(); + + $cachedSubresourceOperationFactory = new CachedSubresourceOperationFactory($cacheItemPool->reveal(), $decoratedSubresourceOperationFactory->reveal()); + $resultedSubresourceOperation = $cachedSubresourceOperationFactory->create(Dummy::class); + + $this->assertEquals(['foo' => 'bar'], $resultedSubresourceOperation); + } + + public function testCreateWithItemNotHit() + { + $cacheItem = $this->prophesize(CacheItemInterface::class); + $cacheItem->isHit()->willReturn(false)->shouldBeCalled(); + $cacheItem->set(['foo' => 'bar'])->willReturn($cacheItem->reveal())->shouldBeCalled(); + + $cacheItemPool = $this->prophesize(CacheItemPoolInterface::class); + $cacheItemPool->getItem($this->generateCacheKey())->willReturn($cacheItem->reveal())->shouldBeCalled(); + $cacheItemPool->save($cacheItem->reveal())->willReturn(true)->shouldBeCalled(); + + $decoratedSubresourceOperationFactory = $this->prophesize(SubresourceOperationFactoryInterface::class); + $decoratedSubresourceOperationFactory->create(Dummy::class)->shouldBeCalled()->willReturn(['foo' => 'bar']); + + $cachedSubresourceOperationFactory = new CachedSubresourceOperationFactory($cacheItemPool->reveal(), $decoratedSubresourceOperationFactory->reveal()); + $resultedSubresourceOperation = $cachedSubresourceOperationFactory->create(Dummy::class); + + $this->assertEquals(['foo' => 'bar'], $resultedSubresourceOperation); + } + + public function testCreateWithGetCacheItemThrowsCacheException() + { + $cacheException = $this->prophesize(CacheException::class); + $cacheException->willExtend(\Exception::class); + + $cacheItemPool = $this->prophesize(CacheItemPoolInterface::class); + $cacheItemPool->getItem($this->generateCacheKey())->willThrow($cacheException->reveal())->shouldBeCalled(); + + $decoratedSubresourceOperationFactory = $this->prophesize(SubresourceOperationFactoryInterface::class); + $decoratedSubresourceOperationFactory->create(Dummy::class)->shouldBeCalled()->willReturn(['foo' => 'bar']); + + $cachedSubresourceOperationFactory = new CachedSubresourceOperationFactory($cacheItemPool->reveal(), $decoratedSubresourceOperationFactory->reveal()); + $resultedSubresourceOperation = $cachedSubresourceOperationFactory->create(Dummy::class); + + $this->assertEquals(['foo' => 'bar'], $resultedSubresourceOperation); + } + + private function generateCacheKey(string $resourceClass = Dummy::class) + { + return CachedSubresourceOperationFactory::CACHE_KEY_PREFIX.str_replace('\\', '', $resourceClass); + } +} diff --git a/tests/Operation/Factory/SubresourceOperationFactoryTest.php b/tests/Operation/Factory/SubresourceOperationFactoryTest.php new file mode 100644 index 00000000000..8aa364c4418 --- /dev/null +++ b/tests/Operation/Factory/SubresourceOperationFactoryTest.php @@ -0,0 +1,221 @@ + + * + * 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\Core\Tests\Operation\Factory; + +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Core\Metadata\Property\SubresourceMetadata; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactory; +use ApiPlatform\Core\Operation\PathSegmentNameGeneratorInterface; +use ApiPlatform\Core\Tests\Fixtures\DummyEntity; +use ApiPlatform\Core\Tests\Fixtures\RelatedDummyEntity; + +/** + * @author Antoine Bluchet + */ +class SubresourceOperationFactoryTest extends \PHPUnit_Framework_TestCase +{ + public function testCreate() + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(RelatedDummyEntity::class)->shouldBeCalled()->willReturn(new ResourceMetadata('relatedDummyEntity')); + $resourceMetadataFactoryProphecy->create(DummyEntity::class)->shouldBeCalled()->willReturn(new ResourceMetadata('dummyEntity')); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(DummyEntity::class)->shouldBeCalled()->willReturn(new PropertyNameCollection(['foo', 'subresource', 'subcollection'])); + $propertyNameCollectionFactoryProphecy->create(RelatedDummyEntity::class)->shouldBeCalled()->willReturn(new PropertyNameCollection(['bar', 'anotherSubresource'])); + + $subresourceMetadata = (new PropertyMetadata())->withSubresource(new SubresourceMetadata(RelatedDummyEntity::class)); + $subresourceMetadataCollection = (new PropertyMetadata())->withSubresource(new SubresourceMetadata(RelatedDummyEntity::class, true)); + $anotherSubresourceMetadata = (new PropertyMetadata())->withSubresource(new SubresourceMetadata(DummyEntity::class, false)); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(DummyEntity::class, 'foo')->shouldBeCalled()->willReturn(new PropertyMetadata()); + $propertyMetadataFactoryProphecy->create(DummyEntity::class, 'subresource')->shouldBeCalled()->willReturn($subresourceMetadata); + $propertyMetadataFactoryProphecy->create(DummyEntity::class, 'subcollection')->shouldBeCalled()->willReturn($subresourceMetadataCollection); + $propertyMetadataFactoryProphecy->create(RelatedDummyEntity::class, 'bar')->shouldBeCalled()->willReturn(new PropertyMetadata()); + $propertyMetadataFactoryProphecy->create(RelatedDummyEntity::class, 'anotherSubresource')->shouldBeCalled()->willReturn($anotherSubresourceMetadata); + + $pathSegmentNameGeneratorProphecy = $this->prophesize(PathSegmentNameGeneratorInterface::class); + $pathSegmentNameGeneratorProphecy->getSegmentName('subresource', false)->shouldBeCalled()->willReturn('subresource'); + $pathSegmentNameGeneratorProphecy->getSegmentName('subcollection', true)->shouldBeCalled()->willReturn('subcollections'); + $pathSegmentNameGeneratorProphecy->getSegmentName('dummyEntity', true)->shouldBeCalled()->willReturn('dummy_entities'); + $pathSegmentNameGeneratorProphecy->getSegmentName('anotherSubresource', false)->shouldBeCalled()->willReturn('another_subresource'); + + $subresourceOperationFactory = new SubresourceOperationFactory($resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $pathSegmentNameGeneratorProphecy->reveal()); + + $this->assertEquals([ + 'ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subresource-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity' => [ + 'property' => 'subresource', + 'collection' => false, + 'resource_class' => RelatedDummyEntity::class, + 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], + 'identifiers' => [ + ['id', DummyEntity::class, true], + ], + 'route_name' => 'api_dummy_entities_subresource_get_subresource', + 'path' => '/dummy_entities/{id}/subresource.{_format}', + ], + 'ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subresource-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity-anotherSubresource-ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity' => [ + 'property' => 'anotherSubresource', + 'collection' => false, + 'resource_class' => DummyEntity::class, + 'shortNames' => ['dummyEntity', 'relatedDummyEntity'], + 'identifiers' => [ + ['id', DummyEntity::class, true], + ['subresource', RelatedDummyEntity::class, false], + ], + 'route_name' => 'api_dummy_entities_subresource_another_subresource_get_subresource', + 'path' => '/dummy_entities/{id}/subresource/another_subresource.{_format}', + ], + 'ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subresource-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity-anotherSubresource-ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subresource-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity' => [ + 'property' => 'subresource', + 'collection' => false, + 'resource_class' => RelatedDummyEntity::class, + 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], + 'identifiers' => [ + ['id', DummyEntity::class, true], + ['subresource', RelatedDummyEntity::class, false], + ['anotherSubresource', DummyEntity::class, false], + ], + 'route_name' => 'api_dummy_entities_subresource_another_subresource_subresource_get_subresource', + 'path' => '/dummy_entities/{id}/subresource/another_subresource/subresource.{_format}', + ], + 'ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subresource-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity-anotherSubresource-ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subcollection-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity' => [ + 'property' => 'subcollection', + 'collection' => true, + 'resource_class' => RelatedDummyEntity::class, + 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], + 'identifiers' => [ + ['id', DummyEntity::class, true], + ['subresource', RelatedDummyEntity::class, false], + ['anotherSubresource', DummyEntity::class, false], + ], + 'route_name' => 'api_dummy_entities_subresource_another_subresource_subcollections_get_subresource', + 'path' => '/dummy_entities/{id}/subresource/another_subresource/subcollections.{_format}', + ], + 'ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subresource-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity-anotherSubresource-ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subcollection-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity-anotherSubresource-ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity' => [ + 'property' => 'anotherSubresource', + 'collection' => false, + 'resource_class' => DummyEntity::class, + 'shortNames' => ['dummyEntity', 'relatedDummyEntity'], + 'identifiers' => [ + ['id', DummyEntity::class, true], + ['subresource', RelatedDummyEntity::class, false], + ['anotherSubresource', DummyEntity::class, false], + ['subcollection', RelatedDummyEntity::class, true], + ], + 'route_name' => 'api_dummy_entities_subresource_another_subresource_subcollections_another_subresource_get_subresource', + 'path' => '/dummy_entities/{id}/subresource/another_subresource/subcollections/{subcollection}/another_subresource.{_format}', + ], + 'ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subresource-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity-anotherSubresource-ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subcollection-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity-anotherSubresource-ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subresource-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity' => [ + 'property' => 'subresource', + 'collection' => false, + 'resource_class' => RelatedDummyEntity::class, + 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], + 'identifiers' => [ + ['id', DummyEntity::class, true], + ['subresource', RelatedDummyEntity::class, false], + ['anotherSubresource', DummyEntity::class, false], + ['subcollection', RelatedDummyEntity::class, true], + ['anotherSubresource', DummyEntity::class, false], + ], + 'route_name' => 'api_dummy_entities_subresource_another_subresource_subcollections_another_subresource_subresource_get_subresource', + 'path' => '/dummy_entities/{id}/subresource/another_subresource/subcollections/{subcollection}/another_subresource/subresource.{_format}', + ], + 'ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subcollection-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity' => [ + 'property' => 'subcollection', + 'collection' => true, + 'resource_class' => RelatedDummyEntity::class, + 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], + 'identifiers' => [ + ['id', DummyEntity::class, true], + ], + 'route_name' => 'api_dummy_entities_subcollections_get_subresource', + 'path' => '/dummy_entities/{id}/subcollections.{_format}', + ], + 'ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subcollection-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity-anotherSubresource-ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity' => [ + 'property' => 'anotherSubresource', + 'collection' => false, + 'resource_class' => DummyEntity::class, + 'shortNames' => ['dummyEntity', 'relatedDummyEntity'], + 'identifiers' => [ + ['id', DummyEntity::class, true], + ['subcollection', RelatedDummyEntity::class, true], + ], + 'route_name' => 'api_dummy_entities_subcollections_another_subresource_get_subresource', + 'path' => '/dummy_entities/{id}/subcollections/{subcollection}/another_subresource.{_format}', + ], + 'ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subcollection-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity-anotherSubresource-ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subresource-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity' => [ + 'property' => 'subresource', + 'collection' => false, + 'resource_class' => RelatedDummyEntity::class, + 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], + 'identifiers' => [ + ['id', DummyEntity::class, true], + ['subcollection', RelatedDummyEntity::class, true], + ['anotherSubresource', DummyEntity::class, false], + ], + 'route_name' => 'api_dummy_entities_subcollections_another_subresource_subresource_get_subresource', + 'path' => '/dummy_entities/{id}/subcollections/{subcollection}/another_subresource/subresource.{_format}', + ], + 'ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subcollection-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity-anotherSubresource-ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subresource-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity-anotherSubresource-ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity' => [ + 'property' => 'anotherSubresource', + 'collection' => false, + 'resource_class' => DummyEntity::class, + 'shortNames' => ['dummyEntity', 'relatedDummyEntity'], + 'identifiers' => [ + ['id', DummyEntity::class, true], + ['subcollection', RelatedDummyEntity::class, true], + ['anotherSubresource', DummyEntity::class, false], + ['subresource', RelatedDummyEntity::class, false], + ], + 'route_name' => 'api_dummy_entities_subcollections_another_subresource_subresource_another_subresource_get_subresource', + 'path' => '/dummy_entities/{id}/subcollections/{subcollection}/another_subresource/subresource/another_subresource.{_format}', + ], + 'ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subcollection-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity-anotherSubresource-ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subresource-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity-anotherSubresource-ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subcollection-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity' => [ + 'property' => 'subcollection', + 'collection' => true, + 'resource_class' => RelatedDummyEntity::class, + 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], + 'identifiers' => [ + ['id', DummyEntity::class, true], + ['subcollection', RelatedDummyEntity::class, true], + ['anotherSubresource', DummyEntity::class, false], + ['subresource', RelatedDummyEntity::class, false], + ['anotherSubresource', DummyEntity::class, false], + ], + 'route_name' => 'api_dummy_entities_subcollections_another_subresource_subresource_another_subresource_subcollections_get_subresource', + 'path' => '/dummy_entities/{id}/subcollections/{subcollection}/another_subresource/subresource/another_subresource/subcollections.{_format}', + ], + 'ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subcollection-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity-anotherSubresource-ApiPlatform\\Core\\Tests\\Fixtures\\DummyEntity-subcollection-ApiPlatform\\Core\\Tests\\Fixtures\\RelatedDummyEntity' => [ + 'property' => 'subcollection', + 'collection' => true, + 'resource_class' => RelatedDummyEntity::class, + 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], + 'identifiers' => [ + ['id', DummyEntity::class, true], + ['subcollection', RelatedDummyEntity::class, true], + ['anotherSubresource', DummyEntity::class, false], + ], + 'route_name' => 'api_dummy_entities_subcollections_another_subresource_subcollections_get_subresource', + 'path' => '/dummy_entities/{id}/subcollections/{subcollection}/another_subresource/subcollections.{_format}', + ], + ], $subresourceOperationFactory->create(DummyEntity::class)); + } +} diff --git a/tests/PathResolver/CustomOperationPathResolverTest.php b/tests/PathResolver/CustomOperationPathResolverTest.php index 12ee5e08f11..5d0fdaa8b9f 100644 --- a/tests/PathResolver/CustomOperationPathResolverTest.php +++ b/tests/PathResolver/CustomOperationPathResolverTest.php @@ -41,7 +41,6 @@ public function testResolveOperationPathWithDeferred() /** * @group legacy - * @expectedDeprecation Method ApiPlatform\Core\PathResolver\CustomOperationPathResolver::resolveOperationPath() will have a 4th `string $operationName` argument in version 3.0. Not defining it is deprecated since 2.1. * @expectedDeprecation Using a boolean for the Operation Type is deprecrated since API Platform 2.1 and will not be possible anymore in API Platform 3 */ public function testLegacyResolveOperationPath() diff --git a/tests/PathResolver/DashOperationPathResolverTest.php b/tests/PathResolver/DashOperationPathResolverTest.php index 7fd7effb073..c746b7f1b19 100644 --- a/tests/PathResolver/DashOperationPathResolverTest.php +++ b/tests/PathResolver/DashOperationPathResolverTest.php @@ -18,6 +18,7 @@ /** * @author Guilhem N. + * @group legacy */ class DashOperationPathResolverTest extends \PHPUnit_Framework_TestCase { @@ -35,22 +36,18 @@ public function testResolveItemOperationPath() $this->assertSame('/short-names/{id}.{_format}', $dashOperationPathResolver->resolveOperationPath('ShortName', [], OperationType::ITEM, 'get')); } + /** + * @expectedException \ApiPlatform\Core\Exception\InvalidArgumentException + * @expectedMessage Subresource operations are not supported by the OperationPathResolver. + */ public function testResolveSubresourceOperationPath() { $dashOperationPathResolver = new DashOperationPathResolver(); - $path = $dashOperationPathResolver->resolveOperationPath('ShortName', ['property' => 'relatedFoo', 'identifiers' => [['id', 'class']], 'collection' => true], OperationType::SUBRESOURCE, 'get'); - - $this->assertSame('/short-names/{id}/related-foos.{_format}', $path); - - $next = $dashOperationPathResolver->resolveOperationPath($path, ['property' => 'bar', 'identifiers' => [['id', 'class'], ['relatedId', 'class']], 'collection' => false], OperationType::SUBRESOURCE, 'get'); - - $this->assertSame('/short-names/{id}/related-foos/{relatedId}/bar.{_format}', $next); + $dashOperationPathResolver->resolveOperationPath('ShortName', ['property' => 'bar', 'identifiers' => [['id', 'class'], ['relatedId', 'class']], 'collection' => false], OperationType::SUBRESOURCE, 'get'); } /** - * @group legacy - * @expectedDeprecation Method ApiPlatform\Core\PathResolver\DashOperationPathResolver::resolveOperationPath() will have a 4th `string $operationName` argument in version 3.0. Not defining it is deprecated since 2.1. * @expectedDeprecation Using a boolean for the Operation Type is deprecrated since API Platform 2.1 and will not be possible anymore in API Platform 3 */ public function testLegacyResolveOperationPath() diff --git a/tests/PathResolver/UnderscoreOperationPathResolverTest.php b/tests/PathResolver/UnderscoreOperationPathResolverTest.php index 12475b70af5..c46d6d1a061 100644 --- a/tests/PathResolver/UnderscoreOperationPathResolverTest.php +++ b/tests/PathResolver/UnderscoreOperationPathResolverTest.php @@ -18,6 +18,7 @@ /** * @author Guilhem N. + * @group legacy */ class UnderscoreOperationPathResolverTest extends \PHPUnit_Framework_TestCase { @@ -35,22 +36,18 @@ public function testResolveItemOperationPath() $this->assertSame('/short_names/{id}.{_format}', $underscoreOperationPathResolver->resolveOperationPath('ShortName', [], OperationType::ITEM, 'get')); } + /** + * @expectedException \ApiPlatform\Core\Exception\InvalidArgumentException + * @expectedMessage Subresource operations are not supported by the OperationPathResolver. + */ public function testResolveSubresourceOperationPath() { $dashOperationPathResolver = new UnderscoreOperationPathResolver(); - $path = $dashOperationPathResolver->resolveOperationPath('ShortName', ['property' => 'relatedFoo', 'identifiers' => [['id', 'class']], 'collection' => true], OperationType::SUBRESOURCE, 'get'); - - $this->assertSame('/short_names/{id}/related_foos.{_format}', $path); - - $next = $dashOperationPathResolver->resolveOperationPath($path, ['property' => 'bar', 'identifiers' => [['id', 'class'], ['relatedId', 'class']], 'collection' => false], OperationType::SUBRESOURCE, 'get'); - - $this->assertSame('/short_names/{id}/related_foos/{relatedId}/bar.{_format}', $next); + $dashOperationPathResolver->resolveOperationPath('ShortName', ['property' => 'relatedFoo', 'identifiers' => [['id', 'class']], 'collection' => true], OperationType::SUBRESOURCE, 'get'); } /** - * @group legacy - * @expectedDeprecation Method ApiPlatform\Core\PathResolver\UnderscoreOperationPathResolver::resolveOperationPath() will have a 4th `string $operationName` argument in version 3.0. Not defining it is deprecated since 2.1. * @expectedDeprecation Using a boolean for the Operation Type is deprecrated since API Platform 2.1 and will not be possible anymore in API Platform 3 */ public function testLegacyResolveOperationPath() diff --git a/tests/Swagger/Serializer/DocumentationNormalizerTest.php b/tests/Swagger/Serializer/DocumentationNormalizerTest.php index ba2f398fad1..60224b7c093 100644 --- a/tests/Swagger/Serializer/DocumentationNormalizerTest.php +++ b/tests/Swagger/Serializer/DocumentationNormalizerTest.php @@ -17,23 +17,34 @@ use ApiPlatform\Core\Api\OperationMethodResolverInterface; use ApiPlatform\Core\Api\ResourceClassResolverInterface; use ApiPlatform\Core\Api\UrlGeneratorInterface; +use ApiPlatform\Core\Bridge\Symfony\Routing\RouterOperationPathResolver; use ApiPlatform\Core\Documentation\Documentation; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Core\Metadata\Property\SubresourceMetadata; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactory; +use ApiPlatform\Core\Operation\UnderscorePathSegmentNameGenerator; use ApiPlatform\Core\PathResolver\CustomOperationPathResolver; +use ApiPlatform\Core\PathResolver\OperationPathResolver; use ApiPlatform\Core\PathResolver\OperationPathResolverInterface; use ApiPlatform\Core\PathResolver\UnderscoreOperationPathResolver; use ApiPlatform\Core\Swagger\Serializer\DocumentationNormalizer; use ApiPlatform\Core\Tests\Fixtures\DummyFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Answer; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Question; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use Prophecy\Argument; use Psr\Container\ContainerInterface; use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; +use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** @@ -61,6 +72,10 @@ public function testLegacyConstruct() $this->assertInstanceOf(DocumentationNormalizer::class, $normalizer); } + /** + * @group legacy + * @expectedDeprecation The use of ApiPlatform\Core\PathResolver\UnderscoreOperationPathResolver is deprecated since 2.1. Please use PathSegmentNameGenerator instead + */ public function testNormalize() { $documentation = new Documentation(new ResourceNameCollection([Dummy::class]), 'Test API', 'This is a test API.', '1.2.3', ['jsonld' => ['application/ld+json']]); @@ -262,6 +277,10 @@ public function testNormalize() $this->assertEquals($expected, $normalizer->normalize($documentation, DocumentationNormalizer::FORMAT, ['base_url' => '/app_dev.php/'])); } + /** + * @group legacy + * @expectedDeprecation The use of ApiPlatform\Core\PathResolver\UnderscoreOperationPathResolver is deprecated since 2.1. Please use PathSegmentNameGenerator instead + */ public function testNormalizeWithNameConverter() { $documentation = new Documentation(new ResourceNameCollection([Dummy::class]), 'Dummy API', 'This is a dummy API', '1.2.3', ['jsonld' => ['application/ld+json']]); @@ -373,6 +392,10 @@ public function testNormalizeWithNameConverter() $this->assertEquals($expected, $normalizer->normalize($documentation)); } + /** + * @group legacy + * @expectedDeprecation The use of ApiPlatform\Core\PathResolver\UnderscoreOperationPathResolver is deprecated since 2.1. Please use PathSegmentNameGenerator instead + */ public function testNormalizeWithOnlyNormalizationGroups() { $title = 'Test API'; @@ -558,6 +581,10 @@ public function testNormalizeWithOnlyNormalizationGroups() $this->assertEquals($expected, $normalizer->normalize($documentation)); } + /** + * @group legacy + * @expectedDeprecation The use of ApiPlatform\Core\PathResolver\UnderscoreOperationPathResolver is deprecated since 2.1. Please use PathSegmentNameGenerator instead + */ public function testNormalizeWithOnlyDenormalizationGroups() { $title = 'Test API'; @@ -740,6 +767,10 @@ public function testNormalizeWithOnlyDenormalizationGroups() $this->assertEquals($expected, $normalizer->normalize($documentation)); } + /** + * @group legacy + * @expectedDeprecation The use of ApiPlatform\Core\PathResolver\UnderscoreOperationPathResolver is deprecated since 2.1. Please use PathSegmentNameGenerator instead + */ public function testNormalizeWithNormalizationAndDenormalizationGroups() { $title = 'Test API'; @@ -925,6 +956,10 @@ public function testNormalizeWithNormalizationAndDenormalizationGroups() $this->assertEquals($expected, $normalizer->normalize($documentation)); } + /** + * @group legacy + * @expectedDeprecation The use of ApiPlatform\Core\PathResolver\UnderscoreOperationPathResolver is deprecated since 2.1. Please use PathSegmentNameGenerator instead + */ public function testFilters() { $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); @@ -1072,6 +1107,10 @@ public function testNoOperations() $this->assertEquals($expected, $normalizer->normalize($documentation)); } + /** + * @group legacy + * @expectedDeprecation The use of ApiPlatform\Core\PathResolver\UnderscoreOperationPathResolver is deprecated since 2.1. Please use PathSegmentNameGenerator instead + */ public function testWithCustomMethod() { $documentation = new Documentation(new ResourceNameCollection([Dummy::class]), '', '', '0.0.0', ['jsonld' => ['application/ld+json']]); @@ -1128,6 +1167,10 @@ public function testWithCustomMethod() $this->assertEquals($expected, $normalizer->normalize($documentation)); } + /** + * @group legacy + * @expectedDeprecation The use of ApiPlatform\Core\PathResolver\UnderscoreOperationPathResolver is deprecated since 2.1. Please use PathSegmentNameGenerator instead + */ public function testNormalizeWithNestedNormalizationGroups() { $title = 'Test API'; @@ -1441,4 +1484,143 @@ private function normalizeWithFilters($filterLocator) $this->assertEquals($expected, $normalizer->normalize($documentation)); } + + public function testNormalizeWithSubResource() + { + $documentation = new Documentation(new ResourceNameCollection([Question::class]), 'Test API', 'This is a test API.', '1.2.3', ['jsonld' => ['application/ld+json']]); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Question::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['answer'])); + $propertyNameCollectionFactoryProphecy->create(Answer::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['content'])); + + $questionMetadata = new ResourceMetadata('Question', 'This is a question.', 'http://schema.example.com/Question', ['get' => ['method' => 'GET']]); + $answerMetadata = new ResourceMetadata('Answer', 'This is an answer.', 'http://schema.example.com/Answer', [], ['get' => ['method' => 'GET']]); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Question::class)->shouldBeCalled()->willReturn($questionMetadata); + $resourceMetadataFactoryProphecy->create(Answer::class)->shouldBeCalled()->willReturn($answerMetadata); + + $subresourceMetadata = new SubresourceMetadata(Answer::class, false); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Question::class, 'answer')->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, false, Question::class, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, Answer::class)), 'This is a name.', true, true, true, true, false, false, null, null, [], $subresourceMetadata)); + + $propertyMetadataFactoryProphecy->create(Answer::class, 'content')->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, false, Question::class, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, Answer::class)), 'This is a name.', true, true, true, true, false, false, null, null, [])); + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Question::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(Answer::class)->willReturn(true); + + $operationMethodResolverProphecy = $this->prophesize(OperationMethodResolverInterface::class); + $operationMethodResolverProphecy->getItemOperationMethod(Question::class, 'get')->shouldBeCalled()->willReturn('GET'); + + $routeCollection = new RouteCollection(); + $routeCollection->add('api_questions_answer_get_subresource', new Route('/api/questions/{id}/answer.{_format}')); + $routeCollection->add('api_questions_get_item', new Route('/api/questions/{id}.{_format}')); + + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->getRouteCollection()->shouldBeCalled()->willReturn($routeCollection); + + $operationPathResolver = new RouterOperationPathResolver($routerProphecy->reveal(), new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator()))); + + $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); + $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); + + $subresourceOperationFactory = new SubresourceOperationFactory($resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new UnderscorePathSegmentNameGenerator()); + + $normalizer = new DocumentationNormalizer( + $resourceMetadataFactory, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $resourceClassResolverProphecy->reveal(), + $operationMethodResolverProphecy->reveal(), + $operationPathResolver, + null, null, null, false, '', '', '', '', [], + $subresourceOperationFactory + ); + + $expected = [ + 'swagger' => '2.0', + 'basePath' => '/', + 'info' => [ + 'title' => 'Test API', + 'description' => 'This is a test API.', + 'version' => '1.2.3', + ], + 'paths' => new \ArrayObject([ + '/api/questions/{id}' => [ + 'get' => new \ArrayObject([ + 'tags' => ['Question'], + 'operationId' => 'getQuestionItem', + 'produces' => ['application/ld+json'], + 'summary' => 'Retrieves a Question resource.', + 'parameters' => [ + [ + 'name' => 'id', + 'in' => 'path', + 'type' => 'string', + 'required' => true, + ], + ], + 'responses' => [ + 200 => [ + 'description' => 'Question resource response', + 'schema' => ['$ref' => '#/definitions/Question'], + ], + 404 => ['description' => 'Resource not found'], + ], + ]), + ], + '/api/questions/{id}/answer' => new \ArrayObject([ + 'get' => new \ArrayObject([ + 'tags' => ['Answer', 'Question'], + 'operationId' => 'ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Question-answer-ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Answer', + 'produces' => ['application/ld+json'], + 'summary' => 'Retrieves a Answer resource.', + 'responses' => [ + 200 => [ + 'description' => 'Answer resource response', + 'schema' => ['$ref' => '#/definitions/Answer'], + ], + 404 => ['description' => 'Resource not found'], + ], + 'parameters' => [ + [ + 'name' => 'id', + 'in' => 'path', + 'type' => 'string', + 'required' => true, + ], + ], + ]), + ]), + ]), + 'definitions' => new \ArrayObject([ + 'Question' => new \ArrayObject([ + 'type' => 'object', + 'description' => 'This is a question.', + 'externalDocs' => ['url' => 'http://schema.example.com/Question'], + 'properties' => [ + 'answer' => new \ArrayObject([ + 'type' => 'array', + 'description' => 'This is a name.', + 'items' => ['$ref' => '#/definitions/Answer'], + ]), + ], + ]), + 'Answer' => new \ArrayObject([ + 'type' => 'object', + 'description' => 'This is an answer.', + 'externalDocs' => ['url' => 'http://schema.example.com/Answer'], + 'properties' => [ + 'content' => new \ArrayObject([ + 'type' => 'array', + 'description' => 'This is a name.', + 'items' => ['$ref' => '#/definitions/Answer'], + ]), + ], + ]), + ]), + ]; + + $this->assertEquals($expected, $normalizer->normalize($documentation)); + } }