diff --git a/src/Metadata/ApiOperation.php b/src/Metadata/ApiOperation.php new file mode 100644 index 00000000000..14f7dc7f53a --- /dev/null +++ b/src/Metadata/ApiOperation.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata; + +/** + * Runs an API Platform operation on a controller method bound to the application's own route. + */ +#[\Attribute(\Attribute::TARGET_METHOD)] +final class ApiOperation +{ + public function __construct(public readonly HttpOperation $operation) + { + } +} diff --git a/src/State/Util/OperationStageDefaults.php b/src/State/Util/OperationStageDefaults.php new file mode 100644 index 00000000000..c8ee19ee3af --- /dev/null +++ b/src/State/Util/OperationStageDefaults.php @@ -0,0 +1,70 @@ + + * + * 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\State\Util; + +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\State\SerializerContextBuilderInterface; +use Symfony\Component\HttpFoundation\Request; + +/** + * Resolves the stage flags left unset on an operation to their request-dependent defaults. + * + * @internal + */ +final class OperationStageDefaults +{ + private function __construct() + { + } + + public static function forProvider(HttpOperation $operation, Request $request): HttpOperation + { + if (null === $operation->canValidate()) { + $operation = $operation->withValidate(!$request->isMethodSafe() && !$request->isMethod('DELETE')); + } + + if (null === $operation->canRead()) { + $operation = $operation->withRead($operation->getUriVariables() || $request->isMethodSafe()); + } + + if (null === $operation->canDeserialize()) { + $operation = $operation->withDeserialize(\in_array($operation->getMethod(), ['POST', 'PUT', 'PATCH'], true)); + } + + $denormalizationContext = $operation->getDenormalizationContext() ?? []; + if ($operation->canDeserialize() && !isset($denormalizationContext[SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE])) { + $method = $operation->getMethod(); + $assignObjectToPopulate = 'POST' === $method + || 'PATCH' === $method + || ('PUT' === $method && !($operation->getExtraProperties()['standard_put'] ?? true)); + + $operation = $operation->withDenormalizationContext($denormalizationContext + [SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE => $assignObjectToPopulate]); + } + + return $operation; + } + + public static function forProcessor(HttpOperation $operation, Request $request): HttpOperation + { + if (null === $operation->canWrite()) { + $operation = $operation->withWrite(!$request->isMethodSafe()); + } + + if (null === $operation->canSerialize()) { + $operation = $operation->withSerialize(true); + } + + return $operation; + } +} diff --git a/src/Symfony/Bundle/ApiPlatformBundle.php b/src/Symfony/Bundle/ApiPlatformBundle.php index 40a81389683..42d4ab94721 100644 --- a/src/Symfony/Bundle/ApiPlatformBundle.php +++ b/src/Symfony/Bundle/ApiPlatformBundle.php @@ -16,6 +16,7 @@ use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\AttributeFilterPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\AttributeResourcePass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\AuthenticatorManagerPass; +use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ControllerApiOperationPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ElasticsearchClientPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ErrorResourceAttributeLoaderPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\FilterPass; @@ -51,6 +52,7 @@ public function build(ContainerBuilder $container): void // Run the compiler pass before the {@see ResolveInstanceofConditionalsPass} to allow autoconfiguration of generated filter definitions. $container->addCompilerPass(new AttributeFilterPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, 101); $container->addCompilerPass(new AttributeResourcePass()); + $container->addCompilerPass(new ControllerApiOperationPass()); $container->addCompilerPass(new FilterPass()); $container->addCompilerPass(new ElasticsearchClientPass()); $container->addCompilerPass(new GraphQlTypePass()); diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 0b2548a3ff0..c7c6a7c3315 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -352,6 +352,9 @@ private function registerCommonConfiguration(ContainerBuilder $container, array if (!$container->hasParameter('serializer.default_context')) { $container->setParameter('serializer.default_context', $container->getParameter('api_platform.serializer.default_context')); } + $container->setParameter('api_platform.controller_operations', []); + $container->setParameter('api_platform.controller_operation_resources', []); + $loader->load('symfony/controller_operation.php'); if ($config['use_symfony_listeners']) { $loader->load('symfony/events.php'); } else { diff --git a/src/Symfony/Bundle/DependencyInjection/Compiler/ControllerApiOperationPass.php b/src/Symfony/Bundle/DependencyInjection/Compiler/ControllerApiOperationPass.php new file mode 100644 index 00000000000..281ee114c54 --- /dev/null +++ b/src/Symfony/Bundle/DependencyInjection/Compiler/ControllerApiOperationPass.php @@ -0,0 +1,78 @@ + + * + * 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\Symfony\Bundle\DependencyInjection\Compiler; + +use ApiPlatform\Metadata\ApiOperation; +use ApiPlatform\Metadata\HttpOperation; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; + +/** + * Discovers the controller methods carrying {@see ApiOperation} and registers their resource classes. + * + * @internal + */ +final class ControllerApiOperationPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + $controllerOperations = []; + $resources = []; + + foreach (array_keys($container->findTaggedServiceIds('controller.service_arguments')) as $id) { + $definition = $container->getDefinition($id); + if ($definition->isAbstract()) { + continue; + } + + $class = $container->getParameterBag()->resolveValue($definition->getClass() ?? $id); + if (!\is_string($class) || !($reflectionClass = $container->getReflectionClass($class, false))) { + continue; + } + + foreach ($reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC) as $method) { + foreach ($method->getAttributes(ApiOperation::class) as $attribute) { + $controller = $reflectionClass->getName().'::'.$method->getName(); + $resourceClass = $this->getResourceClass($attribute->newInstance()->operation); + + if (null === $resourceClass) { + throw new InvalidArgumentException(\sprintf('The "#[%s]" on "%s" must define a "class" or an "output": an "input" alone does not define a resource.', ApiOperation::class, $controller)); + } + + $controllerOperations[$resourceClass][] = $controller; + $resources[$resourceClass] = true; + } + } + } + + $container->setParameter('api_platform.controller_operations', $controllerOperations); + $container->setParameter('api_platform.controller_operation_resources', $resources); + } + + private function getResourceClass(HttpOperation $operation): ?string + { + foreach ([$operation->getClass(), $operation->getOutput()] as $candidate) { + if (\is_array($candidate)) { + $candidate = $candidate['class'] ?? null; + } + + if (\is_string($candidate) && '' !== $candidate) { + return $candidate; + } + } + + return null; + } +} diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource.php b/src/Symfony/Bundle/Resources/config/metadata/resource.php index 137ad0515de..f5387f917ae 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource.php +++ b/src/Symfony/Bundle/Resources/config/metadata/resource.php @@ -32,6 +32,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\UriTemplateResourceMetadataCollectionFactory; use ApiPlatform\Symfony\Metadata\Resource\Factory\ContainerParameterResourceMetadataCollectionFactory; +use ApiPlatform\Symfony\Metadata\Resource\Factory\ControllerApiOperationResourceMetadataCollectionFactory; return function (ContainerConfigurator $container) { $services = $container->services(); @@ -151,6 +152,15 @@ ->decorate('api_platform.metadata.resource.metadata_collection_factory', null, 200) ->args([service('api_platform.metadata.resource.metadata_collection_factory.alternate_uri.inner')]); + $services->set('api_platform.metadata.resource.metadata_collection_factory.controller_api_operation', ControllerApiOperationResourceMetadataCollectionFactory::class) + ->decorate('api_platform.metadata.resource.metadata_collection_factory', null, 1100) + ->args([ + '%api_platform.controller_operations%', + service('api_platform.metadata.resource.metadata_collection_factory.controller_api_operation.inner'), + service('logger')->nullOnInvalid(), + '%api_platform.defaults%', + ]); + $services->set('api_platform.metadata.resource.metadata_collection_factory.parameter', ParameterResourceMetadataCollectionFactory::class) ->decorate('api_platform.metadata.resource.metadata_collection_factory', null, 1000) ->args([ diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource_name.php b/src/Symfony/Bundle/Resources/config/metadata/resource_name.php index bea58a3f06c..0e118cfc29d 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource_name.php +++ b/src/Symfony/Bundle/Resources/config/metadata/resource_name.php @@ -20,6 +20,7 @@ use ApiPlatform\Metadata\Resource\Factory\ExtractorResourceNameCollectionFactory; use ApiPlatform\Metadata\Resource\Factory\PhpFileResourceNameCollectionFactory; use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Symfony\Metadata\Resource\Factory\ControllerApiOperationResourceNameCollectionFactory; return function (ContainerConfigurator $container) { $services = $container->services(); @@ -69,4 +70,11 @@ '%api_platform.class_name_resources%', service('api_platform.metadata.resource.name_collection_factory.class_name.inner'), ]); + + $services->set('api_platform.metadata.resource.name_collection_factory.controller_api_operation', ControllerApiOperationResourceNameCollectionFactory::class) + ->decorate('api_platform.metadata.resource.name_collection_factory', null, 0) + ->args([ + service('api_platform.metadata.resource.name_collection_factory.controller_api_operation.inner'), + '%api_platform.controller_operation_resources%', + ]); }; diff --git a/src/Symfony/Bundle/Resources/config/symfony/controller.php b/src/Symfony/Bundle/Resources/config/symfony/controller.php index 3bbed6d6106..e70ef731879 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/controller.php +++ b/src/Symfony/Bundle/Resources/config/symfony/controller.php @@ -16,6 +16,7 @@ use ApiPlatform\Symfony\Action\DocumentationAction; use ApiPlatform\Symfony\Action\EntrypointAction; use ApiPlatform\Symfony\Controller\MainController; +use ApiPlatform\Symfony\EventListener\ControllerApiOperationPipelineListener; return static function (ContainerConfigurator $container) { $services = $container->services(); @@ -30,6 +31,16 @@ service('logger')->ignoreOnInvalid(), ]); + $services->set('api_platform.listener.controller_api_operation.pipeline', ControllerApiOperationPipelineListener::class) + ->args([ + service('api_platform.state_provider.main'), + service('api_platform.state_processor.main'), + service('api_platform.metadata.resource.metadata_collection_factory'), + service('api_platform.uri_variables.converter')->ignoreOnInvalid(), + ]) + ->tag('kernel.event_listener', ['event' => 'kernel.request', 'method' => 'onKernelRequest', 'priority' => 27]) + ->tag('kernel.event_listener', ['event' => 'kernel.view', 'method' => 'onKernelView']); + $services->set('api_platform.action.entrypoint', EntrypointAction::class) ->public() ->args([ diff --git a/src/Symfony/Bundle/Resources/config/symfony/controller_operation.php b/src/Symfony/Bundle/Resources/config/symfony/controller_operation.php new file mode 100644 index 00000000000..4822b1489af --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/symfony/controller_operation.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace Symfony\Component\DependencyInjection\Loader\Configurator; + +use ApiPlatform\Symfony\EventListener\ControllerApiOperationListener; + +return static function (ContainerConfigurator $container) { + $services = $container->services(); + + $services->set('api_platform.listener.request.controller_api_operation', ControllerApiOperationListener::class) + ->args([ + service('api_platform.metadata.resource.metadata_collection_factory'), + '%api_platform.controller_operations%', + ]) + ->tag('kernel.event_listener', ['event' => 'kernel.request', 'method' => 'onKernelRequest', 'priority' => 30]); +}; diff --git a/src/Symfony/Controller/MainController.php b/src/Symfony/Controller/MainController.php index f50c49ec105..a7141e92a0a 100644 --- a/src/Symfony/Controller/MainController.php +++ b/src/Symfony/Controller/MainController.php @@ -21,9 +21,9 @@ use ApiPlatform\Metadata\UriVariablesConverterInterface; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProviderInterface; -use ApiPlatform\State\SerializerContextBuilderInterface; use ApiPlatform\State\UriVariablesResolverTrait; use ApiPlatform\State\Util\OperationRequestInitiatorTrait; +use ApiPlatform\State\Util\OperationStageDefaults; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -69,27 +69,7 @@ public function __invoke(Request $request): Response 'resource_class' => $operation->getClass(), ]; - if (null === $operation->canValidate()) { - $operation = $operation->withValidate(!$request->isMethodSafe() && !$request->isMethod('DELETE')); - } - - if (null === $operation->canRead()) { - $operation = $operation->withRead($operation->getUriVariables() || $request->isMethodSafe()); - } - - if (null === $operation->canDeserialize()) { - $operation = $operation->withDeserialize(\in_array($operation->getMethod(), ['POST', 'PUT', 'PATCH'], true)); - } - - $denormalizationContext = $operation->getDenormalizationContext() ?? []; - if ($operation->canDeserialize() && !isset($denormalizationContext[SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE])) { - $method = $operation->getMethod(); - $assignObjectToPopulate = 'POST' === $method - || 'PATCH' === $method - || ('PUT' === $method && !($operation->getExtraProperties()['standard_put'] ?? true)); - - $operation = $operation->withDenormalizationContext($denormalizationContext + [SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE => $assignObjectToPopulate]); - } + $operation = OperationStageDefaults::forProvider($operation, $request); $body = $this->provider->provide($operation, $uriVariables, $context); @@ -114,13 +94,7 @@ public function __invoke(Request $request): Response $context['read_data'] = $request->attributes->get('read_data'); $context['mapped_data'] = $request->attributes->get('mapped_data'); - if (null === $operation->canWrite()) { - $operation = $operation->withWrite(!$request->isMethodSafe()); - } - - if (null === $operation->canSerialize()) { - $operation = $operation->withSerialize(true); - } + $operation = OperationStageDefaults::forProcessor($operation, $request); return $this->processor->process($body, $operation, $uriVariables, $context); } diff --git a/src/Symfony/EventListener/ControllerApiOperationListener.php b/src/Symfony/EventListener/ControllerApiOperationListener.php new file mode 100644 index 00000000000..32787b27a2e --- /dev/null +++ b/src/Symfony/EventListener/ControllerApiOperationListener.php @@ -0,0 +1,81 @@ + + * + * 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\Symfony\EventListener; + +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use Symfony\Component\HttpKernel\Event\RequestEvent; + +/** + * Binds a request matching a controller carrying an API operation to that operation. + * + * @internal + */ +final class ControllerApiOperationListener +{ + public const REQUEST_ATTRIBUTE = '_api_controller_operation'; + + /** @var array */ + private readonly array $resourceClasses; + + /** + * @param array> $controllerOperations + */ + public function __construct( + private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + array $controllerOperations, + ) { + $resourceClasses = []; + foreach ($controllerOperations as $resourceClass => $controllers) { + foreach ($controllers as $controller) { + $resourceClasses[$controller] = $resourceClass; + } + } + + $this->resourceClasses = $resourceClasses; + } + + public function onKernelRequest(RequestEvent $event): void + { + $request = $event->getRequest(); + $controller = $request->attributes->get('_controller'); + + if (!$this->resourceClasses || !\is_string($controller) || $request->attributes->has('exception')) { + return; + } + + if (!str_contains($controller, '::')) { + $controller .= '::__invoke'; + } + + if (null === ($resourceClass = $this->resourceClasses[$controller] ?? null)) { + return; + } + + $routeName = $request->attributes->get('_route'); + foreach ($this->resourceMetadataCollectionFactory->create($resourceClass) as $resource) { + foreach ($resource->getOperations() ?? [] as $operationName => $operation) { + if ($operation->getRouteName() !== $routeName || $operation->getController() !== $controller) { + continue; + } + + $request->attributes->set('_api_resource_class', $resourceClass); + $request->attributes->set('_api_operation_name', $operationName); + $request->attributes->set('_api_operation', $operation); + $request->attributes->set(self::REQUEST_ATTRIBUTE, true); + + return; + } + } + } +} diff --git a/src/Symfony/EventListener/ControllerApiOperationPipelineListener.php b/src/Symfony/EventListener/ControllerApiOperationPipelineListener.php new file mode 100644 index 00000000000..a7f8ec995be --- /dev/null +++ b/src/Symfony/EventListener/ControllerApiOperationPipelineListener.php @@ -0,0 +1,114 @@ + + * + * 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\Symfony\EventListener; + +use ApiPlatform\Metadata\Exception\InvalidIdentifierException; +use ApiPlatform\Metadata\Exception\InvalidUriVariableException; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\UriVariablesConverterInterface; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\UriVariablesResolverTrait; +use ApiPlatform\State\Util\OperationRequestInitiatorTrait; +use ApiPlatform\State\Util\OperationStageDefaults; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\Event\ViewEvent; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +/** + * Runs the state provider before, and the state processor after, a controller carrying an API operation + * when the Symfony listeners are not in use. + * + * @internal + */ +final class ControllerApiOperationPipelineListener +{ + use OperationRequestInitiatorTrait; + use UriVariablesResolverTrait; + + public function __construct( + private readonly ProviderInterface $provider, + private readonly ProcessorInterface $processor, + ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, + ?UriVariablesConverterInterface $uriVariablesConverter = null, + ) { + $this->resourceMetadataCollectionFactory = $resourceMetadataCollectionFactory; + $this->uriVariablesConverter = $uriVariablesConverter; + } + + public function onKernelRequest(RequestEvent $event): void + { + $request = $event->getRequest(); + if (!($operation = $this->getOperation($request))) { + return; + } + + try { + $uriVariables = $this->getOperationUriVariables($operation, $request->attributes->all(), $operation->getClass()); + } catch (InvalidIdentifierException|InvalidUriVariableException $e) { + throw new NotFoundHttpException('Invalid uri variables.', $e); + } + + $request->attributes->set('_api_uri_variables', $uriVariables); + + $this->provider->provide( + OperationStageDefaults::forProvider($operation, $request), + $uriVariables, + ['request' => $request, 'uri_variables' => $uriVariables, 'resource_class' => $operation->getClass()], + ); + } + + public function onKernelView(ViewEvent $event): void + { + $request = $event->getRequest(); + if (!($operation = $this->getOperation($request))) { + return; + } + + $uriVariables = $request->attributes->get('_api_uri_variables') ?? []; + + $response = $this->processor->process( + $event->getControllerResult(), + OperationStageDefaults::forProcessor($operation, $request), + $uriVariables, + [ + 'request' => $request, + 'uri_variables' => $uriVariables, + 'resource_class' => $operation->getClass(), + 'previous_data' => $request->attributes->get('previous_data'), + 'data' => $request->attributes->get('data'), + 'read_data' => $request->attributes->get('read_data'), + 'mapped_data' => $request->attributes->get('mapped_data'), + ], + ); + + if ($response instanceof Response) { + $event->setResponse($response); + } + } + + private function getOperation(Request $request): ?HttpOperation + { + if (!$request->attributes->get(ControllerApiOperationListener::REQUEST_ATTRIBUTE)) { + return null; + } + + $operation = $this->initializeOperation($request); + + return $operation instanceof HttpOperation ? $operation : null; + } +} diff --git a/src/Symfony/Metadata/Resource/Factory/ControllerApiOperationResourceMetadataCollectionFactory.php b/src/Symfony/Metadata/Resource/Factory/ControllerApiOperationResourceMetadataCollectionFactory.php new file mode 100644 index 00000000000..3faaf73e9f8 --- /dev/null +++ b/src/Symfony/Metadata/Resource/Factory/ControllerApiOperationResourceMetadataCollectionFactory.php @@ -0,0 +1,107 @@ + + * + * 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\Symfony\Metadata\Resource\Factory; + +use ApiPlatform\Metadata\ApiOperation; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operations; +use ApiPlatform\Metadata\Resource\Factory\OperationDefaultsTrait; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\Util\CamelCaseToSnakeCaseNameConverter; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\Component\Routing\Attribute\Route; + +/** + * Adds the operations declared with {@see ApiOperation} on controller methods to their resource. + * + * @internal + */ +final class ControllerApiOperationResourceMetadataCollectionFactory implements ResourceMetadataCollectionFactoryInterface +{ + use OperationDefaultsTrait; + + /** + * @param array> $controllerOperations resource class to "Class::method" controllers + */ + public function __construct( + private readonly array $controllerOperations, + private readonly ?ResourceMetadataCollectionFactoryInterface $decorated = null, + ?LoggerInterface $logger = null, + array $defaults = [], + ) { + $this->logger = $logger ?? new NullLogger(); + $this->defaults = $defaults; + $this->camelCaseToSnakeCaseNameConverter = new CamelCaseToSnakeCaseNameConverter(); + } + + public function create(string $resourceClass): ResourceMetadataCollection + { + $resourceMetadataCollection = $this->decorated?->create($resourceClass) ?? new ResourceMetadataCollection($resourceClass); + + foreach ($this->controllerOperations[$resourceClass] ?? [] as $controller) { + [$controllerClass, $methodName] = explode('::', $controller, 2); + $method = new \ReflectionMethod($controllerClass, $methodName); + + foreach ($method->getAttributes(ApiOperation::class) as $attribute) { + $operation = $attribute->newInstance()->operation; + + if (null !== $operation->getUriTemplate()) { + throw new RuntimeException(\sprintf('The "#[%s]" on "%s" must not define a "uriTemplate", the path is owned by the route.', ApiOperation::class, $controller)); + } + + $operation = $operation + ->withRouteName($this->getRouteName($controllerClass, $method, $operation, $controller)) + ->withController($controller); + + if (($operation instanceof Get || $operation instanceof GetCollection) && null === $operation->canRead()) { + $operation = $operation->withRead(false); + } + + $resource = $this->getResourceWithDefaults($resourceClass, $this->getDefaultShortname($resourceClass), new ApiResource()); + [$key, $operation] = $this->getOperationWithDefaults($resource, $operation); + $resourceMetadataCollection[] = $resource->withOperations(new Operations([$key => $operation])); + } + } + + return $resourceMetadataCollection; + } + + private function getRouteName(string $controllerClass, \ReflectionMethod $method, HttpOperation $operation, string $controller): string + { + foreach ($method->getAttributes(Route::class) as $routeAttribute) { + if (null === ($name = $routeAttribute->newInstance()->name)) { + continue; + } + + $prefix = ''; + foreach ((new \ReflectionClass($controllerClass))->getAttributes(Route::class) as $classRouteAttribute) { + $prefix = $classRouteAttribute->newInstance()->name ?? ''; + } + + return $prefix.$name; + } + + if (null !== ($name = $operation->getRouteName())) { + return $name; + } + + throw new RuntimeException(\sprintf('The "#[%s]" on "%s" requires a named "#[%s]" or a "routeName" on its operation.', ApiOperation::class, $controller, Route::class)); + } +} diff --git a/src/Symfony/Metadata/Resource/Factory/ControllerApiOperationResourceNameCollectionFactory.php b/src/Symfony/Metadata/Resource/Factory/ControllerApiOperationResourceNameCollectionFactory.php new file mode 100644 index 00000000000..56b56d51c3b --- /dev/null +++ b/src/Symfony/Metadata/Resource/Factory/ControllerApiOperationResourceNameCollectionFactory.php @@ -0,0 +1,48 @@ + + * + * 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\Symfony\Metadata\Resource\Factory; + +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceNameCollection; + +/** + * Adds the resource classes declared by controller methods carrying an API operation. + * + * @internal + */ +final class ControllerApiOperationResourceNameCollectionFactory implements ResourceNameCollectionFactoryInterface +{ + /** + * @param array $controllerOperationResources + */ + public function __construct( + private readonly ResourceNameCollectionFactoryInterface $decorated, + private readonly array $controllerOperationResources, + ) { + } + + public function create(): ResourceNameCollection + { + $classes = []; + foreach ($this->decorated->create() as $resourceClass) { + $classes[$resourceClass] = true; + } + + foreach ($this->controllerOperationResources as $resourceClass => $_) { + $classes[$resourceClass] = true; + } + + return new ResourceNameCollection(array_keys($classes)); + } +} diff --git a/tests/Fixtures/ControllerApiOperation/ControllerApiOperationClassAndOutput.php b/tests/Fixtures/ControllerApiOperation/ControllerApiOperationClassAndOutput.php new file mode 100644 index 00000000000..332ae41cd67 --- /dev/null +++ b/tests/Fixtures/ControllerApiOperation/ControllerApiOperationClassAndOutput.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\ControllerApiOperation; + +use ApiPlatform\Metadata\ApiOperation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation\CheckoutInput; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation\CheckoutOutput; +use Symfony\Component\Routing\Attribute\Route; + +class ControllerApiOperationClassAndOutput +{ + #[Route('/class_and_output', name: 'class_and_output', methods: ['POST'])] + #[ApiOperation(new Post(class: CheckoutInput::class, output: CheckoutOutput::class))] + public function __invoke(): CheckoutOutput + { + return new CheckoutOutput(); + } +} diff --git a/tests/Fixtures/ControllerApiOperation/ControllerApiOperationDefinitions.php b/tests/Fixtures/ControllerApiOperation/ControllerApiOperationDefinitions.php new file mode 100644 index 00000000000..fb1e65401f1 --- /dev/null +++ b/tests/Fixtures/ControllerApiOperation/ControllerApiOperationDefinitions.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\ControllerApiOperation; + +use ApiPlatform\Metadata\ApiOperation; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation\CheckoutOutput; +use Symfony\Component\Routing\Attribute\Route; + +class ControllerApiOperationDefinitions +{ + #[Route('/with_uri_template', name: 'with_uri_template', methods: ['POST'])] + #[ApiOperation(new Post(uriTemplate: '/other', output: CheckoutOutput::class))] + public function withUriTemplate(): CheckoutOutput + { + return new CheckoutOutput(); + } + + #[Route('/unnamed', methods: ['POST'])] + #[ApiOperation(new Post(output: CheckoutOutput::class))] + public function withUnnamedRoute(): CheckoutOutput + { + return new CheckoutOutput(); + } + + #[ApiOperation(new Post(routeName: 'inner_route_name', output: CheckoutOutput::class))] + public function withInnerRouteName(): CheckoutOutput + { + return new CheckoutOutput(); + } + + #[Route('/without_resource_class', name: 'without_resource_class', methods: ['POST'])] + #[ApiOperation(new Post())] + public function withoutResourceClass(): CheckoutOutput + { + return new CheckoutOutput(); + } + + #[Route('/get', name: 'get_route', methods: ['GET'])] + #[ApiOperation(new Get(class: CheckoutOutput::class))] + public function withGet(): CheckoutOutput + { + return new CheckoutOutput(); + } + + #[Route('/get_collection', name: 'get_collection_route', methods: ['GET'])] + #[ApiOperation(new GetCollection(class: CheckoutOutput::class))] + public function withGetCollection(): array + { + return []; + } + + #[Route('/get_explicit_read', name: 'get_explicit_read_route', methods: ['GET'])] + #[ApiOperation(new Get(class: CheckoutOutput::class, read: true))] + public function withExplicitRead(): CheckoutOutput + { + return new CheckoutOutput(); + } + + #[Route('/patch', name: 'patch_route', methods: ['PATCH'])] + #[ApiOperation(new Patch(class: CheckoutOutput::class))] + public function withPatch(): CheckoutOutput + { + return new CheckoutOutput(); + } +} diff --git a/tests/Fixtures/ControllerApiOperation/ControllerApiOperationInputOnly.php b/tests/Fixtures/ControllerApiOperation/ControllerApiOperationInputOnly.php new file mode 100644 index 00000000000..dd9475902ba --- /dev/null +++ b/tests/Fixtures/ControllerApiOperation/ControllerApiOperationInputOnly.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\ControllerApiOperation; + +use ApiPlatform\Metadata\ApiOperation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation\CheckoutInput; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation\CheckoutOutput; +use Symfony\Component\Routing\Attribute\Route; + +class ControllerApiOperationInputOnly +{ + #[Route('/input_only', name: 'input_only', methods: ['POST'])] + #[ApiOperation(new Post(input: CheckoutInput::class))] + public function __invoke(): CheckoutOutput + { + return new CheckoutOutput(); + } +} diff --git a/tests/Fixtures/ControllerApiOperation/ControllerApiOperationOutputOnly.php b/tests/Fixtures/ControllerApiOperation/ControllerApiOperationOutputOnly.php new file mode 100644 index 00000000000..5cc0087ca1c --- /dev/null +++ b/tests/Fixtures/ControllerApiOperation/ControllerApiOperationOutputOnly.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\ControllerApiOperation; + +use ApiPlatform\Metadata\ApiOperation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation\CheckoutInput; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation\CheckoutOutput; +use Symfony\Component\Routing\Attribute\Route; + +class ControllerApiOperationOutputOnly +{ + #[Route('/output_only', name: 'output_only', methods: ['POST'])] + #[ApiOperation(new Post(input: CheckoutInput::class, output: CheckoutOutput::class))] + public function __invoke(): CheckoutOutput + { + return new CheckoutOutput(); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/ControllerApiOperation/Checkout.php b/tests/Fixtures/TestBundle/ApiResource/ControllerApiOperation/Checkout.php new file mode 100644 index 00000000000..f1657c9d313 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/ControllerApiOperation/Checkout.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Operation; + +class Checkout +{ + #[ApiProperty(identifier: true)] + public int $id = 0; + public string $reference = ''; + public string $status = ''; + + public static function create(int $id): ?self + { + if (!\in_array($id, [1, 2], true)) { + return null; + } + + $checkout = new self(); + $checkout->id = $id; + $checkout->reference = 'ref-'.$id; + $checkout->status = 'pending'; + + return $checkout; + } + + /** + * @param array $uriVariables + */ + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): ?self + { + return self::create((int) $uriVariables['id']); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/ControllerApiOperation/CheckoutInput.php b/tests/Fixtures/TestBundle/ApiResource/ControllerApiOperation/CheckoutInput.php new file mode 100644 index 00000000000..fc1f1f17014 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/ControllerApiOperation/CheckoutInput.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation; + +use Symfony\Component\Validator\Constraints as Assert; + +class CheckoutInput +{ + #[Assert\NotBlank] + public string $reference = ''; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/ControllerApiOperation/CheckoutOutput.php b/tests/Fixtures/TestBundle/ApiResource/ControllerApiOperation/CheckoutOutput.php new file mode 100644 index 00000000000..8c083bc97ba --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/ControllerApiOperation/CheckoutOutput.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation; + +class CheckoutOutput +{ + public string $reference = ''; + public string $status = ''; +} diff --git a/tests/Fixtures/TestBundle/Controller/Common/ControllerApiOperationController.php b/tests/Fixtures/TestBundle/Controller/Common/ControllerApiOperationController.php new file mode 100644 index 00000000000..0e1ffc9f077 --- /dev/null +++ b/tests/Fixtures/TestBundle/Controller/Common/ControllerApiOperationController.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Fixtures\TestBundle\Controller\Common; + +use ApiPlatform\Metadata\ApiOperation; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation\Checkout; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation\CheckoutInput; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation\CheckoutOutput; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; +use Symfony\Component\Routing\Attribute\Route; + +class ControllerApiOperationController +{ + #[Route('/controller_api_operation/checkout', name: 'controller_api_operation_checkout', methods: ['POST'])] + #[ApiOperation(new Post(input: CheckoutInput::class, output: CheckoutOutput::class))] + public function __invoke(CheckoutInput $data): CheckoutOutput + { + $output = new CheckoutOutput(); + $output->reference = $data->reference; + $output->status = 'confirmed'; + + return $output; + } + + #[Route('/controller_api_operation/checkouts/{id}', name: 'controller_api_operation_checkout_get', methods: ['GET'], requirements: ['id' => '\d+'])] + #[ApiOperation(new Get(class: Checkout::class, uriVariables: ['id'], formats: ['jsonld' => ['application/ld+json'], 'json' => ['application/json'], 'csv' => ['text/csv']]))] + public function get(int $id): Checkout + { + return Checkout::create($id) ?? throw new NotFoundHttpException(); + } + + /** + * @return Checkout[] + */ + #[Route('/controller_api_operation/checkouts', name: 'controller_api_operation_checkout_collection', methods: ['GET'])] + #[ApiOperation(new GetCollection(class: Checkout::class, formats: ['jsonld' => ['application/ld+json'], 'json' => ['application/json'], 'csv' => ['text/csv']]))] + public function getCollection(): array + { + return [Checkout::create(1), Checkout::create(2)]; + } + + #[Route('/controller_api_operation/checkouts/{id}', name: 'controller_api_operation_checkout_patch', methods: ['PATCH'], requirements: ['id' => '\d+'])] + #[ApiOperation(new Patch(class: Checkout::class, uriVariables: ['id'], provider: [Checkout::class, 'provide'], inputFormats: ['json' => ['application/merge-patch+json']], outputFormats: ['jsonld' => ['application/ld+json'], 'json' => ['application/json']]))] + public function patch(Checkout $data): Checkout + { + $data->status = 'patched'; + + return $data; + } + + #[Route('/controller_api_operation/checkouts/{id}', name: 'controller_api_operation_checkout_delete', methods: ['DELETE'], requirements: ['id' => '\d+'])] + #[ApiOperation(new Delete(class: Checkout::class, uriVariables: ['id'], provider: [Checkout::class, 'provide']))] + public function delete(Checkout $data): null + { + return null; + } +} diff --git a/tests/Functional/ControllerApiOperationTest.php b/tests/Functional/ControllerApiOperationTest.php new file mode 100644 index 00000000000..0d7723b534d --- /dev/null +++ b/tests/Functional/ControllerApiOperationTest.php @@ -0,0 +1,172 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation\Checkout; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation\CheckoutOutput; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; + +final class ControllerApiOperationTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [CheckoutOutput::class, Checkout::class]; + } + + public function testPostRunsPipeline(): void + { + self::createClient()->request('POST', '/controller_api_operation/checkout', [ + 'headers' => ['Accept' => 'application/ld+json', 'Content-Type' => 'application/ld+json'], + 'json' => ['reference' => 'abc'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonContains([ + '@type' => 'CheckoutOutput', + 'reference' => 'abc', + 'status' => 'confirmed', + ]); + } + + public function testValidationRuns(): void + { + self::createClient()->request('POST', '/controller_api_operation/checkout', [ + 'headers' => ['Accept' => 'application/ld+json', 'Content-Type' => 'application/ld+json'], + 'json' => ['reference' => ''], + ]); + + $this->assertResponseStatusCodeSame(422); + } + + #[DataProvider('itemFormatsProvider')] + public function testGet(string $accept, string $contentType, callable $assert): void + { + $response = self::createClient()->request('GET', '/controller_api_operation/checkouts/1', ['headers' => ['Accept' => $accept]]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', $contentType); + $assert($this, $response->getContent()); + } + + /** + * @return iterable + */ + public static function itemFormatsProvider(): iterable + { + yield 'jsonld' => ['application/ld+json', 'application/ld+json', static function (self $test, string $body): void { + $data = json_decode($body, true); + $test->assertSame('Checkout', $data['@type']); + $test->assertSame('/controller_api_operation/checkouts/1', $data['@id']); + $test->assertSame('ref-1', $data['reference']); + }]; + yield 'json' => ['application/json', 'application/json', static function (self $test, string $body): void { + $test->assertSame(['id' => 1, 'reference' => 'ref-1', 'status' => 'pending'], json_decode($body, true)); + }]; + yield 'csv' => ['text/csv', 'text/csv; charset=utf-8', static function (self $test, string $body): void { + $test->assertStringContainsString('id,reference,status', $body); + $test->assertStringContainsString('1,ref-1,pending', $body); + }]; + } + + public function testGetNotFound(): void + { + self::createClient()->request('GET', '/controller_api_operation/checkouts/3', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(404); + } + + #[DataProvider('collectionFormatsProvider')] + public function testGetCollection(string $accept, string $contentType, callable $assert): void + { + $response = self::createClient()->request('GET', '/controller_api_operation/checkouts', ['headers' => ['Accept' => $accept]]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', $contentType); + $assert($this, $response->getContent()); + } + + /** + * @return iterable + */ + public static function collectionFormatsProvider(): iterable + { + yield 'jsonld' => ['application/ld+json', 'application/ld+json', static function (self $test, string $body): void { + $data = json_decode($body, true); + $members = $data['hydra:member'] ?? $data['member']; + $test->assertCount(2, $members); + $test->assertSame('/controller_api_operation/checkouts/2', $members[1]['@id']); + }]; + yield 'json' => ['application/json', 'application/json', static function (self $test, string $body): void { + $data = json_decode($body, true); + $test->assertCount(2, $data); + $test->assertSame('ref-2', $data[1]['reference']); + }]; + yield 'csv' => ['text/csv', 'text/csv; charset=utf-8', static function (self $test, string $body): void { + $rows = array_values(array_filter(explode("\n", $body))); + $test->assertCount(3, $rows); + $test->assertSame('id,reference,status', $rows[0]); + }]; + } + + #[DataProvider('patchFormatsProvider')] + public function testPatch(string $accept): void + { + self::createClient()->request('PATCH', '/controller_api_operation/checkouts/1', [ + 'headers' => ['Accept' => $accept, 'Content-Type' => 'application/merge-patch+json'], + 'json' => ['reference' => 'new'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains(['reference' => 'new', 'status' => 'patched']); + } + + /** + * @return iterable + */ + public static function patchFormatsProvider(): iterable + { + yield 'jsonld' => ['application/ld+json']; + yield 'json' => ['application/json']; + } + + public function testDelete(): void + { + $response = self::createClient()->request('DELETE', '/controller_api_operation/checkouts/1'); + + $this->assertResponseStatusCodeSame(204); + $this->assertSame('', $response->getContent()); + } + + public function testDocumentedInOpenApi(): void + { + $response = self::createClient()->request('GET', '/docs.jsonopenapi', ['headers' => ['Accept' => 'application/vnd.openapi+json']]); + + $this->assertResponseIsSuccessful(); + $paths = $response->toArray()['paths']; + $this->assertArrayHasKey('post', $paths['/controller_api_operation/checkout']); + $this->assertArrayHasKey('get', $paths['/controller_api_operation/checkouts/{id}']); + $this->assertArrayHasKey('patch', $paths['/controller_api_operation/checkouts/{id}']); + $this->assertArrayHasKey('delete', $paths['/controller_api_operation/checkouts/{id}']); + $this->assertArrayHasKey('get', $paths['/controller_api_operation/checkouts']); + } +} diff --git a/tests/Symfony/Bundle/ApiPlatformBundleTest.php b/tests/Symfony/Bundle/ApiPlatformBundleTest.php index 45af8e628d8..bac363b8000 100644 --- a/tests/Symfony/Bundle/ApiPlatformBundleTest.php +++ b/tests/Symfony/Bundle/ApiPlatformBundleTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\AttributeFilterPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\AttributeResourcePass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\AuthenticatorManagerPass; +use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ControllerApiOperationPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ElasticsearchClientPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ErrorResourceAttributeLoaderPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\FilterPass; @@ -48,6 +49,7 @@ public function testBuild(): void $this->assertContains(AttributeFilterPass::class, $passClasses); $this->assertContains(AttributeResourcePass::class, $passClasses); + $this->assertContains(ControllerApiOperationPass::class, $passClasses); $this->assertContains(FilterPass::class, $passClasses); $this->assertContains(ElasticsearchClientPass::class, $passClasses); $this->assertContains(GraphQlTypePass::class, $passClasses); diff --git a/tests/Symfony/Bundle/DependencyInjection/Compiler/ControllerApiOperationPassTest.php b/tests/Symfony/Bundle/DependencyInjection/Compiler/ControllerApiOperationPassTest.php new file mode 100644 index 00000000000..c9bbffb48b0 --- /dev/null +++ b/tests/Symfony/Bundle/DependencyInjection/Compiler/ControllerApiOperationPassTest.php @@ -0,0 +1,111 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Symfony\Bundle\DependencyInjection\Compiler; + +use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ControllerApiOperationPass; +use ApiPlatform\Tests\Fixtures\ControllerApiOperation\ControllerApiOperationClassAndOutput; +use ApiPlatform\Tests\Fixtures\ControllerApiOperation\ControllerApiOperationDefinitions; +use ApiPlatform\Tests\Fixtures\ControllerApiOperation\ControllerApiOperationInputOnly; +use ApiPlatform\Tests\Fixtures\ControllerApiOperation\ControllerApiOperationOutputOnly; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation\Checkout; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation\CheckoutInput; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation\CheckoutOutput; +use ApiPlatform\Tests\Fixtures\TestBundle\Controller\Common\ControllerApiOperationController; +use ApiPlatform\Tests\Fixtures\TestBundle\Controller\Common\CustomController; +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; + +final class ControllerApiOperationPassTest extends TestCase +{ + public function testRegistersControllerOperationsAndResourceClasses(): void + { + $container = $this->createContainer(ControllerApiOperationController::class, CustomController::class); + + (new ControllerApiOperationPass())->process($container); + + $this->assertSame( + [ + CheckoutOutput::class => [ControllerApiOperationController::class.'::__invoke'], + Checkout::class => [ + ControllerApiOperationController::class.'::get', + ControllerApiOperationController::class.'::getCollection', + ControllerApiOperationController::class.'::patch', + ControllerApiOperationController::class.'::delete', + ], + ], + $container->getParameter('api_platform.controller_operations'), + ); + $this->assertSame([CheckoutOutput::class => true, Checkout::class => true], $container->getParameter('api_platform.controller_operation_resources')); + $this->assertSame(['Existing'], $container->getParameter('api_platform.class_name_resources')); + } + + public function testIgnoresUntaggedControllers(): void + { + $container = $this->createContainer(); + $container->setDefinition('untagged', new Definition(ControllerApiOperationController::class)); + + (new ControllerApiOperationPass())->process($container); + + $this->assertSame([], $container->getParameter('api_platform.controller_operations')); + $this->assertSame([], $container->getParameter('api_platform.controller_operation_resources')); + $this->assertSame(['Existing'], $container->getParameter('api_platform.class_name_resources')); + } + + public function testResourceClassIsRequired(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(ControllerApiOperationDefinitions::class.'::withoutResourceClass'); + + (new ControllerApiOperationPass())->process($this->createContainer(ControllerApiOperationDefinitions::class)); + } + + public function testInputOnlyThrows(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('an "input" alone does not define a resource'); + + (new ControllerApiOperationPass())->process($this->createContainer(ControllerApiOperationInputOnly::class)); + } + + public function testClassTakesPrecedenceOverOutput(): void + { + $container = $this->createContainer(ControllerApiOperationClassAndOutput::class); + + (new ControllerApiOperationPass())->process($container); + + $this->assertSame([CheckoutInput::class => true], $container->getParameter('api_platform.controller_operation_resources')); + } + + public function testOutputIsResourceWithoutClass(): void + { + $container = $this->createContainer(ControllerApiOperationOutputOnly::class); + + (new ControllerApiOperationPass())->process($container); + + $this->assertSame([CheckoutOutput::class => true], $container->getParameter('api_platform.controller_operation_resources')); + } + + private function createContainer(string ...$controllers): ContainerBuilder + { + $container = new ContainerBuilder(); + $container->setParameter('api_platform.class_name_resources', ['Existing']); + foreach ($controllers as $controller) { + $container->setDefinition($controller, (new Definition($controller))->addTag('controller.service_arguments')); + } + + return $container; + } +} diff --git a/tests/Symfony/Metadata/Resource/Factory/ControllerApiOperationResourceMetadataCollectionFactoryTest.php b/tests/Symfony/Metadata/Resource/Factory/ControllerApiOperationResourceMetadataCollectionFactoryTest.php new file mode 100644 index 00000000000..ffb6d6d65e2 --- /dev/null +++ b/tests/Symfony/Metadata/Resource/Factory/ControllerApiOperationResourceMetadataCollectionFactoryTest.php @@ -0,0 +1,77 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Symfony\Metadata\Resource\Factory; + +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Symfony\Metadata\Resource\Factory\ControllerApiOperationResourceMetadataCollectionFactory; +use ApiPlatform\Tests\Fixtures\ControllerApiOperation\ControllerApiOperationDefinitions; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation\CheckoutOutput; +use PHPUnit\Framework\TestCase; + +final class ControllerApiOperationResourceMetadataCollectionFactoryTest extends TestCase +{ + public function testUriTemplateIsForbidden(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('must not define a "uriTemplate"'); + + $this->createFactory('withUriTemplate')->create(CheckoutOutput::class); + } + + public function testNamedRouteIsRequired(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('requires a named'); + + $this->createFactory('withUnnamedRoute')->create(CheckoutOutput::class); + } + + public function testOperationIsBoundToTheRouteName(): void + { + $collection = $this->createFactory('withInnerRouteName')->create(CheckoutOutput::class); + + $this->assertCount(1, $collection); + $operation = $collection->getOperation('inner_route_name'); + $this->assertInstanceOf(HttpOperation::class, $operation); + $this->assertSame('inner_route_name', $operation->getRouteName()); + $this->assertSame(ControllerApiOperationDefinitions::class.'::withInnerRouteName', $operation->getController()); + $this->assertSame(CheckoutOutput::class, $operation->getClass()); + } + + public function testGetDefaultsToNoRead(): void + { + $this->assertFalse($this->createFactory('withGet')->create(CheckoutOutput::class)->getOperation('get_route')->canRead()); + } + + public function testGetCollectionDefaultsToNoRead(): void + { + $this->assertFalse($this->createFactory('withGetCollection')->create(CheckoutOutput::class)->getOperation('get_collection_route')->canRead()); + } + + public function testExplicitReadIsKept(): void + { + $this->assertTrue($this->createFactory('withExplicitRead')->create(CheckoutOutput::class)->getOperation('get_explicit_read_route')->canRead()); + } + + public function testPatchKeepsReadUnset(): void + { + $this->assertNull($this->createFactory('withPatch')->create(CheckoutOutput::class)->getOperation('patch_route')->canRead()); + } + + private function createFactory(string $method): ControllerApiOperationResourceMetadataCollectionFactory + { + return new ControllerApiOperationResourceMetadataCollectionFactory([CheckoutOutput::class => [ControllerApiOperationDefinitions::class.'::'.$method]]); + } +} diff --git a/tests/Symfony/Metadata/Resource/Factory/ControllerApiOperationResourceNameCollectionFactoryTest.php b/tests/Symfony/Metadata/Resource/Factory/ControllerApiOperationResourceNameCollectionFactoryTest.php new file mode 100644 index 00000000000..04a4040f8ad --- /dev/null +++ b/tests/Symfony/Metadata/Resource/Factory/ControllerApiOperationResourceNameCollectionFactoryTest.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Symfony\Metadata\Resource\Factory; + +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\Symfony\Metadata\Resource\Factory\ControllerApiOperationResourceNameCollectionFactory; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation\Checkout; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation\CheckoutInput; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ControllerApiOperation\CheckoutOutput; +use PHPUnit\Framework\TestCase; + +final class ControllerApiOperationResourceNameCollectionFactoryTest extends TestCase +{ + public function testMergesControllerResourcesWithDecoratedOnes(): void + { + $decorated = $this->createStub(ResourceNameCollectionFactoryInterface::class); + $decorated->method('create')->willReturn(new ResourceNameCollection([Checkout::class, CheckoutOutput::class])); + + $factory = new ControllerApiOperationResourceNameCollectionFactory($decorated, [CheckoutOutput::class => true, CheckoutInput::class => true]); + + $this->assertSame([Checkout::class, CheckoutOutput::class, CheckoutInput::class], iterator_to_array($factory->create(), false)); + } + + public function testReturnsDecoratedResourcesWhenNoControllerResource(): void + { + $decorated = $this->createStub(ResourceNameCollectionFactoryInterface::class); + $decorated->method('create')->willReturn(new ResourceNameCollection([Checkout::class])); + + $factory = new ControllerApiOperationResourceNameCollectionFactory($decorated, []); + + $this->assertSame([Checkout::class], iterator_to_array($factory->create(), false)); + } +}