From 94f3c7fe8b681dc76d7d061092916d4bd7c1b900 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 5 Mar 2026 11:20:21 +0100 Subject: [PATCH 01/84] feat(openapi): Scalar API Reference documentation support (#7817) Co-authored-by: Claude Sonnet 4.6 --- src/Laravel/ApiPlatformProvider.php | 6 ++- .../Controller/DocumentationController.php | 3 +- src/Laravel/State/SwaggerUiProcessor.php | 7 ++- src/Laravel/State/SwaggerUiProvider.php | 3 +- src/Laravel/config/api-platform.php | 5 ++ .../resources/views/swagger-ui.blade.php | 11 ++-- src/Symfony/Action/DocumentationAction.php | 7 +-- .../ApiPlatformExtension.php | 8 ++- .../DependencyInjection/Configuration.php | 9 ++++ .../Bundle/Resources/config/swagger_ui.php | 2 + .../Resources/config/symfony/controller.php | 1 + .../Resources/config/symfony/events.php | 1 + .../Bundle/Resources/public/init-scalar-ui.js | 12 +++++ .../Resources/views/SwaggerUi/index.html.twig | 29 +++++++--- .../Bundle/SwaggerUi/SwaggerUiContext.php | 12 ++++- .../Bundle/SwaggerUi/SwaggerUiProcessor.php | 2 + .../Tests/Action/DocumentationActionTest.php | 5 +- tests/Functional/DocumentationActionTest.php | 54 +++++++++++++++++-- .../DependencyInjection/ConfigurationTest.php | 2 + 19 files changed, 151 insertions(+), 28 deletions(-) create mode 100644 src/Symfony/Bundle/Resources/public/init-scalar-ui.js diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 893f5406b66..412edbbc126 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -405,7 +405,7 @@ public function register(): void /** @var ConfigRepository */ $config = $app['config']; - return new SwaggerUiProvider($app->make(ReadProvider::class), $app->make(OpenApiFactoryInterface::class), $config->get('api-platform.swagger_ui.enabled', false)); + return new SwaggerUiProvider($app->make(ReadProvider::class), $app->make(OpenApiFactoryInterface::class), $config->get('api-platform.swagger_ui.enabled', false), $config->get('api-platform.scalar.enabled', false)); }); $this->app->singleton(DeserializeProvider::class, static function (Application $app) { @@ -746,6 +746,8 @@ public function register(): void oauthClientId: $config->get('api-platform.swagger_ui.oauth.clientId'), oauthClientSecret: $config->get('api-platform.swagger_ui.oauth.clientSecret'), oauthPkce: $config->get('api-platform.swagger_ui.oauth.pkce', false), + scalarEnabled: $config->get('api-platform.scalar.enabled', false), + scalarExtraConfiguration: $config->get('api-platform.scalar.extra_configuration', []), ); }); @@ -759,7 +761,7 @@ public function register(): void /** @var ConfigRepository */ $config = $app['config']; - return new DocumentationController($app->make(ResourceNameCollectionFactoryInterface::class), $config->get('api-platform.title') ?? '', $config->get('api-platform.description') ?? '', $config->get('api-platform.version') ?? '', $app->make(OpenApiFactoryInterface::class), $app->make(ProviderInterface::class), $app->make(ProcessorInterface::class), $app->make(Negotiator::class), $config->get('api-platform.docs_formats'), $config->get('api-platform.swagger_ui.enabled', false)); + return new DocumentationController($app->make(ResourceNameCollectionFactoryInterface::class), $config->get('api-platform.title') ?? '', $config->get('api-platform.description') ?? '', $config->get('api-platform.version') ?? '', $app->make(OpenApiFactoryInterface::class), $app->make(ProviderInterface::class), $app->make(ProcessorInterface::class), $app->make(Negotiator::class), $config->get('api-platform.docs_formats'), $config->get('api-platform.swagger_ui.enabled', false), $config->get('api-platform.scalar.enabled', false)); }); $this->app->singleton(EntrypointController::class, static function (Application $app) { diff --git a/src/Laravel/Controller/DocumentationController.php b/src/Laravel/Controller/DocumentationController.php index 0b3b1809b74..33f44b22e59 100644 --- a/src/Laravel/Controller/DocumentationController.php +++ b/src/Laravel/Controller/DocumentationController.php @@ -53,6 +53,7 @@ public function __construct( ?Negotiator $negotiator = null, private readonly array $documentationFormats = [OpenApiNormalizer::JSON_FORMAT => ['application/vnd.openapi+json'], OpenApiNormalizer::FORMAT => ['application/json']], private readonly bool $swaggerUiEnabled = true, + private readonly bool $scalarEnabled = true, ) { $this->negotiator = $negotiator ?? new Negotiator(); } @@ -94,7 +95,7 @@ class: OpenApi::class, outputFormats: $this->documentationFormats ); - if ('html' === $format && $this->swaggerUiEnabled) { + if ('html' === $format && ($this->swaggerUiEnabled || $this->scalarEnabled)) { $operation = $operation->withProcessor('api_platform.swagger_ui.processor')->withWrite(true); } diff --git a/src/Laravel/State/SwaggerUiProcessor.php b/src/Laravel/State/SwaggerUiProcessor.php index 6a90a23fdc9..2818517d2ca 100644 --- a/src/Laravel/State/SwaggerUiProcessor.php +++ b/src/Laravel/State/SwaggerUiProcessor.php @@ -34,6 +34,7 @@ final class SwaggerUiProcessor implements ProcessorInterface /** * @param array $formats + * @param array $scalarExtraConfiguration */ public function __construct( private readonly UrlGeneratorInterface $urlGenerator, @@ -43,6 +44,8 @@ public function __construct( private readonly ?string $oauthClientId = null, private readonly ?string $oauthClientSecret = null, private readonly bool $oauthPkce = false, + private readonly bool $scalarEnabled = false, + private readonly array $scalarExtraConfiguration = [], ) { } @@ -92,7 +95,9 @@ public function process(mixed $openApi, Operation $operation, array $uriVariable $status = $requestedOperation->getStatus() ?? $status; } - return new Response(view('api-platform::swagger-ui', $swaggerContext + ['swagger_data' => $swaggerData]), 200); + $swaggerData['scalarExtraConfiguration'] = $this->scalarExtraConfiguration; + + return new Response(view('api-platform::swagger-ui', $swaggerContext + ['swagger_data' => $swaggerData, 'scalar_enabled' => $this->scalarEnabled]), 200); } /** diff --git a/src/Laravel/State/SwaggerUiProvider.php b/src/Laravel/State/SwaggerUiProvider.php index a465e5c1748..dd8d0fbae76 100644 --- a/src/Laravel/State/SwaggerUiProvider.php +++ b/src/Laravel/State/SwaggerUiProvider.php @@ -38,6 +38,7 @@ public function __construct( private readonly ProviderInterface $decorated, private readonly OpenApiFactoryInterface $openApiFactory, private readonly bool $swaggerUiEnabled = true, + private readonly bool $scalarEnabled = false, ) { } @@ -52,7 +53,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c !($operation instanceof HttpOperation) || !($request = $context['request'] ?? null) || 'html' !== $request->getRequestFormat() - || !$this->swaggerUiEnabled + || (!$this->swaggerUiEnabled && !$this->scalarEnabled) || true === ($operation->getExtraProperties()['_api_disable_swagger_provider'] ?? false) ) { return $this->decorated->provide($operation, $uriVariables, $context); diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 81411333ba8..e5117655726 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -97,6 +97,11 @@ AuthorizationException::class => 403, ], + 'scalar' => [ + 'enabled' => true, + 'extra_configuration' => [], + ], + 'swagger_ui' => [ 'enabled' => true, // 'apiKeys' => [ diff --git a/src/Laravel/resources/views/swagger-ui.blade.php b/src/Laravel/resources/views/swagger-ui.blade.php index 4a9436c6e0c..4fec36e76fc 100644 --- a/src/Laravel/resources/views/swagger-ui.blade.php +++ b/src/Laravel/resources/views/swagger-ui.blade.php @@ -213,8 +213,13 @@ @endif
- - - + @if (($scalar_enabled ?? false) && request()->query('ui') === 'scalar') + + + @else + + + + @endif diff --git a/src/Symfony/Action/DocumentationAction.php b/src/Symfony/Action/DocumentationAction.php index 891d5d83d4c..50981993b69 100644 --- a/src/Symfony/Action/DocumentationAction.php +++ b/src/Symfony/Action/DocumentationAction.php @@ -52,6 +52,7 @@ public function __construct( private readonly bool $swaggerUiEnabled = true, private readonly bool $docsEnabled = true, private readonly bool $reDocEnabled = true, + private readonly bool $scalarEnabled = true, ) { $this->negotiator = $negotiator ?? new Negotiator(); } @@ -91,8 +92,8 @@ public function __invoke(?Request $request = null) */ private function getOpenApiDocumentation(array $context, string $format, Request $request): OpenApi|Response { - if ('html' === $format && !$this->swaggerUiEnabled && !$this->reDocEnabled) { - throw new NotFoundHttpException('Swagger UI and ReDoc are disabled.'); + if ('html' === $format && !$this->swaggerUiEnabled && !$this->reDocEnabled && !$this->scalarEnabled) { + throw new NotFoundHttpException('Swagger UI, ReDoc and Scalar are disabled.'); } if ($this->provider && $this->processor) { @@ -105,7 +106,7 @@ class: OpenApi::class, outputFormats: $this->documentationFormats ); - if ('html' === $format && ($this->swaggerUiEnabled || $this->reDocEnabled)) { + if ('html' === $format && ($this->swaggerUiEnabled || $this->reDocEnabled || $this->scalarEnabled)) { $operation = $operation->withProcessor('api_platform.swagger_ui.processor')->withWrite(true); } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index a2e1d698cf4..4b465bfc975 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -151,6 +151,7 @@ public function load(array $configs, ContainerBuilder $container): void // to prevent HTML documentation from being served on resource endpoints. $config['enable_swagger_ui'] = false; $config['enable_re_doc'] = false; + $config['enable_scalar'] = false; } $jsonSchemaFormats = $config['jsonschema_formats']; @@ -647,6 +648,7 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array if (!$config['enable_swagger']) { $container->setParameter('api_platform.enable_swagger_ui', false); $container->setParameter('api_platform.enable_re_doc', false); + $container->setParameter('api_platform.enable_scalar', false); return; } @@ -657,7 +659,7 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array $loader->load('openapi/yaml.php'); } - if ($config['enable_swagger_ui'] || $config['enable_re_doc']) { + if ($config['enable_swagger_ui'] || $config['enable_re_doc'] || $config['enable_scalar']) { $loader->load('swagger_ui.php'); if ($config['use_symfony_listeners']) { @@ -667,13 +669,14 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array $loader->load('state/swagger_ui.php'); } - if (!$config['enable_swagger_ui'] && !$config['enable_re_doc']) { + if (!$config['enable_swagger_ui'] && !$config['enable_re_doc'] && !$config['enable_scalar']) { // Remove the listener but keep the controller to allow customizing the path of the UI $container->removeDefinition('api_platform.swagger.listener.ui'); } $container->setParameter('api_platform.enable_swagger_ui', $config['enable_swagger_ui']); $container->setParameter('api_platform.enable_re_doc', $config['enable_re_doc']); + $container->setParameter('api_platform.enable_scalar', $config['enable_scalar']); $container->setParameter('api_platform.swagger.api_keys', $config['swagger']['api_keys']); $container->setParameter('api_platform.swagger.persist_authorization', $config['swagger']['persist_authorization']); $container->setParameter('api_platform.swagger.http_auth', $config['swagger']['http_auth']); @@ -681,6 +684,7 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array throw new RuntimeException('You can not set "swagger_ui_extra_configuration" twice - in "openapi" and "swagger" section.'); } $container->setParameter('api_platform.swagger_ui.extra_configuration', $config['openapi']['swagger_ui_extra_configuration'] ?: $config['swagger']['swagger_ui_extra_configuration']); + $container->setParameter('api_platform.scalar.extra_configuration', $config['openapi']['scalar_extra_configuration']); } private function registerJsonApiConfiguration(ContainerBuilder $container, array $formats, PhpFileLoader $loader, array $config): void diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 45fd8bebd0c..89f72c7bf94 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -124,6 +124,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->booleanNode('enable_json_streamer')->defaultValue(class_exists(ControllerHelper::class) && class_exists(JsonStreamWriter::class))->info('Enable json streamer.')->end() ->booleanNode('enable_swagger_ui')->defaultValue(class_exists(TwigBundle::class))->info('Enable Swagger UI')->end() ->booleanNode('enable_re_doc')->defaultValue(class_exists(TwigBundle::class))->info('Enable ReDoc')->end() + ->booleanNode('enable_scalar')->defaultValue(class_exists(TwigBundle::class))->info('Enable Scalar API Reference')->end() ->booleanNode('enable_entrypoint')->defaultTrue()->info('Enable the entrypoint')->end() ->booleanNode('enable_docs')->defaultTrue()->info('Enable the docs')->end() ->booleanNode('enable_profiler')->defaultTrue()->info('Enable the data collector and the WebProfilerBundle integration.')->end() @@ -590,6 +591,14 @@ private function addOpenApiSection(ArrayNodeDefinition $rootNode): void ->end() ->info('To pass extra configuration to Swagger UI, like docExpansion or filter.') ->end() + ->variableNode('scalar_extra_configuration') + ->defaultValue([]) + ->validate() + ->ifTrue(static fn ($v): bool => false === \is_array($v)) + ->thenInvalid('The scalar_extra_configuration parameter must be an array.') + ->end() + ->info('To pass extra configuration to Scalar API Reference, like theme or darkMode.') + ->end() ->booleanNode('overrideResponses')->defaultTrue()->info('Whether API Platform adds automatic responses to the OpenAPI documentation.')->end() ->scalarNode('error_resource_class')->defaultNull()->info('The class used to represent errors in the OpenAPI documentation.')->end() ->scalarNode('validation_error_resource_class')->defaultNull()->info('The class used to represent validation errors in the OpenAPI documentation.')->end() diff --git a/src/Symfony/Bundle/Resources/config/swagger_ui.php b/src/Symfony/Bundle/Resources/config/swagger_ui.php index 4d4b756af6e..312e12227af 100644 --- a/src/Symfony/Bundle/Resources/config/swagger_ui.php +++ b/src/Symfony/Bundle/Resources/config/swagger_ui.php @@ -28,6 +28,8 @@ '%api_platform.graphql.graphiql.enabled%', '%api_platform.asset_package%', '%api_platform.swagger_ui.extra_configuration%', + '%api_platform.enable_scalar%', + '%api_platform.scalar.extra_configuration%', ]); $services->set('api_platform.swagger_ui.processor', SwaggerUiProcessor::class) diff --git a/src/Symfony/Bundle/Resources/config/symfony/controller.php b/src/Symfony/Bundle/Resources/config/symfony/controller.php index 203afa69245..2ac24e613e0 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/controller.php +++ b/src/Symfony/Bundle/Resources/config/symfony/controller.php @@ -54,5 +54,6 @@ '%api_platform.enable_swagger_ui%', '%api_platform.enable_docs%', '%api_platform.enable_re_doc%', + '%api_platform.enable_scalar%', ]); }; diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.php b/src/Symfony/Bundle/Resources/config/symfony/events.php index 12488624e08..c8c2c833e70 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.php +++ b/src/Symfony/Bundle/Resources/config/symfony/events.php @@ -202,6 +202,7 @@ '%api_platform.enable_swagger_ui%', '%api_platform.enable_docs%', '%api_platform.enable_re_doc%', + '%api_platform.enable_scalar%', ]); $services->set('api_platform.action.placeholder', PlaceholderAction::class) diff --git a/src/Symfony/Bundle/Resources/public/init-scalar-ui.js b/src/Symfony/Bundle/Resources/public/init-scalar-ui.js new file mode 100644 index 00000000000..ba8c2091117 --- /dev/null +++ b/src/Symfony/Bundle/Resources/public/init-scalar-ui.js @@ -0,0 +1,12 @@ +'use strict'; + +window.onload = function() { + var data = JSON.parse(document.getElementById('swagger-data').innerText); + + var config = Object.assign({ + content: data.spec, + theme: 'default', + }, data.scalarExtraConfiguration || {}); + + Scalar.createApiReference('#swagger-ui', config); +}; diff --git a/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig b/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig index 3405232fbaa..52dfca324ee 100644 --- a/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig +++ b/src/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig @@ -3,17 +3,23 @@ {% block head_metas %} + {% endblock %} {% block title %} {% if title %}{{ title }} - {% endif %}API Platform {% endblock %} + {% set active_ui = app.request.query.get('ui', 'swagger_ui') %} + {% set is_scalar = (scalarEnabled and not swaggerUiEnabled and not reDocEnabled) or (scalarEnabled and 'scalar' == active_ui) %} + {% block stylesheet %} - - - - + {% if not is_scalar %} + + + + + {% endif %} {% endblock %} {% set oauth_data = {'oauth': swagger_data.oauth|merge({'redirectUrl' : absolute_url(asset('bundles/apiplatform/swagger-ui/oauth2-redirect.html', assetPackage)) })} %} @@ -25,6 +31,7 @@ +{% if not is_scalar %} @@ -69,9 +76,11 @@
{% endif %} +{% endif %}
+{% if not is_scalar %}
@@ -81,16 +90,20 @@ {% endfor %}
Other API docs: - {% set active_ui = app.request.query.get('ui', 'swagger_ui') %} {% if swaggerUiEnabled and active_ui != 'swagger_ui' %}Swagger UI{% endif %} {% if reDocEnabled and active_ui != 're_doc' %}ReDoc{% endif %} + {% if scalarEnabled and active_ui != 'scalar' %}Scalar{% endif %} {% if not graphQlEnabled or graphiQlEnabled %}GraphiQL{% endif %}
+{% endif %} {% block javascript %} - {% if (reDocEnabled and not swaggerUiEnabled) or (reDocEnabled and 're_doc' == active_ui) %} + {% if is_scalar %} + + + {% elseif (reDocEnabled and not swaggerUiEnabled) or (reDocEnabled and 're_doc' == active_ui) %} {% else %} @@ -98,7 +111,9 @@ {% endif %} - + {% if not is_scalar %} + + {% endif %} {% endblock %} diff --git a/src/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php b/src/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php index e0240c9c717..1c5e29db84f 100644 --- a/src/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php +++ b/src/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php @@ -18,7 +18,7 @@ final class SwaggerUiContext /** * @param string|null $assetPackage */ - public function __construct(private readonly bool $swaggerUiEnabled = false, private readonly bool $showWebby = true, private readonly bool $reDocEnabled = false, private readonly bool $graphQlEnabled = false, private readonly bool $graphiQlEnabled = false, private $assetPackage = null, private readonly array $extraConfiguration = []) + public function __construct(private readonly bool $swaggerUiEnabled = false, private readonly bool $showWebby = true, private readonly bool $reDocEnabled = false, private readonly bool $graphQlEnabled = false, private readonly bool $graphiQlEnabled = false, private $assetPackage = null, private readonly array $extraConfiguration = [], private readonly bool $scalarEnabled = false, private readonly array $scalarExtraConfiguration = []) { } @@ -56,4 +56,14 @@ public function getExtraConfiguration(): array { return $this->extraConfiguration; } + + public function isScalarEnabled(): bool + { + return $this->scalarEnabled; + } + + public function getScalarExtraConfiguration(): array + { + return $this->scalarExtraConfiguration; + } } diff --git a/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php b/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php index 844f64d9c5c..eba9d89fed8 100644 --- a/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php +++ b/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php @@ -52,6 +52,7 @@ public function process(mixed $openApi, Operation $operation, array $uriVariable 'showWebby' => $this->swaggerUiContext->isWebbyShown(), 'swaggerUiEnabled' => $this->swaggerUiContext->isSwaggerUiEnabled(), 'reDocEnabled' => $this->swaggerUiContext->isRedocEnabled(), + 'scalarEnabled' => $this->swaggerUiContext->isScalarEnabled(), 'graphQlEnabled' => $this->swaggerUiContext->isGraphQlEnabled(), 'graphiQlEnabled' => $this->swaggerUiContext->isGraphiQlEnabled(), 'assetPackage' => $this->swaggerUiContext->getAssetPackage(), @@ -75,6 +76,7 @@ public function process(mixed $openApi, Operation $operation, array $uriVariable 'pkce' => $this->oauthPkce, ], 'extraConfiguration' => $this->swaggerUiContext->getExtraConfiguration(), + 'scalarExtraConfiguration' => $this->swaggerUiContext->getScalarExtraConfiguration(), ]; $status = 200; diff --git a/src/Symfony/Tests/Action/DocumentationActionTest.php b/src/Symfony/Tests/Action/DocumentationActionTest.php index 2595e33b232..802f4ceb3e9 100644 --- a/src/Symfony/Tests/Action/DocumentationActionTest.php +++ b/src/Symfony/Tests/Action/DocumentationActionTest.php @@ -37,10 +37,10 @@ class DocumentationActionTest extends TestCase { use ProphecyTrait; - public function testHtmlFormatWhenSwaggerUiAndReDocDisabledThrows404(): void + public function testHtmlFormatWhenSwaggerUiAndReDocAndScalarDisabledThrows404(): void { $this->expectException(NotFoundHttpException::class); - $this->expectExceptionMessage('Swagger UI and ReDoc are disabled.'); + $this->expectExceptionMessage('Swagger UI, ReDoc and Scalar are disabled.'); $request = new Request(); $request->attributes->set('_format', 'html'); @@ -57,6 +57,7 @@ public function testHtmlFormatWhenSwaggerUiAndReDocDisabledThrows404(): void ], swaggerUiEnabled: false, reDocEnabled: false, + scalarEnabled: false, ); $documentation($request); diff --git a/tests/Functional/DocumentationActionTest.php b/tests/Functional/DocumentationActionTest.php index b31dca0c763..69d8ba90fde 100644 --- a/tests/Functional/DocumentationActionTest.php +++ b/tests/Functional/DocumentationActionTest.php @@ -24,18 +24,19 @@ class DocumentationActionAppKernel extends \AppKernel { public static bool $swaggerUiEnabled = true; public static bool $reDocEnabled = true; + public static bool $scalarEnabled = true; public static bool $docsEnabled = true; public function getCacheDir(): string { - $suffix = (self::$swaggerUiEnabled ? 'ui_' : 'no_ui_').(self::$reDocEnabled ? 'redoc' : 'no_redoc').(self::$docsEnabled ? '' : '_no_docs'); + $suffix = (self::$swaggerUiEnabled ? 'ui_' : 'no_ui_').(self::$reDocEnabled ? 'redoc' : 'no_redoc').(self::$scalarEnabled ? '_scalar' : '_no_scalar').(self::$docsEnabled ? '' : '_no_docs'); return parent::getCacheDir().'/'.$suffix; } public function getLogDir(): string { - $suffix = (self::$swaggerUiEnabled ? 'ui_' : 'no_ui_').(self::$reDocEnabled ? 'redoc' : 'no_redoc').(self::$docsEnabled ? '' : '_no_docs'); + $suffix = (self::$swaggerUiEnabled ? 'ui_' : 'no_ui_').(self::$reDocEnabled ? 'redoc' : 'no_redoc').(self::$scalarEnabled ? '_scalar' : '_no_scalar').(self::$docsEnabled ? '' : '_no_docs'); return parent::getLogDir().'/'.$suffix; } @@ -48,6 +49,7 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load $container->loadFromExtension('api_platform', [ 'enable_swagger_ui' => DocumentationActionAppKernel::$swaggerUiEnabled, 'enable_re_doc' => DocumentationActionAppKernel::$reDocEnabled, + 'enable_scalar' => DocumentationActionAppKernel::$scalarEnabled, 'enable_docs' => DocumentationActionAppKernel::$docsEnabled, ]); }); @@ -63,32 +65,36 @@ protected static function getKernelClass(): string return DocumentationActionAppKernel::class; } - public function testHtmlDocumentationIsNotAccessibleWhenSwaggerUiAndReDocAreDisabled(): void + public function testHtmlDocumentationIsNotAccessibleWhenSwaggerUiAndReDocAndScalarAreDisabled(): void { DocumentationActionAppKernel::$swaggerUiEnabled = false; DocumentationActionAppKernel::$reDocEnabled = false; + DocumentationActionAppKernel::$scalarEnabled = false; $client = self::createClient(); $container = static::getContainer(); $this->assertFalse($container->getParameter('api_platform.enable_swagger_ui')); $this->assertFalse($container->getParameter('api_platform.enable_re_doc')); + $this->assertFalse($container->getParameter('api_platform.enable_scalar')); $client->request('GET', '/docs', ['headers' => ['Accept' => 'text/html']]); $this->assertResponseStatusCodeSame(404); - $this->assertStringContainsString('Swagger UI and ReDoc are disabled.', $client->getResponse()->getContent(false)); + $this->assertStringContainsString('Swagger UI, ReDoc and Scalar are disabled.', $client->getResponse()->getContent(false)); } public function testJsonDocumentationIsAccessibleWhenSwaggerUiIsDisabled(): void { DocumentationActionAppKernel::$swaggerUiEnabled = false; DocumentationActionAppKernel::$reDocEnabled = false; + DocumentationActionAppKernel::$scalarEnabled = false; $client = self::createClient(); $container = static::getContainer(); $this->assertFalse($container->getParameter('api_platform.enable_swagger_ui')); $this->assertFalse($container->getParameter('api_platform.enable_re_doc')); + $this->assertFalse($container->getParameter('api_platform.enable_scalar')); $client->request('GET', '/docs.jsonopenapi', ['headers' => ['Accept' => 'application/vnd.openapi+json']]); $this->assertResponseIsSuccessful(); @@ -161,10 +167,47 @@ public function testJsonDocumentationIsAccessibleWhenSwaggerUiIsEnabled(): void $this->assertJsonContains(['info' => ['title' => 'My Dummy API']]); } - public function testEnableDocsFalseDisablesSwaggerUiAndReDoc(): void + public function testHtmlDocumentationIsAccessibleWhenOnlyScalarIsEnabled(): void + { + DocumentationActionAppKernel::$swaggerUiEnabled = false; + DocumentationActionAppKernel::$reDocEnabled = false; + DocumentationActionAppKernel::$scalarEnabled = true; + + $client = self::createClient(); + + $container = static::getContainer(); + $this->assertFalse($container->getParameter('api_platform.enable_swagger_ui')); + $this->assertFalse($container->getParameter('api_platform.enable_re_doc')); + $this->assertTrue($container->getParameter('api_platform.enable_scalar')); + + $client->request('GET', '/docs', ['headers' => ['Accept' => 'text/html']]); + $this->assertResponseIsSuccessful(); + $content = $client->getResponse()->getContent(); + $this->assertStringContainsString('cdn.jsdelivr.net/npm/@scalar/api-reference', $content); + $this->assertStringContainsString('init-scalar-ui.js', $content); + } + + public function testScalarUiIsAccessibleWithUiQueryParameter(): void + { + DocumentationActionAppKernel::$swaggerUiEnabled = true; + DocumentationActionAppKernel::$reDocEnabled = true; + DocumentationActionAppKernel::$scalarEnabled = true; + + $client = self::createClient(); + + $client->request('GET', '/docs?ui=scalar', ['headers' => ['Accept' => 'text/html']]); + $this->assertResponseIsSuccessful(); + $content = $client->getResponse()->getContent(); + $this->assertStringContainsString('cdn.jsdelivr.net/npm/@scalar/api-reference', $content); + $this->assertStringContainsString('init-scalar-ui.js', $content); + $this->assertStringNotContainsString('swagger-ui-bundle.js', $content); + } + + public function testEnableDocsFalseDisablesSwaggerUiAndReDocAndScalar(): void { DocumentationActionAppKernel::$swaggerUiEnabled = true; DocumentationActionAppKernel::$reDocEnabled = true; + DocumentationActionAppKernel::$scalarEnabled = true; DocumentationActionAppKernel::$docsEnabled = false; $client = self::createClient(); @@ -174,6 +217,7 @@ public function testEnableDocsFalseDisablesSwaggerUiAndReDoc(): void // enable_docs: false acts as a master switch, forcing these to false $this->assertFalse($container->getParameter('api_platform.enable_swagger_ui')); $this->assertFalse($container->getParameter('api_platform.enable_re_doc')); + $this->assertFalse($container->getParameter('api_platform.enable_scalar')); $client->request('GET', '/docs', ['headers' => ['Accept' => 'text/html']]); $this->assertResponseStatusCodeSame(404); diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index def95beb140..233bc358287 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -231,6 +231,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'tags' => [], 'error_resource_class' => null, 'validation_error_resource_class' => null, + 'scalar_extra_configuration' => [], ], 'maker' => [ 'enabled' => true, @@ -250,6 +251,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'jsonapi' => [ 'use_iri_as_id' => true, ], + 'enable_scalar' => true, ], $config); } From c2909a1ff2016fb78ff81ea9f5fa97452e28fcd4 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 6 Mar 2026 09:41:44 +0100 Subject: [PATCH 02/84] fix(mcp): fallback to sdk handler when not found (#7818) Co-authored-by: Claude Opus 4.6 --- src/Laravel/ApiPlatformProvider.php | 9 +- src/Mcp/.gitignore | 3 + src/Mcp/Capability/Registry/Loader.php | 15 +- src/Mcp/JsonSchema/SchemaFactory.php | 144 +++++++++ .../Factory/OperationMetadataFactory.php | 8 +- src/Mcp/Server/Handler.php | 29 +- src/Mcp/State/StructuredContentProcessor.php | 10 +- .../Tests/Capability/Registry/LoaderTest.php | 183 +++++++++++ .../Tests/JsonSchema/SchemaFactoryTest.php | 305 ++++++++++++++++++ src/Mcp/composer.json | 3 + src/Mcp/phpunit.xml.dist | 23 ++ src/Metadata/Delete.php | 2 + src/Metadata/Error.php | 2 + src/Metadata/Get.php | 2 + src/Metadata/GetCollection.php | 2 + src/Metadata/HttpOperation.php | 2 + src/Metadata/McpResource.php | 2 + src/Metadata/McpTool.php | 2 + src/Metadata/NotExposed.php | 2 + src/Metadata/Operation.php | 14 + src/Metadata/Patch.php | 2 + src/Metadata/Post.php | 2 + src/Metadata/Put.php | 2 + ...rmatsResourceMetadataCollectionFactory.php | 19 ++ .../Provider/ContentNegotiationProvider.php | 2 +- .../ApiPlatformExtension.php | 2 + .../DependencyInjection/Configuration.php | 6 + .../Bundle/Resources/config/mcp/mcp.php | 8 +- .../Resources/config/metadata/resource.php | 1 + tests/Functional/McpTest.php | 114 +++---- .../DependencyInjection/ConfigurationTest.php | 1 + 31 files changed, 827 insertions(+), 94 deletions(-) create mode 100644 src/Mcp/.gitignore create mode 100644 src/Mcp/JsonSchema/SchemaFactory.php create mode 100644 src/Mcp/Tests/Capability/Registry/LoaderTest.php create mode 100644 src/Mcp/Tests/JsonSchema/SchemaFactoryTest.php create mode 100644 src/Mcp/phpunit.xml.dist diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 412edbbc126..f4152857848 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -108,6 +108,7 @@ use ApiPlatform\Laravel\State\SwaggerUiProvider; use ApiPlatform\Laravel\State\ValidateProvider; use ApiPlatform\Mcp\Capability\Registry\Loader as McpLoader; +use ApiPlatform\Mcp\JsonSchema\SchemaFactory as McpSchemaFactory; use ApiPlatform\Mcp\Metadata\Operation\Factory\OperationMetadataFactory as McpOperationMetadataFactory; use ApiPlatform\Mcp\Routing\IriConverter as McpIriConverter; use ApiPlatform\Mcp\Server\Handler; @@ -1085,11 +1086,17 @@ private function registerMcp(): void ); }); + $this->app->singleton(McpSchemaFactory::class, static function (Application $app) { + return new McpSchemaFactory( + $app->make(SchemaFactory::class) + ); + }); + $this->app->singleton(McpLoader::class, static function (Application $app) { return new McpLoader( $app->make(ResourceNameCollectionFactoryInterface::class), $app->make(ResourceMetadataCollectionFactoryInterface::class), - $app->make(SchemaFactoryInterface::class) + $app->make(McpSchemaFactory::class) ); }); $this->app->tag(McpLoader::class, 'mcp.loader'); diff --git a/src/Mcp/.gitignore b/src/Mcp/.gitignore new file mode 100644 index 00000000000..8e6e8828bfd --- /dev/null +++ b/src/Mcp/.gitignore @@ -0,0 +1,3 @@ +/composer.lock +/vendor +/.phpunit.cache diff --git a/src/Mcp/Capability/Registry/Loader.php b/src/Mcp/Capability/Registry/Loader.php index eb7e32c784c..32bff5b1089 100644 --- a/src/Mcp/Capability/Registry/Loader.php +++ b/src/Mcp/Capability/Registry/Loader.php @@ -50,22 +50,25 @@ public function load(RegistryInterface $registry): void foreach ($resource->getMcp() ?? [] as $mcp) { if ($mcp instanceof McpTool) { $inputClass = $mcp->getInput()['class'] ?? $mcp->getClass(); - $inputFormat = array_first($mcp->getInputFormats() ?? ['json']); + $inputFormat = array_key_first($mcp->getInputFormats() ?? ['json' => ['application/json']]); $inputSchema = $this->schemaFactory->buildSchema($inputClass, $inputFormat, Schema::TYPE_INPUT, $mcp, null, [SchemaFactory::FORCE_SUBSCHEMA => true]); - $outputClass = $mcp->getOutput()['class'] ?? $mcp->getClass(); - $outputFormat = array_first($mcp->getOutputFormats() ?? ['jsonld']); - $outputSchema = $this->schemaFactory->buildSchema($outputClass, $outputFormat, Schema::TYPE_OUTPUT, $mcp, null, [SchemaFactory::FORCE_SUBSCHEMA => true]); + $outputSchema = null; + if (false !== $mcp->getStructuredContent()) { + $outputClass = $mcp->getOutput()['class'] ?? $mcp->getClass(); + $outputFormat = array_key_first($mcp->getOutputFormats() ?? ['json' => ['application/json']]); + $outputSchema = $this->schemaFactory->buildSchema($outputClass, $outputFormat, Schema::TYPE_OUTPUT, $mcp, null, [SchemaFactory::FORCE_SUBSCHEMA => true])->getArrayCopy(); + } $registry->registerTool( new Tool( name: $mcp->getName(), - inputSchema: $inputSchema->getDefinitions()[$inputSchema->getRootDefinitionKey()]->getArrayCopy(), + inputSchema: $inputSchema->getArrayCopy(), description: $mcp->getDescription(), annotations: $mcp->getAnnotations() ? ToolAnnotations::fromArray($mcp->getAnnotations()) : null, icons: $mcp->getIcons(), meta: $mcp->getMeta(), - outputSchema: $outputSchema->getArrayCopy(), + outputSchema: $outputSchema, ), self::HANDLER, true, diff --git a/src/Mcp/JsonSchema/SchemaFactory.php b/src/Mcp/JsonSchema/SchemaFactory.php new file mode 100644 index 00000000000..7ccc3860ad9 --- /dev/null +++ b/src/Mcp/JsonSchema/SchemaFactory.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\Mcp\JsonSchema; + +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Metadata\Operation; + +/** + * Wraps a SchemaFactoryInterface and flattens the resulting schema + * into a MCP-compliant structure: no $ref, no allOf, no definitions. + * + * @experimental + */ +final class SchemaFactory implements SchemaFactoryInterface +{ + public function __construct( + private readonly SchemaFactoryInterface $decorated, + ) { + } + + public function buildSchema(string $className, string $format = 'json', string $type = Schema::TYPE_OUTPUT, ?Operation $operation = null, ?Schema $schema = null, ?array $serializerContext = null, bool $forceCollection = false): Schema + { + $schema = $this->decorated->buildSchema($className, $format, $type, $operation, $schema, $serializerContext, $forceCollection); + + $definitions = []; + foreach ($schema->getDefinitions() as $key => $definition) { + $definitions[$key] = $definition instanceof \ArrayObject ? $definition->getArrayCopy() : (array) $definition; + } + + $rootKey = $schema->getRootDefinitionKey(); + if (null !== $rootKey) { + $root = $definitions[$rootKey] ?? []; + } else { + // Collection schemas (and others) put allOf/type directly on the root + $root = $schema->getArrayCopy(false); + } + + $flat = self::resolveNode($root, $definitions); + + $flatSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($flatSchema['$schema']); + foreach ($flat as $key => $value) { + $flatSchema[$key] = $value; + } + + return $flatSchema; + } + + /** + * Recursively resolve $ref, allOf, and nested structures into a flat schema node. + * + * @param array $resolving Tracks the current $ref resolution chain to detect circular references + */ + public static function resolveNode(array|\ArrayObject $node, array $definitions, array &$resolving = []): array + { + if ($node instanceof \ArrayObject) { + $node = $node->getArrayCopy(); + } + + if (isset($node['$ref'])) { + $refKey = str_replace('#/definitions/', '', $node['$ref']); + if (!isset($definitions[$refKey]) || isset($resolving[$refKey])) { + return ['type' => 'object']; + } + $resolving[$refKey] = true; + $resolved = self::resolveNode($definitions[$refKey], $definitions, $resolving); + unset($resolving[$refKey]); + + return $resolved; + } + + if (isset($node['allOf'])) { + $merged = ['type' => 'object', 'properties' => []]; + $requiredSets = []; + foreach ($node['allOf'] as $entry) { + $resolved = self::resolveNode($entry, $definitions, $resolving); + if (isset($resolved['properties'])) { + foreach ($resolved['properties'] as $k => $v) { + $merged['properties'][$k] = $v; + } + } + if (isset($resolved['required'])) { + $requiredSets[] = $resolved['required']; + } + } + + if ($requiredSets) { + $merged['required'] = array_merge(...$requiredSets); + } + if ([] === $merged['properties']) { + unset($merged['properties']); + } + if (isset($node['description'])) { + $merged['description'] = $node['description']; + } + + return self::resolveDeep($merged, $definitions, $resolving); + } + + if (!isset($node['type'])) { + $node['type'] = 'object'; + } + + return self::resolveDeep($node, $definitions, $resolving); + } + + /** + * Recursively resolve nested properties and array items. + */ + private static function resolveDeep(array $node, array $definitions, array &$resolving): array + { + if (isset($node['items'])) { + $node['items'] = self::resolveNode( + $node['items'] instanceof \ArrayObject ? $node['items']->getArrayCopy() : $node['items'], + $definitions, + $resolving, + ); + } + + if (isset($node['properties']) && \is_array($node['properties'])) { + foreach ($node['properties'] as $propName => $propSchema) { + $node['properties'][$propName] = self::resolveNode( + $propSchema instanceof \ArrayObject ? $propSchema->getArrayCopy() : $propSchema, + $definitions, + $resolving, + ); + } + } + + return $node; + } +} diff --git a/src/Mcp/Metadata/Operation/Factory/OperationMetadataFactory.php b/src/Mcp/Metadata/Operation/Factory/OperationMetadataFactory.php index c4e1d4d4f1f..ce353489f54 100644 --- a/src/Mcp/Metadata/Operation/Factory/OperationMetadataFactory.php +++ b/src/Mcp/Metadata/Operation/Factory/OperationMetadataFactory.php @@ -13,7 +13,6 @@ namespace ApiPlatform\Mcp\Metadata\Operation\Factory; -use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\McpResource; use ApiPlatform\Metadata\McpTool; @@ -32,10 +31,7 @@ public function __construct( ) { } - /** - * @throws RuntimeException - */ - public function create(string $operationName, array $context = []): HttpOperation + public function create(string $operationName, array $context = []): ?HttpOperation { foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { foreach ($this->resourceMetadataCollectionFactory->create($resourceClass) as $resource) { @@ -55,6 +51,6 @@ public function create(string $operationName, array $context = []): HttpOperatio } } - throw new RuntimeException(\sprintf('MCP operation "%s" not found.', $operationName)); + return null; } } diff --git a/src/Mcp/Server/Handler.php b/src/Mcp/Server/Handler.php index c5c7982ce5c..6a09b1dc16b 100644 --- a/src/Mcp/Server/Handler.php +++ b/src/Mcp/Server/Handler.php @@ -49,7 +49,15 @@ public function __construct( public function supports(Request $request): bool { - return $request instanceof CallToolRequest || $request instanceof ReadResourceRequest; + if ($request instanceof CallToolRequest) { + return null !== $this->operationMetadataFactory->create($request->name); + } + + if ($request instanceof ReadResourceRequest) { + return null !== $this->operationMetadataFactory->create($request->uri); + } + + return false; } /** @@ -70,9 +78,13 @@ public function handle(Request $request, SessionInterface $session): Response|Er $this->logger->debug('Executing tool', ['name' => $operationNameOrUri, 'arguments' => $arguments]); } - /** @var HttpOperation $operation */ + /** @var HttpOperation|null $operation */ $operation = $this->operationMetadataFactory->create($operationNameOrUri); + if (null === $operation) { + return Error::forMethodNotFound(\sprintf('MCP operation "%s" not found.', $operationNameOrUri), $request->getId()); + } + $uriVariables = []; if (!$isResource) { foreach ($operation->getUriVariables() ?? [] as $key => $link) { @@ -83,7 +95,7 @@ public function handle(Request $request, SessionInterface $session): Response|Er } $context = [ - 'request' => ($httpRequest = $this->requestStack->getCurrentRequest()), + 'request' => $this->requestStack->getCurrentRequest(), 'mcp_request' => $request, 'uri_variables' => $uriVariables, 'resource_class' => $operation->getClass(), @@ -93,6 +105,15 @@ public function handle(Request $request, SessionInterface $session): Response|Er $context['mcp_data'] = $arguments; } + $operation = $operation->withExtraProperties( + array_merge($operation->getExtraProperties(), ['_api_disable_swagger_provider' => true]) + ); + + // MCP has its own transport (JSON-RPC) — HTTP content negotiation is irrelevant. + if (null === $operation->canNegotiateContent()) { + $operation = $operation->withContentNegotiation(false); + } + if (null === $operation->canValidate()) { $operation = $operation->withValidate(false); } @@ -111,7 +132,7 @@ public function handle(Request $request, SessionInterface $session): Response|Er $body = $this->provider->provide($operation, $uriVariables, $context); - if (!$isResource) { + if (!$isResource && null !== ($httpRequest = $context['request'] ?? null)) { $context['previous_data'] = $httpRequest->attributes->get('previous_data'); $context['data'] = $httpRequest->attributes->get('data'); $context['read_data'] = $httpRequest->attributes->get('read_data'); diff --git a/src/Mcp/State/StructuredContentProcessor.php b/src/Mcp/State/StructuredContentProcessor.php index b4fac25d2fa..e375c03e2cd 100644 --- a/src/Mcp/State/StructuredContentProcessor.php +++ b/src/Mcp/State/StructuredContentProcessor.php @@ -40,12 +40,7 @@ public function __construct( public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []) { - if ( - !$this->serializer instanceof NormalizerInterface - || !$this->serializer instanceof EncoderInterface - || !isset($context['mcp_request']) - || !($request = $context['request']) - ) { + if (!isset($context['mcp_request'])) { return $this->decorated->process($data, $operation, $uriVariables, $context); } @@ -55,12 +50,13 @@ public function process(mixed $data, Operation $operation, array $uriVariables = return new Response($context['mcp_request']->getId(), $result); } + $request = $context['request'] ?? null; $context['original_data'] = $result; $class = $operation->getClass(); $includeStructuredContent = $operation instanceof McpTool || $operation instanceof McpResource ? $operation->getStructuredContent() ?? true : false; $structuredContent = null; - if ($includeStructuredContent) { + if ($includeStructuredContent && $request && $this->serializer instanceof NormalizerInterface && $this->serializer instanceof EncoderInterface) { $serializerContext = $this->serializerContextBuilder->createFromRequest($request, true, [ 'resource_class' => $class, 'operation' => $operation, diff --git a/src/Mcp/Tests/Capability/Registry/LoaderTest.php b/src/Mcp/Tests/Capability/Registry/LoaderTest.php new file mode 100644 index 00000000000..a5318628cd3 --- /dev/null +++ b/src/Mcp/Tests/Capability/Registry/LoaderTest.php @@ -0,0 +1,183 @@ + + * + * 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\Mcp\Tests\Capability\Registry; + +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Mcp\Capability\Registry\Loader; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\McpResource; +use ApiPlatform\Metadata\McpTool; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\Resource\ResourceNameCollection; +use Mcp\Capability\RegistryInterface; +use Mcp\Schema\Tool; +use PHPUnit\Framework\TestCase; + +class LoaderTest extends TestCase +{ + public function testToolRegistrationWithFlatSchema(): void + { + $inputSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($inputSchema['$schema']); + $inputSchema['type'] = 'object'; + $inputSchema['properties'] = ['name' => ['type' => 'string']]; + + $outputSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($outputSchema['$schema']); + $outputSchema['type'] = 'object'; + $outputSchema['properties'] = ['id' => ['type' => 'integer'], 'name' => ['type' => 'string']]; + + $schemaFactory = $this->createMock(SchemaFactoryInterface::class); + $schemaFactory->method('buildSchema')->willReturnOnConsecutiveCalls($inputSchema, $outputSchema); + + $mcpTool = new McpTool( + name: 'createDummy', + description: 'Creates a dummy', + class: \stdClass::class, + ); + + $resource = (new ApiResource(class: \stdClass::class))->withMcp(['createDummy' => $mcpTool]); + + $nameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $nameCollectionFactory->method('create')->willReturn(new ResourceNameCollection([\stdClass::class])); + + $metadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $metadataCollectionFactory->method('create')->willReturn(new ResourceMetadataCollection(\stdClass::class, [$resource])); + + $registry = $this->createMock(RegistryInterface::class); + $registry->expects($this->once()) + ->method('registerTool') + ->with( + $this->callback(function (Tool $tool): bool { + $this->assertSame('createDummy', $tool->name); + $this->assertSame('Creates a dummy', $tool->description); + $this->assertSame(['type' => 'object', 'properties' => ['name' => ['type' => 'string']]], $tool->inputSchema); + $this->assertSame(['type' => 'object', 'properties' => ['id' => ['type' => 'integer'], 'name' => ['type' => 'string']]], $tool->outputSchema); + + return true; + }), + Loader::HANDLER, + true, + ); + + $loader = new Loader($nameCollectionFactory, $metadataCollectionFactory, $schemaFactory); + $loader->load($registry); + } + + public function testStructuredContentFalseSkipsOutputSchema(): void + { + $inputSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($inputSchema['$schema']); + $inputSchema['type'] = 'object'; + $inputSchema['properties'] = ['query' => ['type' => 'string']]; + + $schemaFactory = $this->createMock(SchemaFactoryInterface::class); + $schemaFactory->method('buildSchema')->willReturn($inputSchema); + + $mcpTool = new McpTool( + name: 'search', + description: 'Search things', + structuredContent: false, + class: \stdClass::class, + ); + + $resource = (new ApiResource(class: \stdClass::class))->withMcp(['search' => $mcpTool]); + + $nameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $nameCollectionFactory->method('create')->willReturn(new ResourceNameCollection([\stdClass::class])); + + $metadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $metadataCollectionFactory->method('create')->willReturn(new ResourceMetadataCollection(\stdClass::class, [$resource])); + + $registry = $this->createMock(RegistryInterface::class); + $registry->expects($this->once()) + ->method('registerTool') + ->with( + $this->callback(function (Tool $tool): bool { + $this->assertSame('search', $tool->name); + $this->assertNull($tool->outputSchema); + + return true; + }), + Loader::HANDLER, + true, + ); + + $loader = new Loader($nameCollectionFactory, $metadataCollectionFactory, $schemaFactory); + $loader->load($registry); + } + + public function testResourceRegistration(): void + { + $mcpResource = new McpResource( + uri: 'dummy://docs', + name: 'docs', + description: 'Documentation resource', + mimeType: 'text/plain', + class: \stdClass::class, + ); + + $resource = (new ApiResource(class: \stdClass::class))->withMcp(['docs' => $mcpResource]); + + $nameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $nameCollectionFactory->method('create')->willReturn(new ResourceNameCollection([\stdClass::class])); + + $metadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $metadataCollectionFactory->method('create')->willReturn(new ResourceMetadataCollection(\stdClass::class, [$resource])); + + $schemaFactory = $this->createMock(SchemaFactoryInterface::class); + + $registry = $this->createMock(RegistryInterface::class); + $registry->expects($this->once()) + ->method('registerResource') + ->with( + $this->callback(function ($resource): bool { + $this->assertSame('dummy://docs', $resource->uri); + $this->assertSame('docs', $resource->name); + $this->assertSame('Documentation resource', $resource->description); + $this->assertSame('text/plain', $resource->mimeType); + + return true; + }), + Loader::HANDLER, + true, + ); + + $loader = new Loader($nameCollectionFactory, $metadataCollectionFactory, $schemaFactory); + $loader->load($registry); + } + + public function testEmptyMcpIsSkipped(): void + { + $resource = new ApiResource(class: \stdClass::class); + + $nameCollectionFactory = $this->createMock(ResourceNameCollectionFactoryInterface::class); + $nameCollectionFactory->method('create')->willReturn(new ResourceNameCollection([\stdClass::class])); + + $metadataCollectionFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); + $metadataCollectionFactory->method('create')->willReturn(new ResourceMetadataCollection(\stdClass::class, [$resource])); + + $schemaFactory = $this->createMock(SchemaFactoryInterface::class); + + $registry = $this->createMock(RegistryInterface::class); + $registry->expects($this->never())->method('registerTool'); + $registry->expects($this->never())->method('registerResource'); + + $loader = new Loader($nameCollectionFactory, $metadataCollectionFactory, $schemaFactory); + $loader->load($registry); + } +} diff --git a/src/Mcp/Tests/JsonSchema/SchemaFactoryTest.php b/src/Mcp/Tests/JsonSchema/SchemaFactoryTest.php new file mode 100644 index 00000000000..66c9e00b35e --- /dev/null +++ b/src/Mcp/Tests/JsonSchema/SchemaFactoryTest.php @@ -0,0 +1,305 @@ + + * + * 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\Mcp\Tests\JsonSchema; + +use ApiPlatform\JsonSchema\Schema; +use ApiPlatform\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Mcp\JsonSchema\SchemaFactory; +use PHPUnit\Framework\TestCase; + +class SchemaFactoryTest extends TestCase +{ + public function testFlatSchemaPassesThrough(): void + { + $innerSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($innerSchema['$schema']); + $definitions = $innerSchema->getDefinitions(); + $definitions['Dummy'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + ], + ]); + $innerSchema['$ref'] = '#/definitions/Dummy'; + + $inner = $this->createMock(SchemaFactoryInterface::class); + $inner->method('buildSchema')->willReturn($innerSchema); + + $factory = new SchemaFactory($inner); + $result = $factory->buildSchema('App\\Dummy', 'json'); + + $arr = $result->getArrayCopy(); + $this->assertSame('object', $arr['type']); + $this->assertSame(['name' => ['type' => 'string']], $arr['properties']); + $this->assertArrayNotHasKey('$ref', $arr); + $this->assertArrayNotHasKey('definitions', $arr); + $this->assertArrayNotHasKey('$schema', $arr); + } + + public function testRefIsResolved(): void + { + $innerSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($innerSchema['$schema']); + $definitions = $innerSchema->getDefinitions(); + $definitions['Wrapper'] = new \ArrayObject([ + '$ref' => '#/definitions/Actual', + ]); + $definitions['Actual'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'integer'], + ], + ]); + $innerSchema['$ref'] = '#/definitions/Wrapper'; + + $inner = $this->createMock(SchemaFactoryInterface::class); + $inner->method('buildSchema')->willReturn($innerSchema); + + $factory = new SchemaFactory($inner); + $result = $factory->buildSchema('App\\Dummy', 'json'); + + $arr = $result->getArrayCopy(); + $this->assertSame('object', $arr['type']); + $this->assertSame(['id' => ['type' => 'integer']], $arr['properties']); + $this->assertArrayNotHasKey('$ref', $arr); + $this->assertArrayNotHasKey('definitions', $arr); + } + + public function testAllOfIsMerged(): void + { + $innerSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($innerSchema['$schema']); + $definitions = $innerSchema->getDefinitions(); + $definitions['Root'] = new \ArrayObject([ + 'description' => 'A dummy resource', + 'allOf' => [ + ['$ref' => '#/definitions/Part1'], + ['$ref' => '#/definitions/Part2'], + ], + ]); + $definitions['Part1'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + ], + 'required' => ['name'], + ]); + $definitions['Part2'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'email' => ['type' => 'string'], + ], + ]); + $innerSchema['$ref'] = '#/definitions/Root'; + + $inner = $this->createMock(SchemaFactoryInterface::class); + $inner->method('buildSchema')->willReturn($innerSchema); + + $factory = new SchemaFactory($inner); + $result = $factory->buildSchema('App\\Dummy', 'jsonld'); + + $arr = $result->getArrayCopy(); + $this->assertSame('object', $arr['type']); + $this->assertSame('A dummy resource', $arr['description']); + $this->assertArrayHasKey('name', $arr['properties']); + $this->assertArrayHasKey('email', $arr['properties']); + $this->assertSame(['name'], $arr['required']); + $this->assertArrayNotHasKey('allOf', $arr); + $this->assertArrayNotHasKey('$ref', $arr); + $this->assertArrayNotHasKey('definitions', $arr); + } + + public function testMissingTypeGetsObjectAdded(): void + { + $innerSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($innerSchema['$schema']); + $definitions = $innerSchema->getDefinitions(); + $definitions['NoType'] = new \ArrayObject([ + 'properties' => [ + 'foo' => ['type' => 'string'], + ], + ]); + $innerSchema['$ref'] = '#/definitions/NoType'; + + $inner = $this->createMock(SchemaFactoryInterface::class); + $inner->method('buildSchema')->willReturn($innerSchema); + + $factory = new SchemaFactory($inner); + $result = $factory->buildSchema('App\\Dummy', 'json'); + + $arr = $result->getArrayCopy(); + $this->assertSame('object', $arr['type']); + $this->assertSame(['foo' => ['type' => 'string']], $arr['properties']); + } + + public function testNestedRefInsideAllOf(): void + { + $innerSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($innerSchema['$schema']); + $definitions = $innerSchema->getDefinitions(); + $definitions['Root'] = new \ArrayObject([ + 'allOf' => [ + ['$ref' => '#/definitions/Middle'], + ], + ]); + $definitions['Middle'] = new \ArrayObject([ + '$ref' => '#/definitions/Leaf', + ]); + $definitions['Leaf'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'deep' => ['type' => 'boolean'], + ], + 'required' => ['deep'], + ]); + $innerSchema['$ref'] = '#/definitions/Root'; + + $inner = $this->createMock(SchemaFactoryInterface::class); + $inner->method('buildSchema')->willReturn($innerSchema); + + $factory = new SchemaFactory($inner); + $result = $factory->buildSchema('App\\Dummy', 'json'); + + $arr = $result->getArrayCopy(); + $this->assertSame('object', $arr['type']); + $this->assertSame(['deep' => ['type' => 'boolean']], $arr['properties']); + $this->assertSame(['deep'], $arr['required']); + } + + public function testCircularRefFallsBackToObject(): void + { + $innerSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($innerSchema['$schema']); + $definitions = $innerSchema->getDefinitions(); + $definitions['A'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'b' => ['$ref' => '#/definitions/B'], + ], + ]); + $definitions['B'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'a' => ['$ref' => '#/definitions/A'], + ], + ]); + $innerSchema['$ref'] = '#/definitions/A'; + + $inner = $this->createMock(SchemaFactoryInterface::class); + $inner->method('buildSchema')->willReturn($innerSchema); + + $factory = new SchemaFactory($inner); + $result = $factory->buildSchema('App\\Dummy', 'json'); + + $arr = $result->getArrayCopy(); + $this->assertSame('object', $arr['type']); + // A.b resolves B, B.a resolves A again, then A.b hits the cycle and breaks + $this->assertSame(['type' => 'object'], $arr['properties']['b']['properties']['a']['properties']['b']); + } + + public function testAllOfInsidePropertyIsResolved(): void + { + $innerSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($innerSchema['$schema']); + $definitions = $innerSchema->getDefinitions(); + $definitions['Root'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'nested' => [ + 'allOf' => [ + ['$ref' => '#/definitions/PartA'], + ['$ref' => '#/definitions/PartB'], + ], + ], + ], + ]); + $definitions['PartA'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'x' => ['type' => 'integer'], + ], + ]); + $definitions['PartB'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'y' => ['type' => 'string'], + ], + ]); + $innerSchema['$ref'] = '#/definitions/Root'; + + $inner = $this->createMock(SchemaFactoryInterface::class); + $inner->method('buildSchema')->willReturn($innerSchema); + + $factory = new SchemaFactory($inner); + $result = $factory->buildSchema('App\\Dummy', 'json'); + + $arr = $result->getArrayCopy(); + $this->assertSame('object', $arr['type']); + $this->assertArrayHasKey('x', $arr['properties']['nested']['properties']); + $this->assertArrayHasKey('y', $arr['properties']['nested']['properties']); + $this->assertArrayNotHasKey('allOf', $arr['properties']['nested']); + } + + public function testSameRefUsedTwiceIsResolvedBothTimes(): void + { + $innerSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($innerSchema['$schema']); + $definitions = $innerSchema->getDefinitions(); + $definitions['Root'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'address' => ['$ref' => '#/definitions/Address'], + 'billingAddress' => ['$ref' => '#/definitions/Address'], + ], + ]); + $definitions['Address'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'street' => ['type' => 'string'], + ], + ]); + $innerSchema['$ref'] = '#/definitions/Root'; + + $inner = $this->createMock(SchemaFactoryInterface::class); + $inner->method('buildSchema')->willReturn($innerSchema); + + $factory = new SchemaFactory($inner); + $result = $factory->buildSchema('App\\Dummy', 'json'); + + $arr = $result->getArrayCopy(); + // Both properties should be fully resolved (not circular-ref fallback) + $this->assertSame(['street' => ['type' => 'string']], $arr['properties']['address']['properties']); + $this->assertSame(['street' => ['type' => 'string']], $arr['properties']['billingAddress']['properties']); + } + + public function testUnresolvableRefFallsBackToObject(): void + { + $innerSchema = new Schema(Schema::VERSION_JSON_SCHEMA); + unset($innerSchema['$schema']); + $definitions = $innerSchema->getDefinitions(); + $definitions['Root'] = new \ArrayObject([ + '$ref' => '#/definitions/DoesNotExist', + ]); + $innerSchema['$ref'] = '#/definitions/Root'; + + $inner = $this->createMock(SchemaFactoryInterface::class); + $inner->method('buildSchema')->willReturn($innerSchema); + + $factory = new SchemaFactory($inner); + $result = $factory->buildSchema('App\\Dummy', 'json'); + + $arr = $result->getArrayCopy(); + $this->assertSame(['type' => 'object'], $arr); + } +} diff --git a/src/Mcp/composer.json b/src/Mcp/composer.json index 10bcc257331..bdbcf0af8a6 100644 --- a/src/Mcp/composer.json +++ b/src/Mcp/composer.json @@ -33,6 +33,9 @@ "mcp/sdk": "^0.4.0", "symfony/polyfill-php85": "^1.32" }, + "require-dev": { + "phpunit/phpunit": "^12.2" + }, "autoload": { "psr-4": { "ApiPlatform\\Mcp\\": "" diff --git a/src/Mcp/phpunit.xml.dist b/src/Mcp/phpunit.xml.dist new file mode 100644 index 00000000000..79772319f23 --- /dev/null +++ b/src/Mcp/phpunit.xml.dist @@ -0,0 +1,23 @@ + + + + + + + + ./Tests/ + + + + + trigger_deprecation + + + ./ + + + ./Tests + ./vendor + + + diff --git a/src/Metadata/Delete.php b/src/Metadata/Delete.php index 5f459c0e35e..b4e55ef6765 100644 --- a/src/Metadata/Delete.php +++ b/src/Metadata/Delete.php @@ -86,6 +86,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -168,6 +169,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/Error.php b/src/Metadata/Error.php index abeb8ed7a58..dabe1b854d5 100644 --- a/src/Metadata/Error.php +++ b/src/Metadata/Error.php @@ -86,6 +86,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -163,6 +164,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index 0139b825611..4babd54eb27 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -86,6 +86,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -168,6 +169,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index 74886491a23..27df4b9ad41 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -86,6 +86,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -169,6 +170,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index 3f5e0daaeb4..58d4cf98c7f 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -207,6 +207,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -265,6 +266,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/McpResource.php b/src/Metadata/McpResource.php index 0e01513ec44..c36342c1e6b 100644 --- a/src/Metadata/McpResource.php +++ b/src/Metadata/McpResource.php @@ -168,6 +168,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -250,6 +251,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/McpTool.php b/src/Metadata/McpTool.php index 87f79928c45..3a1d12c44bb 100644 --- a/src/Metadata/McpTool.php +++ b/src/Metadata/McpTool.php @@ -162,6 +162,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -244,6 +245,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/NotExposed.php b/src/Metadata/NotExposed.php index c7afb4941f0..e106aa23b4e 100644 --- a/src/Metadata/NotExposed.php +++ b/src/Metadata/NotExposed.php @@ -99,6 +99,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -175,6 +176,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/Operation.php b/src/Metadata/Operation.php index 359f583d163..cbd53751e59 100644 --- a/src/Metadata/Operation.php +++ b/src/Metadata/Operation.php @@ -792,6 +792,7 @@ public function __construct( protected ?bool $validate = null, protected ?bool $write = null, protected ?bool $serialize = null, + protected ?bool $contentNegotiation = null, protected ?bool $fetchPartial = null, protected ?bool $forceEager = null, /** @@ -936,6 +937,19 @@ public function withSerialize(bool $serialize = true): static return $self; } + public function canNegotiateContent(): ?bool + { + return $this->contentNegotiation; + } + + public function withContentNegotiation(bool $contentNegotiation = true): static + { + $self = clone $this; + $self->contentNegotiation = $contentNegotiation; + + return $self; + } + public function getPriority(): ?int { return $this->priority; diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php index b81814350d7..13d7dc442a0 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -86,6 +86,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -169,6 +170,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index 208366234ef..419512a851d 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -86,6 +86,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -170,6 +171,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php index 5fbfbfd49f6..3ea21ffeadd 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -86,6 +86,7 @@ public function __construct( ?bool $validate = null, ?bool $write = null, ?bool $serialize = null, + ?bool $contentNegotiation = null, ?bool $fetchPartial = null, ?bool $forceEager = null, ?int $priority = null, @@ -170,6 +171,7 @@ class: $class, validate: $validate, write: $write, serialize: $serialize, + contentNegotiation: $contentNegotiation, fetchPartial: $fetchPartial, forceEager: $forceEager, priority: $priority, diff --git a/src/Metadata/Resource/Factory/FormatsResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/FormatsResourceMetadataCollectionFactory.php index df787da2b4e..965d615d2e7 100644 --- a/src/Metadata/Resource/Factory/FormatsResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/FormatsResourceMetadataCollectionFactory.php @@ -18,6 +18,8 @@ use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\McpResource; +use ApiPlatform\Metadata\McpTool; use ApiPlatform\Metadata\Operations; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; @@ -39,6 +41,7 @@ public function __construct( private readonly array $formats, private readonly array $patchFormats, private readonly ?array $errorFormats = null, + private readonly ?string $mcpFormat = null, ) { } @@ -63,6 +66,22 @@ public function create(string $resourceClass): ResourceMetadataCollection } $resourceMetadataCollection[$index] = $resourceMetadataCollection[$index]->withOperations($this->normalize($resourceInputFormats, $resourceOutputFormats, $resourceMetadata->getOperations())); + + // Apply MCP-specific format to MCP operations + if (null !== $this->mcpFormat && null !== ($mcp = $resourceMetadata->getMcp())) { + if (!isset($this->formats[$this->mcpFormat])) { + throw new InvalidArgumentException(\sprintf('The MCP format "%s" is not configured in api_platform.formats. Available formats: %s.', $this->mcpFormat, implode(', ', array_keys($this->formats)))); + } + $mcpFormats = [$this->mcpFormat => $this->formats[$this->mcpFormat]]; + $newMcp = []; + foreach ($mcp as $key => $operation) { + if (($operation instanceof McpTool || $operation instanceof McpResource) && null === $operation->getFormats() && null === $operation->getInputFormats() && null === $operation->getOutputFormats()) { + $operation = $operation->withInputFormats($mcpFormats)->withOutputFormats($mcpFormats); + } + $newMcp[$key] = $operation; + } + $resourceMetadataCollection[$index] = $resourceMetadataCollection[$index]->withMcp($newMcp); + } } return $resourceMetadataCollection; diff --git a/src/State/Provider/ContentNegotiationProvider.php b/src/State/Provider/ContentNegotiationProvider.php index 09b693ed8a7..42e81252282 100644 --- a/src/State/Provider/ContentNegotiationProvider.php +++ b/src/State/Provider/ContentNegotiationProvider.php @@ -40,7 +40,7 @@ public function __construct(private readonly ?ProviderInterface $decorated = nul public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - if (!($request = $context['request'] ?? null) || !$operation instanceof HttpOperation) { + if (!($request = $context['request'] ?? null) || !$operation instanceof HttpOperation || false === $operation->canNegotiateContent()) { return $this->decorated?->provide($operation, $uriVariables, $context); } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 4b465bfc975..08f2464bd4a 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -207,6 +207,8 @@ public function load(array $configs, ContainerBuilder $container): void $loader->load($config['use_symfony_listeners'] ? 'symfony/object_mapper.php' : 'state/object_mapper_processor.php'); } + $container->setParameter('api_platform.mcp.format', $config['mcp']['format'] ?? null); + if (($config['mcp']['enabled'] ?? false) && class_exists(McpBundle::class)) { $loader->load('mcp/mcp.php'); $loader->load($config['use_symfony_listeners'] ? 'mcp/events.php' : 'mcp/state.php'); diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 89f72c7bf94..a6f6ba16765 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -726,6 +726,12 @@ private function addMcpSection(ArrayNodeDefinition $rootNode): void ->children() ->arrayNode('mcp') ->canBeDisabled() + ->children() + ->scalarNode('format') + ->defaultValue('jsonld') + ->info('The serialization format used for MCP tool input/output. Must be a format registered in api_platform.formats (e.g. "jsonld", "json", "jsonapi").') + ->end() + ->end() ->end() ->end(); } diff --git a/src/Symfony/Bundle/Resources/config/mcp/mcp.php b/src/Symfony/Bundle/Resources/config/mcp/mcp.php index 8ffdffaea8c..97e042661b3 100644 --- a/src/Symfony/Bundle/Resources/config/mcp/mcp.php +++ b/src/Symfony/Bundle/Resources/config/mcp/mcp.php @@ -14,6 +14,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; use ApiPlatform\Mcp\Capability\Registry\Loader; +use ApiPlatform\Mcp\JsonSchema\SchemaFactory; use ApiPlatform\Mcp\Metadata\Operation\Factory\OperationMetadataFactory; use ApiPlatform\Mcp\Routing\IriConverter; use ApiPlatform\Mcp\State\ToolProvider; @@ -21,11 +22,16 @@ return static function (ContainerConfigurator $container) { $services = $container->services(); + $services->set('api_platform.mcp.json_schema.schema_factory', SchemaFactory::class) + ->args([ + service('api_platform.json_schema.schema_factory'), + ]); + $services->set('api_platform.mcp.loader', Loader::class) ->args([ service('api_platform.metadata.resource.name_collection_factory'), service('api_platform.metadata.resource.metadata_collection_factory'), - service('api_platform.json_schema.schema_factory'), + service('api_platform.mcp.json_schema.schema_factory'), ]) ->tag('mcp.loader'); diff --git a/src/Symfony/Bundle/Resources/config/metadata/resource.php b/src/Symfony/Bundle/Resources/config/metadata/resource.php index 0e0b3c088d4..eb11e227e64 100644 --- a/src/Symfony/Bundle/Resources/config/metadata/resource.php +++ b/src/Symfony/Bundle/Resources/config/metadata/resource.php @@ -132,6 +132,7 @@ '%api_platform.formats%', '%api_platform.patch_formats%', '%api_platform.error_formats%', + '%api_platform.mcp.format%', ]); $services->set('api_platform.metadata.resource.metadata_collection_factory.filters', FiltersResourceMetadataCollectionFactory::class) diff --git a/tests/Functional/McpTest.php b/tests/Functional/McpTest.php index 46ed75109ae..b08458c0047 100644 --- a/tests/Functional/McpTest.php +++ b/tests/Functional/McpTest.php @@ -444,44 +444,31 @@ public function testToolsList(): void ], $listBooks); self::assertArrayHasKeyAndValue('description', 'List Books', $listBooks); + // Output schemas are flattened for MCP compliance: no $ref, no allOf, no definitions $outputSchema = $listBooks['outputSchema']; - self::assertArrayHasKeyAndValue('$schema', 'http://json-schema.org/draft-07/schema#', $outputSchema); + self::assertArrayNotHasKey('$schema', $outputSchema); + self::assertArrayNotHasKey('definitions', $outputSchema); + self::assertArrayNotHasKey('allOf', $outputSchema); self::assertArrayHasKeyAndValue('type', 'object', $outputSchema); - self::assertArrayHasKey('definitions', $outputSchema); - $definitions = $outputSchema['definitions']; - self::assertArrayHasKey('McpBook.jsonld', $definitions); - $McpBookJsonLd = $definitions['McpBook.jsonld']; - self::assertArrayHasKeyAndValue('allOf', [ - [ - '$ref' => '#/definitions/HydraItemBaseSchema', - ], - [ - 'type' => 'object', - 'properties' => [ - 'id' => ['readOnly' => true, 'type' => 'integer'], - 'title' => ['type' => 'string'], - 'isbn' => ['type' => 'string'], - 'status' => ['type' => ['string', 'null']], - ], - ], - ], $McpBookJsonLd); - - self::assertArrayHasKeyAndValue('allOf', [ - ['$ref' => '#/definitions/HydraCollectionBaseSchema'], - [ - 'type' => 'object', - 'required' => ['hydra:member'], - 'properties' => [ - 'hydra:member' => [ - 'type' => 'array', - 'items' => [ - '$ref' => '#/definitions/McpBook.jsonld', - ], - ], - ], - ], - ], $outputSchema); + // Collection schema: hydra:member contains flattened item schemas + self::assertArrayHasKey('properties', $outputSchema); + self::assertArrayHasKey('hydra:member', $outputSchema['properties']); + $hydraMember = $outputSchema['properties']['hydra:member']; + self::assertArrayHasKeyAndValue('type', 'array', $hydraMember); + + // Items are inlined (no $ref) + self::assertArrayHasKey('items', $hydraMember); + self::assertArrayNotHasKey('$ref', $hydraMember['items']); + self::assertArrayHasKeyAndValue('type', 'object', $hydraMember['items']); + self::assertArrayHasKey('properties', $hydraMember['items']); + $itemProps = $hydraMember['items']['properties']; + self::assertArrayHasKey('id', $itemProps); + self::assertArrayHasKey('title', $itemProps); + self::assertArrayHasKey('isbn', $itemProps); + self::assertArrayHasKey('status', $itemProps); + + self::assertSame(['hydra:member'], $outputSchema['required']); $listBooksDto = array_filter($tools, static function (array $input) { return 'list_books_dto' === $input['name']; @@ -499,20 +486,15 @@ public function testToolsList(): void ], $listBooksDto); self::assertArrayHasKeyAndValue('description', 'List Books and return a DTO', $listBooksDto); + // DTO output schema is also flattened $outputSchema = $listBooksDto['outputSchema']; - self::assertArrayHasKeyAndValue('$schema', 'http://json-schema.org/draft-07/schema#', $outputSchema); - self::assertArrayNotHasKey('type', $outputSchema); - - self::assertArrayHasKey('definitions', $outputSchema); - $definitions = $outputSchema['definitions']; - self::assertArrayHasKeyAndValue('McpBookOutputDto.jsonld', [ - 'type' => 'object', - 'properties' => [ - 'id' => ['type' => 'integer'], - 'name' => ['type' => 'string'], - 'isbn' => ['type' => 'string'], - ], - ], $definitions); + self::assertArrayNotHasKey('$schema', $outputSchema); + self::assertArrayNotHasKey('definitions', $outputSchema); + self::assertArrayHasKeyAndValue('type', 'object', $outputSchema); + self::assertArrayHasKey('properties', $outputSchema); + self::assertArrayHasKey('id', $outputSchema['properties']); + self::assertArrayHasKey('name', $outputSchema['properties']); + self::assertArrayHasKey('isbn', $outputSchema['properties']); } public function testMcpToolAttribute(): void @@ -799,14 +781,11 @@ public function testMcpListBooks(): void $structuredContent = $result['structuredContent'] ?? null; $this->assertIsArray($structuredContent); - // when api_platform.use_symfony_listeners is true, the result is formatted as JSON-LD - if (true === $this->getContainer()->getParameter('api_platform.use_symfony_listeners')) { - self::assertArrayHasKeyAndValue('@context', '/contexts/McpBook', $structuredContent); - self::assertArrayHasKeyAndValue('hydra:totalItems', 1, $structuredContent); - $members = $structuredContent['hydra:member']; - } else { - $members = $structuredContent; - } + // MCP Handler overrides Accept to match the operation's output format (jsonld by default), + // so the response is always formatted as JSON-LD regardless of use_symfony_listeners. + self::assertArrayHasKeyAndValue('@context', '/contexts/McpBook', $structuredContent); + self::assertArrayHasKeyAndValue('hydra:totalItems', 1, $structuredContent); + $members = $structuredContent['hydra:member']; $this->assertCount(1, $members, json_encode($members, \JSON_PRETTY_PRINT)); $actualBook = array_first($members); @@ -877,18 +856,17 @@ public function testMcpListBooksDto(): void $structuredContent = $result['structuredContent'] ?? null; $this->assertIsArray($structuredContent); - // when api_platform.use_symfony_listeners is true, the result is formatted as JSON-LD - if (true === $this->getContainer()->getParameter('api_platform.use_symfony_listeners')) { - self::assertArrayHasKeyAndValue('@context', [ - '@vocab' => 'http://localhost/docs.jsonld#', - 'hydra' => 'http://www.w3.org/ns/hydra/core#', - 'id' => 'McpBookOutputDto/id', - 'name' => 'McpBookOutputDto/name', - 'isbn' => 'McpBookOutputDto/isbn', - ], $structuredContent); - self::assertArrayHasKey('@id', $structuredContent); - self::assertArrayHasKeyAndValue('@type', 'McpBookOutputDto', $structuredContent); - } + // MCP Handler overrides Accept to match the operation's output format (jsonld by default), + // so the response is always formatted as JSON-LD. + self::assertArrayHasKeyAndValue('@context', [ + '@vocab' => 'http://localhost/docs.jsonld#', + 'hydra' => 'http://www.w3.org/ns/hydra/core#', + 'id' => 'McpBookOutputDto/id', + 'name' => 'McpBookOutputDto/name', + 'isbn' => 'McpBookOutputDto/isbn', + ], $structuredContent); + self::assertArrayHasKey('@id', $structuredContent); + self::assertArrayHasKeyAndValue('@type', 'McpBookOutputDto', $structuredContent); self::assertArrayHasKeyAndValue('id', 1, $structuredContent); self::assertArrayHasKeyAndValue('name', 'API Platform Guide for MCP', $structuredContent); diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 233bc358287..056ab68d16a 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -247,6 +247,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'enable_phpdoc_parser' => true, 'mcp' => [ 'enabled' => true, + 'format' => 'jsonld', ], 'jsonapi' => [ 'use_iri_as_id' => true, From cc0ae1254acaf9742c7f899dd24a14d46c89ca6e Mon Sep 17 00:00:00 2001 From: Antoine Griffon <78740961+Griffon-Weglot@users.noreply.github.com> Date: Mon, 4 May 2026 14:39:50 +0200 Subject: [PATCH 03/84] feat: support dynamic HTTP response status code via request attribute (#7904) --- src/State/Util/HttpResponseStatusTrait.php | 4 ++++ tests/State/RespondProcessorTest.php | 28 ++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/State/Util/HttpResponseStatusTrait.php b/src/State/Util/HttpResponseStatusTrait.php index 89b9156c3ea..86745540386 100644 --- a/src/State/Util/HttpResponseStatusTrait.php +++ b/src/State/Util/HttpResponseStatusTrait.php @@ -37,6 +37,10 @@ trait HttpResponseStatusTrait */ private function getStatus(Request $request, HttpOperation $operation, array $context): int { + if ($request->attributes->has('_api_response_status')) { + return $request->attributes->getInt('_api_response_status'); + } + $status = $operation->getStatus(); $method = $request->getMethod(); diff --git a/tests/State/RespondProcessorTest.php b/tests/State/RespondProcessorTest.php index 34db1b78173..1db422acf49 100644 --- a/tests/State/RespondProcessorTest.php +++ b/tests/State/RespondProcessorTest.php @@ -162,6 +162,34 @@ public function testAddsLinkedDataPlatformHeaders(): void $this->assertSame('application/ld+json', $response->headers->get('Accept-Post')); } + public function testDynamicResponseStatusFromRequestAttribute(): void + { + $operation = new Post(class: Employee::class); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Employee::class)->willReturn(true); + + $respondProcessor = new RespondProcessor(null, $resourceClassResolver->reveal()); + + $req = new Request([], [], ['_api_response_status' => 200]); + $req->setMethod('POST'); + $response = $respondProcessor->process('content', $operation, context: [ + 'request' => $req, + 'original_data' => new Employee(), + ]); + + $this->assertSame(200, $response->getStatusCode()); + + $req = new Request(); + $req->setMethod('POST'); + $response = $respondProcessor->process('content', $operation, context: [ + 'request' => $req, + 'original_data' => new Employee(), + ]); + + $this->assertSame(201, $response->getStatusCode()); + } + public function testDoesNotAddLinkedDataPlatformHeadersWithoutFactory(): void { $operation = new Get(uriTemplate: '/employees/{id}', class: Employee::class); From a47e36c33b8436abe2e52413dee3acb71e83843f Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 4 May 2026 15:11:29 +0200 Subject: [PATCH 04/84] fix(state): scope ReadLinkParameterProvider to current Link's class (#7943) --- .../ReadLinkParameterProvider.php | 36 +++++---- .../ApiResource/Issue7939BarResource.php | 62 ++++++++++++++ .../ApiResource/Issue7939BazResource.php | 81 +++++++++++++++++++ .../ApiResource/Issue7939FooResource.php | 39 +++++++++ .../Parameters/LinkProviderParameterTest.php | 46 ++++++++++- 5 files changed, 249 insertions(+), 15 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/Issue7939BarResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Issue7939BazResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Issue7939FooResource.php diff --git a/src/State/ParameterProvider/ReadLinkParameterProvider.php b/src/State/ParameterProvider/ReadLinkParameterProvider.php index 9b64676d5c0..906eb0ac9d1 100644 --- a/src/State/ParameterProvider/ReadLinkParameterProvider.php +++ b/src/State/ParameterProvider/ReadLinkParameterProvider.php @@ -105,11 +105,13 @@ public function provide(Parameter $parameter, array $parameters = [], array $con } /** - * @return array + * @return array */ private function getUriVariables(mixed $value, Parameter $parameter, Operation $operation): array { - $extraProperties = $parameter->getExtraProperties(); + if (\is_array($value)) { + return $value; + } if ($operation instanceof HttpOperation) { $links = $operation->getUriVariables(); @@ -119,24 +121,30 @@ private function getUriVariables(mixed $value, Parameter $parameter, Operation $ $links = []; } - if (!\is_array($value)) { - $uriVariables = []; + $extraProperties = $parameter->getExtraProperties(); + $linkClass = $parameter instanceof Link + ? ($parameter->getFromClass() ?? $parameter->getToClass()) + : null; + + $fallbackKey = null; + foreach ($links as $key => $link) { + if (!\is_string($key)) { + $key = $link->getParameterName() ?? $extraProperties['uri_variable'] ?? $link->getFromProperty(); + } - foreach ($links as $key => $link) { - if (!\is_string($key)) { - $key = $link->getParameterName() ?? $extraProperties['uri_variable'] ?? $link->getFromProperty(); - } + if (!$key || !\is_string($key)) { + continue; + } - if (!$key || !\is_string($key)) { - continue; - } + $linkFromClass = $link instanceof Link ? ($link->getFromClass() ?? $link->getToClass()) : null; - $uriVariables[$key] = $value; + if (null !== $linkClass && $linkFromClass === $linkClass) { + return [$key => $value]; } - return $uriVariables; + $fallbackKey ??= $key; } - return $value; + return null === $fallbackKey ? [] : [$fallbackKey => $value]; } } diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue7939BarResource.php b/tests/Fixtures/TestBundle/ApiResource/Issue7939BarResource.php new file mode 100644 index 00000000000..485c9e75aee --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue7939BarResource.php @@ -0,0 +1,62 @@ + + * + * 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; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + operations: [ + new Get( + uriTemplate: '/issue7939_foos/{fooId}/bars/{id}', + uriVariables: [ + 'fooId' => new Link(fromClass: Issue7939FooResource::class, toProperty: 'foo'), + 'id' => new Link(fromClass: self::class), + ], + provider: [self::class, 'provide'], + ), + ], +)] +final class Issue7939BarResource +{ + private const PARENTS = ['B' => 'F2']; + + public string $id = ''; + public ?Issue7939FooResource $foo = null; + + public static function parentOf(string $barId): ?string + { + return self::PARENTS[$barId] ?? null; + } + + public static function provide(Operation $operation, array $uriVariables = []) + { + $id = (string) ($uriVariables['id'] ?? ''); + $parent = self::parentOf($id); + + if (null === $parent) { + return null; + } + + $bar = new self(); + $bar->id = $id; + $foo = new Issue7939FooResource(); + $foo->id = $parent; + $bar->foo = $foo; + + return $bar; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue7939BazResource.php b/tests/Fixtures/TestBundle/ApiResource/Issue7939BazResource.php new file mode 100644 index 00000000000..51f49c569c1 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue7939BazResource.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\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Parameter; +use ApiPlatform\State\ParameterProvider\ReadLinkParameterProvider; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; + +#[ApiResource( + operations: [ + new Get( + uriTemplate: '/issue7939_foos/{fooId}/bars/{barId}/baz', + uriVariables: [ + 'fooId' => new Link(fromClass: Issue7939FooResource::class), + 'barId' => new Link( + fromClass: Issue7939BarResource::class, + identifiers: ['id'], + provider: ReadLinkParameterProvider::class, + ), + ], + provider: [self::class, 'provide'], + ), + new Get( + uriTemplate: '/issue7939_foos/{fooId}/bars/{barId}/baz_strict', + uriVariables: [ + 'fooId' => new Link( + fromClass: Issue7939FooResource::class, + provider: [self::class, 'validateParent'], + ), + 'barId' => new Link( + fromClass: Issue7939BarResource::class, + identifiers: ['id'], + provider: ReadLinkParameterProvider::class, + ), + ], + provider: [self::class, 'provide'], + ), + ], +)] +final class Issue7939BazResource +{ + public string $id = '1'; + public string $barId = ''; + public string $fooId = ''; + + public static function provide(Operation $operation, array $uriVariables = []) + { + $r = new self(); + $r->fooId = (string) ($uriVariables['fooId'] ?? ''); + $r->barId = (string) ($uriVariables['barId'] ?? ''); + + return $r; + } + + public static function validateParent(Parameter $parameter, array $values = [], array $context = []): ?Operation + { + $barId = (string) ($values['barId'] ?? ''); + $fooId = (string) ($values['fooId'] ?? ''); + + if (Issue7939BarResource::parentOf($barId) !== $fooId) { + throw new NotFoundHttpException('Bar does not belong to the requested Foo.'); + } + + return $context['operation'] ?? null; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Issue7939FooResource.php b/tests/Fixtures/TestBundle/ApiResource/Issue7939FooResource.php new file mode 100644 index 00000000000..670179b82cd --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Issue7939FooResource.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\Tests\Fixtures\TestBundle\ApiResource; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + operations: [ + new Get( + uriTemplate: '/issue7939_foos/{id}', + provider: [self::class, 'provide'], + ), + ], +)] +final class Issue7939FooResource +{ + public string $id = ''; + + public static function provide(Operation $operation, array $uriVariables = []) + { + $r = new self(); + $r->id = (string) ($uriVariables['id'] ?? ''); + + return $r; + } +} diff --git a/tests/Functional/Parameters/LinkProviderParameterTest.php b/tests/Functional/Parameters/LinkProviderParameterTest.php index 97025821195..cd7ef7d5a4e 100644 --- a/tests/Functional/Parameters/LinkProviderParameterTest.php +++ b/tests/Functional/Parameters/LinkProviderParameterTest.php @@ -15,6 +15,9 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7469TestResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7939BarResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7939BazResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7939FooResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\LinkParameterProviderResource; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\WithParameter; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Company; @@ -40,7 +43,7 @@ final class LinkProviderParameterTest extends ApiTestCase */ public static function getResources(): array { - return [WithParameter::class, Dummy::class, Employee::class, Company::class, LinkParameterProviderResource::class, Issue7469TestResource::class, Issue7469Dummy::class, Pairing::class, Plan::class]; + return [WithParameter::class, Dummy::class, Employee::class, Company::class, LinkParameterProviderResource::class, Issue7469TestResource::class, Issue7469Dummy::class, Pairing::class, Plan::class, Issue7939FooResource::class, Issue7939BarResource::class, Issue7939BazResource::class]; } /** @@ -236,6 +239,47 @@ public function testSecurityLinkWithDifferentFromClassDoesNotBreakDoctrine(): vo ]); } + /** + * @see https://github.com/api-platform/core/issues/7939 + */ + public function testReadLinkParameterProviderResolvesNestedUriVariables(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + $response = self::createClient()->request('GET', '/issue7939_foos/F/bars/B/baz'); + self::assertResponseStatusCodeSame(200); + self::assertJsonContains([ + 'fooId' => 'F', + 'barId' => 'B', + ]); + } + + /** + * @see https://github.com/api-platform/core/issues/7939 + */ + public function testParentLinkProviderEnforcesParentScope(): void + { + $container = static::getContainer(); + if ('mongodb' === $container->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + $client = self::createClient(); + + $client->request('GET', '/issue7939_foos/F2/bars/B/baz_strict'); + self::assertResponseStatusCodeSame(200); + self::assertJsonContains([ + 'fooId' => 'F2', + 'barId' => 'B', + ]); + + $client->request('GET', '/issue7939_foos/F1/bars/B/baz_strict'); + self::assertResponseStatusCodeSame(404); + } + public function testIssue7469IriGenerationFailsForLinkedResource(): void { $container = static::getContainer(); From 5ca0b57a2b696f6ba8b681fd53e7f78f2464d3e0 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 5 May 2026 14:50:30 +0200 Subject: [PATCH 05/84] test: phpunit exception not restored (#7949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: phpunit exception not restored * test(symfony): track exception handler stack to fix risky tests | Q | A | ------------- | --- | Branch? | main | Tickets | ∅ | License | MIT | Doc PR | ∅ ApiTestCase now snapshots the exception handler stack via #[Before]/#[After] hooks (works even when subclasses override setUp without parent::setUp()). Bump symfony/http-kernel to ^6.4.13 to skip the ErrorListener handler leak and drop the AppKernel restore_exception_handler() workarounds. --- composer.json | 2 +- phpunit.xml.dist | 3 +- src/Doctrine/Odm/Tests/AppKernel.php | 7 ---- .../DoctrineMongoDbOdmFilterTestCase.php | 33 ++++++++++++++++++ src/Doctrine/Orm/Tests/AppKernel.php | 7 ---- .../Orm/Tests/DoctrineOrmFilterTestCase.php | 33 ++++++++++++++++++ src/State/composer.json | 2 +- src/Symfony/Bundle/Test/ApiTestCase.php | 34 +++++++++++++++++++ .../Tests/EventListener/ErrorListenerTest.php | 5 --- src/Symfony/composer.json | 1 + src/Validator/composer.json | 2 +- tests/Fixtures/app/AppKernel.php | 6 ---- 12 files changed, 106 insertions(+), 29 deletions(-) diff --git a/composer.json b/composer.json index c1c92405403..02dd37b9349 100644 --- a/composer.json +++ b/composer.json @@ -114,7 +114,7 @@ "psr/container": "^1.0 || ^2.0", "symfony/deprecation-contracts": "^3.1", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", - "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", "symfony/property-info": "^6.4 || ^7.1 || ^8.0", "symfony/serializer": "^6.4 || ^7.0 || ^8.0", diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d8527fddfa3..9f177ef37f1 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -3,7 +3,8 @@ backupGlobals="false" bootstrap="tests/Fixtures/app/bootstrap.php" colors="true" - cacheDirectory=".phpunit.cache"> + cacheDirectory=".phpunit.cache" + failOnRisky="true"> diff --git a/src/Doctrine/Odm/Tests/AppKernel.php b/src/Doctrine/Odm/Tests/AppKernel.php index e33f36dced5..773a4e31592 100644 --- a/src/Doctrine/Odm/Tests/AppKernel.php +++ b/src/Doctrine/Odm/Tests/AppKernel.php @@ -18,7 +18,6 @@ use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\HttpKernel\Kernel; /** @@ -43,12 +42,6 @@ public function registerBundles(): array return [ new FrameworkBundle(), new DoctrineMongoDBBundle(), - new class extends Bundle { - public function shutdown(): void - { - restore_exception_handler(); - } - }, ]; } diff --git a/src/Doctrine/Odm/Tests/DoctrineMongoDbOdmFilterTestCase.php b/src/Doctrine/Odm/Tests/DoctrineMongoDbOdmFilterTestCase.php index 02f6a2f68fb..388b1bbade4 100644 --- a/src/Doctrine/Odm/Tests/DoctrineMongoDbOdmFilterTestCase.php +++ b/src/Doctrine/Odm/Tests/DoctrineMongoDbOdmFilterTestCase.php @@ -17,8 +17,11 @@ use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; use Doctrine\Persistence\ManagerRegistry; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\ErrorHandler\ErrorHandler; /** * @internal @@ -37,6 +40,8 @@ abstract class DoctrineMongoDbOdmFilterTestCase extends KernelTestCase protected string $filterClass; + private bool $symfonyErrorHandlerWasRegistered = false; + protected function setUp(): void { self::bootKernel(); @@ -46,6 +51,34 @@ protected function setUp(): void $this->repository = $this->manager->getRepository($this->resourceClass); } + /** + * Symfony\Bundle\FrameworkBundle\FrameworkBundle::boot() registers Symfony's ErrorHandler via + * set_exception_handler() but never unregisters it: each kernel boot leaks one entry on the + * exception handler stack, which PHPUnit flags as Risky. Track whether the handler was already + * present before the test so we only pop the entry our own test introduced. + */ + #[Before] + protected function captureExceptionHandlerStack(): void + { + $this->symfonyErrorHandlerWasRegistered = self::isSymfonyErrorHandlerRegistered(); + } + + #[After] + protected function restoreExceptionHandlerStack(): void + { + if (!$this->symfonyErrorHandlerWasRegistered && self::isSymfonyErrorHandlerRegistered()) { + restore_exception_handler(); + } + } + + private static function isSymfonyErrorHandlerRegistered(): bool + { + $current = set_exception_handler(static fn () => null); + restore_exception_handler(); + + return \is_array($current) && $current[0] instanceof ErrorHandler; + } + #[DataProvider('provideApplyTestData')] public function testApply(?array $properties, array $filterParameters, array $expectedPipeline, ?callable $factory = null, ?string $resourceClass = null): void { diff --git a/src/Doctrine/Orm/Tests/AppKernel.php b/src/Doctrine/Orm/Tests/AppKernel.php index 2037665337a..66c5948a28c 100644 --- a/src/Doctrine/Orm/Tests/AppKernel.php +++ b/src/Doctrine/Orm/Tests/AppKernel.php @@ -18,7 +18,6 @@ use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\HttpKernel\Bundle\Bundle; use Symfony\Component\HttpKernel\Kernel; /** @@ -44,12 +43,6 @@ public function registerBundles(): array new FrameworkBundle(), new DoctrineBundle(), new TestBundle(), - new class extends Bundle { - public function shutdown(): void - { - restore_exception_handler(); - } - }, ]; } diff --git a/src/Doctrine/Orm/Tests/DoctrineOrmFilterTestCase.php b/src/Doctrine/Orm/Tests/DoctrineOrmFilterTestCase.php index e588c48f124..d43e67b51f3 100644 --- a/src/Doctrine/Orm/Tests/DoctrineOrmFilterTestCase.php +++ b/src/Doctrine/Orm/Tests/DoctrineOrmFilterTestCase.php @@ -18,8 +18,11 @@ use ApiPlatform\Doctrine\Orm\Util\QueryNameGenerator; use Doctrine\ORM\EntityRepository; use Doctrine\Persistence\ManagerRegistry; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; use PHPUnit\Framework\Attributes\DataProvider; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\ErrorHandler\ErrorHandler; /** * @internal @@ -38,6 +41,8 @@ abstract class DoctrineOrmFilterTestCase extends KernelTestCase protected string $filterClass; + private bool $symfonyErrorHandlerWasRegistered = false; + protected function setUp(): void { self::bootKernel(); @@ -46,6 +51,34 @@ protected function setUp(): void $this->repository = $this->managerRegistry->getManagerForClass(Dummy::class)->getRepository(Dummy::class); } + /** + * Symfony\Bundle\FrameworkBundle\FrameworkBundle::boot() registers Symfony's ErrorHandler via + * set_exception_handler() but never unregisters it: each kernel boot leaks one entry on the + * exception handler stack, which PHPUnit flags as Risky. Track whether the handler was already + * present before the test so we only pop the entry our own test introduced. + */ + #[Before] + protected function captureExceptionHandlerStack(): void + { + $this->symfonyErrorHandlerWasRegistered = self::isSymfonyErrorHandlerRegistered(); + } + + #[After] + protected function restoreExceptionHandlerStack(): void + { + if (!$this->symfonyErrorHandlerWasRegistered && self::isSymfonyErrorHandlerRegistered()) { + restore_exception_handler(); + } + } + + private static function isSymfonyErrorHandlerRegistered(): bool + { + $current = set_exception_handler(static fn () => null); + restore_exception_handler(); + + return \is_array($current) && $current[0] instanceof ErrorHandler; + } + #[DataProvider('provideApplyTestData')] public function testApply(?array $properties, array $filterParameters, string $expectedDql, ?array $expectedParameters = null, ?callable $factory = null, ?string $resourceClass = null): void { diff --git a/src/State/composer.json b/src/State/composer.json index 7f8e4250337..9e383a3b0af 100644 --- a/src/State/composer.json +++ b/src/State/composer.json @@ -30,7 +30,7 @@ "php": ">=8.2", "api-platform/metadata": "^4.3", "psr/container": "^1.0 || ^2.0", - "symfony/http-kernel": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", "symfony/serializer": "^6.4 || ^7.0 || ^8.0", "symfony/translation-contracts": "^3.0", "symfony/deprecation-contracts": "^3.1" diff --git a/src/Symfony/Bundle/Test/ApiTestCase.php b/src/Symfony/Bundle/Test/ApiTestCase.php index 211ed631528..891b5f09ab9 100644 --- a/src/Symfony/Bundle/Test/ApiTestCase.php +++ b/src/Symfony/Bundle/Test/ApiTestCase.php @@ -14,9 +14,12 @@ namespace ApiPlatform\Symfony\Bundle\Test; use ApiPlatform\Metadata\IriConverterInterface; +use PHPUnit\Framework\Attributes\After; +use PHPUnit\Framework\Attributes\Before; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\BrowserKit\AbstractBrowser; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; +use Symfony\Component\ErrorHandler\ErrorHandler; use Symfony\Component\HttpClient\HttpClientTrait; /** @@ -38,6 +41,37 @@ abstract class ApiTestCase extends KernelTestCase */ protected static ?bool $alwaysBootKernel = null; + private bool $symfonyErrorHandlerWasRegistered = false; + + /** + * Symfony\Bundle\FrameworkBundle\FrameworkBundle::boot() registers Symfony's ErrorHandler via + * set_exception_handler() but never unregisters it: each kernel boot leaks one entry on the + * exception handler stack, which PHPUnit flags as Risky. Track whether the handler was already + * present before the test (e.g. the kernel was booted from setUpBeforeClass) so we only pop + * the entry our own test introduced. + */ + #[Before] + protected function captureExceptionHandlerStack(): void + { + $this->symfonyErrorHandlerWasRegistered = self::isSymfonyErrorHandlerRegistered(); + } + + #[After] + protected function restoreExceptionHandlerStack(): void + { + if (!$this->symfonyErrorHandlerWasRegistered && self::isSymfonyErrorHandlerRegistered()) { + restore_exception_handler(); + } + } + + private static function isSymfonyErrorHandlerRegistered(): bool + { + $current = set_exception_handler(static fn () => null); + restore_exception_handler(); + + return \is_array($current) && $current[0] instanceof ErrorHandler; + } + /** * Creates a Client. * diff --git a/src/Symfony/Tests/EventListener/ErrorListenerTest.php b/src/Symfony/Tests/EventListener/ErrorListenerTest.php index effa85bb103..d9e0a97fffe 100644 --- a/src/Symfony/Tests/EventListener/ErrorListenerTest.php +++ b/src/Symfony/Tests/EventListener/ErrorListenerTest.php @@ -30,11 +30,6 @@ class ErrorListenerTest extends TestCase { - protected function tearDown(): void - { - restore_exception_handler(); - } - public function testDuplicateException(): void { $exception = new \Exception(); diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index 53e3a939885..820533edcb2 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -41,6 +41,7 @@ "api-platform/openapi": "^4.3", "symfony/asset": "^6.4 || ^7.0 || ^8.0", "symfony/finder": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", "symfony/property-info": "^6.4 || ^7.0 || ^8.0", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", "symfony/serializer": "^6.4 || ^7.0 || ^8.0", diff --git a/src/Validator/composer.json b/src/Validator/composer.json index 136b82b4194..392e1f5a8bd 100644 --- a/src/Validator/composer.json +++ b/src/Validator/composer.json @@ -25,7 +25,7 @@ "php": ">=8.2", "api-platform/metadata": "^4.3", "symfony/type-info": "^7.3 || ^8.0", - "symfony/http-kernel": "^6.4 || ^7.1 || ^8.0", + "symfony/http-kernel": "^6.4.13 || ^7.1 || ^8.0", "symfony/serializer": "^6.4 || ^7.1 || ^8.0", "symfony/validator": "^6.4.11 || ^7.1 || ^8.0", "symfony/web-link": "^6.4 || ^7.1 || ^8.0" diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index 0f0a235c468..92ec2c4411e 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -98,12 +98,6 @@ public function registerBundles(): array return $bundles; } - public function shutdown(): void - { - parent::shutdown(); - restore_exception_handler(); - } - public function getProjectDir(): string { return __DIR__; From 72b02afb031d9aad0e84d2f8c230905f3a4437a9 Mon Sep 17 00:00:00 2001 From: Abderrahim GHAZALI Date: Wed, 6 May 2026 14:10:36 +0200 Subject: [PATCH 06/84] feat(hydra): use hydra:memberAssertion instead of owl:equivalentClass (#7944) --- .../Serializer/DocumentationNormalizer.php | 12 +++----- .../DocumentationNormalizerTest.php | 28 +++++++------------ 2 files changed, 14 insertions(+), 26 deletions(-) diff --git a/src/Hydra/Serializer/DocumentationNormalizer.php b/src/Hydra/Serializer/DocumentationNormalizer.php index 428c6da7a94..14fbee9c03d 100644 --- a/src/Hydra/Serializer/DocumentationNormalizer.php +++ b/src/Hydra/Serializer/DocumentationNormalizer.php @@ -108,14 +108,10 @@ private function populateEntrypointProperties(ApiResource $resourceMetadata, str '@type' => $hydraPrefix.'Link', 'domain' => '#Entrypoint', 'owl:maxCardinality' => 1, - 'range' => [ - ['@id' => 'hydra:Collection'], - [ - 'owl:equivalentClass' => [ - 'owl:onProperty' => ['@id' => 'hydra:member'], - 'owl:allValuesFrom' => ['@id' => $prefixedShortName], - ], - ], + 'range' => 'hydra:Collection', + $hydraPrefix.'memberAssertion' => [ + $hydraPrefix.'property' => ['@id' => 'rdf:type'], + $hydraPrefix.'object' => ['@id' => $prefixedShortName], ], $hydraPrefix.'supportedOperation' => $hydraCollectionOperations, ], diff --git a/src/Hydra/Tests/Serializer/DocumentationNormalizerTest.php b/src/Hydra/Tests/Serializer/DocumentationNormalizerTest.php index f9ca871d9e2..1780a949f8a 100644 --- a/src/Hydra/Tests/Serializer/DocumentationNormalizerTest.php +++ b/src/Hydra/Tests/Serializer/DocumentationNormalizerTest.php @@ -333,16 +333,12 @@ private function doTestNormalize($resourceMetadataFactory = null): void '@id' => '#Entrypoint/dummy', '@type' => 'hydra:Link', 'domain' => '#Entrypoint', - 'range' => [ - ['@id' => 'hydra:Collection'], - [ - 'owl:equivalentClass' => [ - 'owl:onProperty' => ['@id' => 'hydra:member'], - 'owl:allValuesFrom' => ['@id' => '#dummy'], - ], - ], - ], 'owl:maxCardinality' => 1, + 'range' => 'hydra:Collection', + 'hydra:memberAssertion' => [ + 'hydra:property' => ['@id' => 'rdf:type'], + 'hydra:object' => ['@id' => '#dummy'], + ], 'hydra:supportedOperation' => [ [ '@type' => ['hydra:Operation', 'schema:FindAction'], @@ -898,16 +894,12 @@ public function testNormalizeWithoutPrefix(): void '@id' => '#Entrypoint/dummy', '@type' => 'Link', 'domain' => '#Entrypoint', - 'range' => [ - ['@id' => 'hydra:Collection'], - [ - 'owl:equivalentClass' => [ - 'owl:onProperty' => ['@id' => 'hydra:member'], - 'owl:allValuesFrom' => ['@id' => '#dummy'], - ], - ], - ], 'owl:maxCardinality' => 1, + 'range' => 'hydra:Collection', + 'memberAssertion' => [ + 'property' => ['@id' => 'rdf:type'], + 'object' => ['@id' => '#dummy'], + ], 'supportedOperation' => [ [ '@type' => ['Operation', 'schema:FindAction'], From 217803d22ec1685b139b47a882cf1ecc13fd9f39 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 7 May 2026 12:33:11 +0200 Subject: [PATCH 07/84] refactor(jsonld): simplify @context building (#7952) --- phpstan.neon.dist | 1 + src/JsonLd/ContextBuilder.php | 35 +++---------- src/JsonLd/Serializer/ItemNormalizer.php | 51 ++++++++++++------- src/JsonLd/Serializer/JsonLdContextTrait.php | 4 +- src/JsonLd/Serializer/ObjectNormalizer.php | 15 ------ tests/JsonLd/ContextBuilderTest.php | 51 ------------------- .../Serializer/ObjectNormalizerTest.php | 37 ++------------ 7 files changed, 45 insertions(+), 149 deletions(-) diff --git a/phpstan.neon.dist b/phpstan.neon.dist index d621e1f5e89..a221b595573 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -59,6 +59,7 @@ parameters: - tests/Fixtures/TestBundle/Document/ - tests/Fixtures/TestBundle/Entity/ - src/OpenApi/Factory/OpenApiFactory.php + - src/JsonLd/Serializer/ObjectNormalizer.php - message: '#is never assigned .* so it can be removed from the property type.#' paths: diff --git a/src/JsonLd/ContextBuilder.php b/src/JsonLd/ContextBuilder.php index 18ec68f475a..4f35aa67057 100644 --- a/src/JsonLd/ContextBuilder.php +++ b/src/JsonLd/ContextBuilder.php @@ -82,19 +82,8 @@ public function getResourceContext(string $resourceClass, int $referenceType = U { /** @var HttpOperation $operation */ $operation = $this->resourceMetadataFactory->create($resourceClass)->getOperation(null, false, true); - if (null === $shortName = $operation->getShortName()) { - return []; - } - - $context = $operation->getNormalizationContext(); - if ($context['iri_only'] ?? false) { - $context = $this->getBaseContext($referenceType); - $context[$this->getHydraPrefix($context).'member']['@type'] = '@id'; - - return $context; - } - return $this->getResourceContextWithShortname($resourceClass, $referenceType, $shortName, $operation); + return $this->getResourceContextFromOperation($operation, $resourceClass, $referenceType); } /** @@ -103,11 +92,8 @@ public function getResourceContext(string $resourceClass, int $referenceType = U public function getResourceContextUri(string $resourceClass, ?int $referenceType = null): string { $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass)[0]; - if (null === $referenceType) { - $referenceType = $resourceMetadata->getUrlGenerationStrategy(); - } - return $this->urlGenerator->generate('api_jsonld_context', ['shortName' => $resourceMetadata->getShortName()], $referenceType ?? UrlGeneratorInterface::ABS_PATH); + return $this->generateContextUri($resourceMetadata->getShortName(), $referenceType ?? $resourceMetadata->getUrlGenerationStrategy()); } /** @@ -155,12 +141,6 @@ public function getAnonymousResourceContext(object $object, array $context = [], unset($jsonLdContext['@context']); } - // here the object can be different from the resource given by the $context['api_resource'] value - // TODO: this is probably not used anymore and is slow we get that @type way earlier, remove this - if (isset($context['api_resource'])) { - $jsonLdContext['@type'] = $this->resourceMetadataFactory->create($this->getObjectClass($context['api_resource']))[0]->getShortName(); - } - return $jsonLdContext; } @@ -169,11 +149,7 @@ public function getAnonymousResourceContext(object $object, array $context = [], */ public function getResourceContextUriFromOperation(HttpOperation $operation, ?int $referenceType = null): string { - if (null === $referenceType) { - $referenceType = $operation->getUrlGenerationStrategy(); - } - - return $this->urlGenerator->generate('api_jsonld_context', ['shortName' => $operation->getShortName()], $referenceType ?? UrlGeneratorInterface::ABS_PATH); + return $this->generateContextUri($operation->getShortName(), $referenceType ?? $operation->getUrlGenerationStrategy()); } /** @@ -196,6 +172,11 @@ public function getResourceContextFromOperation(HttpOperation $operation, string return $this->getResourceContextWithShortname($resourceClass, $referenceType, $shortName, $operation); } + private function generateContextUri(?string $shortName, ?int $referenceType): string + { + return $this->urlGenerator->generate('api_jsonld_context', ['shortName' => $shortName], $referenceType ?? UrlGeneratorInterface::ABS_PATH); + } + private function getResourceContextWithShortname(string $resourceClass, int $referenceType, string $shortName, ?HttpOperation $operation = null): array { $context = $this->getBaseContext($referenceType); diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 888fa9e5058..8bc69c4fc06 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -152,6 +152,18 @@ public function normalize(mixed $data, ?string $format = null, array $context = return $normalizedData; } + if (!isset($metadata['@type']) && null !== ($type = $this->resolveType($resourceClass, $isResourceClass, $context))) { + $metadata['@type'] = $type; + } + + return $metadata + $normalizedData; + } + + /** + * @return string|array|null + */ + private function resolveType(string $resourceClass, bool $isResourceClass, array $context): string|array|null + { $operation = $context['operation'] ?? null; if ($this->operationMetadataFactory && isset($context['item_uri_template']) && !$operation) { @@ -162,30 +174,31 @@ public function normalize(mixed $data, ?string $format = null, array $context = $operation = $this->resourceMetadataCollectionFactory->create($resourceClass)->getOperation(); } - if (!isset($metadata['@type']) && $operation) { - $types = $operation instanceof HttpOperation ? $operation->getTypes() : null; - if (null === $types) { - // TODO: 5.x break on this as this looks wrong, CollectionReferencingItem returns an IRI that point through - // ItemReferencedInCollection but it returns a CollectionReferencingItem therefore we should use the current - // object's class Type and not rely on operation ? - if (isset($context['item_uri_template'])) { - // When the operation comes from item_uri_template, use its shortName directly - // as $resourceClass refers to the collection resource, not the item resource + if (!$operation) { + return null; + } + + $types = $operation instanceof HttpOperation ? $operation->getTypes() : null; + if (null === $types) { + // TODO: 5.x break on this as this looks wrong, CollectionReferencingItem returns an IRI that point through + // ItemReferencedInCollection but it returns a CollectionReferencingItem therefore we should use the current + // object's class Type and not rely on operation ? + if (isset($context['item_uri_template'])) { + // When the operation comes from item_uri_template, use its shortName directly + // as $resourceClass refers to the collection resource, not the item resource + $types = [$operation->getShortName()]; + } else { + // Use resource-level shortName to avoid operation-specific overrides + $typeClass = $isResourceClass ? $resourceClass : ($operation->getClass() ?? $resourceClass); + try { + $types = [$this->resourceMetadataCollectionFactory->create($typeClass)[0]->getShortName()]; + } catch (\Exception) { $types = [$operation->getShortName()]; - } else { - // Use resource-level shortName to avoid operation-specific overrides - $typeClass = $isResourceClass ? $resourceClass : ($operation->getClass() ?? $resourceClass); - try { - $types = [$this->resourceMetadataCollectionFactory->create($typeClass)[0]->getShortName()]; - } catch (\Exception) { - $types = [$operation->getShortName()]; - } } } - $metadata['@type'] = 1 === \count($types) ? $types[0] : $types; } - return $metadata + $normalizedData; + return 1 === \count($types) ? $types[0] : $types; } /** diff --git a/src/JsonLd/Serializer/JsonLdContextTrait.php b/src/JsonLd/Serializer/JsonLdContextTrait.php index 34d7e8bbe18..9a7320caa8a 100644 --- a/src/JsonLd/Serializer/JsonLdContextTrait.php +++ b/src/JsonLd/Serializer/JsonLdContextTrait.php @@ -59,9 +59,7 @@ private function addJsonLdContext(ContextBuilderInterface $contextBuilder, strin private function createJsonLdContext(AnonymousContextBuilderInterface $contextBuilder, object $object, array &$context): array { - $anonymousContext = ($context['output'] ?? []) + [ - 'api_resource' => $context['api_resource'] ?? null, - ]; + $anonymousContext = $context['output'] ?? []; if (isset($context['item_uri_template'])) { $anonymousContext['item_uri_template'] = $context['item_uri_template']; diff --git a/src/JsonLd/Serializer/ObjectNormalizer.php b/src/JsonLd/Serializer/ObjectNormalizer.php index 24755c41e3b..9631ff755ea 100644 --- a/src/JsonLd/Serializer/ObjectNormalizer.php +++ b/src/JsonLd/Serializer/ObjectNormalizer.php @@ -14,7 +14,6 @@ namespace ApiPlatform\JsonLd\Serializer; use ApiPlatform\JsonLd\AnonymousContextBuilderInterface; -use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\IriConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -53,11 +52,6 @@ public function getSupportedTypes(?string $format): array */ public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { - if (isset($context['api_resource'])) { - $originalResource = $context['api_resource']; - unset($context['api_resource']); - } - /* * Converts the normalized data array of a resource into an IRI, if the * normalized data array is empty. @@ -75,15 +69,6 @@ public function normalize(mixed $data, ?string $format = null, array $context = return $normalizedData; } - if (isset($originalResource)) { - try { - $context['output']['iri'] = $this->iriConverter->getIriFromResource($originalResource); - } catch (InvalidArgumentException) { - // The original resource has no identifiers - } - $context['api_resource'] = $originalResource; - } - $metadata = $this->createJsonLdContext($this->anonymousContextBuilder, $data, $context); return $metadata + $normalizedData; diff --git a/tests/JsonLd/ContextBuilderTest.php b/tests/JsonLd/ContextBuilderTest.php index f0f3f87a030..b9fb0727b54 100644 --- a/tests/JsonLd/ContextBuilderTest.php +++ b/tests/JsonLd/ContextBuilderTest.php @@ -226,57 +226,6 @@ public function testAnonymousResourceContextWithIri(): void $this->assertEquals($expected, $contextBuilder->getAnonymousResourceContext($output, ['iri' => '/dummies', 'name' => 'Dummy'])); } - public function testAnonymousResourceContextWithApiResource(): void - { - $output = new OutputDto(); - $this->propertyNameCollectionFactoryProphecy->create(OutputDto::class)->willReturn(new PropertyNameCollection(['dummyPropertyA'])); - $this->propertyMetadataFactoryProphecy->create(OutputDto::class, 'dummyPropertyA', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('Dummy property A')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)); - $this->urlGeneratorProphecy->generate('api_doc', ['_format' => 'jsonld'], UrlGeneratorInterface::ABS_URL)->willReturn(''); - - $this->resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ - (new ApiResource()) - ->withShortName('Dummy') - ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), - ])); - - $contextBuilder = new ContextBuilder($this->resourceNameCollectionFactoryProphecy->reveal(), $this->resourceMetadataCollectionFactoryProphecy->reveal(), $this->propertyNameCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal(), $this->urlGeneratorProphecy->reveal()); - - $expected = [ - '@context' => [ - '@vocab' => '#', - 'hydra' => 'http://www.w3.org/ns/hydra/core#', - 'dummyPropertyA' => 'OutputDto/dummyPropertyA', - ], - '@id' => '/dummies', - '@type' => 'Dummy', - ]; - - $this->assertEquals($expected, $contextBuilder->getAnonymousResourceContext($output, ['iri' => '/dummies', 'name' => 'Dummy', 'api_resource' => new Dummy()])); - } - - public function testAnonymousResourceContextWithApiResourceHavingContext(): void - { - $output = new OutputDto(); - $this->propertyNameCollectionFactoryProphecy->create(OutputDto::class)->willReturn(new PropertyNameCollection(['dummyPropertyA'])); - $this->propertyMetadataFactoryProphecy->create(OutputDto::class, 'dummyPropertyA', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('Dummy property A')->withReadable(true)->withWritable(true)->withReadableLink(true)->withWritableLink(true)); - $this->urlGeneratorProphecy->generate('api_doc', ['_format' => 'jsonld'], UrlGeneratorInterface::ABS_URL)->willReturn(''); - - $this->resourceMetadataCollectionFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadataCollection('Dummy', [ - (new ApiResource()) - ->withShortName('Dummy') - ->withOperations(new Operations(['get' => (new Get())->withShortName('Dummy')])), - ])); - - $contextBuilder = new ContextBuilder($this->resourceNameCollectionFactoryProphecy->reveal(), $this->resourceMetadataCollectionFactoryProphecy->reveal(), $this->propertyNameCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal(), $this->urlGeneratorProphecy->reveal()); - - $expected = [ - '@id' => '/dummies', - '@type' => 'Dummy', - ]; - - $this->assertEquals($expected, $contextBuilder->getAnonymousResourceContext($output, ['iri' => '/dummies', 'name' => 'Dummy', 'api_resource' => new Dummy(), 'has_context' => true])); - } - public function testResourceContextWithoutHydraPrefix(): void { $this->resourceMetadataCollectionFactoryProphecy->create($this->entityClass)->willReturn(new ResourceMetadataCollection('DummyEntity', [ diff --git a/tests/JsonLd/Serializer/ObjectNormalizerTest.php b/tests/JsonLd/Serializer/ObjectNormalizerTest.php index 65dcfe619b4..fa9006911b4 100644 --- a/tests/JsonLd/Serializer/ObjectNormalizerTest.php +++ b/tests/JsonLd/Serializer/ObjectNormalizerTest.php @@ -86,50 +86,19 @@ public function testNormalizeEmptyArray(): void $this->assertEquals([], $normalizer->normalize($dummy)); } - public function testNormalizeWithOutput(): void + public function testNormalizeWithJsonLdContextSet(): void { $dummy = new Dummy(); $dummy->setName('hello'); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getIriFromResource($dummy)->willReturn('/dummy/1234'); $serializerProphecy = $this->prophesize(SerializerInterface::class); $serializerProphecy->willImplement(NormalizerInterface::class); $serializerProphecy->normalize($dummy, null, Argument::type('array'))->willReturn(['name' => 'hello']); $contextBuilderProphecy = $this->prophesize(AnonymousContextBuilderInterface::class); - $contextBuilderProphecy->getAnonymousResourceContext($dummy, ['api_resource' => $dummy, 'iri' => '/dummy/1234'])->shouldBeCalled()->willReturn(['@id' => '/dummy/1234', '@type' => 'Dummy', '@context' => []]); - - $normalizer = new ObjectNormalizer( - $serializerProphecy->reveal(), - $iriConverterProphecy->reveal(), - $contextBuilderProphecy->reveal() - ); - - $expected = [ - '@context' => [], - '@id' => '/dummy/1234', - '@type' => 'Dummy', - 'name' => 'hello', - ]; - $this->assertEquals($expected, $normalizer->normalize($dummy, null, ['api_resource' => $dummy])); - } - - public function testNormalizeWithContext(): void - { - $dummy = new Dummy(); - $dummy->setName('hello'); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getIriFromResource($dummy)->willReturn('/dummy/1234'); - - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(NormalizerInterface::class); - $serializerProphecy->normalize($dummy, null, Argument::type('array'))->willReturn(['name' => 'hello']); - - $contextBuilderProphecy = $this->prophesize(AnonymousContextBuilderInterface::class); - $contextBuilderProphecy->getAnonymousResourceContext($dummy, ['api_resource' => $dummy, 'has_context' => true, 'iri' => '/dummy/1234'])->shouldBeCalled()->willReturn(['@id' => '/dummy/1234', '@type' => 'Dummy']); + $contextBuilderProphecy->getAnonymousResourceContext($dummy, ['has_context' => true])->shouldBeCalled()->willReturn(['@id' => '/dummy/1234', '@type' => 'Dummy']); $normalizer = new ObjectNormalizer( $serializerProphecy->reveal(), @@ -142,6 +111,6 @@ public function testNormalizeWithContext(): void '@type' => 'Dummy', 'name' => 'hello', ]; - $this->assertEquals($expected, $normalizer->normalize($dummy, null, ['api_resource' => $dummy, 'jsonld_has_context' => true])); + $this->assertEquals($expected, $normalizer->normalize($dummy, null, ['jsonld_has_context' => true])); } } From d36c14cbdb0a6b323c30a433686ad49fa45fa666 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 7 May 2026 13:56:31 +0200 Subject: [PATCH 08/84] refactor: split normalizer/denormalizer (#7713) --- src/GraphQl/Serializer/ItemDenormalizer.php | 62 +++ .../State/Provider/DenormalizeProvider.php | 4 +- .../Tests/Serializer/ItemDenormalizerTest.php | 86 ++++ .../Tests/Serializer/ItemNormalizerTest.php | 36 -- src/JsonApi/Serializer/ItemDenormalizer.php | 64 +++ src/JsonApi/Serializer/ItemNormalizer.php | 213 +-------- .../Serializer/ItemNormalizerTrait.php | 149 +++++++ .../Tests/Serializer/ItemDenormalizerTest.php | 82 ++++ .../Tests/Serializer/ItemNormalizerTest.php | 2 + src/JsonLd/Serializer/ItemDenormalizer.php | 59 +++ src/JsonLd/Serializer/ItemNormalizer.php | 62 +-- src/JsonLd/Serializer/ItemNormalizerTrait.php | 87 ++++ src/Laravel/ApiPlatformProvider.php | 83 ++++ src/Laravel/Tests/McpTest.php.orig | 411 ++++++++++++++++++ src/Serializer/ItemDenormalizer.php | 50 +++ src/Serializer/ItemNormalizer.php | 77 +--- src/Serializer/ItemNormalizerTrait.php | 99 +++++ src/Serializer/Tests/ItemDenormalizerTest.php | 271 ++++++++++++ src/Serializer/Tests/ItemNormalizerTest.php | 209 +-------- .../ApiPlatformExtension.php | 3 + src/Symfony/Bundle/Resources/config/api.php | 19 + .../Bundle/Resources/config/elasticsearch.php | 4 + .../Bundle/Resources/config/graphql.php | 16 + .../Bundle/Resources/config/jsonapi.php | 18 + .../Bundle/Resources/config/jsonld.php | 18 + 25 files changed, 1626 insertions(+), 558 deletions(-) create mode 100644 src/GraphQl/Serializer/ItemDenormalizer.php create mode 100644 src/GraphQl/Tests/Serializer/ItemDenormalizerTest.php create mode 100644 src/JsonApi/Serializer/ItemDenormalizer.php create mode 100644 src/JsonApi/Serializer/ItemNormalizerTrait.php create mode 100644 src/JsonApi/Tests/Serializer/ItemDenormalizerTest.php create mode 100644 src/JsonLd/Serializer/ItemDenormalizer.php create mode 100644 src/JsonLd/Serializer/ItemNormalizerTrait.php create mode 100644 src/Laravel/Tests/McpTest.php.orig create mode 100644 src/Serializer/ItemDenormalizer.php create mode 100644 src/Serializer/ItemNormalizerTrait.php create mode 100644 src/Serializer/Tests/ItemDenormalizerTest.php diff --git a/src/GraphQl/Serializer/ItemDenormalizer.php b/src/GraphQl/Serializer/ItemDenormalizer.php new file mode 100644 index 00000000000..cd7aa0b3a1b --- /dev/null +++ b/src/GraphQl/Serializer/ItemDenormalizer.php @@ -0,0 +1,62 @@ + + * + * 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\GraphQl\Serializer; + +use ApiPlatform\Serializer\AbstractItemNormalizer; + +/** + * Converts GraphQL inputs to objects (denormalization only). + * + * @author Kévin Dunglas + */ +final class ItemDenormalizer extends AbstractItemNormalizer +{ + public const FORMAT = 'graphql'; + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return false; + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); + } + + public function getSupportedTypes(?string $format): array + { + return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; + } + + protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool + { + $allowedAttributes = parent::getAllowedAttributes($classOrObject, $context, $attributesAsString); + + if (($context['api_denormalize'] ?? false) && \is_array($allowedAttributes) && false !== ($indexId = array_search('id', $allowedAttributes, true))) { + $allowedAttributes[] = '_id'; + array_splice($allowedAttributes, (int) $indexId, 1); + } + + return $allowedAttributes; + } + + protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void + { + if ('_id' === $attribute) { + $attribute = 'id'; + } + + parent::setAttributeValue($object, $attribute, $value, $format, $context); + } +} diff --git a/src/GraphQl/State/Provider/DenormalizeProvider.php b/src/GraphQl/State/Provider/DenormalizeProvider.php index 481bb8bb6b0..45743f5fc64 100644 --- a/src/GraphQl/State/Provider/DenormalizeProvider.php +++ b/src/GraphQl/State/Provider/DenormalizeProvider.php @@ -13,7 +13,7 @@ namespace ApiPlatform\GraphQl\State\Provider; -use ApiPlatform\GraphQl\Serializer\ItemNormalizer; +use ApiPlatform\GraphQl\Serializer\ItemDenormalizer; use ApiPlatform\GraphQl\Serializer\SerializerContextBuilderInterface; use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\Operation; @@ -47,7 +47,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $denormalizationContext[AbstractNormalizer::OBJECT_TO_POPULATE] = $data; } - $item = $this->denormalizer->denormalize($context['args']['input'], $operation->getClass(), ItemNormalizer::FORMAT, $denormalizationContext); + $item = $this->denormalizer->denormalize($context['args']['input'], $operation->getClass(), ItemDenormalizer::FORMAT, $denormalizationContext); if (!\is_object($item)) { throw new \UnexpectedValueException('Expected item to be an object.'); diff --git a/src/GraphQl/Tests/Serializer/ItemDenormalizerTest.php b/src/GraphQl/Tests/Serializer/ItemDenormalizerTest.php new file mode 100644 index 00000000000..d36a23f8fbf --- /dev/null +++ b/src/GraphQl/Tests/Serializer/ItemDenormalizerTest.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\GraphQl\Tests\Serializer; + +use ApiPlatform\GraphQl\Serializer\ItemDenormalizer; +use ApiPlatform\GraphQl\Tests\Fixtures\ApiResource\Dummy; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\SerializerInterface; + +class ItemDenormalizerTest extends TestCase +{ + use ProphecyTrait; + + public function testSupportsDenormalizationOnlyForGraphQlFormat(): void + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + + $this->assertFalse($denormalizer->supportsNormalization(new Dummy(), ItemDenormalizer::FORMAT)); + $this->assertTrue($denormalizer->supportsDenormalization([], Dummy::class, ItemDenormalizer::FORMAT)); + $this->assertFalse($denormalizer->supportsDenormalization([], Dummy::class, 'jsonld')); + } + + public function testDenormalize(): void + { + $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; + + $propertyNameCollection = new PropertyNameCollection(['name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); + + $propertyMetadata = (new ApiProperty())->withWritable(true)->withReadable(true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + $denormalizer->setSerializer($serializerProphecy->reveal()); + + $this->assertInstanceOf(Dummy::class, $denormalizer->denormalize(['name' => 'hello'], Dummy::class, ItemDenormalizer::FORMAT, $context)); + } +} diff --git a/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php b/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php index e528ee4e941..94ad5540266 100644 --- a/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php +++ b/src/GraphQl/Tests/Serializer/ItemNormalizerTest.php @@ -28,7 +28,6 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\SerializerInterface; @@ -253,39 +252,4 @@ public function testNormalizeNoResolverData(): void 'no_resolver_data' => true, ])); } - - public function testDenormalize(): void - { - $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; - - $propertyNameCollection = new PropertyNameCollection(['name']); - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); - - $propertyMetadata = (new ApiProperty())->withWritable(true)->withReadable(true); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - - $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); - - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); - - $normalizer = new ItemNormalizer( - $propertyNameCollectionFactoryProphecy->reveal(), - $propertyMetadataFactoryProphecy->reveal(), - $iriConverterProphecy->reveal(), - $identifiersExtractorProphecy->reveal(), - $resourceClassResolverProphecy->reveal() - ); - $normalizer->setSerializer($serializerProphecy->reveal()); - - $this->assertInstanceOf(Dummy::class, $normalizer->denormalize(['name' => 'hello'], Dummy::class, ItemNormalizer::FORMAT, $context)); - } } diff --git a/src/JsonApi/Serializer/ItemDenormalizer.php b/src/JsonApi/Serializer/ItemDenormalizer.php new file mode 100644 index 00000000000..8ed3a0ac319 --- /dev/null +++ b/src/JsonApi/Serializer/ItemDenormalizer.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\JsonApi\Serializer; + +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceAccessCheckerInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Serializer\AbstractItemNormalizer; +use ApiPlatform\Serializer\OperationResourceClassResolverInterface; +use ApiPlatform\Serializer\TagCollectorInterface; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Converts JSON:API documents to objects (denormalization only). + * + * @author Kévin Dunglas + * @author Amrouche Hamza + * @author Baptiste Meyer + */ +final class ItemDenormalizer extends AbstractItemNormalizer +{ + use ItemNormalizerTrait; + + public const FORMAT = 'jsonapi'; + + public function __construct( + PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, + PropertyMetadataFactoryInterface $propertyMetadataFactory, + IriConverterInterface $iriConverter, + ResourceClassResolverInterface $resourceClassResolver, + ?PropertyAccessorInterface $propertyAccessor = null, + ?NameConverterInterface $nameConverter = null, + ?ClassMetadataFactoryInterface $classMetadataFactory = null, + array $defaultContext = [], + ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, + ?ResourceAccessCheckerInterface $resourceAccessChecker = null, + protected ?TagCollectorInterface $tagCollector = null, + ?OperationResourceClassResolverInterface $operationResourceResolver = null, + private readonly bool $useIriAsId = true, + ) { + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return false; + } +} diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index b97c1411dc2..cd40caedcca 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -14,8 +14,6 @@ namespace ApiPlatform\JsonApi\Serializer; use ApiPlatform\Metadata\ApiProperty; -use ApiPlatform\Metadata\Exception\ItemNotFoundException; -use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IdentifiersExtractorInterface; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; @@ -36,8 +34,6 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\Exception\LogicException; -use Symfony\Component\Serializer\Exception\NotNormalizableValueException; -use Symfony\Component\Serializer\Exception\RuntimeException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -47,7 +43,7 @@ use Symfony\Component\TypeInfo\Type\ObjectType; /** - * Converts between objects and array. + * Converts objects to JSON:API documents (normalization only). * * @author Kévin Dunglas * @author Amrouche Hamza @@ -58,11 +54,13 @@ final class ItemNormalizer extends AbstractItemNormalizer use CacheKeyTrait; use ClassInfoTrait; use ContextTrait; + use ItemNormalizerTrait { + denormalize as private doDenormalize; + } public const FORMAT = 'jsonapi'; private array $componentsCache = []; - private bool $useIriAsId; public function __construct( PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, @@ -78,31 +76,28 @@ public function __construct( protected ?TagCollectorInterface $tagCollector = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null, private readonly ?IdentifiersExtractorInterface $identifiersExtractor = null, - bool $useIriAsId = true, + private readonly bool $useIriAsId = true, ) { parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); - $this->useIriAsId = $useIriAsId; } - /** - * {@inheritdoc} - */ public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool { return self::FORMAT === $format && parent::supportsNormalization($data, $format, $context) && !($data instanceof \Exception || $data instanceof FlattenException); } - /** - * {@inheritdoc} - */ public function getSupportedTypes(?string $format): array { return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; } - /** - * {@inheritdoc} - */ + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + trigger_deprecation('api-platform/core', '4.4', 'Calling "denormalize()" on "%s" is deprecated, use "%s" instead.', self::class, ItemDenormalizer::class); + + return $this->doDenormalize($data, $type, $format, $context); + } + public function normalize(mixed $data, ?string $format = null, array $context = []): array|string|int|float|bool|\ArrayObject|null { $resourceClass = $this->getObjectClass($data); @@ -135,7 +130,6 @@ public function normalize(mixed $data, ?string $format = null, array $context = return $normalizedData; } - // Get and populate relations ['relationships' => $allRelationshipsData, 'links' => $links] = $this->getComponents($data, $format, $context); $populatedRelationContext = $context; $relationshipsData = $this->getPopulatedRelations($data, $format, $populatedRelationContext, $allRelationshipsData); @@ -158,7 +152,6 @@ public function normalize(mixed $data, ?string $format = null, array $context = 'type' => $resourceShortName, ]; - // TODO: consider always adding links.self — it's valid per the JSON:API spec even when id is the IRI if (!$this->useIriAsId) { $resourceData['links'] = ['self' => $iri]; } @@ -186,62 +179,6 @@ public function normalize(mixed $data, ?string $format = null, array $context = return $document; } - /** - * {@inheritdoc} - */ - public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool - { - return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); - } - - /** - * {@inheritdoc} - * - * @throws NotNormalizableValueException - */ - public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed - { - // When re-entering for input DTO denormalization, data has already been - // unwrapped from the JSON:API structure by the first pass. Skip extraction. - if (isset($context['api_platform_input'])) { - return parent::denormalize($data, $type, $format, $context); - } - - // Avoid issues with proxies if we populated the object - if (!isset($context[self::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) { - if (true !== ($context['api_allow_update'] ?? true)) { - throw new NotNormalizableValueException('Update is not allowed for this operation.'); - } - - $context += ['fetch_data' => false]; - if ($this->useIriAsId) { - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri( - $data['data']['id'], - $context - ); - } else { - $operation = $context['operation'] ?? null; - if ($operation instanceof HttpOperation) { - $iri = $this->reconstructIri($type, (string) $data['data']['id'], $operation); - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context); - } - } - } - - // Merge attributes and relationships, into format expected by the parent normalizer - $dataToDenormalize = array_merge( - $data['data']['attributes'] ?? [], - $data['data']['relationships'] ?? [] - ); - - return parent::denormalize( - $dataToDenormalize, - $type, - $format, - $context - ); - } - /** * {@inheritdoc} */ @@ -251,59 +188,6 @@ protected function getAttributes(object $object, ?string $format = null, array $ } /** - * {@inheritdoc} - */ - protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void - { - parent::setAttributeValue($object, $attribute, \is_array($value) && \array_key_exists('data', $value) ? $value['data'] : $value, $format, $context); - } - - /** - * {@inheritdoc} - * - * @see http://jsonapi.org/format/#document-resource-object-linkage - * - * @throws RuntimeException - * @throws UnexpectedValueException - */ - protected function denormalizeRelation(string $attributeName, ApiProperty $propertyMetadata, string $className, mixed $value, ?string $format, array $context): ?object - { - if (!\is_array($value) || !isset($value['id'], $value['type'])) { - throw new UnexpectedValueException('Only resource linkage supported currently, see: http://jsonapi.org/format/#document-resource-object-linkage.'); - } - - try { - $context += ['fetch_data' => true]; - if ($this->useIriAsId) { - return $this->iriConverter->getResourceFromIri($value['id'], $context); - } - - /** @var HttpOperation $getOperation */ - $getOperation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(httpOperation: true); - $iri = $this->reconstructIri($className, (string) $value['id'], $getOperation); - - return $this->iriConverter->getResourceFromIri($iri, $context); - } catch (ItemNotFoundException $e) { - if (!isset($context['not_normalizable_value_exceptions'])) { - throw new RuntimeException($e->getMessage(), $e->getCode(), $e); - } - $context['not_normalizable_value_exceptions'][] = NotNormalizableValueException::createForUnexpectedDataType( - $e->getMessage(), - $value, - [$className], - $context['deserialization_path'] ?? null, - true, - $e->getCode(), - $e - ); - - return null; - } - } - - /** - * {@inheritdoc} - * * @see http://jsonapi.org/format/#document-resource-object-linkage */ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $relatedObject, string $resourceClass, ?string $format, array $context): \ArrayObject|array|string|null @@ -336,13 +220,11 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel $id = $this->getIdStringFromIdentifiers($identifiers); } - $relationData = [ - 'type' => $this->getResourceShortName($resourceClass), - 'id' => $id, - ]; - $context['data'] = [ - 'data' => $relationData, + 'data' => [ + 'type' => $this->getResourceShortName($resourceClass), + 'id' => $id, + ], ]; $context['iri'] = $iri; @@ -357,14 +239,6 @@ protected function normalizeRelation(ApiProperty $propertyMetadata, ?object $rel return $context['data']; } - /** - * {@inheritdoc} - */ - protected function isAllowedAttribute(object|string $classOrObject, string $attribute, ?string $format = null, array $context = []): bool - { - return preg_match('/^\\w[-\\w_]*$/', $attribute) && parent::isAllowedAttribute($classOrObject, $attribute, $format, $context); - } - /** * Gets JSON API components of the resource: attributes, relationships, meta and links. */ @@ -392,7 +266,6 @@ private function getComponents(object $object, ?string $format, array $context): ->propertyMetadataFactory ->create($context['resource_class'], $attribute, $options); - // prevent declaring $attribute as attribute if it's already declared as relationship $isRelationship = false; if (!method_exists(PropertyInfoExtractor::class, 'getType')) { @@ -409,7 +282,6 @@ private function getComponents(object $object, ?string $format, array $context): } if (!isset($className) || !$isOne && !$isMany) { - // don't declare it as an attribute too quick: maybe the next type is a valid resource continue; } @@ -419,8 +291,6 @@ private function getComponents(object $object, ?string $format, array $context): 'cardinality' => $isOne ? 'one' : 'many', ]; - // if we specify the uriTemplate, generates its value for link definition - // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) { $attributeValue = $this->propertyAccessor->getValue($object, $attribute); $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); @@ -457,7 +327,6 @@ private function getComponents(object $object, ?string $format, array $context): } if (!$className || (!$isOne && !$isMany)) { - // don't declare it as an attribute too quick: maybe the next type is a valid resource continue; } @@ -467,8 +336,6 @@ private function getComponents(object $object, ?string $format, array $context): 'cardinality' => $isOne ? 'one' : 'many', ]; - // if we specify the uriTemplate, generates its value for link definition - // @see ApiPlatform\Serializer\AbstractItemNormalizer:getAttributeValue logic for intentional duplicate content if ($itemUriTemplate = $propertyMetadata->getUriTemplate()) { $attributeValue = $this->propertyAccessor->getValue($object, $attribute); $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); @@ -489,7 +356,6 @@ private function getComponents(object $object, ?string $format, array $context): } } - // if all types are not relationships, declare it as an attribute if (!$isRelationship) { $components['attributes'][] = $attribute; } @@ -503,8 +369,6 @@ private function getComponents(object $object, ?string $format, array $context): } /** - * Populates relationships keys. - * * @throws UnexpectedValueException */ private function getPopulatedRelations(object $object, ?string $format, array $context, array $relationships): array @@ -525,11 +389,8 @@ private function getPopulatedRelations(object $object, ?string $format, array $c $relationshipName = $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context); } - // Many to one relationship if ('one' === $relationshipDataArray['cardinality']) { - $data[$relationshipName] = [ - 'data' => null, - ]; + $data[$relationshipName] = ['data' => null]; if (!$attributeValue) { continue; @@ -541,10 +402,7 @@ private function getPopulatedRelations(object $object, ?string $format, array $c continue; } - // Many to many relationship - $data[$relationshipName] = [ - 'data' => [], - ]; + $data[$relationshipName] = ['data' => []]; if (!$attributeValue) { continue; @@ -562,9 +420,6 @@ private function getPopulatedRelations(object $object, ?string $format, array $c return $data; } - /** - * Populates included keys. - */ private function getRelatedResources(object $object, ?string $format, array $context, array $relationships): array { if (!isset($context['api_included'])) { @@ -588,9 +443,7 @@ private function getRelatedResources(object $object, ?string $format, array $con continue; } - // Many to many relationship $attributeValues = $attributeValue; - // Many to one relationship if ('one' === $relationshipDataArray['cardinality']) { $attributeValues = [$attributeValue]; } @@ -610,9 +463,6 @@ private function getRelatedResources(object $object, ?string $format, array $con return $included; } - /** - * Add data to included array if it's not already included. - */ private function addIncluded(array $data, array &$included, array &$context): void { $trackingKey = ($data['type'] ?? '').':'.($data['id'] ?? ''); @@ -622,9 +472,6 @@ private function addIncluded(array $data, array &$included, array &$context): vo } } - /** - * Figures out if the relationship is in the api_included hash or has included nested resources (path). - */ private function shouldIncludeRelation(string $relationshipName, array $context): bool { $normalizedName = $this->nameConverter ? $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context) : $relationshipName; @@ -632,9 +479,6 @@ private function shouldIncludeRelation(string $relationshipName, array $context) return \in_array($normalizedName, $context['api_included'], true) || \count($this->getIncludedNestedResources($relationshipName, $context)) > 0; } - /** - * Returns the names of the nested resources from a path relationship. - */ private function getIncludedNestedResources(string $relationshipName, array $context): array { $normalizedName = $this->nameConverter ? $this->nameConverter->normalize($relationshipName, $context['resource_class'], self::FORMAT, $context) : $relationshipName; @@ -653,27 +497,6 @@ private function getIdStringFromIdentifiers(array $identifiers): string return CompositeIdentifierParser::stringify($identifiers); } - /** - * Reconstructs an IRI from a resource class and a raw JSON:API id string. - * - * Maps the id to the operation's single URI variable parameter name and generates - * the IRI via IriConverter. Composite identifiers on a single Link work naturally - * since the composite string (e.g. "field1=val1;field2=val2") is passed as-is. - */ - private function reconstructIri(string $resourceClass, string $id, HttpOperation $operation): string - { - $uriVariables = $operation->getUriVariables() ?? []; - - if (\count($uriVariables) > 1) { - throw new UnexpectedValueException(\sprintf('JSON:API entity identifier mode requires operations with a single URI variable, operation "%s" has %d. Consider adding a NotExposed Get operation on the resource.', $operation->getName() ?? $operation->getUriTemplate(), \count($uriVariables))); - } - - $parameterName = array_key_first($uriVariables) ?? 'id'; - - return $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => [$parameterName => $id]]); - } - - // TODO: this code is similar to the one used in JsonLd private function getResourceShortName(string $resourceClass): string { if ($this->resourceClassResolver->isResourceClass($resourceClass)) { diff --git a/src/JsonApi/Serializer/ItemNormalizerTrait.php b/src/JsonApi/Serializer/ItemNormalizerTrait.php new file mode 100644 index 00000000000..5b00aa13ce0 --- /dev/null +++ b/src/JsonApi/Serializer/ItemNormalizerTrait.php @@ -0,0 +1,149 @@ + + * + * 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\JsonApi\Serializer; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Exception\ItemNotFoundException; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Serializer\AbstractItemNormalizer; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\RuntimeException; +use Symfony\Component\Serializer\Exception\UnexpectedValueException; + +/** + * Shared support gates and denormalization logic for the JSON:API item (de)normalizer. + * + * @author Kévin Dunglas + * + * @internal + */ +trait ItemNormalizerTrait +{ + public function getSupportedTypes(?string $format): array + { + return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); + } + + /** + * @throws NotNormalizableValueException + */ + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + // When re-entering for input DTO denormalization, data has already been + // unwrapped from the JSON:API structure by the first pass. Skip extraction. + if (isset($context['api_platform_input'])) { + return parent::denormalize($data, $type, $format, $context); + } + + // Avoid issues with proxies if we populated the object + if (!isset($context[AbstractItemNormalizer::OBJECT_TO_POPULATE]) && isset($data['data']['id'])) { + if (true !== ($context['api_allow_update'] ?? true)) { + throw new NotNormalizableValueException('Update is not allowed for this operation.'); + } + + $context += ['fetch_data' => false]; + if ($this->useIriAsId) { + $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($data['data']['id'], $context); + } else { + $operation = $context['operation'] ?? null; + if ($operation instanceof HttpOperation) { + $iri = $this->reconstructIri($type, (string) $data['data']['id'], $operation); + $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context); + } + } + } + + $dataToDenormalize = array_merge( + $data['data']['attributes'] ?? [], + $data['data']['relationships'] ?? [] + ); + + return parent::denormalize($dataToDenormalize, $type, $format, $context); + } + + protected function isAllowedAttribute(object|string $classOrObject, string $attribute, ?string $format = null, array $context = []): bool + { + return preg_match('/^\\w[-\\w_]*$/', $attribute) && parent::isAllowedAttribute($classOrObject, $attribute, $format, $context); + } + + protected function setAttributeValue(object $object, string $attribute, mixed $value, ?string $format = null, array $context = []): void + { + parent::setAttributeValue($object, $attribute, \is_array($value) && \array_key_exists('data', $value) ? $value['data'] : $value, $format, $context); + } + + /** + * @see http://jsonapi.org/format/#document-resource-object-linkage + * + * @throws RuntimeException + * @throws UnexpectedValueException + */ + protected function denormalizeRelation(string $attributeName, ApiProperty $propertyMetadata, string $className, mixed $value, ?string $format, array $context): ?object + { + if (!\is_array($value) || !isset($value['id'], $value['type'])) { + throw new UnexpectedValueException('Only resource linkage supported currently, see: http://jsonapi.org/format/#document-resource-object-linkage.'); + } + + try { + $context += ['fetch_data' => true]; + if ($this->useIriAsId) { + return $this->iriConverter->getResourceFromIri($value['id'], $context); + } + + /** @var HttpOperation $getOperation */ + $getOperation = $this->resourceMetadataCollectionFactory->create($className)->getOperation(httpOperation: true); + $iri = $this->reconstructIri($className, (string) $value['id'], $getOperation); + + return $this->iriConverter->getResourceFromIri($iri, $context); + } catch (ItemNotFoundException $e) { + if (!isset($context['not_normalizable_value_exceptions'])) { + throw new RuntimeException($e->getMessage(), $e->getCode(), $e); + } + $context['not_normalizable_value_exceptions'][] = NotNormalizableValueException::createForUnexpectedDataType( + $e->getMessage(), + $value, + [$className], + $context['deserialization_path'] ?? null, + true, + $e->getCode(), + $e + ); + + return null; + } + } + + /** + * Maps the id to the operation's single URI variable parameter and generates the IRI. + * Composite identifiers on a single Link work naturally since the composite string + * (e.g. "field1=val1;field2=val2") is passed as-is. + */ + private function reconstructIri(string $resourceClass, string $id, HttpOperation $operation): string + { + $uriVariables = $operation->getUriVariables() ?? []; + + if (\count($uriVariables) > 1) { + throw new UnexpectedValueException(\sprintf('JSON:API entity identifier mode requires operations with a single URI variable, operation "%s" has %d. Consider adding a NotExposed Get operation on the resource.', $operation->getName() ?? $operation->getUriTemplate(), \count($uriVariables))); + } + + $parameterName = array_key_first($uriVariables) ?? 'id'; + + return $this->iriConverter->getIriFromResource($resourceClass, UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => [$parameterName => $id]]); + } +} diff --git a/src/JsonApi/Tests/Serializer/ItemDenormalizerTest.php b/src/JsonApi/Tests/Serializer/ItemDenormalizerTest.php new file mode 100644 index 00000000000..24956eea3cb --- /dev/null +++ b/src/JsonApi/Tests/Serializer/ItemDenormalizerTest.php @@ -0,0 +1,82 @@ + + * + * 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\JsonApi\Tests\Serializer; + +use ApiPlatform\JsonApi\Serializer\ItemDenormalizer; +use ApiPlatform\JsonApi\Serializer\ItemNormalizer; +use ApiPlatform\JsonApi\Tests\Fixtures\Dummy; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use PHPUnit\Framework\TestCase; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; + +class ItemDenormalizerTest extends TestCase +{ + use ProphecyTrait; + + public function testSupportsDenormalizationOnlyForJsonApiFormat(): void + { + $dummy = new Dummy(); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + + $this->assertFalse($denormalizer->supportsNormalization($dummy, ItemNormalizer::FORMAT)); + $this->assertTrue($denormalizer->supportsDenormalization($dummy, Dummy::class, ItemNormalizer::FORMAT)); + $this->assertFalse($denormalizer->supportsDenormalization($dummy, Dummy::class, 'jsonld')); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testDenormalizeOnLegacyItemNormalizerIsDeprecated(): void + { + $this->expectUserDeprecationMessage('Since api-platform/core 4.4: Calling "denormalize()" on "ApiPlatform\JsonApi\Serializer\ItemNormalizer" is deprecated, use "ApiPlatform\JsonApi\Serializer\ItemDenormalizer" instead.'); + $this->expectException(NotNormalizableValueException::class); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + + $normalizer->denormalize( + ['data' => ['id' => '/dummies/1']], + Dummy::class, + ItemNormalizer::FORMAT, + ['api_allow_update' => false] + ); + } +} diff --git a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php index fd4b2ea12ef..2809e2fed9b 100644 --- a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php +++ b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php @@ -34,6 +34,7 @@ use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use Doctrine\Common\Collections\ArrayCollection; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -51,6 +52,7 @@ /** * @author Amrouche Hamza */ +#[IgnoreDeprecations] class ItemNormalizerTest extends TestCase { use ProphecyTrait; diff --git a/src/JsonLd/Serializer/ItemDenormalizer.php b/src/JsonLd/Serializer/ItemDenormalizer.php new file mode 100644 index 00000000000..c1f3d53acd0 --- /dev/null +++ b/src/JsonLd/Serializer/ItemDenormalizer.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\JsonLd\Serializer; + +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceAccessCheckerInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Serializer\AbstractItemNormalizer; +use ApiPlatform\Serializer\OperationResourceClassResolverInterface; +use ApiPlatform\Serializer\TagCollectorInterface; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Converts JSON-LD data to objects (denormalization only). + * + * @author Kévin Dunglas + */ +final class ItemDenormalizer extends AbstractItemNormalizer +{ + use ItemNormalizerTrait; + + public const FORMAT = 'jsonld'; + + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null) + { + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataCollectionFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return false; + } + + public function supportsDenormalization(mixed $data, string $type, ?string $format = null, array $context = []): bool + { + return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); + } + + public function getSupportedTypes(?string $format): array + { + return self::FORMAT === $format ? parent::getSupportedTypes($format) : []; + } +} diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 8bc69c4fc06..2c93881c19d 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -15,7 +15,6 @@ use ApiPlatform\JsonLd\AnonymousContextBuilderInterface; use ApiPlatform\JsonLd\ContextBuilderInterface; -use ApiPlatform\Metadata\Exception\ItemNotFoundException; use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; @@ -32,7 +31,6 @@ use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Exception\LogicException; -use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -45,32 +43,12 @@ final class ItemNormalizer extends AbstractItemNormalizer { use ClassInfoTrait; use ContextTrait; + use ItemNormalizerTrait { + denormalize as private doDenormalize; + } use JsonLdContextTrait; public const FORMAT = 'jsonld'; - private const JSONLD_KEYWORDS = [ - '@context', - '@direction', - '@graph', - '@id', - '@import', - '@included', - '@index', - '@json', - '@language', - '@list', - '@nest', - '@none', - '@prefix', - '@propagate', - '@protected', - '@reverse', - '@set', - '@type', - '@value', - '@version', - '@vocab', - ]; public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, private readonly ContextBuilderInterface $contextBuilder, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], ?ResourceAccessCheckerInterface $resourceAccessChecker = null, protected ?TagCollectorInterface $tagCollector = null, private ?OperationMetadataFactoryInterface $operationMetadataFactory = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null) { @@ -209,40 +187,10 @@ public function supportsDenormalization(mixed $data, string $type, ?string $form return self::FORMAT === $format && parent::supportsDenormalization($data, $type, $format, $context); } - /** - * {@inheritdoc} - * - * @throws NotNormalizableValueException - */ public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { - // Avoid issues with proxies if we populated the object - if (isset($data['@id']) && !isset($context[self::OBJECT_TO_POPULATE])) { - if (true !== ($context['api_allow_update'] ?? true)) { - throw new NotNormalizableValueException('Update is not allowed for this operation.'); - } - - try { - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($data['@id'], $context + ['fetch_data' => true], $context['operation'] ?? null); - } catch (ItemNotFoundException $e) { - $operation = $context['operation'] ?? null; - - if (!('PUT' === $operation?->getMethod() && ($operation->getExtraProperties()['standard_put'] ?? true))) { - throw $e; - } - } - } - - return parent::denormalize($data, $type, $format, $context); - } - - protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool - { - $allowedAttributes = parent::getAllowedAttributes($classOrObject, $context, $attributesAsString); - if (\is_array($allowedAttributes) && ($context['api_denormalize'] ?? false)) { - $allowedAttributes = array_merge($allowedAttributes, self::JSONLD_KEYWORDS); - } + trigger_deprecation('api-platform/core', '4.4', 'Calling "denormalize()" on "%s" is deprecated, use "%s" instead.', self::class, ItemDenormalizer::class); - return $allowedAttributes; + return $this->doDenormalize($data, $type, $format, $context); } } diff --git a/src/JsonLd/Serializer/ItemNormalizerTrait.php b/src/JsonLd/Serializer/ItemNormalizerTrait.php new file mode 100644 index 00000000000..6bc141f410b --- /dev/null +++ b/src/JsonLd/Serializer/ItemNormalizerTrait.php @@ -0,0 +1,87 @@ + + * + * 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\JsonLd\Serializer; + +use ApiPlatform\Metadata\Exception\ItemNotFoundException; +use ApiPlatform\Serializer\AbstractItemNormalizer; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; + +/** + * Shared denormalization logic for the JSON-LD item (de)normalizer. + * + * @author Kévin Dunglas + * + * @internal + */ +trait ItemNormalizerTrait +{ + private const JSONLD_KEYWORDS = [ + '@context', + '@direction', + '@graph', + '@id', + '@import', + '@included', + '@index', + '@json', + '@language', + '@list', + '@nest', + '@none', + '@prefix', + '@propagate', + '@protected', + '@reverse', + '@set', + '@type', + '@value', + '@version', + '@vocab', + ]; + + /** + * @throws NotNormalizableValueException + */ + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + // Avoid issues with proxies if we populated the object + if (isset($data['@id']) && !isset($context[AbstractItemNormalizer::OBJECT_TO_POPULATE])) { + if (true !== ($context['api_allow_update'] ?? true)) { + throw new NotNormalizableValueException('Update is not allowed for this operation.'); + } + + try { + $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($data['@id'], $context + ['fetch_data' => true], $context['operation'] ?? null); + } catch (ItemNotFoundException $e) { + $operation = $context['operation'] ?? null; + + if (!('PUT' === $operation?->getMethod() && ($operation->getExtraProperties()['standard_put'] ?? true))) { + throw $e; + } + } + } + + return parent::denormalize($data, $type, $format, $context); + } + + protected function getAllowedAttributes(string|object $classOrObject, array $context, bool $attributesAsString = false): array|bool + { + $allowedAttributes = parent::getAllowedAttributes($classOrObject, $context, $attributesAsString); + if (\is_array($allowedAttributes) && ($context['api_denormalize'] ?? false)) { + $allowedAttributes = array_merge($allowedAttributes, self::JSONLD_KEYWORDS); + } + + return $allowedAttributes; + } +} diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 15c5c6bd3aa..1ffe86f980f 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -27,6 +27,7 @@ use ApiPlatform\GraphQl\Serializer\Exception\HttpExceptionNormalizer as GraphQlHttpExceptionNormalizer; use ApiPlatform\GraphQl\Serializer\Exception\RuntimeExceptionNormalizer as GraphQlRuntimeExceptionNormalizer; use ApiPlatform\GraphQl\Serializer\Exception\ValidationExceptionNormalizer as GraphQlValidationExceptionNormalizer; +use ApiPlatform\GraphQl\Serializer\ItemDenormalizer as GraphQlItemDenormalizer; use ApiPlatform\GraphQl\Serializer\ItemNormalizer as GraphQlItemNormalizer; use ApiPlatform\GraphQl\Serializer\ObjectNormalizer as GraphQlObjectNormalizer; use ApiPlatform\GraphQl\Serializer\SerializerContextBuilder as GraphQlSerializerContextBuilder; @@ -62,12 +63,14 @@ use ApiPlatform\JsonApi\Serializer\CollectionNormalizer as JsonApiCollectionNormalizer; use ApiPlatform\JsonApi\Serializer\EntrypointNormalizer as JsonApiEntrypointNormalizer; use ApiPlatform\JsonApi\Serializer\ErrorNormalizer as JsonApiErrorNormalizer; +use ApiPlatform\JsonApi\Serializer\ItemDenormalizer as JsonApiItemDenormalizer; use ApiPlatform\JsonApi\Serializer\ItemNormalizer as JsonApiItemNormalizer; use ApiPlatform\JsonApi\Serializer\ObjectNormalizer as JsonApiObjectNormalizer; use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter; use ApiPlatform\JsonLd\AnonymousContextBuilderInterface; use ApiPlatform\JsonLd\ContextBuilder as JsonLdContextBuilder; use ApiPlatform\JsonLd\ContextBuilderInterface; +use ApiPlatform\JsonLd\Serializer\ItemDenormalizer as JsonLdItemDenormalizer; use ApiPlatform\JsonLd\Serializer\ItemNormalizer as JsonLdItemNormalizer; use ApiPlatform\JsonLd\Serializer\ObjectNormalizer as JsonLdObjectNormalizer; use ApiPlatform\JsonSchema\DefinitionNameFactory; @@ -146,6 +149,7 @@ use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface; use ApiPlatform\OpenApi\Options; use ApiPlatform\OpenApi\Serializer\OpenApiNormalizer; +use ApiPlatform\Serializer\ItemDenormalizer; use ApiPlatform\Serializer\ItemNormalizer; use ApiPlatform\Serializer\JsonEncoder; use ApiPlatform\Serializer\Mapping\Factory\ClassMetadataFactory as SerializerClassMetadataFactory; @@ -668,6 +672,28 @@ public function register(): void ); }); + $this->app->singleton(ItemDenormalizer::class, static function (Application $app) { + /** @var ConfigRepository */ + $config = $app['config']; + $defaultContext = $config->get('api-platform.serializer', []); + + return new ItemDenormalizer( + $app->make(PropertyNameCollectionFactoryInterface::class), + $app->make(PropertyMetadataFactoryInterface::class), + $app->make(IriConverterInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(PropertyAccessorInterface::class), + $app->make(NameConverterInterface::class), + $app->make(ClassMetadataFactoryInterface::class), + $app->make(LoggerInterface::class), + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(ResourceAccessCheckerInterface::class), + $defaultContext, + null, + $app->make(OperationResourceClassResolverInterface::class), + ); + }); + $this->app->bind(AnonymousContextBuilderInterface::class, JsonLdContextBuilder::class); $this->app->singleton(JsonLdObjectNormalizer::class, static function (Application $app) { @@ -991,6 +1017,24 @@ public function register(): void ); }); + $this->app->singleton(JsonApiItemDenormalizer::class, static function (Application $app) { + $config = $app['config']; + $defaultContext = $config->get('api-platform.serializer', []); + + return new JsonApiItemDenormalizer( + $app->make(PropertyNameCollectionFactoryInterface::class), + $app->make(PropertyMetadataFactoryInterface::class), + $app->make(IriConverterInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(PropertyAccessorInterface::class), + $app->make(NameConverterInterface::class), + $app->make(ClassMetadataFactoryInterface::class), + $defaultContext, + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(ResourceAccessCheckerInterface::class), + ); + }); + $this->app->singleton(JsonApiErrorNormalizer::class, static function (Application $app) { return new JsonApiErrorNormalizer( $app->make(JsonApiItemNormalizer::class), @@ -1015,6 +1059,7 @@ public function register(): void $list->insert($app->make(HalObjectNormalizer::class), -995); $list->insert($app->make(HalItemNormalizer::class), -890); $list->insert($app->make(JsonLdItemNormalizer::class), -890); + $list->insert($app->make(JsonLdItemDenormalizer::class), -889); $list->insert($app->make(JsonLdObjectNormalizer::class), -995); $list->insert($app->make(ArrayDenormalizer::class), -990); $list->insert($app->make(DateTimeZoneNormalizer::class), -915); @@ -1023,17 +1068,20 @@ public function register(): void $list->insert($app->make(BackedEnumNormalizer::class), -910); $list->insert($app->make(ObjectNormalizer::class), -1000); $list->insert($app->make(ItemNormalizer::class), -895); + $list->insert($app->make(ItemDenormalizer::class), -894); $list->insert($app->make(OpenApiNormalizer::class), -780); $list->insert($app->make(HydraDocumentationNormalizer::class), -790); $list->insert($app->make(JsonApiEntrypointNormalizer::class), -800); $list->insert($app->make(JsonApiCollectionNormalizer::class), -985); $list->insert($app->make(JsonApiItemNormalizer::class), -890); + $list->insert($app->make(JsonApiItemDenormalizer::class), -889); $list->insert($app->make(JsonApiErrorNormalizer::class), -790); $list->insert($app->make(JsonApiObjectNormalizer::class), -995); if (interface_exists(FieldsBuilderEnumInterface::class)) { $list->insert($app->make(GraphQlItemNormalizer::class), -890); + $list->insert($app->make(GraphQlItemDenormalizer::class), -889); $list->insert($app->make(GraphQlObjectNormalizer::class), -995); $list->insert($app->make(GraphQlErrorNormalizer::class), -790); $list->insert($app->make(GraphQlValidationExceptionNormalizer::class), -780); @@ -1089,6 +1137,26 @@ public function register(): void ); }); + $this->app->singleton(JsonLdItemDenormalizer::class, static function (Application $app) { + $config = $app['config']; + $defaultContext = $config->get('api-platform.serializer', []); + + return new JsonLdItemDenormalizer( + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(PropertyNameCollectionFactoryInterface::class), + $app->make(PropertyMetadataFactoryInterface::class), + $app->make(IriConverterInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(PropertyAccessorInterface::class), + $app->make(NameConverterInterface::class), + $app->make(ClassMetadataFactoryInterface::class), + $defaultContext, + $app->make(ResourceAccessCheckerInterface::class), + null, + $app->make(OperationResourceClassResolverInterface::class), + ); + }); + $this->app->singleton(InflectorInterface::class, static function (Application $app) { return new Inflector(); }); @@ -1244,6 +1312,21 @@ private function registerGraphQl(): void ); }); + $this->app->singleton(GraphQlItemDenormalizer::class, static function (Application $app) { + return new GraphQlItemDenormalizer( + $app->make(PropertyNameCollectionFactoryInterface::class), + $app->make(PropertyMetadataFactoryInterface::class), + $app->make(IriConverterInterface::class), + $app->make(ResourceClassResolverInterface::class), + $app->make(PropertyAccessorInterface::class), + $app->make(NameConverterInterface::class), + $app->make(SerializerClassMetadataFactory::class), + [], + $app->make(ResourceMetadataCollectionFactoryInterface::class), + $app->make(ResourceAccessCheckerInterface::class) + ); + }); + $this->app->singleton(GraphQlObjectNormalizer::class, static function (Application $app) { return new GraphQlObjectNormalizer( $app->make(ObjectNormalizer::class), diff --git a/src/Laravel/Tests/McpTest.php.orig b/src/Laravel/Tests/McpTest.php.orig new file mode 100644 index 00000000000..a25cf181fda --- /dev/null +++ b/src/Laravel/Tests/McpTest.php.orig @@ -0,0 +1,411 @@ + + * + * 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\Laravel\Tests; + +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Testing\TestResponse; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; +use Symfony\AI\McpBundle\McpBundle; +use Symfony\Component\HttpFoundation\Response; + +class McpTest extends TestCase +{ + use RefreshDatabase; + use WithWorkbench; + + private function isPsr17FactoryAvailable(): bool + { + try { + if (!class_exists('Http\Discovery\Psr17FactoryDiscovery')) { + return false; + } + + \Http\Discovery\Psr17FactoryDiscovery::findServerRequestFactory(); + + return true; + } catch (\Throwable) { + return false; + } + } + + /** + * @param array $arguments + * + * @return TestResponse + */ + private function callTool(string $sessionId, string $toolName, array $arguments = []): TestResponse + { + return $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/call', + 'params' => [ + 'name' => $toolName, + 'arguments' => $arguments, + ], + ], [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ]); + } + + private function initializeMcpSession(): string + { + $response = $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 1, + 'method' => 'initialize', + 'params' => [ + 'protocolVersion' => '2024-11-05', + 'clientInfo' => [ + 'name' => 'ApiPlatform Test Suite', + 'version' => '1.0', + ], + 'capabilities' => [], + ], + ], [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + ]); + + $response->assertStatus(200); + + return $response->headers->get('mcp-session-id'); + } + + public function testBasicProvider(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'get_book_info'); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertNotNull($content); + $this->assertStringContainsString('API Platform Guide', $content); + $this->assertStringContainsString('978-1234567890', $content); + } + + public function testBasicProcessor(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'update_book_status', [ + 'id' => null, + 'isbn' => '123', + 'title' => 'Test Book', + 'status' => 'pending', + ]); + + $result = $response->json(); + if (isset($result['error'])) { + $this->fail('MCP Error: '.json_encode($result['error'])); + } + $response->assertStatus(200); + $this->assertArrayHasKey('result', $result); + } + + public function testCustomResultWithoutMetadata(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'custom_result', [ + 'text' => 'Test content', + 'includeMetadata' => false, + 'name' => null, + 'email' => null, + 'age' => null, + ]); + + $result = $response->json(); + if (isset($result['error'])) { + $this->fail('MCP Error: '.json_encode($result['error'])); + } + $response->assertStatus(200); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertEquals('Custom result: Test content', $content); + $this->assertNull($result['result']['_meta'] ?? null); + } + + public function testCustomResultWithMetadata(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'custom_result', [ + 'text' => 'Test with metadata', + 'includeMetadata' => true, + 'name' => null, + 'email' => null, + 'age' => null, + ]); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertEquals('Custom result: Test with metadata', $content); + $hasMeta = isset($result['result']['_meta']) || isset($result['result']['meta']) || isset($result['result']['structuredContent']); + $this->assertTrue($hasMeta, 'No metadata found in: '.json_encode(array_keys($result['result']))); + } + + public function testValidationFailure(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'validate_input', [ + 'name' => 'ab', + 'email' => 'invalid-email', + 'age' => -5, + 'text' => null, + 'includeMetadata' => null, + ]); + + $result = $response->json(); + if (422 === $response->getStatusCode()) { + $this->assertArrayHasKey('error', $result); + } else { + $response->assertStatus(200); + } + } + + public function testValidationSuccess(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'validate_input', [ + 'name' => 'John Doe', + 'email' => 'john@example.com', + 'age' => 30, + 'text' => null, + 'includeMetadata' => null, + ]); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertNotNull($content); + $this->assertStringContainsString('Valid: John Doe', $content); + } + + public function testMarkdownWithoutCodeBlock(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'generate_markdown', [ + 'title' => 'API Platform Guide', + 'content' => 'This is a comprehensive guide to using API Platform.', + 'includeCodeBlock' => false, + ]); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertNotNull($content, 'No text content in result'); + $this->assertStringContainsString('# API Platform Guide', $content); + $this->assertStringContainsString('This is a comprehensive guide to using API Platform.', $content); + $this->assertStringNotContainsString('```', $content); + $this->assertNull($result['result']['_meta'] ?? null); + $this->assertArrayNotHasKey('structuredContent', $result['result']); + } + + public function testMarkdownWithCodeBlock(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->callTool($sessionId, 'generate_markdown', [ + 'title' => 'Code Example', + 'content' => 'Here is how to use the feature:', + 'includeCodeBlock' => true, + ]); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + $content = $result['result']['content'][0]['text'] ?? null; + $this->assertNotNull($content); + $this->assertStringContainsString('# Code Example', $content); + $this->assertStringContainsString('Here is how to use the feature:', $content); + $this->assertStringContainsString('```php', $content); + $this->assertStringContainsString("echo 'Hello, World!';", $content); + $this->assertStringContainsString('```', $content); + $this->assertNull($result['result']['_meta'] ?? null); + $this->assertArrayNotHasKey('structuredContent', $result['result']); + } + + public function testToolsList(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/list', + ], [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ]); + + $data = $response->json(); + $this->assertArrayHasKey('result', $data); + $this->assertArrayHasKey('tools', $data['result']); + + $tools = $data['result']['tools']; + $toolNames = array_column($tools, 'name'); + + $this->assertContains('get_book_info', $toolNames); + $this->assertContains('update_book_status', $toolNames); + $this->assertContains('custom_result', $toolNames); + $this->assertContains('validate_input', $toolNames); + $this->assertContains('generate_markdown', $toolNames); + $this->assertContains('process_message', $toolNames); + + foreach ($tools as $tool) { + $this->assertArrayHasKey('name', $tool); + $this->assertArrayHasKey('inputSchema', $tool); + $this->assertEquals('object', $tool['inputSchema']['type']); + } + + $response->assertStatus(200); + } + + public function testMcpToolAttribute(): void + { + if (!class_exists(McpBundle::class)) { + $this->markTestSkipped('MCP bundle is not installed'); + } + + if (!$this->isPsr17FactoryAvailable()) { + $this->markTestSkipped('PSR-17 HTTP factory implementation not available (required for MCP)'); + } + + $sessionId = $this->initializeMcpSession(); + $response = $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 2, + 'method' => 'tools/list', + ], [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ]); + + $data = $response->json(); + $tools = $data['result']['tools']; + $processMessageTool = null; + foreach ($tools as $tool) { + if ('process_message' === $tool['name']) { + $processMessageTool = $tool; + break; + } + } + + $this->assertNotNull($processMessageTool); + $this->assertEquals('process_message', $processMessageTool['name']); + $this->assertEquals('Process a message with priority', $processMessageTool['description'] ?? null); + $this->assertArrayHasKey('inputSchema', $processMessageTool); + $this->assertEquals('object', $processMessageTool['inputSchema']['type']); + + $response = $this->postJson('/mcp', [ + 'jsonrpc' => '2.0', + 'id' => 3, + 'method' => 'tools/call', + 'params' => [ + 'name' => 'process_message', + 'arguments' => [ + 'message' => 'Hello World', + 'priority' => 5, + ], + ], + ], [ + 'Accept' => 'application/json, text/event-stream', + 'Content-Type' => 'application/json', + 'mcp-session-id' => $sessionId, + ]); + + $response->assertStatus(200); + $result = $response->json(); + $this->assertArrayHasKey('result', $result); + } +} diff --git a/src/Serializer/ItemDenormalizer.php b/src/Serializer/ItemDenormalizer.php new file mode 100644 index 00000000000..288bf3e20ec --- /dev/null +++ b/src/Serializer/ItemDenormalizer.php @@ -0,0 +1,50 @@ + + * + * 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\Serializer; + +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\ResourceAccessCheckerInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Generic item denormalizer. + * + * @author Kévin Dunglas + */ +class ItemDenormalizer extends AbstractItemNormalizer +{ + use ItemNormalizerTrait; + + private readonly LoggerInterface $logger; + + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, ?LoggerInterface $logger = null, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, array $defaultContext = [], protected ?TagCollectorInterface $tagCollector = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null) + { + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $defaultContext, $resourceMetadataFactory, $resourceAccessChecker, $tagCollector, $operationResourceResolver); + + $this->logger = $logger ?: new NullLogger(); + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return false; + } +} diff --git a/src/Serializer/ItemNormalizer.php b/src/Serializer/ItemNormalizer.php index 051171bbe5d..0d683eca5da 100644 --- a/src/Serializer/ItemNormalizer.php +++ b/src/Serializer/ItemNormalizer.php @@ -13,20 +13,15 @@ namespace ApiPlatform\Serializer; -use ApiPlatform\Metadata\Exception\InvalidArgumentException; -use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\IriConverterInterface; -use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceAccessCheckerInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; -use ApiPlatform\Metadata\UrlGeneratorInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; @@ -39,6 +34,10 @@ */ class ItemNormalizer extends AbstractItemNormalizer { + use ItemNormalizerTrait { + denormalize as private doDenormalize; + } + private readonly LoggerInterface $logger; public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor = null, ?NameConverterInterface $nameConverter = null, ?ClassMetadataFactoryInterface $classMetadataFactory = null, ?LoggerInterface $logger = null, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory = null, ?ResourceAccessCheckerInterface $resourceAccessChecker = null, array $defaultContext = [], protected ?TagCollectorInterface $tagCollector = null, ?OperationResourceClassResolverInterface $operationResourceResolver = null) @@ -48,74 +47,10 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName $this->logger = $logger ?: new NullLogger(); } - /** - * {@inheritdoc} - * - * @throws NotNormalizableValueException - */ public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed { - // Avoid issues with proxies if we populated the object - if (isset($data['id']) && !isset($context[self::OBJECT_TO_POPULATE])) { - if (isset($context['api_allow_update']) && true !== $context['api_allow_update']) { - throw new NotNormalizableValueException('Update is not allowed for this operation.'); - } - - if (isset($context['resource_class'])) { - if ($this->updateObjectToPopulate($data, $context)) { - unset($data['id']); - } - } else { - // See https://github.com/api-platform/core/pull/2326 to understand this message. - $this->logger->warning('The "resource_class" key is missing from the context.', [ - 'context' => $context, - ]); - } - } - - return parent::denormalize($data, $type, $format, $context); - } - - private function updateObjectToPopulate(array $data, array &$context): bool - { - try { - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri((string) $data['id'], $context + ['fetch_data' => true]); - - return true; - } catch (InvalidArgumentException) { - $operation = $this->resourceMetadataCollectionFactory?->create($context['resource_class'])->getOperation(); - if ( - !$operation || ( - null !== ($context['uri_variables'] ?? null) - && $operation instanceof HttpOperation - && \count($operation->getUriVariables() ?? []) > 1 - ) - ) { - throw new InvalidArgumentException('Cannot find object to populate, use JSON-LD or specify an IRI at path "id".'); - } - $uriVariables = $this->getContextUriVariables($data, $operation, $context); - $iri = $this->iriConverter->getIriFromResource($context['resource_class'], UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => $uriVariables]); - - $context[self::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context + ['fetch_data' => true]); - } - - return false; - } - - private function getContextUriVariables(array $data, Operation $operation, array $context): array - { - $uriVariables = $context['uri_variables'] ?? []; - - if ($operation instanceof HttpOperation) { - $operationUriVariables = $operation->getUriVariables(); - if ((null !== $uriVariable = array_shift($operationUriVariables)) && \count($uriVariable->getIdentifiers())) { - $identifier = $uriVariable->getIdentifiers()[0]; - if (isset($data[$identifier])) { - $uriVariables[$uriVariable->getParameterName()] = $data[$identifier]; - } - } - } + trigger_deprecation('api-platform/core', '4.4', 'Calling "denormalize()" on "%s" is deprecated, use "%s" instead.', self::class, ItemDenormalizer::class); - return $uriVariables; + return $this->doDenormalize($data, $type, $format, $context); } } diff --git a/src/Serializer/ItemNormalizerTrait.php b/src/Serializer/ItemNormalizerTrait.php new file mode 100644 index 00000000000..1334f598148 --- /dev/null +++ b/src/Serializer/ItemNormalizerTrait.php @@ -0,0 +1,99 @@ + + * + * 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\Serializer; + +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\HttpOperation; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; + +/** + * Shared denormalization logic for the generic item (de)normalizer. + * + * @author Kévin Dunglas + * + * @internal + */ +trait ItemNormalizerTrait +{ + /** + * @throws NotNormalizableValueException + */ + public function denormalize(mixed $data, string $type, ?string $format = null, array $context = []): mixed + { + // Avoid issues with proxies if we populated the object + if (isset($data['id']) && !isset($context[AbstractItemNormalizer::OBJECT_TO_POPULATE])) { + if (isset($context['api_allow_update']) && true !== $context['api_allow_update']) { + throw new NotNormalizableValueException('Update is not allowed for this operation.'); + } + + if (isset($context['resource_class'])) { + if ($this->updateObjectToPopulate($data, $context)) { + unset($data['id']); + } + } else { + // See https://github.com/api-platform/core/pull/2326 to understand this message. + $this->logger->warning('The "resource_class" key is missing from the context.', [ + 'context' => $context, + ]); + } + } + + return parent::denormalize($data, $type, $format, $context); + } + + private function updateObjectToPopulate(array $data, array &$context): bool + { + try { + $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri((string) $data['id'], $context + ['fetch_data' => true]); + + return true; + } catch (InvalidArgumentException) { + $operation = $this->resourceMetadataCollectionFactory?->create($context['resource_class'])->getOperation(); + if ( + !$operation || ( + null !== ($context['uri_variables'] ?? null) + && $operation instanceof HttpOperation + && \count($operation->getUriVariables() ?? []) > 1 + ) + ) { + throw new InvalidArgumentException('Cannot find object to populate, use JSON-LD or specify an IRI at path "id".'); + } + $uriVariables = $this->getContextUriVariables($data, $operation, $context); + $iri = $this->iriConverter->getIriFromResource($context['resource_class'], UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => $uriVariables]); + + $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] = $this->iriConverter->getResourceFromIri($iri, $context + ['fetch_data' => true]); + } + + return false; + } + + private function getContextUriVariables(array $data, Operation $operation, array $context): array + { + $uriVariables = $context['uri_variables'] ?? []; + + if ($operation instanceof HttpOperation) { + $operationUriVariables = $operation->getUriVariables(); + if ((null !== $uriVariable = array_shift($operationUriVariables)) && \count($uriVariable->getIdentifiers())) { + $identifier = $uriVariable->getIdentifiers()[0]; + if (isset($data[$identifier])) { + $uriVariables[$uriVariable->getParameterName()] = $data[$identifier]; + } + } + } + + return $uriVariables; + } +} diff --git a/src/Serializer/Tests/ItemDenormalizerTest.php b/src/Serializer/Tests/ItemDenormalizerTest.php new file mode 100644 index 00000000000..9fd567ca530 --- /dev/null +++ b/src/Serializer/Tests/ItemDenormalizerTest.php @@ -0,0 +1,271 @@ + + * + * 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\Serializer\Tests; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\IriConverterInterface; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Serializer\ItemDenormalizer; +use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\Dummy; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Prophecy\PhpUnit\ProphecyTrait; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\SerializerInterface; + +class ItemDenormalizerTest extends TestCase +{ + use ProphecyTrait; + + public function testSupportsDenormalization(): void + { + $dummy = new Dummy(); + $std = new \stdClass(); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(\stdClass::class)->willReturn(false); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + + $this->assertFalse($denormalizer->supportsNormalization($dummy)); + $this->assertTrue($denormalizer->supportsDenormalization($dummy, Dummy::class)); + $this->assertFalse($denormalizer->supportsDenormalization($std, \stdClass::class)); + } + + public function testDenormalize(): void + { + $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; + + $propertyNameCollection = new PropertyNameCollection(['name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); + + $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + $denormalizer->setSerializer($serializerProphecy->reveal()); + + $this->assertInstanceOf(Dummy::class, $denormalizer->denormalize(['name' => 'hello'], Dummy::class, null, $context)); + } + + public function testDenormalizeWithIri(): void + { + $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; + + $propertyNameCollection = new PropertyNameCollection(['id', 'name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); + + $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getResourceFromIri('/dummies/12', ['resource_class' => Dummy::class, 'api_allow_update' => true, 'fetch_data' => true])->shouldBeCalled(); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + $denormalizer->setSerializer($serializerProphecy->reveal()); + + $this->assertInstanceOf(Dummy::class, $denormalizer->denormalize(['id' => '/dummies/12', 'name' => 'hello'], Dummy::class, null, $context)); + } + + public function testDenormalizeWithIdAndUpdateNotAllowed(): void + { + $this->expectException(NotNormalizableValueException::class); + $this->expectExceptionMessage('Update is not allowed for this operation.'); + + $context = ['resource_class' => Dummy::class, 'api_allow_update' => false]; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + $denormalizer->setSerializer($serializerProphecy->reveal()); + $denormalizer->denormalize(['id' => '12', 'name' => 'hello'], Dummy::class, null, $context); + } + + public function testDenormalizeWithIdAndNoResourceClass(): void + { + $context = []; + + $propertyNameCollection = new PropertyNameCollection(['id', 'name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); + + $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + $denormalizer->setSerializer($serializerProphecy->reveal()); + + $object = $denormalizer->denormalize(['id' => '42', 'name' => 'hello'], Dummy::class, null, $context); + $this->assertInstanceOf(Dummy::class, $object); + $this->assertSame('42', $object->getId()); + $this->assertSame('hello', $object->getName()); + } + + public function testDenormalizeWithWrongIdAndNoResourceMetadataFactory(): void + { + $this->expectException(InvalidArgumentException::class); + $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getResourceFromIri('fail', $context + ['fetch_data' => true])->willThrow(new InvalidArgumentException()); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal() + ); + $denormalizer->setSerializer($serializerProphecy->reveal()); + + $this->assertInstanceOf(Dummy::class, $denormalizer->denormalize(['name' => 'hello', 'id' => 'fail'], Dummy::class, null, $context)); + } + + public function testDenormalizeWithWrongId(): void + { + $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; + $operation = new Get(uriVariables: ['id' => new Link(identifiers: ['id'], parameterName: 'id')]); + $obj = new Dummy(); + + $propertyNameCollection = new PropertyNameCollection(['id', 'name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); + + $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn((new ApiProperty())->withIdentifier(true))->shouldBeCalled(); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getResourceFromIri('fail', $context + ['fetch_data' => true])->willThrow(new InvalidArgumentException()); + $iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => ['id' => 'fail']])->willReturn('/dummies/fail'); + $iriConverterProphecy->getResourceFromIri('/dummies/fail', $context + ['fetch_data' => true])->willReturn($obj); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($obj, Dummy::class)->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ + new ApiResource(operations: [$operation]), + ])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(DenormalizerInterface::class); + + $denormalizer = new ItemDenormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + $resourceMetadataCollectionFactory->reveal() + ); + $denormalizer->setSerializer($serializerProphecy->reveal()); + + $this->assertInstanceOf(Dummy::class, $denormalizer->denormalize(['name' => 'hello', 'id' => 'fail'], Dummy::class, null, $context)); + } +} diff --git a/src/Serializer/Tests/ItemNormalizerTest.php b/src/Serializer/Tests/ItemNormalizerTest.php index 3c2f06346a8..8ed4b0364b4 100644 --- a/src/Serializer/Tests/ItemNormalizerTest.php +++ b/src/Serializer/Tests/ItemNormalizerTest.php @@ -14,25 +14,19 @@ namespace ApiPlatform\Serializer\Tests; use ApiPlatform\Metadata\ApiProperty; -use ApiPlatform\Metadata\ApiResource; -use ApiPlatform\Metadata\Exception\InvalidArgumentException; -use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\IriConverterInterface; -use ApiPlatform\Metadata\Link; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Property\PropertyNameCollection; -use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; -use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; -use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Serializer\ItemNormalizer; use ApiPlatform\Serializer\Tests\Fixtures\ApiResource\Dummy; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; -use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\SerializerInterface; @@ -113,100 +107,7 @@ public function testNormalize(): void $this->assertEquals(['name' => 'hello'], $normalizer->normalize($dummy, null, ['resources' => []])); } - public function testDenormalize(): void - { - $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; - - $propertyNameCollection = new PropertyNameCollection(['name']); - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); - - $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); - - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); - $normalizer = new ItemNormalizer( - $propertyNameCollectionFactoryProphecy->reveal(), - $propertyMetadataFactoryProphecy->reveal(), - $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() - ); - $normalizer->setSerializer($serializerProphecy->reveal()); - - $this->assertInstanceOf(Dummy::class, $normalizer->denormalize(['name' => 'hello'], Dummy::class, null, $context)); - } - - public function testDenormalizeWithIri(): void - { - $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; - - $propertyNameCollection = new PropertyNameCollection(['id', 'name']); - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); - - $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getResourceFromIri('/dummies/12', ['resource_class' => Dummy::class, 'api_allow_update' => true, 'fetch_data' => true])->shouldBeCalled(); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); - - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); - - $normalizer = new ItemNormalizer( - $propertyNameCollectionFactoryProphecy->reveal(), - $propertyMetadataFactoryProphecy->reveal(), - $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() - ); - $normalizer->setSerializer($serializerProphecy->reveal()); - - $this->assertInstanceOf(Dummy::class, $normalizer->denormalize(['id' => '/dummies/12', 'name' => 'hello'], Dummy::class, null, $context)); - } - - public function testDenormalizeWithIdAndUpdateNotAllowed(): void - { - $this->expectException(NotNormalizableValueException::class); - $this->expectExceptionMessage('Update is not allowed for this operation.'); - - $context = ['resource_class' => Dummy::class, 'api_allow_update' => false]; - - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); - - $normalizer = new ItemNormalizer( - $propertyNameCollectionFactoryProphecy->reveal(), - $propertyMetadataFactoryProphecy->reveal(), - $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() - ); - $normalizer->setSerializer($serializerProphecy->reveal()); - $normalizer->denormalize(['id' => '12', 'name' => 'hello'], Dummy::class, null, $context); - } - - public function testDenormalizeWithDefinedIri(): void + public function testNormalizeWithDefinedIri(): void { $dummy = new Dummy(); $dummy->setName('hello'); @@ -245,116 +146,30 @@ public function testDenormalizeWithDefinedIri(): void $this->assertEquals(['name' => 'hello'], $normalizer->normalize($dummy, null, ['resources' => [], 'iri' => '/custom'])); } - public function testDenormalizeWithIdAndNoResourceClass(): void - { - $context = []; - - $propertyNameCollection = new PropertyNameCollection(['id', 'name']); - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); - - $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); - - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); - - $normalizer = new ItemNormalizer( - $propertyNameCollectionFactoryProphecy->reveal(), - $propertyMetadataFactoryProphecy->reveal(), - $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal() - ); - $normalizer->setSerializer($serializerProphecy->reveal()); - - $object = $normalizer->denormalize(['id' => '42', 'name' => 'hello'], Dummy::class, null, $context); - $this->assertInstanceOf(Dummy::class, $object); - $this->assertSame('42', $object->getId()); - $this->assertSame('hello', $object->getName()); - } - - public function testDenormalizeWithWrongIdAndNoResourceMetadataFactory(): void + #[Group('legacy')] + #[IgnoreDeprecations] + public function testDenormalizeIsDeprecated(): void { - $this->expectException(InvalidArgumentException::class); - $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; + $this->expectUserDeprecationMessage('Since api-platform/core 4.4: Calling "denormalize()" on "ApiPlatform\Serializer\ItemNormalizer" is deprecated, use "ApiPlatform\Serializer\ItemDenormalizer" instead.'); + $this->expectException(NotNormalizableValueException::class); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getResourceFromIri('fail', $context + ['fetch_data' => true])->willThrow(new InvalidArgumentException()); - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); $normalizer = new ItemNormalizer( $propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal() ); - $normalizer->setSerializer($serializerProphecy->reveal()); - - $this->assertInstanceOf(Dummy::class, $normalizer->denormalize(['name' => 'hello', 'id' => 'fail'], Dummy::class, null, $context)); - } - - public function testDenormalizeWithWrongId(): void - { - $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; - $operation = new Get(uriVariables: ['id' => new Link(identifiers: ['id'], parameterName: 'id')]); - $obj = new Dummy(); - - $propertyNameCollection = new PropertyNameCollection(['id', 'name']); - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn($propertyNameCollection)->shouldBeCalled(); - - $propertyMetadata = (new ApiProperty())->withReadable(true)->withWritable(true); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn($propertyMetadata)->shouldBeCalled(); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::type('array'))->willReturn((new ApiProperty())->withIdentifier(true))->shouldBeCalled(); - - $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); - $iriConverterProphecy->getResourceFromIri('fail', $context + ['fetch_data' => true])->willThrow(new InvalidArgumentException()); - $iriConverterProphecy->getIriFromResource(Dummy::class, UrlGeneratorInterface::ABS_PATH, $operation, ['uri_variables' => ['id' => 'fail']])->willReturn('/dummies/fail'); - $iriConverterProphecy->getResourceFromIri('/dummies/fail', $context + ['fetch_data' => true])->willReturn($obj); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->getResourceClass(null, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->getResourceClass($obj, Dummy::class)->willReturn(Dummy::class); - $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); - - $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - $resourceMetadataCollectionFactory->create(Dummy::class)->willReturn(new ResourceMetadataCollection(Dummy::class, [ - new ApiResource(operations: [$operation]), - ])); - $serializerProphecy = $this->prophesize(SerializerInterface::class); - $serializerProphecy->willImplement(DenormalizerInterface::class); - $normalizer = new ItemNormalizer( - $propertyNameCollectionFactoryProphecy->reveal(), - $propertyMetadataFactoryProphecy->reveal(), - $iriConverterProphecy->reveal(), - $resourceClassResolverProphecy->reveal(), - null, + $normalizer->denormalize( + ['id' => '12', 'name' => 'hello'], + Dummy::class, null, - null, - null, - $resourceMetadataCollectionFactory->reveal() + ['resource_class' => Dummy::class, 'api_allow_update' => false] ); - $normalizer->setSerializer($serializerProphecy->reveal()); - - $this->assertInstanceOf(Dummy::class, $normalizer->denormalize(['name' => 'hello', 'id' => 'fail'], Dummy::class, null, $context)); } } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 01c696c4e74..57b2cdfaac3 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -703,6 +703,9 @@ private function registerJsonApiConfiguration(ContainerBuilder $container, array $container->getDefinition('api_platform.jsonapi.normalizer.item') ->addArgument($config['jsonapi']['use_iri_as_id']); + + $container->getDefinition('api_platform.jsonapi.denormalizer.item') + ->addArgument($config['jsonapi']['use_iri_as_id']); } private function registerJsonLdHydraConfiguration(ContainerBuilder $container, array $formats, PhpFileLoader $loader, array $config): void diff --git a/src/Symfony/Bundle/Resources/config/api.php b/src/Symfony/Bundle/Resources/config/api.php index c1685a0c4ae..19c91120836 100644 --- a/src/Symfony/Bundle/Resources/config/api.php +++ b/src/Symfony/Bundle/Resources/config/api.php @@ -29,6 +29,7 @@ use ApiPlatform\Serializer\ConstraintViolationListNormalizer; use ApiPlatform\Serializer\Filter\GroupFilter; use ApiPlatform\Serializer\Filter\PropertyFilter; +use ApiPlatform\Serializer\ItemDenormalizer; use ApiPlatform\Serializer\ItemNormalizer; use ApiPlatform\Serializer\Mapping\Factory\ClassMetadataFactory; use ApiPlatform\Serializer\Mapping\Loader\PropertyMetadataLoader; @@ -138,6 +139,24 @@ ]) ->tag('serializer.normalizer', ['priority' => -895]); + $services->set('api_platform.serializer.denormalizer.item', ItemDenormalizer::class) + ->args([ + service('api_platform.metadata.property.name_collection_factory'), + service('api_platform.metadata.property.metadata_factory'), + service('api_platform.iri_converter'), + service('api_platform.resource_class_resolver'), + service('api_platform.property_accessor'), + service('api_platform.name_converter')->ignoreOnInvalid(), + service('serializer.mapping.class_metadata_factory')->ignoreOnInvalid(), + null, + service('api_platform.metadata.resource.metadata_collection_factory')->ignoreOnInvalid(), + service('api_platform.security.resource_access_checker')->ignoreOnInvalid(), + [], + service('api_platform.http_cache.tag_collector')->ignoreOnInvalid(), + service('api_platform.serializer.operation_resource_resolver'), + ]) + ->tag('serializer.normalizer', ['priority' => -894]); + $services->set('api_platform.normalizer.object', ObjectNormalizer::class) ->args([ service('serializer.mapping.class_metadata_factory'), diff --git a/src/Symfony/Bundle/Resources/config/elasticsearch.php b/src/Symfony/Bundle/Resources/config/elasticsearch.php index 212fa22343b..b1fba6f6f1b 100644 --- a/src/Symfony/Bundle/Resources/config/elasticsearch.php +++ b/src/Symfony/Bundle/Resources/config/elasticsearch.php @@ -36,6 +36,10 @@ ->decorate('api_platform.serializer.normalizer.item', null, 0) ->args([service('api_platform.elasticsearch.normalizer.item.inner')]); + $services->set('api_platform.elasticsearch.denormalizer.item', ItemNormalizer::class) + ->decorate('api_platform.serializer.denormalizer.item', null, 0) + ->args([service('api_platform.elasticsearch.denormalizer.item.inner')]); + $services->set('api_platform.elasticsearch.normalizer.document', DocumentNormalizer::class) ->args([ service('api_platform.metadata.resource.metadata_collection_factory'), diff --git a/src/Symfony/Bundle/Resources/config/graphql.php b/src/Symfony/Bundle/Resources/config/graphql.php index 0453cc84485..4ed76ada9f5 100644 --- a/src/Symfony/Bundle/Resources/config/graphql.php +++ b/src/Symfony/Bundle/Resources/config/graphql.php @@ -24,6 +24,7 @@ use ApiPlatform\GraphQl\Serializer\Exception\HttpExceptionNormalizer; use ApiPlatform\GraphQl\Serializer\Exception\RuntimeExceptionNormalizer; use ApiPlatform\GraphQl\Serializer\Exception\ValidationExceptionNormalizer; +use ApiPlatform\GraphQl\Serializer\ItemDenormalizer; use ApiPlatform\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\GraphQl\Serializer\ObjectNormalizer; use ApiPlatform\GraphQl\Serializer\SerializerContextBuilder; @@ -250,6 +251,21 @@ ]) ->tag('serializer.normalizer', ['priority' => -890]); + $services->set('api_platform.graphql.denormalizer.item', ItemDenormalizer::class) + ->args([ + service('api_platform.metadata.property.name_collection_factory'), + service('api_platform.metadata.property.metadata_factory'), + service('api_platform.symfony.iri_converter'), + service('api_platform.resource_class_resolver'), + service('api_platform.property_accessor'), + service('api_platform.name_converter')->ignoreOnInvalid(), + service('serializer.mapping.class_metadata_factory')->ignoreOnInvalid(), + [], + service('api_platform.metadata.resource.metadata_collection_factory')->ignoreOnInvalid(), + service('api_platform.security.resource_access_checker')->ignoreOnInvalid(), + ]) + ->tag('serializer.normalizer', ['priority' => -889]); + $services->set('api_platform.graphql.normalizer.object', ObjectNormalizer::class) ->args([ service('api_platform.normalizer.object'), diff --git a/src/Symfony/Bundle/Resources/config/jsonapi.php b/src/Symfony/Bundle/Resources/config/jsonapi.php index 6ad6d49ab4c..0270591f0d0 100644 --- a/src/Symfony/Bundle/Resources/config/jsonapi.php +++ b/src/Symfony/Bundle/Resources/config/jsonapi.php @@ -18,6 +18,7 @@ use ApiPlatform\JsonApi\Serializer\ConstraintViolationListNormalizer; use ApiPlatform\JsonApi\Serializer\EntrypointNormalizer; use ApiPlatform\JsonApi\Serializer\ErrorNormalizer; +use ApiPlatform\JsonApi\Serializer\ItemDenormalizer; use ApiPlatform\JsonApi\Serializer\ItemNormalizer; use ApiPlatform\JsonApi\Serializer\ObjectNormalizer; use ApiPlatform\JsonApi\Serializer\ReservedAttributeNameConverter; @@ -77,6 +78,23 @@ ]) ->tag('serializer.normalizer', ['priority' => -890]); + $services->set('api_platform.jsonapi.denormalizer.item', ItemDenormalizer::class) + ->args([ + service('api_platform.metadata.property.name_collection_factory'), + service('api_platform.metadata.property.metadata_factory'), + service('api_platform.iri_converter'), + service('api_platform.resource_class_resolver'), + service('api_platform.property_accessor'), + service('api_platform.jsonapi.name_converter.reserved_attribute_name'), + service('serializer.mapping.class_metadata_factory')->ignoreOnInvalid(), + [], + service('api_platform.metadata.resource.metadata_collection_factory'), + service('api_platform.security.resource_access_checker')->ignoreOnInvalid(), + service('api_platform.http_cache.tag_collector')->ignoreOnInvalid(), + service('api_platform.serializer.operation_resource_resolver'), + ]) + ->tag('serializer.normalizer', ['priority' => -889]); + $services->set('api_platform.jsonapi.normalizer.object', ObjectNormalizer::class) ->args([ service('api_platform.normalizer.object'), diff --git a/src/Symfony/Bundle/Resources/config/jsonld.php b/src/Symfony/Bundle/Resources/config/jsonld.php index 33859c30723..2bfc88269a5 100644 --- a/src/Symfony/Bundle/Resources/config/jsonld.php +++ b/src/Symfony/Bundle/Resources/config/jsonld.php @@ -15,6 +15,7 @@ use ApiPlatform\JsonLd\ContextBuilder; use ApiPlatform\JsonLd\Serializer\ErrorNormalizer; +use ApiPlatform\JsonLd\Serializer\ItemDenormalizer; use ApiPlatform\JsonLd\Serializer\ItemNormalizer; use ApiPlatform\JsonLd\Serializer\ObjectNormalizer; use ApiPlatform\Serializer\JsonEncoder; @@ -54,6 +55,23 @@ ]) ->tag('serializer.normalizer', ['priority' => -890]); + $services->set('api_platform.jsonld.denormalizer.item', ItemDenormalizer::class) + ->args([ + service('api_platform.metadata.resource.metadata_collection_factory'), + service('api_platform.metadata.property.name_collection_factory'), + service('api_platform.metadata.property.metadata_factory'), + service('api_platform.iri_converter'), + service('api_platform.resource_class_resolver'), + service('api_platform.property_accessor'), + service('api_platform.name_converter')->ignoreOnInvalid(), + service('serializer.mapping.class_metadata_factory')->ignoreOnInvalid(), + '%api_platform.serializer.default_context%', + service('api_platform.security.resource_access_checker')->ignoreOnInvalid(), + service('api_platform.http_cache.tag_collector')->ignoreOnInvalid(), + service('api_platform.serializer.operation_resource_resolver'), + ]) + ->tag('serializer.normalizer', ['priority' => -889]); + $services->set('api_platform.jsonld.normalizer.error', ErrorNormalizer::class) ->args([ service('api_platform.jsonld.normalizer.item'), From 1d7695d83736ba763e1a0e2ba7c799975594e549 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 11 May 2026 15:35:10 +0200 Subject: [PATCH 09/84] test(jsonld,hydra,hal): migrate behat features to ApiTestCase (#7957) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(jsonld): migrate trivial behat features to ApiTestCase Replaces 9 jsonld behat features with ApiTestCase functional tests backed by isolated static-provider fixtures (no Doctrine entities, except PropertyCollectionIriOnlyTest which keeps the existing entities to preserve Link/uriVariables semantics). Covers disable_id_generation, no_output, getter_setter_renaming, interface_as_resource, interface_dto_output, max_depth, json_serializable, iri_only and drops the debug-only inheritance scenarios. * test(jsonld): migrate context/non-resource and abs/net URL features Replaces context.feature (entrypoint, resource, embed-relation, extended jsonldContext), non_resource.feature (genid, sparse fieldsets, DateTime, plain object, non-resource relation), absolute_url.feature, network_path.feature and hydra/absolute.feature (paged hydra:view). Per-operation urlGenerationStrategy replaces the legacy app-level configuration; new fixtures use static providers and ArrayPaginator. * test(hydra): migrate entrypoint, error and item_uri_template features Replaces the entrypoint, error and item_uri_template hydra behat features with ApiTestCase functional tests. Error scenarios use ValidationException + BadRequestHttpException via dedicated processors; item_uri_template covers cars/brands plus the existing issue5662 and CollectionReferencingItem fixtures registered through SetupClassResources. * test(hydra): migrate collection, docs and input_output features Replaces the remaining hydra/jsonld behat features: * hydra/collection.feature: pagination, filters, partial pagination, no-prefix mode and cursor pagination (cursor variant uses SoMany). * hydra/docs.feature: simplified to assert vocabulary, supportedClass, property/operation surface and deprecations on a dedicated fixture. * jsonld/input_output.feature: custom input/output DTO, output: false, input: false, full input/output cycle. Drops legacy @v3 / messenger / DataTransformer scenarios that no longer reflect 4.x semantics. * test(jsonld): close coverage gaps left by behat migration * abs/network URL tests now run against http://example.com via base_uri override, matching the original behat host. * HydraDocsTest exercises the original assertions on a richer fixture: subClassOf via types, range/maxCardinality on relations, schema:FindAction type on GET, per-method hydra:title and description, deprecation propagating from a deprecated resource into the entrypoint. * InputOutputDtoTest restores the three legacy @v3 collection-DTO scenarios (genid for resource without id, custom itemUriTemplate, identifier-bearing resource still falling back to genid when no item operation exists). * New EntityClassWithDateTimeTest covers JSON-LD on a resource backed by a Doctrine entity through stateOptions. * HydraCollectionTest now uses a partial-only paginator implementation, asserting that hydra:view drops first/last and keeps next/previous. * RenamedGetterSetterTest restores the exact @id assertion (collection URI fallback when the resource has no identifier). * chore(tests): remove fixtures orphaned by jsonld behat migration EntityWithRenamedGetterAndSetter, EntityWithDtoOutput, JsonldContextDummy and MaxDepthEagerDummy (entity + document) were only referenced by the deleted behat features. Other fixtures touched by the migration (IriOnlyDummy, AbsoluteUrlDummy, NetworkPathDummy, MaxDepthDummy, ContainNonResource, PlainObjectDummy, NonRelationResource, DisableIdGeneration) are still referenced from DoctrineContext, the JSON Schema command test, the jsonapi feature suite or HAL tests, so they stay. * chore(tests): fix references orphaned by jsonld behat migration DtoOutput was defined inside EntityWithDtoOutput.php (deleted in fbfe043d0); src/JsonSchema/Tests/DefinitionNameFactoryTest.php still imports it as a class-string for definition-name generation. Re-adds a minimal class. Also narrows InterfaceTaxonImpl::getCode() to non-nullable string (covariant) which phpstan now catches. * test(jsonld): fix CI failures from behat migration Two fixes for the JsonLd functional tests added yesterday: * Consolidate dual #[ApiResource] declarations sharing the same shortName on AbsoluteUrlChild, NetworkPathResource and UriTemplateCar into a single attribute with all operations. Avoids the 4.2-deprecated duplicate-shortName path that was failing the no-deprecation and Symfony-dev PHPUnit jobs. * Skip MongoDB-only paths in ItemUriTemplateHydraTest and the UserResource branch of InputOutputDtoTest where the underlying fixtures live in the Entity namespace and aren't loaded under the Document kernel; skip InputOutputDtoTest::testCreateNoInputResource in event-listener mode where PlaceholderAction cannot resolve $data for input:false POST. * test(hal): migrate behat features to ApiTestCase Replaces the eleven hal behat features with ApiTestCase functional tests under tests/Functional/Hal, mirroring the jsonld/hydra migration done yesterday. Most use static-provider fixtures; PropertyCollectionIriOnly and TableInheritance keep the existing entity fixtures (still referenced by features/main/table_inheritance.feature). MaxDepthEagerDummy was already deleted in fbfe043d0 — this restores coverage for that scenario under HalMaxDepth. With features/hal, features/jsonld and features/hydra all gone, the ld-api-hal-hydra shard reduced to features/jsonapi alone; renames the shard accordingly. * cs: enforce yoda style in JsonLd CollectionPagedResource * chore(tests): move root JSON-LD tests into JsonLd/ subdir * HydraTest -> JsonLd/HydraHideFromDocsTest (descriptive name reflects what the single test asserts: hiding hydra:supportedClass and operations) * LinkedDataPlatformTest -> JsonLd/LinkedDataPlatformTest (LDP is JSON-LD) * ItemUriTemplateTest -> JsonLd/ItemUriTemplateNotFoundTest (rename testIssue6718 to testNotFoundOnInvalidItemUriTemplateRelation) * chore(tests): split monolithic JsonLdTest into per-feature files Replace the 9-method root JsonLdTest.php with 6 focused JsonLd/* test files, one per feature: * InputDtoIriDenormalizationTest (input DTO with IRI relation) * ContextOutputTest (output DTO @context shape, ignored properties) * GenIdFalseTest (genId:false at resource and nested levels) * PolymorphicResourceCollectionTest (per-item @type in collections) * ItemUriTemplateCollectionTest (itemUriTemplate as @id, with stateOptions) * MultiResourceContextTest (correct shortName per ApiResource variant) Each file scopes its setUp/recreateSchema to only the entities it uses, and replaces issue-numbered method names with descriptive ones. * chore(tests): split monolithic JsonApiTest into JsonApi/ subdir Replace the 8-method root JsonApiTest.php with 4 focused JsonApi/* test files, mirroring the JsonLd/ and Hal/ subdir layout: * JsonApi/ErrorTest (ErrorResource rendered in vnd.api+json) * JsonApi/IdentifierModeTest (4 use_iri_as_id:false tests sharing the bootJsonApiKernel helper) * JsonApi/IriModeTest (default use_iri_as_id:true mode) * JsonApi/InputDtoTest (POST with input DTOs, preserved attributes and required constructor args) Each file scopes its fixtures to only what its tests use. * test(security): migrate authorization behat features to ApiTestCase Replaces features/authorization/{deny,legacy_deny}.feature with functional tests under tests/Functional/Authorization, preserving full scenario coverage. Each @link_security scenario lives in its own test so the positive and negative paths are asserted independently. Drops features/authorization from the misc behat shard; the SecuredDummy-with-related-dummies fixture step stays since features/graphql/authorization.feature still uses it. * test(jsonapi): migrate behat features to ApiTestCase Replaces 13 jsonapi behat features with ApiTestCase functional tests backed by static-provider fixtures (CollectionUriTemplateTest reuses the existing PropertyCollectionIriOnly entities to preserve Link semantics). Covers errors, absolute/network URLs, item/collection URI templates, circular refs, non-resource handling, DTO output, ordering, filtering with sparse fieldsets, pagination, entrypoint, CRUD with relationships, and include= related-resources resolution. * test(jsonapi): back CRUD and inclusion tests with Doctrine persistence CrudTest now uses Dummy/RelatedDummy/ThirdLevel/RelationEmbedder entities through recreateSchema/persist/flush instead of static processors that fabricated ids, matching the authorization migration pattern. RelatedResourcesInclusionTest grows from 5 partial scenarios to all 15 original behat scenarios (many-to-one, many-to-many, dedup, path-based, collection variants) backed by DummyProperty/DummyGroup/ FourthLevel/RelatedOwningDummy. The 6 now-unused static JsonApi fixtures are dropped. * chore(ci): fix jsonapi migration follow-ups * Drop empty `jsonapi` behat shard from CI matrix (features/jsonapi was deleted in 0bf7af524). * PHPStan: replace `is_int($offset)` always-true narrowing with an upfront page-range guard in PaginationDummy. * CS: prefix `array_slice` with `\` in OrderingTest per project style. * test(security): skip DenyTest ORM-id-bound scenarios on MongoDB Seven DenyTest cases assume the freshly-seeded SecuredDummy gets id=1 on every run. The MongoDB ODM INCREMENT strategy keeps its counter outside the dropped document collection, so subsequent test runs re-target a non-existent id and the API returns 404 instead of the expected 403 / 200. Skip them on MongoDB to match the existing isMongoDB() pattern used by the link-security tests in this file. Also baseline the multi-shortName deprecation triggered by RelatedLinkedDummy (used only by DenyTest now) so the phpunit-no-deprecations CI job stays green. * test(security): modernize DenyTest fixtures, drop INCREMENT id assumptions * RelatedLinkedDummy (Entity + Document) gives each #[ApiResource] a distinct shortName so the multi-shortName 4.2 deprecation no longer fires. The baseline entry added in 8f0cb0909 is reverted. * DenyTest / LegacyDenyTest no longer hard-code /secured_dummies/1: POSTs now return the IRI which subsequent GET/PUT calls consume. seedLinkedDummy switches to API POST and returns the created ids, so the helper works on both ORM and ODM. This removes seven isMongoDB() skips and the failures from the MongoDB CI job (the ODM INCREMENT counter survives collection drops and never gives id=1 twice in a row). Verified locally against both APP_ENV=test and APP_ENV=mongodb. * test(hal,jsonld): close remaining behat migration coverage gaps Add PUT/PATCH on HAL relation embedder, PUT on HAL max-depth resource, JSON-LD messenger and DataTransformerInitializer scenarios. Restore the PropertyFilter on JsonLdNonResourceContainer (lost during migration) and tighten the sparse-fieldset assertion so excluded keys are checked. * test(jsonld): cover Issue5438 inheritance IRIs The deleted features/jsonld/inheritance.feature only had "print last JSON response" steps. Replace with a real assertion that confirms each collection member uses its concrete subtype's URI template (Contractor5438 → /contractor_5438/{id}, Employee5438 → /employee_5438/{id}) — the original behavior fixed in #5449. --- .github/workflows/ci.yml | 4 +- features/authorization/deny.feature | 319 ---- features/authorization/legacy_deny.feature | 96 - features/hal/absolute_url.feature | 119 -- features/hal/collection.feature | 637 ------- features/hal/collection_uri_template.feature | 81 - features/hal/hal.feature | 224 --- features/hal/input_output.feature | 48 - features/hal/item_uri_template.feature | 128 -- features/hal/max_depth.feature | 69 - features/hal/network_path.feature | 117 -- features/hal/non_resource.feature | 45 - features/hal/problem.feature | 52 - features/hal/table_inheritance.feature | 141 -- features/hydra/absolute.feature | 23 - features/hydra/collection.feature | 596 ------ features/hydra/docs.feature | 84 - features/hydra/entrypoint.feature | 30 - features/hydra/error.feature | 138 -- features/hydra/item_uri_template.feature | 237 --- features/jsonapi/absolute_url.feature | 125 -- .../jsonapi/collection_attributes.feature | 20 - .../jsonapi/collection_uri_template.feature | 60 - features/jsonapi/errors.feature | 63 - features/jsonapi/filtering.feature | 51 - features/jsonapi/input_output.feature | 59 - features/jsonapi/item_uri_template.feature | 200 -- features/jsonapi/jsonapi.feature | 257 --- features/jsonapi/network_path.feature | 125 -- features/jsonapi/non_resource.feature | 126 -- features/jsonapi/ordering.feature | 144 -- features/jsonapi/pagination.feature | 42 - .../related-resouces-inclusion.feature | 1637 ----------------- features/jsonld/absolute_url.feature | 83 - features/jsonld/context.feature | 88 - features/jsonld/disable_id_generation.feature | 9 - .../jsonld/getter_setter_renaming.feature | 25 - features/jsonld/inheritance.feature | 12 - features/jsonld/input_output.feature | 454 ----- features/jsonld/interface_as_resource.feature | 57 - features/jsonld/interface_dto_output.feature | 11 - features/jsonld/iri_only.feature | 95 - features/jsonld/json_serializable.feature | 72 - features/jsonld/max_depth.feature | 43 - features/jsonld/network_path.feature | 86 - features/jsonld/no_output.feature | 10 - features/jsonld/non_resource.feature | 145 -- .../TestBundle/ApiResource/DtoOutput.php | 21 + .../ApiResource/EntityWithDtoOutput.php | 71 - .../ApiResource/Hal/AbsoluteUrlChild.php | 78 + .../ApiResource/Hal/AbsoluteUrlParent.php | 60 + .../Hal/CollectionPagedResource.php | 122 ++ .../ApiResource/Hal/CustomOutputResource.php | 65 + .../ApiResource/Hal/HalRelatedResource.php | 49 + .../ApiResource/Hal/HalThirdLevel.php | 45 + .../ApiResource/Hal/MaxDepthResource.php | 79 + .../ApiResource/Hal/NetworkPathParent.php | 57 + .../ApiResource/Hal/NetworkPathResource.php | 78 + .../ApiResource/Hal/NonResourceContainer.php | 69 + .../ApiResource/Hal/ProblemRelation.php | 45 + .../ApiResource/Hal/ProblemResource.php | 46 + .../ApiResource/Hal/RelationEmbedder.php | 86 + .../ApiResource/Hal/UriTemplateCar.php | 85 + .../ApiResource/JsonApi/AbsoluteUrlDummy.php | 68 + .../JsonApi/AbsoluteUrlRelationDummy.php | 61 + .../ApiResource/JsonApi/CircularReference.php | 60 + .../JsonApi/CustomOutputResource.php | 66 + .../ApiResource/JsonApi/EntrypointDummy.php | 54 + .../ApiResource/JsonApi/ErrorProblem.php | 60 + .../ApiResource/JsonApi/FilteringDummy.php | 75 + .../ApiResource/JsonApi/FilteringProperty.php | 56 + .../ApiResource/JsonApi/NetworkPathDummy.php | 68 + .../JsonApi/NetworkPathRelationDummy.php | 61 + .../JsonApi/NonRelationResource.php | 59 + .../JsonApi/NonResourceContainer.php | 70 + .../ApiResource/JsonApi/OrderingDummy.php | 75 + .../ApiResource/JsonApi/PaginationDummy.php | 71 + .../JsonApi/PlainObjectResource.php | 59 + .../ApiResource/JsonApi/UriTemplateCar.php | 86 + .../JsonLd/AbsolutePagedResource.php | 51 + .../ApiResource/JsonLd/AbsoluteUrlChild.php | 78 + .../ApiResource/JsonLd/AbsoluteUrlParent.php | 60 + .../ApiResource/JsonLd/CollectionNoPrefix.php | 44 + .../JsonLd/CollectionPagedResource.php | 124 ++ .../JsonLd/CustomInputResource.php | 74 + .../JsonLd/CustomOutputResource.php | 65 + .../JsonLd/DateTimeOnlyResource.php | 46 + .../JsonLd/DisableIdGenAnonymous.php | 47 + .../ApiResource/JsonLd/DummyCollectionDto.php | 54 + .../JsonLd/DummyFooCollectionDto.php | 69 + .../JsonLd/DummyIdCollectionDto.php | 62 + .../ApiResource/JsonLd/GenIdFalseProperty.php | 58 + .../JsonLd/HydraDocsDeprecated.php | 50 + .../ApiResource/JsonLd/HydraDocsRelated.php | 49 + .../ApiResource/JsonLd/HydraDocsResource.php | 71 + .../ApiResource/JsonLd/HydraErrorResource.php | 70 + .../JsonLd/InputOutputResource.php | 117 ++ .../JsonLd/InterfaceDtoOutputResource.php | 63 + .../ApiResource/JsonLd/InterfaceTaxon.php | 55 + .../JsonLd/InterfaceTaxonProduct.php | 50 + .../ApiResource/JsonLd/IriOnlyResource.php | 63 + .../ApiResource/JsonLd/JsonLdContextDummy.php | 70 + .../JsonLd/JsonLdContextRelation.php | 37 + .../JsonLd/JsonSerializableResource.php | 103 ++ .../ApiResource/JsonLd/MaxDepthResource.php | 58 + .../ApiResource/JsonLd/NetworkPathParent.php | 57 + .../JsonLd/NetworkPathResource.php | 78 + .../ApiResource/JsonLd/NoInputResource.php | 81 + .../ApiResource/JsonLd/NoOutputMessage.php | 40 + .../JsonLd/NonRelationResource.php | 58 + .../JsonLd/NonResourceContainer.php | 72 + .../ApiResource/JsonLd/PaginationCapped.php | 43 + .../JsonLd/PlainObjectResource.php | 58 + .../JsonLd/PostNoOutputResource.php | 39 + .../RenamedGetterSetter.php} | 19 +- .../ApiResource/JsonLd/UriTemplateCar.php | 85 + .../Document/JsonldContextDummy.php | 56 - .../Document/MaxDepthEagerDummy.php | 43 - .../Document/RelatedLinkedDummy.php | 5 +- .../TestBundle/Entity/DummyProblem.php | 1 - .../TestBundle/Entity/JsonldContextDummy.php | 58 - .../TestBundle/Entity/MaxDepthEagerDummy.php | 45 - .../TestBundle/Entity/RelatedLinkedDummy.php | 5 +- .../State/JsonLdPaginationCappedProvider.php | 39 + tests/Fixtures/app/config/config_common.yml | 6 + tests/Functional/Authorization/DenyTest.php | 573 ++++++ .../Authorization/LegacyDenyTest.php | 159 ++ tests/Functional/Hal/AbsoluteUrlTest.php | 85 + tests/Functional/Hal/CollectionTest.php | 167 ++ tests/Functional/Hal/HalTest.php | 120 ++ tests/Functional/Hal/InputOutputDtoTest.php | 56 + tests/Functional/Hal/ItemUriTemplateTest.php | 69 + tests/Functional/Hal/MaxDepthTest.php | 94 + tests/Functional/Hal/NetworkPathTest.php | 84 + tests/Functional/Hal/NonResourceTest.php | 50 + tests/Functional/Hal/ProblemTest.php | 77 + .../Hal/PropertyCollectionIriOnlyTest.php | 89 + tests/Functional/Hal/TableInheritanceTest.php | 165 ++ tests/Functional/JsonApi/AbsoluteUrlTest.php | 100 + .../JsonApi/CollectionAttributesTest.php | 47 + .../JsonApi/CollectionUriTemplateTest.php | 108 ++ tests/Functional/JsonApi/CrudTest.php | 362 ++++ tests/Functional/JsonApi/EntrypointTest.php | 56 + tests/Functional/JsonApi/ErrorTest.php | 117 ++ tests/Functional/JsonApi/FilteringTest.php | 89 + .../Functional/JsonApi/IdentifierModeTest.php | 149 ++ tests/Functional/JsonApi/InputDtoTest.php | 100 + tests/Functional/JsonApi/InputOutputTest.php | 65 + tests/Functional/JsonApi/IriModeTest.php | 52 + .../JsonApi/ItemUriTemplateTest.php | 98 + tests/Functional/JsonApi/NetworkPathTest.php | 98 + tests/Functional/JsonApi/NonResourceTest.php | 132 ++ tests/Functional/JsonApi/OrderingTest.php | 63 + tests/Functional/JsonApi/PaginationTest.php | 83 + .../JsonApi/RelatedResourcesInclusionTest.php | 591 ++++++ tests/Functional/JsonApiTest.php | 270 --- .../JsonLd/AbsolutePaginationTest.php | 48 + tests/Functional/JsonLd/AbsoluteUrlTest.php | 89 + tests/Functional/JsonLd/ContextOutputTest.php | 51 + tests/Functional/JsonLd/ContextTest.php | 115 ++ .../JsonLd/CursorPaginationTest.php | 104 ++ .../JsonLd/DisableIdGenerationTest.php | 41 + .../JsonLd/EntityClassWithDateTimeTest.php | 59 + tests/Functional/JsonLd/EntrypointTest.php | 46 + tests/Functional/JsonLd/GenIdFalseTest.php | 53 + .../Functional/JsonLd/HydraCollectionTest.php | 205 +++ tests/Functional/JsonLd/HydraDocsTest.php | 210 +++ tests/Functional/JsonLd/HydraErrorTest.php | 158 ++ .../HydraHideFromDocsTest.php} | 9 +- .../Functional/JsonLd/InheritanceIriTest.php | 64 + .../Functional/JsonLd/InitializeInputTest.php | 65 + .../JsonLd/InputDtoIriDenormalizationTest.php | 69 + .../Functional/JsonLd/InputOutputDtoTest.php | 277 +++ .../JsonLd/InterfaceAsResourceTest.php | 60 + .../JsonLd/InterfaceDtoOutputTest.php | 44 + tests/Functional/JsonLd/IriOnlyTest.php | 80 + .../JsonLd/ItemUriTemplateCollectionTest.php | 112 ++ .../JsonLd/ItemUriTemplateHydraTest.php | 176 ++ .../ItemUriTemplateNotFoundTest.php} | 6 +- .../JsonLd/JsonSerializableTest.php | 67 + .../{ => JsonLd}/LinkedDataPlatformTest.php | 2 +- tests/Functional/JsonLd/MaxDepthTest.php | 69 + tests/Functional/JsonLd/MessengerTest.php | 73 + .../JsonLd/MultiResourceContextTest.php | 67 + tests/Functional/JsonLd/NetworkPathTest.php | 104 ++ tests/Functional/JsonLd/NoOutputTest.php | 44 + tests/Functional/JsonLd/NonResourceTest.php | 152 ++ .../PolymorphicResourceCollectionTest.php | 55 + .../JsonLd/PropertyCollectionIriOnlyTest.php | 98 + .../JsonLd/RenamedGetterSetterTest.php | 49 + tests/Functional/JsonLdTest.php | 283 --- 191 files changed, 11410 insertions(+), 8130 deletions(-) delete mode 100644 features/authorization/deny.feature delete mode 100644 features/authorization/legacy_deny.feature delete mode 100644 features/hal/absolute_url.feature delete mode 100644 features/hal/collection.feature delete mode 100644 features/hal/collection_uri_template.feature delete mode 100644 features/hal/hal.feature delete mode 100644 features/hal/input_output.feature delete mode 100644 features/hal/item_uri_template.feature delete mode 100644 features/hal/max_depth.feature delete mode 100644 features/hal/network_path.feature delete mode 100644 features/hal/non_resource.feature delete mode 100644 features/hal/problem.feature delete mode 100644 features/hal/table_inheritance.feature delete mode 100644 features/hydra/absolute.feature delete mode 100644 features/hydra/collection.feature delete mode 100644 features/hydra/docs.feature delete mode 100644 features/hydra/entrypoint.feature delete mode 100644 features/hydra/error.feature delete mode 100644 features/hydra/item_uri_template.feature delete mode 100644 features/jsonapi/absolute_url.feature delete mode 100644 features/jsonapi/collection_attributes.feature delete mode 100644 features/jsonapi/collection_uri_template.feature delete mode 100644 features/jsonapi/errors.feature delete mode 100644 features/jsonapi/filtering.feature delete mode 100644 features/jsonapi/input_output.feature delete mode 100644 features/jsonapi/item_uri_template.feature delete mode 100644 features/jsonapi/jsonapi.feature delete mode 100644 features/jsonapi/network_path.feature delete mode 100644 features/jsonapi/non_resource.feature delete mode 100644 features/jsonapi/ordering.feature delete mode 100644 features/jsonapi/pagination.feature delete mode 100644 features/jsonapi/related-resouces-inclusion.feature delete mode 100644 features/jsonld/absolute_url.feature delete mode 100644 features/jsonld/context.feature delete mode 100644 features/jsonld/disable_id_generation.feature delete mode 100644 features/jsonld/getter_setter_renaming.feature delete mode 100644 features/jsonld/inheritance.feature delete mode 100644 features/jsonld/input_output.feature delete mode 100644 features/jsonld/interface_as_resource.feature delete mode 100644 features/jsonld/interface_dto_output.feature delete mode 100644 features/jsonld/iri_only.feature delete mode 100644 features/jsonld/json_serializable.feature delete mode 100644 features/jsonld/max_depth.feature delete mode 100644 features/jsonld/network_path.feature delete mode 100644 features/jsonld/no_output.feature delete mode 100644 features/jsonld/non_resource.feature create mode 100644 tests/Fixtures/TestBundle/ApiResource/DtoOutput.php delete mode 100644 tests/Fixtures/TestBundle/ApiResource/EntityWithDtoOutput.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/AbsoluteUrlChild.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/AbsoluteUrlParent.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/CollectionPagedResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/CustomOutputResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/HalRelatedResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/HalThirdLevel.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/MaxDepthResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/NetworkPathParent.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/NetworkPathResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/NonResourceContainer.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/ProblemRelation.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/ProblemResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/RelationEmbedder.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/Hal/UriTemplateCar.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/AbsoluteUrlDummy.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/AbsoluteUrlRelationDummy.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/CircularReference.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/CustomOutputResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/EntrypointDummy.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/ErrorProblem.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/FilteringDummy.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/FilteringProperty.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/NetworkPathDummy.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/NetworkPathRelationDummy.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/NonRelationResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/NonResourceContainer.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/OrderingDummy.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/PaginationDummy.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/PlainObjectResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonApi/UriTemplateCar.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsolutePagedResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsoluteUrlChild.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsoluteUrlParent.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/CollectionNoPrefix.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/CollectionPagedResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/CustomInputResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/CustomOutputResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/DateTimeOnlyResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/DisableIdGenAnonymous.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyCollectionDto.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyFooCollectionDto.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyIdCollectionDto.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/GenIdFalseProperty.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsDeprecated.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsRelated.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraErrorResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/InputOutputResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceDtoOutputResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceTaxon.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceTaxonProduct.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/IriOnlyResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextDummy.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextRelation.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonSerializableResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/MaxDepthResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/NetworkPathParent.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/NetworkPathResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/NoInputResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/NoOutputMessage.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/NonRelationResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/NonResourceContainer.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/PaginationCapped.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/PlainObjectResource.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/PostNoOutputResource.php rename tests/Fixtures/TestBundle/ApiResource/{EntityWithRenamedGetterAndSetter.php => JsonLd/RenamedGetterSetter.php} (63%) create mode 100644 tests/Fixtures/TestBundle/ApiResource/JsonLd/UriTemplateCar.php delete mode 100644 tests/Fixtures/TestBundle/Document/JsonldContextDummy.php delete mode 100644 tests/Fixtures/TestBundle/Document/MaxDepthEagerDummy.php delete mode 100644 tests/Fixtures/TestBundle/Entity/JsonldContextDummy.php delete mode 100644 tests/Fixtures/TestBundle/Entity/MaxDepthEagerDummy.php create mode 100644 tests/Fixtures/TestBundle/State/JsonLdPaginationCappedProvider.php create mode 100644 tests/Functional/Authorization/DenyTest.php create mode 100644 tests/Functional/Authorization/LegacyDenyTest.php create mode 100644 tests/Functional/Hal/AbsoluteUrlTest.php create mode 100644 tests/Functional/Hal/CollectionTest.php create mode 100644 tests/Functional/Hal/HalTest.php create mode 100644 tests/Functional/Hal/InputOutputDtoTest.php create mode 100644 tests/Functional/Hal/ItemUriTemplateTest.php create mode 100644 tests/Functional/Hal/MaxDepthTest.php create mode 100644 tests/Functional/Hal/NetworkPathTest.php create mode 100644 tests/Functional/Hal/NonResourceTest.php create mode 100644 tests/Functional/Hal/ProblemTest.php create mode 100644 tests/Functional/Hal/PropertyCollectionIriOnlyTest.php create mode 100644 tests/Functional/Hal/TableInheritanceTest.php create mode 100644 tests/Functional/JsonApi/AbsoluteUrlTest.php create mode 100644 tests/Functional/JsonApi/CollectionAttributesTest.php create mode 100644 tests/Functional/JsonApi/CollectionUriTemplateTest.php create mode 100644 tests/Functional/JsonApi/CrudTest.php create mode 100644 tests/Functional/JsonApi/EntrypointTest.php create mode 100644 tests/Functional/JsonApi/ErrorTest.php create mode 100644 tests/Functional/JsonApi/FilteringTest.php create mode 100644 tests/Functional/JsonApi/IdentifierModeTest.php create mode 100644 tests/Functional/JsonApi/InputDtoTest.php create mode 100644 tests/Functional/JsonApi/InputOutputTest.php create mode 100644 tests/Functional/JsonApi/IriModeTest.php create mode 100644 tests/Functional/JsonApi/ItemUriTemplateTest.php create mode 100644 tests/Functional/JsonApi/NetworkPathTest.php create mode 100644 tests/Functional/JsonApi/NonResourceTest.php create mode 100644 tests/Functional/JsonApi/OrderingTest.php create mode 100644 tests/Functional/JsonApi/PaginationTest.php create mode 100644 tests/Functional/JsonApi/RelatedResourcesInclusionTest.php delete mode 100644 tests/Functional/JsonApiTest.php create mode 100644 tests/Functional/JsonLd/AbsolutePaginationTest.php create mode 100644 tests/Functional/JsonLd/AbsoluteUrlTest.php create mode 100644 tests/Functional/JsonLd/ContextOutputTest.php create mode 100644 tests/Functional/JsonLd/ContextTest.php create mode 100644 tests/Functional/JsonLd/CursorPaginationTest.php create mode 100644 tests/Functional/JsonLd/DisableIdGenerationTest.php create mode 100644 tests/Functional/JsonLd/EntityClassWithDateTimeTest.php create mode 100644 tests/Functional/JsonLd/EntrypointTest.php create mode 100644 tests/Functional/JsonLd/GenIdFalseTest.php create mode 100644 tests/Functional/JsonLd/HydraCollectionTest.php create mode 100644 tests/Functional/JsonLd/HydraDocsTest.php create mode 100644 tests/Functional/JsonLd/HydraErrorTest.php rename tests/Functional/{HydraTest.php => JsonLd/HydraHideFromDocsTest.php} (88%) create mode 100644 tests/Functional/JsonLd/InheritanceIriTest.php create mode 100644 tests/Functional/JsonLd/InitializeInputTest.php create mode 100644 tests/Functional/JsonLd/InputDtoIriDenormalizationTest.php create mode 100644 tests/Functional/JsonLd/InputOutputDtoTest.php create mode 100644 tests/Functional/JsonLd/InterfaceAsResourceTest.php create mode 100644 tests/Functional/JsonLd/InterfaceDtoOutputTest.php create mode 100644 tests/Functional/JsonLd/IriOnlyTest.php create mode 100644 tests/Functional/JsonLd/ItemUriTemplateCollectionTest.php create mode 100644 tests/Functional/JsonLd/ItemUriTemplateHydraTest.php rename tests/Functional/{ItemUriTemplateTest.php => JsonLd/ItemUriTemplateNotFoundTest.php} (84%) create mode 100644 tests/Functional/JsonLd/JsonSerializableTest.php rename tests/Functional/{ => JsonLd}/LinkedDataPlatformTest.php (98%) create mode 100644 tests/Functional/JsonLd/MaxDepthTest.php create mode 100644 tests/Functional/JsonLd/MessengerTest.php create mode 100644 tests/Functional/JsonLd/MultiResourceContextTest.php create mode 100644 tests/Functional/JsonLd/NetworkPathTest.php create mode 100644 tests/Functional/JsonLd/NoOutputTest.php create mode 100644 tests/Functional/JsonLd/NonResourceTest.php create mode 100644 tests/Functional/JsonLd/PolymorphicResourceCollectionTest.php create mode 100644 tests/Functional/JsonLd/PropertyCollectionIriOnlyTest.php create mode 100644 tests/Functional/JsonLd/RenamedGetterSetterTest.php delete mode 100644 tests/Functional/JsonLdTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5eb2dc171e..4697ffba2fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,6 @@ jobs: php: ${{ fromJSON(github.event_name == 'pull_request' && '["8.2","8.5"]' || '["8.2","8.3","8.4","8.5"]') }} shard: - main - - ld-api-hal-hydra - graphql-doctrine - misc include: @@ -496,9 +495,8 @@ jobs: run: | case "${{ matrix.shard }}" in main) paths="features/main" ;; - ld-api-hal-hydra) paths="features/jsonld features/jsonapi features/hal features/hydra" ;; graphql-doctrine) paths="features/graphql features/doctrine" ;; - misc) paths="features/authorization features/filter features/issues features/security features/serializer features/http_cache features/sub_resources features/json features/xml features/push_relations features/mercure" ;; + misc) paths="features/filter features/issues features/security features/serializer features/http_cache features/sub_resources features/json features/xml features/push_relations features/mercure" ;; esac echo "paths=$paths" >> $GITHUB_OUTPUT - name: Run Behat tests (PHP ${{ matrix.php }} ${{ matrix.shard }}) diff --git a/features/authorization/deny.feature b/features/authorization/deny.feature deleted file mode 100644 index 8aeac46c7bd..00000000000 --- a/features/authorization/deny.feature +++ /dev/null @@ -1,319 +0,0 @@ -Feature: Authorization checking - In order to use the API - As a client software user - I need to be authorized to access a given resource. - - @createSchema - Scenario: An anonymous user retrieves a secured resource - When I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/secured_dummies" - Then the response status code should be 401 - - Scenario: An authenticated user retrieve a secured resource - When I add "Accept" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies" - Then the response status code should be 200 - And the response should be in JSON - - Scenario: Data provider that's return generator has null previous object - When I add "Accept" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/custom_data_provider_generator" - Then the response status code should be 200 - - Scenario: A standard user cannot create a secured resource - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "POST" request to "/secured_dummies" with body: - """ - { - "title": "Title", - "description": "Description", - "owner": "foo" - } - """ - Then the response status code should be 403 - - Scenario: An admin can create a secured resource - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "POST" request to "/secured_dummies" with body: - """ - { - "title": "Title", - "description": "Description", - "owner": "someone" - } - """ - Then the response status code should be 201 - - Scenario: An admin can create another secured resource - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "POST" request to "/secured_dummies" with body: - """ - { - "title": "Special Title", - "description": "Description", - "owner": "dunglas", - "adminOnlyProperty": "secret" - } - """ - Then the response status code should be 201 - - Scenario: A user cannot retrieve an item they doesn't own - When I add "Accept" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies/1" - Then the response status code should be 403 - And the response should be in JSON - - Scenario: A user can retrieve an item they owns - When I add "Accept" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies/2" - Then the response status code should be 200 - - Scenario: A user can see a secured owner-only property, or accessible property based on voter, on an object they own - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies/2" - Then the response status code should be 200 - And the JSON node "ownerOnlyProperty" should exist - And the JSON node "ownerOnlyProperty" should not be null - And the JSON node "attributeBasedProperty" should exist - And the JSON node "attributeBasedProperty" should not be null - - @!mongodb - Scenario: An admin can create a secured resource with properties depending on themselves - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "POST" request to "/secured_dummy_with_properties_depending_on_themselves" with body: - """ - { - "canUpdateProperty": false, - "property": false - } - """ - Then the response status code should be 201 - - @!mongodb - Scenario: A user cannot patch a secured property if not granted - When I add "Content-Type" header equal to "application/merge-patch+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "PATCH" request to "/secured_dummy_with_properties_depending_on_themselves/1" with body: - """ - { - "canUpdateProperty": true, - "property": true - } - """ - Then the response status code should be 200 - And the JSON node "canUpdateProperty" should be true - And the JSON node "property" should be false - - Scenario: An admin can't see a secured owner-only property, or non-accessible property based on voter, on objects they don't own - When I add "Accept" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "GET" request to "/secured_dummies" - Then the response status code should be 200 - And the response should not contain "ownerOnlyProperty" - And the response should not contain "attributeBasedProperty" - - Scenario: A user can't assign to themself an item they doesn't own - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "PUT" request to "/secured_dummies/2" with body: - """ - { - "owner": "kitten" - } - """ - Then the response status code should be 403 - - Scenario: A user can update an item they owns and transfer it - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "PUT" request to "/secured_dummies/2" with body: - """ - { - "owner": "vincent" - } - """ - Then the response status code should be 200 - - Scenario: An admin retrieves a resource with an admin only viewable property - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "GET" request to "/secured_dummies" - Then the response status code should be 200 - And the response should contain "adminOnlyProperty" - - Scenario: A user retrieves a resource with an admin only viewable property - When I add "Accept" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies" - Then the response status code should be 200 - And the response should not contain "adminOnlyProperty" - - Scenario: An admin can create a secured resource with a secured Property - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "POST" request to "/secured_dummies" with body: - """ - { - "title": "Common Title", - "description": "Description", - "owner": "dunglas", - "adminOnlyProperty": "Is it safe?" - } - """ - Then the response status code should be 201 - And the response should contain "adminOnlyProperty" - And the JSON node "adminOnlyProperty" should be equal to the string "Is it safe?" - - Scenario: A user cannot update a secured property - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "PUT" request to "/secured_dummies/3" with body: - """ - { - "adminOnlyProperty": "Yes it is!" - } - """ - Then the response status code should be 200 - And the response should not contain "adminOnlyProperty" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "GET" request to "/secured_dummies" - Then the response status code should be 200 - And the response should contain "adminOnlyProperty" - And the JSON node "hydra:member[2].adminOnlyProperty" should be equal to the string "Is it safe?" - - Scenario: An user can update owner-only secured or accessible properties on an object they own - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "PUT" request to "/secured_dummies/3" with body: - """ - { - "ownerOnlyProperty": "updated", - "attributeBasedProperty": "updated" - } - """ - Then the response status code should be 200 - And the response should contain "ownerOnlyProperty" - And the JSON node "ownerOnlyProperty" should be equal to the string "updated" - And the JSON node "attributeBasedProperty" should be equal to the string "updated" - - @link_security - Scenario: An non existing entity should return Not found - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies/40000/to_from" - Then the response status code should be 404 - - @link_security - Scenario: An user can get related linked dummies for an secured dummy they own - Given there are 1 SecuredDummy objects owned by dunglas with related dummies - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies/4/to_from" - Then the response status code should be 200 - And the response should contain "securedDummy" - And the JSON node "hydra:member[0].id" should be equal to 1 - - @link_security - Scenario: I define a custom name of the security object - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies/4/with_name" - Then the response status code should be 200 - And the response should contain "securedDummy" - And the JSON node "hydra:member[0].id" should be equal to 1 - - @link_security - Scenario: I define a from from link - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/related_linked_dummies/1/from_from" - Then the response status code should be 200 - And the response should contain "id" - And the JSON node "hydra:member[0].id" should be equal to 4 - - @link_security - Scenario: I define multiple links with security - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies/4/related/1" - Then the response status code should be 200 - And the response should contain "id" - And the JSON node "hydra:member[0].id" should be equal to 1 - - @link_security - Scenario: An user can not get related linked dummies for an secured dummy they do not own - Given there are 1 SecuredDummy objects owned by someone with related dummies - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies/5/to_from" - Then the response status code should be 403 - - @link_security - Scenario: I define a custom name of the security object - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies/5/with_name" - Then the response status code should be 403 - - @link_security - Scenario: I define a from from link - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/related_linked_dummies/2/from_from" - Then the response status code should be 403 - - @link_security - Scenario: I define multiple links with security - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies/5/related/2" - Then the response status code should be 403 - - Scenario: A user retrieves a resource with an admin only viewable property - When I add "Accept" header equal to "application/json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/secured_dummies" - Then the response status code should be 200 - And the response should contain "ownerOnlyProperty" - And the response should contain "attributeBasedProperty" - - Scenario: Security post validation should be hit - When I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "POST" request to "/issue_6446" with body: - """ - { - "title": "" - } - """ - Then the response status code should be 403 - diff --git a/features/authorization/legacy_deny.feature b/features/authorization/legacy_deny.feature deleted file mode 100644 index b911022c3bd..00000000000 --- a/features/authorization/legacy_deny.feature +++ /dev/null @@ -1,96 +0,0 @@ -Feature: Authorization checking - In order to use the API - As a client software user - I need to be authorized to access a given resource using legacy access_control attribute. - - @createSchema - Scenario: An anonymous user retrieves a secured resource - When I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/legacy_secured_dummies" - Then the response status code should be 401 - - Scenario: An authenticated user retrieve a secured resource - When I add "Accept" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/legacy_secured_dummies" - Then the response status code should be 200 - And the response should be in JSON - - Scenario: A standard user cannot create a secured resource - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "POST" request to "/legacy_secured_dummies" with body: - """ - { - "title": "Title", - "description": "Description", - "owner": "foo" - } - """ - Then the response status code should be 403 - - Scenario: An admin can create a secured resource - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "POST" request to "/legacy_secured_dummies" with body: - """ - { - "title": "Title", - "description": "Description", - "owner": "someone" - } - """ - Then the response status code should be 201 - - Scenario: An admin can create another secured resource - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "POST" request to "/legacy_secured_dummies" with body: - """ - { - "title": "Special Title", - "description": "Description", - "owner": "dunglas" - } - """ - Then the response status code should be 201 - - Scenario: A user cannot retrieve an item they doesn't own - When I add "Accept" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/legacy_secured_dummies/1" - Then the response status code should be 403 - And the response should be in JSON - - Scenario: A user can retrieve an item they owns - When I add "Accept" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "GET" request to "/legacy_secured_dummies/2" - Then the response status code should be 200 - - Scenario: A user can't assign to themself an item they doesn't own - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send a "PUT" request to "/legacy_secured_dummies/2" with body: - """ - { - "owner": "kitten" - } - """ - Then the response status code should be 403 - - Scenario: A user can update an item they owns and transfer it - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send a "PUT" request to "/legacy_secured_dummies/2" with body: - """ - { - "owner": "vincent" - } - """ - Then the response status code should be 200 diff --git a/features/hal/absolute_url.feature b/features/hal/absolute_url.feature deleted file mode 100644 index 87be61f0670..00000000000 --- a/features/hal/absolute_url.feature +++ /dev/null @@ -1,119 +0,0 @@ -Feature: IRI should contain Absolute URL - In order to add detail to IRIs - Include the absolute url - - @createSchema - Scenario: I should be able to GET a collection of Objects with Absolute Urls - Given there are 1 absoluteUrlDummy objects with a related absoluteUrlRelationDummy - And I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/absolute_url_dummies" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "http://example.com/absolute_url_dummies" - }, - "item": [ - { - "href": "http://example.com/absolute_url_dummies/1" - } - ] - }, - "totalItems": 1, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "http://example.com/absolute_url_dummies/1" - }, - "absoluteUrlRelationDummy": { - "href": "http://example.com/absolute_url_relation_dummies/1" - } - }, - "id": 1 - } - ] - } - } - """ - - Scenario: I should be able to POST an object using an Absolute Url - Given I add "Accept" header equal to "application/hal+json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/absolute_url_relation_dummies" with body: - """ - { - "absolute_url_dummies": "http://example.com/absolute_url_dummies/1" - } - """ - Then the response status code should be 201 - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "http://example.com/absolute_url_relation_dummies/2" - } - }, - "id": 2 - } - """ - - Scenario: I should be able to GET an Item with Absolute Urls - Given I add "Accept" header equal to "application/hal+json" - And I add "Content-Type" header equal to "application/json" - And I send a "GET" request to "/absolute_url_dummies/1" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "http://example.com/absolute_url_dummies/1" - }, - "absoluteUrlRelationDummy": { - "href": "http://example.com/absolute_url_relation_dummies/1" - } - }, - "id": 1 - } - """ - - Scenario: I should be able to GET resources with Absolute Urls - Given I add "Accept" header equal to "application/hal+json" - And I add "Content-Type" header equal to "application/json" - And I send a "GET" request to "/absolute_url_relation_dummies/1/absolute_url_dummies" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "http://example.com/absolute_url_relation_dummies/1/absolute_url_dummies" - }, - "item": [ - { - "href": "http://example.com/absolute_url_dummies/1" - } - ] - }, - "totalItems": 1, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "http://example.com/absolute_url_dummies/1" - }, - "absoluteUrlRelationDummy": { - "href": "http://example.com/absolute_url_relation_dummies/1" - } - }, - "id": 1 - } - ] - } - } - """ diff --git a/features/hal/collection.feature b/features/hal/collection.feature deleted file mode 100644 index 8507c886621..00000000000 --- a/features/hal/collection.feature +++ /dev/null @@ -1,637 +0,0 @@ -Feature: HAL Collections support - In order to retrieve large collections of resources - As a client software developer - I need to retrieve paged collections respecting the HAL specification - - @createSchema - Scenario: Retrieve an empty collection - When I add "Accept" header equal to "application/hal+json" - When I send a "GET" request to "/dummies" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies" - } - }, - "totalItems": 0, - "itemsPerPage": 3 - } - """ - - Scenario: Retrieve the first page of a collection - Given there are 10 dummy objects - And I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies?page=1" - }, - "first": { - "href": "/dummies?page=1" - }, - "last": { - "href": "/dummies?page=4" - }, - "next": { - "href": "/dummies?page=2" - }, - "item": [ - { - "href": "/dummies/1" - }, - { - "href": "/dummies/2" - }, - { - "href": "/dummies/3" - } - ] - }, - "totalItems": 10, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "/dummies/1" - } - }, - "description": "Smart dummy.", - "dummy": "SomeDummyTest1", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 1", - "id": 1, - "name": "Dummy #1", - "alias": "Alias #9", - "foo": null - }, - { - "_links": { - "self": { - "href": "/dummies/2" - } - }, - "description": "Not so smart dummy.", - "dummy": "SomeDummyTest2", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 2", - "id": 2, - "name": "Dummy #2", - "alias": "Alias #8", - "foo": null - }, - { - "_links": { - "self": { - "href": "/dummies/3" - } - }, - "description": "Smart dummy.", - "dummy": "SomeDummyTest3", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 3", - "id": 3, - "name": "Dummy #3", - "alias": "Alias #7", - "foo": null - } - ] - } - } - """ - - Scenario: Retrieve a page of a collection - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?page=3" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies?page=3" - }, - "first": { - "href": "/dummies?page=1" - }, - "last": { - "href": "/dummies?page=4" - }, - "prev": { - "href": "/dummies?page=2" - }, - "next": { - "href": "/dummies?page=4" - }, - "item": [ - { - "href": "/dummies/7" - }, - { - "href": "/dummies/8" - }, - { - "href": "/dummies/9" - } - ] - }, - "totalItems": 10, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "/dummies/7" - } - }, - "description": "Smart dummy.", - "dummy": "SomeDummyTest7", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 7", - "id": 7, - "name": "Dummy #7", - "alias": "Alias #3", - "foo": null - }, - { - "_links": { - "self": { - "href": "/dummies/8" - } - }, - "description": "Not so smart dummy.", - "dummy": "SomeDummyTest8", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 8", - "id": 8, - "name": "Dummy #8", - "alias": "Alias #2", - "foo": null - }, - { - "_links": { - "self": { - "href": "/dummies/9" - } - }, - "description": "Smart dummy.", - "dummy": "SomeDummyTest9", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 9", - "id": 9, - "name": "Dummy #9", - "alias": "Alias #1", - "foo": null - } - ] - } - } - """ - - Scenario: Retrieve the last page of a collection - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?page=4" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies?page=4" - }, - "first": { - "href": "/dummies?page=1" - }, - "last": { - "href": "/dummies?page=4" - }, - "prev": { - "href": "/dummies?page=3" - }, - "item": [ - { - "href": "/dummies/10" - } - ] - }, - "totalItems": 10, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "/dummies/10" - } - }, - "description": "Not so smart dummy.", - "dummy": "SomeDummyTest10", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 10", - "id": 10, - "name": "Dummy #10", - "alias": "Alias #0", - "foo": null - } - ] - } - } - """ - - @!mongodb - Scenario: Enable the partial pagination client side - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?page=2&partial=1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies?partial=1&page=2" - }, - "prev": { - "href": "/dummies?partial=1&page=1" - }, - "next": { - "href": "/dummies?partial=1&page=3" - }, - "item": [ - { - "href": "/dummies/4" - }, - { - "href": "/dummies/5" - }, - { - "href": "/dummies/6" - } - ] - }, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "/dummies/4" - } - }, - "description": "Not so smart dummy.", - "dummy": "SomeDummyTest4", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 4", - "id": 4, - "name": "Dummy #4", - "alias": "Alias #6", - "foo": null - }, - { - "_links": { - "self": { - "href": "/dummies/5" - } - }, - "description": "Smart dummy.", - "dummy": "SomeDummyTest5", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 5", - "id": 5, - "name": "Dummy #5", - "alias": "Alias #5", - "foo": null - }, - { - "_links": { - "self": { - "href": "/dummies/6" - } - }, - "description": "Not so smart dummy.", - "dummy": "SomeDummyTest6", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 6", - "id": 6, - "name": "Dummy #6", - "alias": "Alias #4", - "foo": null - } - ] - } - } - """ - - Scenario: Disable the pagination client side - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?pagination=0" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "_links": { - "type": "object", - "properties": { - "self": { - "type": "object", - "properties": {"href": {"pattern": "^/dummies\\?pagination=0$"}} - }, - "item": { - "type": "array", - "minItems": 10, - "maxItems": 10, - "items": { - "type": "object", - "properties": {"href": {"pattern": "^/dummies/[0-9]+$"}} - } - } - } - }, - "totalItems": {"type":"number", "minimum": 10, "maximum": 10}, - "_embedded": { - "type": "object", - "properties": { - "item": { - "type": "array", - "minItems": 10, - "maxItems": 10, - "items": { - "type": "object", - "properties": { - "_links": { - "type": "object", - "properties": { - "self": { - "type": "object", - "properties": {"href": {"pattern": "^/dummies/[0-9]+$"}} - } - } - }, - "description": {"pattern": "(Smart dummy.|Not so smart dummy.)"} - } - } - } - } - } - }, - "additionalProperties": false - } - """ - - Scenario: Change the number of element by page client side - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?page=2&itemsPerPage=1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies?itemsPerPage=1&page=2" - }, - "first": { - "href": "/dummies?itemsPerPage=1&page=1" - }, - "last": { - "href": "/dummies?itemsPerPage=1&page=10" - }, - "prev": { - "href": "/dummies?itemsPerPage=1&page=1" - }, - "next": { - "href": "/dummies?itemsPerPage=1&page=3" - }, - "item": [ - { - "href": "/dummies/2" - } - ] - }, - "totalItems": 10, - "itemsPerPage": 1, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "/dummies/2" - } - }, - "description": "Not so smart dummy.", - "dummy": "SomeDummyTest2", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 2", - "id": 2, - "name": "Dummy #2", - "alias": "Alias #8", - "foo": null - } - ] - } - } - """ - - Scenario: Filter with a raw URL - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?id=%2fdummies%2f8" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies?id=%2Fdummies%2F8" - }, - "item": [ - { - "href": "/dummies/8" - } - ] - }, - "totalItems": 1, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "/dummies/8" - } - }, - "description": "Not so smart dummy.", - "dummy": "SomeDummyTest8", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 8", - "id": 8, - "name": "Dummy #8", - "alias": "Alias #2", - "foo": null - } - ] - } -} - """ - - Scenario: Filter with non-exact match - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?name=Dummy%20%238" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies?name=Dummy%20%238" - }, - "item": [ - { - "href": "/dummies/8" - } - ] - }, - "totalItems": 1, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "/dummies/8" - } - }, - "description": "Not so smart dummy.", - "dummy": "SomeDummyTest8", - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": "Converted 8", - "id": 8, - "name": "Dummy #8", - "alias": "Alias #2", - "foo": null - } - ] - } - } - """ - - @!mongodb - Scenario: Allow passing 0 to `itemsPerPage` - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?itemsPerPage=0" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links":{ - "self":{ - "href":"/dummies?itemsPerPage=0" - } - }, - "totalItems":10, - "itemsPerPage":0 - } - """ diff --git a/features/hal/collection_uri_template.feature b/features/hal/collection_uri_template.feature deleted file mode 100644 index a71c2d9329c..00000000000 --- a/features/hal/collection_uri_template.feature +++ /dev/null @@ -1,81 +0,0 @@ -@php8 -@v3 -Feature: Exposing a property being a collection of resources - can return an IRI instead of an array - when the uriTemplate is set on the ApiProperty attribute - - @createSchema - Scenario: Retrieve Resource with uriTemplate collection Property - Given there are propertyCollectionIriOnly with relations - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/property_collection_iri_onlies/1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/property_collection_iri_onlies/1" - }, - "propertyCollectionIriOnlyRelation": { - "href": "/property-collection-relations" - }, - "iterableIri": { - "href": "/parent/1/another-collection-operations" - }, - "toOneRelation": { - "href": "/parent/1/property-uri-template/one-to-ones/1" - } - }, - "_embedded": { - "propertyCollectionIriOnlyRelation": [ - { - "_links": { - "self": { - "href": "/property_collection_iri_only_relations/1" - }, - "children": { - "href": "/property_collection_iri_only_relations/1/children" - } - }, - "name": "asb1" - }, - { - "_links": { - "self": { - "href": "/property_collection_iri_only_relations/2" - }, - "children": { - "href": "/property_collection_iri_only_relations/2/children" - } - }, - "name": "asb2" - } - ], - "iterableIri": [ - { - "_links": { - "self": { - "href": "/property_collection_iri_only_relations/9999" - }, - "children": { - "href": "/property_collection_iri_only_relations/9999/children" - } - }, - "name": "Michel" - } - ], - "toOneRelation": { - "_links": { - "self": { - "href": "/parent/1/property-uri-template/one-to-ones/1" - } - }, - "name": "xarguš" - } - } - } - """ diff --git a/features/hal/hal.feature b/features/hal/hal.feature deleted file mode 100644 index be469651f58..00000000000 --- a/features/hal/hal.feature +++ /dev/null @@ -1,224 +0,0 @@ -Feature: HAL support - In order to use the HAL hypermedia format - As a client software developer - I need to be able to retrieve valid HAL responses. - - @createSchema - Scenario: Retrieve the API entrypoint - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON node "_links.self.href" should be equal to "/" - And the JSON node "_links.dummy.href" should be equal to "/dummies" - - Scenario: Create a third level - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/third_levels" with body: - """ - {"level": 3} - """ - Then the response status code should be 201 - - Scenario: Create a related dummy - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/related_dummies" with body: - """ - {"thirdLevel": "/third_levels/1"} - """ - Then the response status code should be 201 - - Scenario: Create a dummy with relations - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Dummy with relations", - "dummyDate": "2015-03-01T10:00:00+00:00", - "relatedDummy": "http://example.com/related_dummies/1", - "relatedDummies": [ - "/related_dummies/1" - ] - } - """ - Then the response status code should be 201 - - Scenario: Get a resource with relations - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies/1" - }, - "relatedDummy": { - "href": "/related_dummies/1" - }, - "relatedDummies": [ - { - "href": "/related_dummies/1" - } - ] - }, - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": "2015-03-01T10:00:00+00:00", - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null, - "id": 1, - "name": "Dummy with relations", - "alias": null, - "foo": null - } - """ - - Scenario: Update a resource (legacy PUT as standard_put: false) - When I add "Accept" header equal to "application/hal+json" - And I add "Content-Type" header equal to "application/json" - And I send a "PUT" request to "/dummies/1" with body: - """ - {"name": "A nice dummy"} - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies/1" - }, - "relatedDummy": { - "href": "/related_dummies/1" - }, - "relatedDummies": [ - { - "href": "/related_dummies/1" - } - ] - }, - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": "2015-03-01T10:00:00+00:00", - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null, - "id": 1, - "name": "A nice dummy", - "alias": null, - "foo": null - } - """ - - Scenario: Update a resource - When I add "Accept" header equal to "application/hal+json" - When I add "Content-Type" header equal to "application/merge-patch+json" - And I send a "PATCH" request to "/dummies/1" with body: - """ - {"name": "A nice dummy"} - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummies/1" - }, - "relatedDummy": { - "href": "/related_dummies/1" - }, - "relatedDummies": [ - { - "href": "/related_dummies/1" - } - ] - }, - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": "2015-03-01T10:00:00+00:00", - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null, - "id": 1, - "name": "A nice dummy", - "alias": null, - "foo": null - } - """ - - Scenario: Embed a relation in a parent object - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "related": "/related_dummies/1" - } - """ - Then the response status code should be 201 - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/relation_embedders/1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/relation_embedders/1" - }, - "related": { - "href": "/related_dummies/1" - } - }, - "_embedded": { - "related": { - "_links": { - "self": { - "href": "/related_dummies/1" - }, - "thirdLevel": { - "href": "/third_levels/1" - } - }, - "_embedded": { - "thirdLevel": { - "_links": { - "self": { - "href": "/third_levels/1" - } - }, - "level": 3 - } - }, - "symfony": "symfony" - } - }, - "krondstadt": "Krondstadt" - } - """ diff --git a/features/hal/input_output.feature b/features/hal/input_output.feature deleted file mode 100644 index c7acbe989f0..00000000000 --- a/features/hal/input_output.feature +++ /dev/null @@ -1,48 +0,0 @@ -Feature: HAL DTO input and output - In order to use a hypermedia API - As a client software developer - I need to be able to use DTOs on my resources as Input or Output objects. - for the collection we can search for an Operation with the same Output class as the given one for the collection - - Background: - Given I add "Accept" header equal to "application/hal+json" - - @createSchema - Scenario: Get an item with a custom output - Given there is a DummyDtoCustom - When I send a "GET" request to "/dummy_dto_custom_output/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "foo": "test", - "bar": 1 - } - """ - - @createSchema - Scenario: Get a collection with a custom output - Given there are 2 DummyDtoCustom - When I send a "GET" request to "/dummy_dto_custom_output" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "_embedded": { - "item": [ - { - "foo": "test", - "bar": 1 - }, - { - "foo": "test", - "bar": 2 - } - ] - } - } - """ diff --git a/features/hal/item_uri_template.feature b/features/hal/item_uri_template.feature deleted file mode 100644 index 5c949d07161..00000000000 --- a/features/hal/item_uri_template.feature +++ /dev/null @@ -1,128 +0,0 @@ -@php8 -@v3 -Feature: Exposing a collection of objects should use the specified operation to generate the IRI - - Scenario: Get a collection of objects without any itemUriTemplate should generate the IRI from the first Get operation - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/cars" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["_links", "_embedded", "totalItems"], - "properties": { - "_links": { - "type": "object", - "properties": { - "self": { - "type": "object", - "properties": {"href": {"pattern": "^/cars$"}} - }, - "item": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": { - "type": "object", - "properties": {"href": {"pattern": "^/cars/.+$"}} - } - } - } - }, - "totalItems": {"type":"number", "minimum": 2, "maximum": 2}, - "_embedded": { - "type": "object", - "properties": { - "item": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": { - "type": "object", - "properties": { - "_links": { - "type": "object", - "properties": { - "self": { - "type": "object", - "properties": {"href": {"pattern": "^/cars/.+$"}} - } - } - }, - "id": {"type": "string"}, - "owner": {"type": "string"} - } - } - } - } - } - } - } - """ - - Scenario: Get a collection of objects with an itemUriTemplate should generate the IRI from the correct operation - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/brands/renault/cars" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["_links", "_embedded", "totalItems"], - "properties": { - "_links": { - "type": "object", - "properties": { - "self": { - "type": "object", - "properties": {"href": {"pattern": "^/brands/renault/cars$"}} - }, - "item": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": { - "type": "object", - "properties": {"href": {"pattern": "^/brands/renault/cars/.+$"}} - } - } - } - }, - "totalItems": {"type":"number", "minimum": 2, "maximum": 2}, - "_embedded": { - "type": "object", - "properties": { - "item": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "items": { - "type": "object", - "properties": { - "_links": { - "type": "object", - "properties": { - "self": { - "type": "object", - "properties": {"href": {"pattern": "^/brands/renault/cars/.+$"}} - } - } - }, - "id": {"type": "string"}, - "owner": {"type": "string"} - } - } - } - } - } - } - } - """ diff --git a/features/hal/max_depth.feature b/features/hal/max_depth.feature deleted file mode 100644 index ef1b9601dd7..00000000000 --- a/features/hal/max_depth.feature +++ /dev/null @@ -1,69 +0,0 @@ -Feature: Max depth handling - In order to handle MaxChildDepth resources - As a developer - I need to be able to limit their depth with @maxDepth - - @createSchema - Scenario: Create a resource with 1 level of descendants - When I add "Accept" header equal to "application/hal+json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/max_depth_eager_dummies" with body: - """ - { - "name": "level 1", - "child": { - "name": "level 2" - } - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - Then the JSON node "_embedded" should exist - Then the JSON node "_embedded.child" should exist - Then the JSON node "_embedded.child._embedded" should not exist - - Scenario: Create a resource with 2 levels of descendants - When I add "Accept" header equal to "application/hal+json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/max_depth_eager_dummies" with body: - """ - { - "name": "level 1", - "child": { - "name": "level 2", - "child": { - "name": "level 3" - } - } - } - """ - And the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - Then the JSON node "_embedded" should exist - Then the JSON node "_embedded.child" should exist - Then the JSON node "_embedded.child._embedded" should not exist - - Scenario: Create a resource with 1 levels of descendants then add a 2nd level of descendants when eager fetching is disabled - Given there is a max depth dummy with 1 level of descendants - When I add "Accept" header equal to "application/hal+json" - And I add "Content-Type" header equal to "application/json" - And I send a "PUT" request to "max_depth_dummies/1" with body: - """ - { - "id": "/max_depth_dummies/1", - "child": { - "id": "/max_depth_dummies/2", - "child": { - "name": "level 3" - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - Then the JSON node "_embedded" should exist - Then the JSON node "_embedded.child" should exist - Then the JSON node "_embedded.child._embedded" should not exist diff --git a/features/hal/network_path.feature b/features/hal/network_path.feature deleted file mode 100644 index 264d2a49f82..00000000000 --- a/features/hal/network_path.feature +++ /dev/null @@ -1,117 +0,0 @@ -Feature: IRI should contain network path - In order to add detail to IRIs - Include the network path - - @createSchema - Scenario: I should be able to GET a collection of objects with network paths - Given there are 1 networkPathDummy objects with a related networkPathRelationDummy - And I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/network_path_dummies" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "//example.com/network_path_dummies" - }, - "item": [ - { - "href": "//example.com/network_path_dummies/1" - } - ] - }, - "totalItems": 1, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "//example.com/network_path_dummies/1" - }, - "networkPathRelationDummy": { - "href": "//example.com/network_path_relation_dummies/1" - } - }, - "id": 1 - } - ] - } - } - """ - - Scenario: I should be able to POST an object using a network path - Given I add "Accept" header equal to "application/hal+json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/network_path_relation_dummies" with body: - """ - { - "network_path_dummies": "//example.com/network_path_dummies/1" - } - """ - Then the response status code should be 201 - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "//example.com/network_path_relation_dummies/2" - } - }, - "id": 2 - } - """ - - Scenario: I should be able to GET an Item with network paths - Given I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/network_path_dummies/1" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "//example.com/network_path_dummies/1" - }, - "networkPathRelationDummy": { - "href": "//example.com/network_path_relation_dummies/1" - } - }, - "id": 1 - } - """ - - Scenario: I should be able to GET resources with network paths - Given I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/network_path_relation_dummies/1/network_path_dummies" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "//example.com/network_path_relation_dummies/1/network_path_dummies" - }, - "item": [ - { - "href": "//example.com/network_path_dummies/1" - } - ] - }, - "totalItems": 1, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "//example.com/network_path_dummies/1" - }, - "networkPathRelationDummy": { - "href": "//example.com/network_path_relation_dummies/1" - } - }, - "id": 1 - } - ] - } - } - """ diff --git a/features/hal/non_resource.feature b/features/hal/non_resource.feature deleted file mode 100644 index 320ea122b83..00000000000 --- a/features/hal/non_resource.feature +++ /dev/null @@ -1,45 +0,0 @@ -Feature: HAL non-resource handling - In order to use non-resource types - As a developer - I should be able to serialize types not mapped to an API resource. - - Background: - Given I add "Accept" header equal to "application/hal+json" - - Scenario: Get a resource containing a raw object - When I send a "GET" request to "/contain_non_resources/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/contain_non_resources/1" - }, - "nested": { - "href": "/contain_non_resources/1-nested" - } - }, - "_embedded": { - "nested": { - "_links": { - "self": { - "href": "/contain_non_resources/1-nested" - } - }, - "id": "1-nested", - "notAResource": { - "foo": "f2", - "bar": "b2" - } - } - }, - "id": 1, - "notAResource": { - "foo": "f1", - "bar": "b1" - } - } - """ diff --git a/features/hal/problem.feature b/features/hal/problem.feature deleted file mode 100644 index 692cd0aa105..00000000000 --- a/features/hal/problem.feature +++ /dev/null @@ -1,52 +0,0 @@ -@!mongodb -Feature: Error handling valid according to RFC 7807 (application/problem+json) - In order to be able to handle error client side - As a client software developer - I need to retrieve an RFC 7807 compliant serialization of errors - - Scenario: Get an error - When I add "Content-Type" header equal to "application/json" - And I add "Accept" header equal to "application/json" - And I send a "POST" request to "/dummy_problems" with body: - """ - {} - """ - Then the response status code should be 422 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "type": "/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3", - "title": "An error occurred", - "detail": "name: This value should not be blank.", - "status": "422", - "violations": [ - { - "propertyPath": "name", - "message": "This value should not be blank.", - "code": "c1051bb4-d103-4f74-8988-acbcafc7fdc3" - } - ] - } - """ - - Scenario: Get an error during deserialization of simple relation - When I add "Content-Type" header equal to "application/json" - And I add "Accept" header equal to "application/json" - And I send a "POST" request to "/dummy_problems" with body: - """ - { - "name": "Foo", - "relatedDummy": { - "name": "bar" - } - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "type" should be equal to "/errors/400" - And the JSON node "title" should be equal to "An error occurred" - And the JSON node "detail" should be equal to 'Nested documents for attribute "relatedDummy" are not allowed. Use IRIs instead.' - And the JSON node "trace" should exist diff --git a/features/hal/table_inheritance.feature b/features/hal/table_inheritance.feature deleted file mode 100644 index 55ef27b7cad..00000000000 --- a/features/hal/table_inheritance.feature +++ /dev/null @@ -1,141 +0,0 @@ -Feature: Table inheritance - In order to use the api with Doctrine table inheritance - As a client software developer - I need to be able to create resources and fetch them on the upper entity - - Background: - Given I add "Accept" header equal to "application/hal+json" - And I add "Content-Type" header equal to "application/json" - - @createSchema - Scenario: Create a table inherited resource - And I send a "POST" request to "/dummy_table_inheritance_children" with body: - """ - { - "name": "foo", - "nickname": "bar" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummy_table_inheritance_children/1" - } - }, - "nickname": "bar", - "id": 1, - "name": "foo" - } - """ - - Scenario: Get the parent entity collection - When some dummy table inheritance data but not api resource child are created - When I send a "GET" request to "/dummy_table_inheritances" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummy_table_inheritances" - }, - "item": [ - { - "href": "/dummy_table_inheritance_children/1" - }, - { - "href": "/dummy_table_inheritances/2" - } - ] - }, - "totalItems": 2, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "/dummy_table_inheritance_children/1" - } - }, - "nickname": "bar", - "id": 1, - "name": "foo" - }, - { - "_links": { - "self": { - "href": "/dummy_table_inheritances/2" - } - }, - "id": 2, - "name": "Foobarbaz inheritance" - } - ] - } - } - """ - - - Scenario: Get related entity with multiple inherited children types - And I send a "POST" request to "/dummy_table_inheritance_relateds" with body: - """ - { - "children": [ - "/dummy_table_inheritance_children/1", - "/dummy_table_inheritances/2" - ] - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/dummy_table_inheritance_relateds/1" - }, - "children": [ - { - "href": "/dummy_table_inheritance_children/1" - }, - { - "href": "/dummy_table_inheritances/2" - } - ] - }, - "_embedded": { - "children": [ - { - "_links": { - "self": { - "href": "/dummy_table_inheritance_children/1" - } - }, - "nickname": "bar", - "id": 1, - "name": "foo" - }, - { - "_links": { - "self": { - "href": "/dummy_table_inheritances/2" - } - }, - "id": 2, - "name": "Foobarbaz inheritance" - } - ] - }, - "id": 1 - } - """ \ No newline at end of file diff --git a/features/hydra/absolute.feature b/features/hydra/absolute.feature deleted file mode 100644 index ba26000260a..00000000000 --- a/features/hydra/absolute.feature +++ /dev/null @@ -1,23 +0,0 @@ -Feature: Collections with absolute IRIs support - In order to retrieve large collections of resources - As a client software developer - I need to retrieve paged collections respecting the Hydra specification and with absolute iris - - @createSchema - Scenario: Retrieve third page of collection with absolute iris - Given there are 30 absoluteUrlDummy objects with a related absoluteUrlRelationDummy - When I send a "GET" request to "/absolute_url_dummies?page=3" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:view" should be equal to: - """ - { - "@id": "http://example.com/absolute_url_dummies?page=3", - "@type": "hydra:PartialCollectionView", - "hydra:first": "http://example.com/absolute_url_dummies?page=1", - "hydra:last": "http://example.com/absolute_url_dummies?page=10", - "hydra:previous": "http://example.com/absolute_url_dummies?page=2", - "hydra:next": "http://example.com/absolute_url_dummies?page=4" - } - """ diff --git a/features/hydra/collection.feature b/features/hydra/collection.feature deleted file mode 100644 index 8e9b0c33aba..00000000000 --- a/features/hydra/collection.feature +++ /dev/null @@ -1,596 +0,0 @@ -Feature: Collections support - In order to retrieve large collections of resources - As a client software developer - I need to retrieve paged collections respecting the Hydra specification - - @createSchema - Scenario: Retrieve an empty collection - When I send a "GET" request to "/dummies" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 0}, - "hydra:member": { - "type": "array", - "maxItems": 0 - }, - "hydra:search": {} - }, - "additionalProperties": false - } - """ - - Scenario: Retrieve the first page of a collection - Given there are 30 dummy objects - And I send a "GET" request to "/dummies" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 30}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - }, - "maxItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?page=1$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"}, - "hydra:first": {"pattern": "^/dummies\\?page=1$"}, - "hydra:last": {"pattern": "^/dummies\\?page=10$"}, - "hydra:next": {"pattern": "^/dummies\\?page=2$"} - } - } - } - } - """ - - Scenario: Retrieve a page of a collection - When I send a "GET" request to "/dummies?page=7" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 30}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/19$"}, - {"pattern": "^/dummies/20$"}, - {"pattern": "^/dummies/21$"} - ] - } - } - }, - "maxItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?page=7$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"}, - "hydra:first": {"pattern": "^/dummies\\?page=1$"}, - "hydra:last": {"pattern": "^/dummies\\?page=10$"}, - "hydra:next": {"pattern": "^/dummies\\?page=8$"}, - "hydra:previous": {"pattern": "^/dummies\\?page=6$"} - } - } - } - } - """ - - Scenario: Retrieve the last page of a collection - When I send a "GET" request to "/dummies?page=10" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 30}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/28$"}, - {"pattern": "^/dummies/29$"}, - {"pattern": "^/dummies/30$"} - ] - } - } - }, - "maxItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?page=10$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"}, - "hydra:first": {"pattern": "^/dummies\\?page=1$"}, - "hydra:last": {"pattern": "^/dummies\\?page=10$"}, - "hydra:previous": {"pattern": "^/dummies\\?page=9$"} - } - }, - "hydra:search": {} - }, - "additionalProperties": false - } - """ - - @!mongodb - Scenario: Enable the partial pagination client side - When I send a "GET" request to "/dummies?page=7&partial=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 30}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/19$"}, - {"pattern": "^/dummies/20$"}, - {"pattern": "^/dummies/21$"} - ] - } - } - }, - "maxItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?partial=1&page=7$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"}, - "hydra:next": {"pattern": "^/dummies\\?partial=1&page=8$"}, - "hydra:previous": {"pattern": "^/dummies\\?partial=1&page=6$"} - }, - "required": ["@id", "@type", "hydra:next", "hydra:previous"], - "additionalProperties": false, - "maxProperties": 4 - } - }, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:view", "hydra:search"], - "maxProperties": 6 - } - """ - - Scenario: Disable the pagination client side - When I send a "GET" request to "/dummies?pagination=0" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "minimum": 30}, - "hydra:member": { - "type": "array", - "minItems": 30 - }, - "hydra:search": {} - }, - "additionalProperties": false - } - """ - - Scenario: Change the number of element by page client side - When I send a "GET" request to "/dummies?page=2&itemsPerPage=10" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 30}, - "hydra:member": { - "type": "array", - "minItems": 10, - "maxItems": 10 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?itemsPerPage=10&page=2$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"}, - "hydra:first": {"pattern": "^/dummies\\?itemsPerPage=10&page=1$"}, - "hydra:last": {"pattern": "^/dummies\\?itemsPerPage=10&page=3$"}, - "hydra:previous": {"pattern": "^/dummies\\?itemsPerPage=10&page=1$"}, - "hydra:next": {"pattern": "^/dummies\\?itemsPerPage=10&page=3$"} - } - }, - "hydra:search": {} - }, - "additionalProperties": false - } - """ - - @!mongodb - @php8 - Scenario: Change the number of element by page client side with v3, attributes and PHP8. Defaults (max 40) should not override resource attribute (max 30) - Given there are 80 pagination entities - When I send a "GET" request to "/pagination_entities?page=2&itemsPerPage=40" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/PaginationEntity"}, - "@id": {"pattern": "^/pagination_entities"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 80}, - "hydra:member": { - "type": "array", - "minItems": 30, - "maxItems": 30 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/pagination_entities\\?itemsPerPage=40&page=2$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"}, - "hydra:first": {"pattern": "^/pagination_entities\\?itemsPerPage=40&page=1$"}, - "hydra:last": {"pattern": "^/pagination_entities\\?itemsPerPage=40&page=3$"}, - "hydra:previous": {"pattern": "^/pagination_entities\\?itemsPerPage=40&page=1$"}, - "hydra:next": {"pattern": "^/pagination_entities\\?itemsPerPage=40&page=3$"} - } - }, - "hydra:search": {} - }, - "additionalProperties": false - } - """ - - Scenario: Test presence of next - When I send a "GET" request to "/dummies?page=3" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "@id":"/dummies?page=3", - "@type":"hydra:PartialCollectionView", - "hydra:first":"/dummies?page=1", - "hydra:last":"/dummies?page=10", - "hydra:previous":"/dummies?page=2", - "hydra:next":"/dummies?page=4" - } - """ - - Scenario: Filter with exact match - When I send a "GET" request to "/dummies?id=8" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 1}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/8$"} - } - }, - "maxItems": 1 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?id=8$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - }, - "hydra:search": {} - }, - "additionalProperties": false - } - """ - - Scenario: Filter with a raw URL - When I send a "GET" request to "/dummies?id=%2fdummies%2f8" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 1}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/8$"} - } - }, - "maxItems": 1 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?id=%2Fdummies%2F8$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - }, - "hydra:search": {} - }, - "additionalProperties": false - } - """ - - Scenario: Filter with non-exact match - When I send a "GET" request to "/dummies?name=Dummy%20%238" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 1}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/8$"} - } - }, - "maxItems": 1 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?name=Dummy%20%238$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - }, - "hydra:search": {} - }, - "additionalProperties": false - } - """ - - @createSchema - Scenario: Allow passing 0 to `itemsPerPage` - When I send a "GET" request to "/dummies?itemsPerPage=0" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 30}, - "hydra:member": { - "type": "array", - "minItems": 0, - "maxItems": 0 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?itemsPerPage=0$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"}, - "hydra:first": {"pattern": "^/dummies\\?itemsPerPage=0&page=1$"}, - "hydra:last": {"pattern": "^/dummies\\?itemsPerPage=0&page=1$"}, - "hydra:previous": {"pattern": "^/dummies\\?itemsPerPage=0&page=1$"}, - "hydra:next": {"pattern": "^/dummies\\?itemsPerPage=0&page=1$"} - } - }, - "hydra:search": {} - }, - "additionalProperties": false - } - """ - - When I send a "GET" request to "/dummies?itemsPerPage=0&page=2" - Then the response status code should be 400 - And the JSON node "detail" should be equal to "Page should not be greater than 1 if limit is equal to 0" - - Scenario: Cursor-based pagination with an empty collection - When I send a "GET" request to "/so_manies" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/SoMany$"}, - "@id": {"pattern": "^/so_manies$"}, - "@type": {"pattern": "^hydra:Collection"}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/so_manies$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "additionalProperties": false - }, - "hydra:member": { - "type": "array" - } - } - } - """ - - @createSchema - Scenario: Cursor-based pagination with ranged items - Given there are 10 of these so many objects - When I send a "GET" request to "/so_manies?order[id]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/SoMany$"}, - "@id": {"pattern": "^/so_manies$"}, - "@type": {"pattern": "^hydra:Collection"}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/so_manies\\?order%5Bid%5D=desc$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"}, - "hydra:previous": {"pattern": "^/so_manies\\?order%5Bid%5D=desc&id%5Bgt%5D=10$"}, - "hydra:next": {"pattern": "^/so_manies\\?order%5Bid%5D=desc&id%5Blt%5D=8$"} - }, - "additionalProperties": false - }, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/so_manies/8$"}, - {"pattern": "^/so_manies/9$"}, - {"pattern": "^/so_manies/10$"} - ] - } - } - }, - "minItems": 3 - } - } - } - """ - - @createSchema - Scenario: Cursor-based pagination with range filtered items - Given there are 10 of these so many objects - When I send a "GET" request to "/so_manies?order[id]=desc&id[gt]=10" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/SoMany$"}, - "@id": {"pattern": "^/so_manies$"}, - "@type": {"pattern": "^hydra:Collection"}, - "hydra:member": { - "type": "array", - "maxItems": 0 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/so_manies\\?id%5Bgt%5D=10&order%5Bid%5D=desc$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"}, - "hydra:previous": {"pattern": "^/so_manies\\?id%5Bgt%5D=13&order%5Bid%5D=desc$"}, - "hydra:next": {"pattern": "^/so_manies\\?id%5Blt%5D=10&order%5Bid%5D=desc$"} - }, - "additionalProperties": false - } - } - } - """ - - Scenario: Hydra collection without prefix - When I send a "GET" request to "/no_hydra_prefixes" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "totalItems" should exist - And the JSON node "member" should exist diff --git a/features/hydra/docs.feature b/features/hydra/docs.feature deleted file mode 100644 index 76e2d83d8e2..00000000000 --- a/features/hydra/docs.feature +++ /dev/null @@ -1,84 +0,0 @@ -Feature: Documentation support - In order to build an auto-discoverable API - As a client software developer - I need to know Hydra specifications of objects I send and receive - - Scenario: Checks that the Link pointing to the Hydra documentation is set - Given I send a "GET" request to "/" - Then the header "Link" should be equal to '; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"' - - Scenario: Retrieve the API vocabulary - Given I send a "GET" request to "/docs.jsonld" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - # Context - And the Hydra context matches the online resource "http://www.w3.org/ns/hydra/context.jsonld" - And the JSON node "@context[1].@vocab" should be equal to "http://example.com/docs.jsonld#" - And the JSON node "@context[1].domain.@id" should be equal to "rdfs:domain" - And the JSON node "@context[1].domain.@type" should be equal to "@id" - And the JSON node "@context[1].range.@id" should be equal to "rdfs:range" - And the JSON node "@context[1].range.@type" should be equal to "@id" - And the JSON node "@context[1].subClassOf.@id" should be equal to "rdfs:subClassOf" - And the JSON node "@context[1].subClassOf.@type" should be equal to "@id" - # Root properties - And the JSON node "@id" should be equal to "/docs.jsonld" - And the JSON node "hydra:title" should be equal to "My Dummy API" - And the JSON node "hydra:description" should contain "This is a test API." - And the JSON node "hydra:description" should contain "Made with love" - And the JSON node "hydra:entrypoint" should be equal to "/" - # Supported classes - And the Hydra class "Entrypoint" exists - And the Hydra class "ConstraintViolation" exists - And the Hydra class "ConstraintViolationList" exists - And the Hydra class "CircularReference" exists - And the Hydra class "CustomIdentifierDummy" exists - And the Hydra class "CustomNormalizedDummy" exists - And the Hydra class "CustomWritableIdentifierDummy" exists - And the Hydra class "Dummy" exists - And the Hydra class "RelatedDummy" exists - And the Hydra class "RelationEmbedder" exists - And the Hydra class "ThirdLevel" exists - And the Hydra class "ParentDummy" doesn't exist - And the Hydra class "UnknownDummy" doesn't exist - # Doc - And the value of the node "@id" of the Hydra class "Dummy" is "#Dummy" - And the value of the node "@type" of the Hydra class "Dummy" is "hydra:Class" - And the value of the node "hydra:title" of the Hydra class "Dummy" is "Dummy" - And the value of the node "hydra:description" of the Hydra class "Dummy" is "Dummy." - # Properties - And "name" property is readable for Hydra class "Dummy" - And "name" property is writable for Hydra class "Dummy" - And "name" property is required for Hydra class "Dummy" - And "plainPassword" property is not readable for Hydra class "User" - And "plainPassword" property is writable for Hydra class "User" - And "plainPassword" property is not required for Hydra class "User" - And the value of the node "@type" of the property "name" of the Hydra class "Dummy" is "hydra:SupportedProperty" - And the value of the node "hydra:property.@id" of the property "name" of the Hydra class "Dummy" is "https://schema.org/name" - And the value of the node "hydra:property.@type" of the property "name" of the Hydra class "Dummy" is "rdf:Property" - And the value of the node "hydra:property.label" of the property "name" of the Hydra class "Dummy" is "name" - And the value of the node "hydra:property.domain" of the property "name" of the Hydra class "Dummy" is "#Dummy" - And the value of the node "hydra:property.range" of the property "name" of the Hydra class "Dummy" is "xsd:string" - And the value of the node "subClassOf" of the Hydra class "RelatedDummy" is "https://schema.org/Product" - And the value of the node "hydra:property.range" of the property "relatedDummy" of the Hydra class "Dummy" is "#RelatedDummy" - And the value of the node "hydra:property.owl:maxCardinality" of the property "relatedDummy" of the Hydra class "Dummy" is "1" - And the value of the node "hydra:property.range" of the property "relatedDummies" of the Hydra class "Dummy" is "#RelatedDummy" - And the value of the node "hydra:title" of the property "name" of the Hydra class "Dummy" is "name" - And the value of the node "hydra:description" of the property "name" of the Hydra class "Dummy" is "The dummy name" - # Operations - And the value of the node "@type" of the operation "GET" of the Hydra class "Dummy" contains "hydra:Operation" - And the value of the node "@type" of the operation "GET" of the Hydra class "Dummy" contains "schema:FindAction" - And the value of the node "hydra:method" of the operation "GET" of the Hydra class "Dummy" is "GET" - And the value of the node "hydra:title" of the operation "GET" of the Hydra class "Dummy" is "getDummy" - And the value of the node "hydra:description" of the operation "GET" of the Hydra class "Dummy" is "Retrieves a Dummy resource." - And the value of the node "returns" of the operation "GET" of the Hydra class "Dummy" is "Dummy" - And the value of the node "hydra:title" of the operation "PUT" of the Hydra class "Dummy" is "putDummy" - And the value of the node "hydra:description" of the operation "PUT" of the Hydra class "Dummy" is "Replaces the Dummy resource." - And the value of the node "hydra:description" of the operation "DELETE" of the Hydra class "Dummy" is "Deletes the Dummy resource." - And the value of the node "hydra:title" of the operation "DELETE" of the Hydra class "Dummy" is "deleteDummy" - And the value of the node "returns" of the operation "DELETE" of the Hydra class "Dummy" is "owl:Nothing" - # Deprecations - And the boolean value of the node "owl:deprecated" of the Hydra class "DeprecatedResource" is true - And the boolean value of the node "hydra:property.owl:deprecated" of the property "deprecatedField" of the Hydra class "DeprecatedResource" is true - And the boolean value of the node "owl:deprecated" of the property "getDeprecatedResourceCollection" of the Hydra class "Entrypoint" is true - And the boolean value of the node "owl:deprecated" of the operation "GET" of the Hydra class "DeprecatedResource" is true diff --git a/features/hydra/entrypoint.feature b/features/hydra/entrypoint.feature deleted file mode 100644 index b2b8f731d27..00000000000 --- a/features/hydra/entrypoint.feature +++ /dev/null @@ -1,30 +0,0 @@ -Feature: Entrypoint support - In order to build an auto-discoverable API - As a client software developer - I need to access to an entrypoint listing top-level resources - - Scenario: Retrieve the Entrypoint - When I add "Accept" header equal to "application/ld+json" - When I send a "GET" request to "/" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be sorted - And the JSON node "@context" should be equal to "/contexts/Entrypoint" - And the JSON node "@id" should be equal to "/" - And the JSON node "@type" should be equal to "Entrypoint" - And the JSON node "abstractDummy" should be equal to "/abstract_dummies" - And the JSON node "circularReference" should be equal to "/circular_references" - And the JSON node "compositeItem" should be equal to "/composite_items" - And the JSON node "compositeLabel" should be equal to "/composite_labels" - And the JSON node "compositeRelation" should be equal to "/composite_relations" - And the JSON node "concreteDummy" should be equal to "/concrete_dummies" - And the JSON node "customIdentifierDummy" should be equal to "/custom_identifier_dummies" - And the JSON node "customNormalizedDummy" should be equal to "/custom_normalized_dummies" - And the JSON node "customWritableIdentifierDummy" should be equal to "/custom_writable_identifier_dummies" - And the JSON node "dummy" should be equal to "/dummies" - And the JSON node "relatedDummy" should be equal to "/related_dummies" - And the JSON node "relationEmbedder" should be equal to "/relation_embedders" - And the JSON node "thirdLevel" should be equal to "/third_levels" - And the JSON node "user" should be equal to "/users" - And the JSON node "fileconfigdummy" should be equal to "/fileconfigdummies" diff --git a/features/hydra/error.feature b/features/hydra/error.feature deleted file mode 100644 index 07fe8210f02..00000000000 --- a/features/hydra/error.feature +++ /dev/null @@ -1,138 +0,0 @@ -@!mongodb -Feature: Error handling - In order to be able to handle error client side - As a client software developer - I need to retrieve an Hydra serialization of errors - That is compatible with the JSON Problem specification - - Scenario: Get an rfc 7807 error - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/exception_problems" with body: - """ - {} - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' - And the JSON node "type" should exist - And the JSON node "title" should not exists - And the JSON node "hydra:title" should be equal to "An error occurred" - And the JSON node "detail" should exist - And the JSON node "description" should not exist - And the JSON node "hydra:description" should exist - And the JSON node "trace" should exist - And the JSON node "status" should exist - And the JSON node "@context" should exist - - Scenario: Get validation constraint violations - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_problems" with body: - """ - {} - """ - Then the response status code should be 422 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/ConstraintViolation", - "@id": "/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3", - "@type": "ConstraintViolation", - "status": 422, - "violations": [ - { - "propertyPath": "name", - "message": "This value should not be blank.", - "code": "c1051bb4-d103-4f74-8988-acbcafc7fdc3" - } - ], - "detail": "name: This value should not be blank.", - "hydra:title": "An error occurred", - "hydra:description": "name: This value should not be blank.", - "type": "/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3" - } - """ - - Scenario: Get an rfc 7807 bad request error - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/exception_problems" with body: - """ - {} - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' - And the JSON node "@context" should exist - And the JSON node "type" should exist - And the JSON node "hydra:title" should be equal to "An error occurred" - And the JSON node "detail" should exist - - Scenario: Get an rfc 7807 not found error - When I add "Accept" header equal to "application/ld+json" - And I send a "POST" request to "/does_not_exist" with body: - """ - {} - """ - Then the response status code should be 404 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' - And the JSON node "@context" should exist - And the JSON node "type" should exist - And the JSON node "hydra:title" should be equal to "An error occurred" - And the JSON node "detail" should exist - And the JSON node "description" should not exist - - Scenario: Get an rfc 7807 bad method error - When I add "Content-Type" header equal to "application/ld+json" - And I add "Accept" header equal to "application/ld+json" - And I send a "PATCH" request to "/dummy_problems" with body: - """ - {} - """ - Then the response status code should be 405 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' - And the JSON node "@context" should exist - And the JSON node "type" should exist - And the JSON node "hydra:title" should be equal to "An error occurred" - And the JSON node "detail" should exist - And the JSON node "description" should not exist - - Scenario: Get an rfc 7807 validation error - When I add "Content-Type" header equal to "application/ld+json" - And I add "Accept" header equal to "application/ld+json" - And I send a "POST" request to "/validation_exception_problems" with body: - """ - {} - """ - Then the response status code should be 422 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' - And the JSON node "@context" should exist - And the JSON node "type" should exist - And the JSON node "hydra:title" should be equal to "An error occurred" - And the JSON node "detail" should exist - And the JSON node "violations" should exist - - Scenario: Get an rfc 7807 error - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/exception_problems_without_prefix" with body: - """ - {} - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' - And the JSON node "type" should exist - And the JSON node "hydra:title" should be equal to "An error occurred" - And the JSON node "detail" should exist - And the JSON node "description" should not exist - And the JSON node "trace" should exist - And the JSON node "status" should exist diff --git a/features/hydra/item_uri_template.feature b/features/hydra/item_uri_template.feature deleted file mode 100644 index 0c732832351..00000000000 --- a/features/hydra/item_uri_template.feature +++ /dev/null @@ -1,237 +0,0 @@ -@!mongodb -@v3 -Feature: Exposing a collection of objects should use the specified operation to generate the IRI - Background: - Given I add "Accept" header equal to "application/ld+json" - - Scenario: Get a collection of objects without any itemUriTemplate should generate the IRI from the first Get operation - When I send a "GET" request to "/cars" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], - "properties": { - "@context": {"pattern": "^/contexts/Car$"}, - "@id": {"pattern": "^/cars$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "uniqueItems": true, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "id", "owner"], - "properties": { - "@id": {"pattern": "^/cars/.+$"}, - "@type": {"pattern": "^Car$"}, - "id": {"type": "string"}, - "owner": {"type": "string"} - } - } - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} - } - } - """ - - Scenario: Get a collection of objects with an itemUriTemplate should generate the IRI from the correct operation - When I send a "GET" request to "/brands/renault/cars" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], - "properties": { - "@context": {"pattern": "^/contexts/Car$"}, - "@id": {"pattern": "^/brands/renault/cars$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "uniqueItems": true, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "id", "owner"], - "properties": { - "@id": {"pattern": "^/brands/renault/cars/.+$"}, - "@type": {"pattern": "^Car$"}, - "id": {"type": "string"}, - "owner": {"type": "string"} - } - } - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} - } - } - """ - - Scenario: Create an object without an itemUriTemplate should generate the IRI from the first Get operation - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/cars" with body: - """ - { - "owner": "Vincent" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "id", "owner"], - "properties": { - "@context": {"pattern": "^/contexts/Car$"}, - "@id": {"pattern": "^/cars/.+$"}, - "@type": {"pattern": "^Car$"}, - "id": {"type": "string"}, - "owner": {"type": "string"} - } - } - """ - - Scenario: Create an object with an itemUriTemplate should generate the IRI from the correct operation - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/brands/renault/cars" with body: - """ - { - "owner": "Vincent" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "id", "owner"], - "properties": { - "@context": {"pattern": "^/contexts/Car$"}, - "@id": {"pattern": "^/brands/renault/cars/.+$"}, - "@type": {"pattern": "^Car$"}, - "id": {"type": "string"}, - "owner": {"type": "string"} - } - } - """ - - Scenario: Get a collection referencing another resource for its IRI - When I send a "GET" request to "/item_referenced_in_collection" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context":"/contexts/CollectionReferencingItem", - "@id":"/item_referenced_in_collection", - "@type":"hydra:Collection", - "hydra:member":[ - { - "@id":"/item_referenced_in_collection/a", - "@type":"ItemReferencedInCollection", - "id":"a", - "name":"hello" - }, - { - "@id":"/item_referenced_in_collection/b", - "@type":"ItemReferencedInCollection", - "id":"b", - "name":"you" - } - ], - "hydra:totalItems":2 - } - """ - - Scenario: Get a collection referencing an itemUriTemplate - When I send a "GET" request to "/issue5662/books/a/reviews" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context":"/contexts/Review", - "@id":"/issue5662/books/a/reviews", - "@type":"hydra:Collection", - "hydra:member":[ - { - "@id":"/issue5662/books/a/reviews/1", - "@type":"Review", - "book":"/issue5662/books/a", - "id":1, - "body":"Best book ever!" - }, - { - "@id":"/issue5662/books/b/reviews/2", - "@type":"Review", - "book":"/issue5662/books/b", - "id":2, - "body":"Worst book ever!" - } - ], - "hydra:totalItems":2 - } - """ - - Scenario: Get a collection referencing an invalid itemUriTemplate - When I send a "GET" request to "/issue5662/admin/reviews" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Review", - "@id": "/issue5662/admin/reviews", - "@type": "hydra:Collection", - "hydra:totalItems": 2, - "hydra:member": [ - { - "@id": "/issue5662/admin/reviews/1", - "@type": "Review", - "book": "/issue5662/books/a", - "id": 1, - "body": "Best book ever!" - }, - { - "@id": "/issue5662/admin/reviews/2", - "@type": "Review", - "book": "/issue5662/books/b", - "id": 2, - "body": "Worst book ever!" - } - ] - } - """ - - Scenario: Create an object with an itemUriTemplate should generate the IRI according to the specified itemUriTemplate - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/issue5662/books/a/reviews" with body: - """ - { - "body": "Good book" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "@id" should be equal to "/issue5662/books/a/reviews/0" diff --git a/features/jsonapi/absolute_url.feature b/features/jsonapi/absolute_url.feature deleted file mode 100644 index 45b4444e442..00000000000 --- a/features/jsonapi/absolute_url.feature +++ /dev/null @@ -1,125 +0,0 @@ -Feature: IRI should contain Absolute URL - In order to add detail to IRIs - Include the absolute url - - @createSchema - Scenario: I should be able to GET a collection of Objects with Absolute Urls - Given there are 1 absoluteUrlDummy objects with a related absoluteUrlRelationDummy - And I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/absolute_url_dummies" - And the JSON should be equal to: - """ - { - "links": { - "self": "http://example.com/absolute_url_dummies" - }, - "meta": { - "totalItems": 1, - "itemsPerPage": 3, - "currentPage": 1 - }, - "data": [ - { - "id": "http://example.com/absolute_url_dummies/1", - "type": "AbsoluteUrlDummy", - "attributes": { - "_id": 1 - }, - "relationships": { - "absoluteUrlRelationDummy": { - "data": { - "type": "AbsoluteUrlRelationDummy", - "id": "http://example.com/absolute_url_relation_dummies/1" - } - } - } - } - ] - } - """ - - Scenario: I should be able to POST an object using an Absolute Url - Given I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/absolute_url_relation_dummies" with body: - """ - { - "absolute_url_dummies": "http://example.com/absolute_url_dummies/1" - } - """ - Then the response status code should be 201 - And the JSON should be equal to: - """ - { - "data": { - "id": "http://example.com/absolute_url_relation_dummies/2", - "type": "AbsoluteUrlRelationDummy", - "attributes": { - "_id": 2 - }, - "relationships": { - "absoluteUrlDummies": { - "data": [] - } - } - } - } - """ - - Scenario: I should be able to GET an Item with Absolute Urls - Given I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/absolute_url_dummies/1" - And the JSON should be equal to: - """ - { - "data": { - "id": "http://example.com/absolute_url_dummies/1", - "type": "AbsoluteUrlDummy", - "attributes": { - "_id": 1 - }, - "relationships": { - "absoluteUrlRelationDummy": { - "data": { - "type": "AbsoluteUrlRelationDummy", - "id": "http://example.com/absolute_url_relation_dummies/1" - } - } - } - } - } - """ - - Scenario: I should be able to GET resources with Absolute Urls - Given I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/absolute_url_relation_dummies/1/absolute_url_dummies" - And the JSON should be equal to: - """ - { - "links": { - "self": "http://example.com/absolute_url_relation_dummies/1/absolute_url_dummies" - }, - "meta": { - "totalItems": 1, - "itemsPerPage": 3, - "currentPage": 1 - }, - "data": [ - { - "id": "http://example.com/absolute_url_dummies/1", - "type": "AbsoluteUrlDummy", - "attributes": { - "_id": 1 - }, - "relationships": { - "absoluteUrlRelationDummy": { - "data": { - "type": "AbsoluteUrlRelationDummy", - "id": "http://example.com/absolute_url_relation_dummies/1" - } - } - } - } - ] - } - """ diff --git a/features/jsonapi/collection_attributes.feature b/features/jsonapi/collection_attributes.feature deleted file mode 100644 index 6603d36087d..00000000000 --- a/features/jsonapi/collection_attributes.feature +++ /dev/null @@ -1,20 +0,0 @@ -Feature: JSON API collections support - In order to use the JSON API hypermedia format - As a client software developer - I need to be able to retrieve valid JSON API responses for collection attributes on entities. - - Background: - Given I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/vnd.api+json" - - @createSchema - Scenario: Correctly serialize a collection - Given there is a CircularReference - When I send a "GET" request to "/circular_references/1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data.id" should be equal to "/circular_references/1" - And the JSON node "data.relationships.parent.data.id" should be equal to "/circular_references/1" - And the JSON node "data.relationships.children.data[0].id" should match "#/circular_references/(1|2)#" - And the JSON node "data.relationships.children.data[1].id" should match "#/circular_references/(1|2)#" diff --git a/features/jsonapi/collection_uri_template.feature b/features/jsonapi/collection_uri_template.feature deleted file mode 100644 index 6fa13c00732..00000000000 --- a/features/jsonapi/collection_uri_template.feature +++ /dev/null @@ -1,60 +0,0 @@ -@php8 -@v3 -Feature: Exposing a property being a collection of resources - can return an IRI instead of an array - when the uriTemplate is set on the ApiProperty attribute - - Background: - Given I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/vnd.api+json" - - @createSchema - Scenario: Retrieve Resource with uriTemplate collection Property - Given there are propertyCollectionIriOnly with relations - And I send a "GET" request to "/property_collection_iri_onlies/1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "links": { - "propertyCollectionIriOnlyRelation": "/property-collection-relations", - "iterableIri": "/parent/1/another-collection-operations", - "toOneRelation": "/parent/1/property-uri-template/one-to-ones/1" - }, - "data": { - "id": "/property_collection_iri_onlies/1", - "type": "PropertyCollectionIriOnly", - "relationships": { - "propertyCollectionIriOnlyRelation": { - "data": [ - { - "type": "PropertyCollectionIriOnlyRelation", - "id": "/property_collection_iri_only_relations/1" - }, - { - "type": "PropertyCollectionIriOnlyRelation", - "id": "/property_collection_iri_only_relations/2" - } - ] - }, - "iterableIri": { - "data": [ - { - "type": "PropertyCollectionIriOnlyRelation", - "id": "/property_collection_iri_only_relations/9999" - } - ] - }, - "toOneRelation": { - "data": { - "type": "PropertyUriTemplateOneToOneRelation", - "id": "/parent/1/property-uri-template/one-to-ones/1" - } - } - } - } - } - """ diff --git a/features/jsonapi/errors.feature b/features/jsonapi/errors.feature deleted file mode 100644 index 24e99594478..00000000000 --- a/features/jsonapi/errors.feature +++ /dev/null @@ -1,63 +0,0 @@ -@!mongodb -Feature: JSON API error handling - In order to be able to handle error client side - As a client software developer - I need to retrieve an JSON API serialization of errors - - Background: - Given I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/vnd.api+json" - - @createSchema - Scenario: Get a validation error on an attribute - When I send a "POST" request to "/dummy_problems" with body: - """ - { - "data": { - "type": "dummy", - "attributes": {} - } - } - """ - Then the response status code should be 422 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "errors": [ - { - "detail": "This value should not be blank.", - "source": { - "pointer": "data/attributes/name" - } - } - ] - } - """ - - Scenario: Get an rfc 7807 error - When I send a "POST" request to "/exception_problems" with body: - """ - {} - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" - And the JSON node "errors[0].title" should be equal to "An error occurred" - And the JSON node "errors[0].status" should be equal to 400 - And the JSON node "errors[0].detail" should exist - And the JSON node "errors[0].type" should exist - - Scenario: Get an rfc 7807 error - When I send a "POST" request to "/does_not_exist" with body: - """ - {} - """ - Then the response status code should be 404 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" - And the JSON node "errors[0].title" should be equal to "An error occurred" - And the JSON node "errors[0].status" should be equal to 404 - And the JSON node "errors[0].detail" should exist - And the JSON node "errors[0].type" should exist diff --git a/features/jsonapi/filtering.feature b/features/jsonapi/filtering.feature deleted file mode 100644 index ce8f209caa5..00000000000 --- a/features/jsonapi/filtering.feature +++ /dev/null @@ -1,51 +0,0 @@ -Feature: JSON API filter handling - In order to be able to handle filtering - As a client software developer - I need to be able to specify filtering parameters according to JSON API recommendation - - Background: - Given I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/vnd.api+json" - - @createSchema - Scenario: Apply filters based on the 'filter' query parameter with 'my' as value - Given there are 30 dummy objects with dummyDate - When I send a "GET" request to "/dummies?filter[name]=my" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data" should have 3 elements - - Scenario: Apply filters based on the 'filter' query parameter with 'foo' as value - When I send a "GET" request to "/dummies?filter[name]=foo" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data" should have 0 elements - - Scenario: Apply filters and pagination at the same time - When I send a "GET" request to "/dummies?filter[name]=foo&page[page]=2" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - Then the JSON node "meta.currentPage" should be a number - Then the JSON node "meta.currentPage" should be equal to "2" - - Scenario: Apply property filter based on the 'fields' - Given there are 2 dummy property objects - When I send a "GET" request to "/dummy_properties?fields[DummyProperty]=id,foo,bar" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data" should have 2 elements - And the JSON node "data[0].attributes._id" should be equal to "1" - And the JSON node "data[0].attributes.foo" should be equal to "Foo #1" - And the JSON node "data[0].attributes.bar" should be equal to "Bar #1" - And the JSON node "data[0].attributes.group" should not exist - - Scenario: Apply filters based on the 'filter' query parameter with second level arguments - When I send a "GET" request to "/dummies?filter[dummyDate][after]=2015-04-28" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data" should have 2 elements diff --git a/features/jsonapi/input_output.feature b/features/jsonapi/input_output.feature deleted file mode 100644 index 1fb6771081d..00000000000 --- a/features/jsonapi/input_output.feature +++ /dev/null @@ -1,59 +0,0 @@ -Feature: JSON API DTO input and output - In order to use a hypermedia API - As a client software developer - I need to be able to use DTOs on my resources as Input or Output objects. - - Background: - Given I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/vnd.api+json" - - @createSchema - Scenario: Get an item with a custom output - Given there is a DummyDtoCustom - When I send a "GET" request to "/dummy_dto_custom_output/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" - And the JSON should be valid according to the JSON API schema - And the JSON should be a superset of: - """ - { - "data": { - "type": "CustomOutputDto", - "attributes": { - "foo": "test", - "bar": 1 - } - } - } - """ - - @createSchema - Scenario: Get a collection with a custom output - Given there are 2 DummyDtoCustom - When I send a "GET" request to "/dummy_dto_custom_output" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" - And the JSON should be valid according to the JSON API schema - And the JSON should be a superset of: - """ - { - "data": [ - { - "type": "CustomOutputDto", - "attributes": { - "foo": "test", - "bar": 1 - } - }, - { - "type": "CustomOutputDto", - "attributes": { - "foo": "test", - "bar": 2 - } - } - ] - } - """ diff --git a/features/jsonapi/item_uri_template.feature b/features/jsonapi/item_uri_template.feature deleted file mode 100644 index 7dbfb223580..00000000000 --- a/features/jsonapi/item_uri_template.feature +++ /dev/null @@ -1,200 +0,0 @@ -@php8 -@v3 -Feature: Exposing a collection of objects should use the specified operation to generate the IRI - - Scenario: Get a collection of objects without any itemUriTemplate should generate the IRI from the first Get operation - When I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/cars" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON HAL schema - And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["links", "meta", "data"], - "properties": { - "links": { - "type": "object", - "additionalProperties": false, - "required": ["self"], - "properties": { - "self": {"pattern": "^/cars$"} - } - }, - "meta": { - "type": "object", - "additionalProperties": false, - "required": ["totalItems"], - "properties": { - "totalItems": {"type": "number", "minimum": 2, "maximum": 2} - } - }, - "data": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "uniqueItems": true, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "type", "attributes"], - "properties": { - "id": {"pattern": "^/cars/.+$"}, - "type": {"pattern": "^Car$"}, - "attributes": { - "type": "object", - "additionalProperties": false, - "required": ["_id", "owner"], - "properties": { - "_id": {"type": "string"}, - "owner": {"type": "string"} - } - } - } - } - } - } - } - """ - - Scenario: Get a collection of objects with an itemUriTemplate should generate the IRI from the correct operation - When I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/brands/renault/cars" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["links", "meta", "data"], - "properties": { - "links": { - "type": "object", - "additionalProperties": false, - "required": ["self"], - "properties": { - "self": {"pattern": "^/brands/renault/cars$"} - } - }, - "meta": { - "type": "object", - "additionalProperties": false, - "required": ["totalItems"], - "properties": { - "totalItems": {"type": "number", "minimum": 2, "maximum": 2} - } - }, - "data": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "uniqueItems": true, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["id", "type", "attributes"], - "properties": { - "id": {"pattern": "^/brands/renault/cars/.+$"}, - "type": {"pattern": "^Car$"}, - "attributes": { - "type": "object", - "additionalProperties": false, - "required": ["_id", "owner"], - "properties": { - "_id": {"type": "string"}, - "owner": {"type": "string"} - } - } - } - } - } - } - } - """ - - Scenario: Create an object without an itemUriTemplate should generate the IRI from the first Get operation - When I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/cars" with body: - """ - { - "owner": "Vincent" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["data"], - "properties": { - "data": { - "type": "object", - "additionalProperties": false, - "required": ["id", "type", "attributes"], - "properties": { - "id": {"pattern": "^/cars/.+$"}, - "type": {"pattern": "^Car$"}, - "attributes": { - "type": "object", - "additionalProperties": false, - "required": ["_id", "owner"], - "properties": { - "_id": {"type": "string"}, - "owner": {"type": "string"} - } - } - } - } - } - } - """ - - Scenario: Create an object with an itemUriTemplate should generate the IRI from the correct operation - When I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/brands/renault/cars" with body: - """ - { - "owner": "Vincent" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["data"], - "properties": { - "data": { - "type": "object", - "additionalProperties": false, - "required": ["id", "type", "attributes"], - "properties": { - "id": {"pattern": "^/brands/renault/cars/.+$"}, - "type": {"pattern": "^Car$"}, - "attributes": { - "type": "object", - "additionalProperties": false, - "required": ["_id", "owner"], - "properties": { - "_id": {"type": "string"}, - "owner": {"type": "string"} - } - } - } - } - } - } - """ diff --git a/features/jsonapi/jsonapi.feature b/features/jsonapi/jsonapi.feature deleted file mode 100644 index 39a5d3920ea..00000000000 --- a/features/jsonapi/jsonapi.feature +++ /dev/null @@ -1,257 +0,0 @@ -Feature: JSON API basic support - In order to use the JSON API hypermedia format - As a client software developer - I need to be able to retrieve valid JSON API responses. - - Background: - Given I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/vnd.api+json" - - @createSchema - Scenario: Retrieve the API entrypoint - When I send a "GET" request to "/" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" - And the JSON node "links.self" should be equal to "http://example.com/" - And the JSON node "links.dummy" should be equal to "http://example.com/dummies" - - Scenario: Test empty list against JSON API schema - When I send a "GET" request to "/dummies" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data" should be an empty array - - Scenario: Create a ThirdLevel - When I send a "POST" request to "/third_levels" with body: - """ - { - "data": { - "type": "third-level", - "attributes": { - "level": 3 - } - } - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data.id" should not be an empty string - - Scenario: Retrieve the collection - When I send a "GET" request to "/third_levels" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - - Scenario: Retrieve the third level - When I send a "GET" request to "/third_levels/1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - - Scenario: Create a related dummy - When I send a "POST" request to "/related_dummies" with body: - """ - { - "data": { - "type": "related-dummy", - "attributes": { - "name": "John Doe", - "age": 23 - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "third-level", - "id": "/third_levels/1" - } - } - } - } - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data.id" should not be an empty string - And the JSON node "data.attributes.name" should be equal to "John Doe" - And the JSON node "data.attributes.age" should be equal to the number 23 - - Scenario: Create a dummy with relations - Given there is a RelatedDummy - When I send a "POST" request to "/dummies" with body: - """ - { - "data": { - "type": "dummy", - "attributes": { - "name": "Dummy with relations", - "dummyDate": "2015-03-01T10:00:00+00:00" - }, - "relationships": { - "relatedDummy": { - "data": { - "type": "related-dummy", - "id": "/related_dummies/2" - } - }, - "relatedDummies": { - "data": [ - { - "type": "related-dummy", - "id": "/related_dummies/1" - }, - { - "type": "related-dummy", - "id": "/related_dummies/2" - } - ] - } - } - } - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data.relationships.relatedDummies.data" should have 2 elements - And the JSON node "data.relationships.relatedDummy.data.id" should be equal to "/related_dummies/2" - - Scenario: Update a resource with a many-to-many relationship via PATCH - When I send a "PATCH" request to "/dummies/1" with body: - """ - { - "data": { - "type": "dummy", - "relationships": { - "relatedDummy": { - "data": { - "type": "related-dummy", - "id": "/related_dummies/1" - } - }, - "relatedDummies": { - "data": [ - { - "type": "related-dummy", - "id": "/related_dummies/2" - } - ] - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data.relationships.relatedDummies.data" should have 1 elements - And the JSON node "data.relationships.relatedDummy.data.id" should be equal to "/related_dummies/1" - - Scenario: Create a related dummy with an empty relationship - When I send a "POST" request to "/related_dummies" with body: - """ - { - "data": { - "type": "related-dummy", - "attributes": { - "name": "John Doe" - }, - "relationships": { - "thirdLevel": { - "data": null - } - } - } - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - - Scenario: Retrieve a collection with relationships - When I send a "GET" request to "/related_dummies" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data[0].relationships.thirdLevel.data.id" should be equal to "/third_levels/1" - - Scenario: Retrieve the related dummy - When I send a "GET" request to "/related_dummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/related_dummies/1", - "type": "RelatedDummy", - "attributes": { - "_id": 1, - "name": "John Doe", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": [], - "age": 23 - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - } - } - """ - - Scenario: Update a resource via PATCH - When I send a "PATCH" request to "/related_dummies/1" with body: - """ - { - "data": { - "type": "related-dummy", - "attributes": { - "name": "Jane Doe" - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data.id" should not be an empty string - And the JSON node "data.attributes.name" should be equal to "Jane Doe" - And the JSON node "data.attributes.age" should be equal to the number 23 - - Scenario: Embed a relation in a parent object - When I send a "POST" request to "/relation_embedders" with body: - """ - { - "data": { - "relationships": { - "related": { - "data": { - "type": "related-dummy", - "id": "/related_dummies/1" - } - } - } - } - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON node "data.id" should not be an empty string - And the JSON node "data.attributes.krondstadt" should be equal to "Krondstadt" - And the JSON node "data.relationships.related.data.id" should be equal to "/related_dummies/1" diff --git a/features/jsonapi/network_path.feature b/features/jsonapi/network_path.feature deleted file mode 100644 index 810ba0f9612..00000000000 --- a/features/jsonapi/network_path.feature +++ /dev/null @@ -1,125 +0,0 @@ -Feature: IRI should contain network path - In order to add detail to IRIs - Include the network path - - @createSchema - Scenario: I should be able to GET a collection of objects with network paths - Given there are 1 networkPathDummy objects with a related networkPathRelationDummy - And I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/network_path_dummies" - And the JSON should be equal to: - """ - { - "links": { - "self": "//example.com/network_path_dummies" - }, - "meta": { - "totalItems": 1, - "itemsPerPage": 3, - "currentPage": 1 - }, - "data": [ - { - "id": "//example.com/network_path_dummies/1", - "type": "NetworkPathDummy", - "attributes": { - "_id": 1 - }, - "relationships": { - "networkPathRelationDummy": { - "data": { - "type": "NetworkPathRelationDummy", - "id": "//example.com/network_path_relation_dummies/1" - } - } - } - } - ] - } - """ - - Scenario: I should be able to POST an object using a network path - Given I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/network_path_relation_dummies" with body: - """ - { - "network_path_dummies": "//example.com/network_path_dummies/1" - } - """ - Then the response status code should be 201 - And the JSON should be equal to: - """ - { - "data": { - "id": "//example.com/network_path_relation_dummies/2", - "type": "NetworkPathRelationDummy", - "attributes": { - "_id": 2 - }, - "relationships": { - "networkPathDummies": { - "data": [] - } - } - } - } - """ - - Scenario: I should be able to GET an Item with network paths - Given I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/network_path_dummies/1" - And the JSON should be equal to: - """ - { - "data": { - "id": "//example.com/network_path_dummies/1", - "type": "NetworkPathDummy", - "attributes": { - "_id": 1 - }, - "relationships": { - "networkPathRelationDummy": { - "data": { - "type": "NetworkPathRelationDummy", - "id": "//example.com/network_path_relation_dummies/1" - } - } - } - } - } - """ - - Scenario: I should be able to GET resources with network paths - Given I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/network_path_relation_dummies/1/network_path_dummies" - And the JSON should be equal to: - """ - { - "links": { - "self": "//example.com/network_path_relation_dummies/1/network_path_dummies" - }, - "meta": { - "totalItems": 1, - "itemsPerPage": 3, - "currentPage": 1 - }, - "data": [ - { - "id": "//example.com/network_path_dummies/1", - "type": "NetworkPathDummy", - "attributes": { - "_id": 1 - }, - "relationships": { - "networkPathRelationDummy": { - "data": { - "type": "NetworkPathRelationDummy", - "id": "//example.com/network_path_relation_dummies/1" - } - } - } - } - ] - } - """ diff --git a/features/jsonapi/non_resource.feature b/features/jsonapi/non_resource.feature deleted file mode 100644 index 493e83efcc0..00000000000 --- a/features/jsonapi/non_resource.feature +++ /dev/null @@ -1,126 +0,0 @@ -Feature: JSON API non-resource handling - In order to use non-resource types - As a developer - I should be able to serialize types not mapped to an API resource. - - Background: - Given I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/vnd.api+json" - - Scenario: Get a resource containing a raw object - When I send a "GET" request to "/contain_non_resources/1?include=nested" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" - And the JSON should be valid according to the JSON API schema - And the JSON should be a superset of: - """ - { - "data": { - "id": "/contain_non_resources/1", - "type": "ContainNonResource", - "attributes": { - "_id": 1, - "notAResource": { - "foo": "f1", - "bar": "b1" - } - }, - "relationships": { - "nested": { - "data": { - "id": "/contain_non_resources/1-nested", - "type": "ContainNonResource" - } - } - } - }, - "included": [ - { - "id": "/contain_non_resources/1-nested", - "type": "ContainNonResource", - "attributes": { - "_id": "1-nested", - "notAResource": { - "foo": "f2", - "bar": "b2" - } - } - } - ] - } - """ - - @!mongodb - @createSchema - Scenario: Create a resource that has a non-resource relation. - When I send a "POST" request to "/non_relation_resources" with body: - """ - { - "data": { - "type": "NonRelationResource", - "attributes": { - "relation": { - "foo": "test" - } - } - } - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" - And the JSON should be valid according to the JSON API schema - And the JSON should be a superset of: - """ - { - "data": { - "id": "/non_relation_resources/1", - "type": "NonRelationResource", - "attributes": { - "_id": 1, - "relation": { - "foo": "test" - } - } - } - } - """ - - @!mongodb - @createSchema - Scenario: Create a resource that contains a stdClass object. - When I send a "POST" request to "/plain_object_dummies" with body: - """ - { - "data": { - "type": "PlainObjectDummy", - "attributes": { - "content":"{\"fields\":{\"title\":{\"value\":\"\"},\"images\":[{\"id\":0,\"categoryId\":0,\"uri\":\"/api/pictures\",\"resource\":\"{}\",\"description\":\"\",\"alt\":\"\",\"type\":\"picture\",\"text\":\"\",\"src\":\"\"}],\"alternativeAudio\":{},\"caption\":\"\"},\"showCaption\":false,\"alternativeContent\":false,\"alternativeAudioContent\":false,\"blockLayout\":\"default\"}" - } - } - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" - And the JSON should be valid according to the JSON API schema - And the JSON should be a superset of: - """ - { - "data": { - "id": "/plain_object_dummies/1", - "type": "PlainObjectDummy", - "attributes": { - "_id": 1, - "data": { - "fields": [], - "showCaption": false, - "alternativeContent": false, - "alternativeAudioContent": false, - "blockLayout": "default" - } - } - } - } - """ diff --git a/features/jsonapi/ordering.feature b/features/jsonapi/ordering.feature deleted file mode 100644 index b3d4f687a3c..00000000000 --- a/features/jsonapi/ordering.feature +++ /dev/null @@ -1,144 +0,0 @@ -Feature: JSON API order handling - In order to be able to handle ordering - As a client software developer - I need to be able to specify ordering parameters according to JSON API recommendation - - Background: - Given I add "Content-Type" header equal to "application/vnd.api+json" - And I add "Accept" header equal to "application/vnd.api+json" - - @createSchema - Scenario: Get collection ordered in ascending order on an integer property and on which order filter has been enabled in whitelist mode - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?sort=id" - Then the response status code should be 200 - And the JSON should be valid according to the JSON API schema - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "_id": { - "type": "string", - "pattern": "^1$" - } - } - }, - { - "type": "object", - "properties": { - "_id": { - "type": "string", - "pattern": "^2$" - } - } - }, - { - "type": "object", - "properties": { - "_id": { - "type": "string", - "pattern": "^3$" - } - } - } - ] - } - } - } - """ - - Scenario: Get collection ordered in descending order on an integer property and on which order filter has been enabled in whitelist mode - When I send a "GET" request to "/dummies?sort=-id" - Then the response status code should be 200 - And the JSON should be valid according to the JSON API schema - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "_id": { - "type": "string", - "pattern": "^30$" - } - } - }, - { - "type": "object", - "properties": { - "_id": { - "type": "string", - "pattern": "^29$" - } - } - }, - { - "type": "object", - "properties": { - "_id": { - "type": "string", - "pattern": "^28$" - } - } - } - ] - } - } - } - """ - - Scenario: Get collection ordered on two properties previously whitelisted - When I send a "GET" request to "/dummies?sort=description,-id" - Then the JSON should be valid according to the JSON API schema - Then the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "_id": { - "type": "string", - "pattern": "^30$" - } - } - }, - { - "type": "object", - "properties": { - "_id": { - "type": "string", - "pattern": "^28$" - } - } - }, - { - "type": "object", - "properties": { - "_id": { - "type": "string", - "pattern": "^26$" - } - } - } - ] - } - } - } - """ diff --git a/features/jsonapi/pagination.feature b/features/jsonapi/pagination.feature deleted file mode 100644 index d07afd8d89c..00000000000 --- a/features/jsonapi/pagination.feature +++ /dev/null @@ -1,42 +0,0 @@ -Feature: JSON API pagination handling - In order to be able to handle pagination - As a client software developer - I need to retrieve an JSON API pagination information as metadata and links - - Background: - Given I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/vnd.api+json" - - @createSchema - Scenario: Get the first page of a paginated collection according to basic config - Given there are 10 dummy objects - When I send a "GET" request to "/dummies" - Then the response status code should be 200 - And the JSON should be valid according to the JSON API schema - And the JSON node "data" should have 3 elements - And the JSON node "meta.totalItems" should be equal to the number 10 - And the JSON node "meta.itemsPerPage" should be equal to the number 3 - And the JSON node "meta.currentPage" should be equal to the number 1 - - Scenario: Get the fourth page of a paginated collection according to basic config - When I send a "GET" request to "/dummies?page[page]=4" - Then the JSON should be valid according to the JSON API schema - And the JSON node "data" should have 1 elements - And the JSON node "meta.currentPage" should be equal to the number 4 - - Scenario: Get a paginated collection according to custom items per page in request - When I send a "GET" request to "/dummies?page[itemsPerPage]=15" - Then the response status code should be 200 - And the JSON should be valid according to the JSON API schema - And the JSON node "data" should have 10 elements - And the JSON node "meta.totalItems" should be equal to the number 10 - And the JSON node "meta.itemsPerPage" should be equal to the number 15 - And the JSON node "meta.currentPage" should be equal to the number 1 - - Scenario: Get an error when provided page number is not valid - When I send a "GET" request to "/dummies?page[page]=0" - Then the response status code should be 400 - - Scenario: Get an error when provided page number is too large - When I send a "GET" request to "/dummies?page[page]=9223372036854775807" - Then the response status code should be 400 diff --git a/features/jsonapi/related-resouces-inclusion.feature b/features/jsonapi/related-resouces-inclusion.feature deleted file mode 100644 index 2a6663d3fd1..00000000000 --- a/features/jsonapi/related-resouces-inclusion.feature +++ /dev/null @@ -1,1637 +0,0 @@ -Feature: JSON API Inclusion of Related Resources - In order to be able to handle inclusion of related resources - As a client software developer - I need to be able to specify include parameters according to JSON API recommendation - - Background: - Given I add "Accept" header equal to "application/vnd.api+json" - And I add "Content-Type" header equal to "application/vnd.api+json" - - @createSchema - Scenario: Request inclusion of a related resource (many to one) - Given there are 3 dummy property objects - When I send a "GET" request to "/dummy_properties/1?include=group" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummy_properties/1", - "type": "DummyProperty", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1", - "name_converted": "NameConverted #1" - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/1" - } - }, - "groups": { - "data": [] - } - } - }, - "included": [ - { - "id": "/dummy_groups/1", - "type": "DummyGroup", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1" - } - } - ] - } - """ - - @createSchema - Scenario: Request inclusion of a non existing related resource - Given there are 3 dummy property objects - When I send a "GET" request to "/dummy_properties/1?include=foo" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummy_properties/1", - "type": "DummyProperty", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1", - "name_converted": "NameConverted #1" - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/1" - } - }, - "groups": { - "data": [] - } - } - } - } - """ - - @createSchema - Scenario: Request inclusion of a related resource keeping main object properties unfiltered - Given there are 3 dummy property objects - When I send a "GET" request to "/dummy_properties/1?include=group&fields[group]=id,foo&fields[DummyProperty]=bar,baz" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummy_properties/1", - "type": "DummyProperty", - "attributes": { - "bar": "Bar #1", - "baz": "Baz #1" - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/1" - } - } - } - }, - "included": [ - { - "id": "/dummy_groups/1", - "type": "DummyGroup", - "attributes": { - "_id": 1, - "foo": "Foo #1" - } - } - ] - } - """ - - Scenario: Request inclusion of related resources and specific fields - When I send a "GET" request to "/dummy_properties/1?include=group&fields[group]=id,foo" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummy_properties/1", - "type": "DummyProperty", - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/1" - } - } - } - }, - "included": [ - { - "id": "/dummy_groups/1", - "type": "DummyGroup", - "attributes": { - "_id": 1, - "foo": "Foo #1" - } - } - ] - } - """ - - @createSchema - Scenario: Request inclusion of related resources (many to many) - Given there are 1 dummy property objects with 3 groups - When I send a "GET" request to "/dummy_properties/1?include=groups" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummy_properties/1", - "type": "DummyProperty", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1", - "name_converted": null - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/1" - } - }, - "groups": { - "data": [ - { - "type": "DummyGroup", - "id": "/dummy_groups/2" - }, - { - "type": "DummyGroup", - "id": "/dummy_groups/3" - }, - { - "type": "DummyGroup", - "id": "/dummy_groups/4" - } - ] - } - } - }, - "included": [ - { - "id": "/dummy_groups/2", - "type": "DummyGroup", - "attributes": { - "_id": 2, - "foo": "Foo #11", - "bar": "Bar #11", - "baz": "Baz #11" - } - }, - { - "id": "/dummy_groups/3", - "type": "DummyGroup", - "attributes": { - "_id": 3, - "foo": "Foo #12", - "bar": "Bar #12", - "baz": "Baz #12" - } - }, - { - "id": "/dummy_groups/4", - "type": "DummyGroup", - "attributes": { - "_id": 4, - "foo": "Foo #13", - "bar": "Bar #13", - "baz": "Baz #13" - } - } - ] - } - """ - - @createSchema - Scenario: Request inclusion of related resources (many to many and many to one) - Given there are 1 dummy property objects with 3 groups - When I send a "GET" request to "/dummy_properties/1?include=groups,group" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummy_properties/1", - "type": "DummyProperty", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1", - "name_converted": null - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/1" - } - }, - "groups": { - "data": [ - { - "type": "DummyGroup", - "id": "/dummy_groups/2" - }, - { - "type": "DummyGroup", - "id": "/dummy_groups/3" - }, - { - "type": "DummyGroup", - "id": "/dummy_groups/4" - } - ] - } - } - }, - "included": [ - { - "id": "/dummy_groups/1", - "type": "DummyGroup", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1" - } - }, - { - "id": "/dummy_groups/2", - "type": "DummyGroup", - "attributes": { - "_id": 2, - "foo": "Foo #11", - "bar": "Bar #11", - "baz": "Baz #11" - } - }, - { - "id": "/dummy_groups/3", - "type": "DummyGroup", - "attributes": { - "_id": 3, - "foo": "Foo #12", - "bar": "Bar #12", - "baz": "Baz #12" - } - }, - { - "id": "/dummy_groups/4", - "type": "DummyGroup", - "attributes": { - "_id": 4, - "foo": "Foo #13", - "bar": "Bar #13", - "baz": "Baz #13" - } - } - ] - } - """ - - @createSchema - Scenario: Request inclusion of resource with relation - Given there are 1 dummy objects with relatedDummy and its thirdLevel - When I send a "GET" request to "/dummies/1?include=relatedDummy" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummies/1", - "type": "Dummy", - "attributes": { - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null, - "_id": 1, - "name": "Dummy #1", - "alias": "Alias #0", - "foo": null - }, - "relationships": { - "relatedDummy": { - "data": { - "type": "RelatedDummy", - "id": "/related_dummies/1" - } - }, - "relatedDummies": { - "data": [] - }, - "relatedOwnedDummy": { - "data": [] - }, - "relatedOwningDummy": { - "data": [] - } - } - }, - "included": [ - { - "id": "/related_dummies/1", - "type": "RelatedDummy", - "attributes": { - "name": "RelatedDummy #1", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "_id": 1, - "symfony": "symfony", - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - } - ] - } - """ - - @createSchema - Scenario: Request inclusion of resources from path - Given there is a dummy object with a fourth level relation - When I send a "GET" request to "/dummies/1?include=relatedDummy.thirdLevel.fourthLevel" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummies/1", - "type": "Dummy", - "attributes": { - "_id": 1, - "name": "Dummy with relations", - "alias": null, - "foo": null, - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null - }, - "relationships": { - "relatedDummy": { - "data": { - "type": "RelatedDummy", - "id": "/related_dummies/1" - } - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/1" - }, - { - "type": "RelatedDummy", - "id": "/related_dummies/2" - } - ] - }, - "relatedOwnedDummy": { - "data": [] - }, - "relatedOwningDummy": { - "data": [] - } - } - }, - "included": [ - { - "id": "/related_dummies/1", - "type": "RelatedDummy", - "attributes": { - "_id": 1, - "name": "Hello", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - }, - { - "id": "/third_levels/1", - "type": "ThirdLevel", - "attributes": { - "_id": 1, - "level": 3, - "test": true - }, - "relationships": { - "fourthLevel": { - "data": { - "type": "FourthLevel", - "id": "/fourth_levels/1" - } - }, - "badFourthLevel": { - "data": [] - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/1" - }, - { - "type": "RelatedDummy", - "id": "/related_dummies/2" - } - ] - } - } - }, - { - "id": "/fourth_levels/1", - "type": "FourthLevel", - "attributes": { - "_id": 1, - "level": 4 - }, - "relationships": { - "badThirdLevel": { - "data": [] - } - } - } - ] - } - """ - - @createSchema - Scenario: Request inclusion of resources from path with collection - Given there is a dummy object with 3 relatedDummies and their thirdLevel - When I send a "GET" request to "/dummies/1?include=relatedDummies.thirdLevel" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummies/1", - "type": "Dummy", - "attributes": { - "_id": 1, - "name": "Dummy with relations", - "alias": null, - "foo": null, - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null - }, - "relationships": { - "relatedDummy": { - "data": [] - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/1" - }, - { - "type": "RelatedDummy", - "id": "/related_dummies/2" - }, - { - "type": "RelatedDummy", - "id": "/related_dummies/3" - } - ] - }, - "relatedOwnedDummy": { - "data": [] - }, - "relatedOwningDummy": { - "data": [] - } - } - }, - "included": [ - { - "id": "/related_dummies/1", - "type": "RelatedDummy", - "attributes": { - "_id": 1, - "name": "RelatedDummy #1", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - }, - { - "id": "/third_levels/1", - "type": "ThirdLevel", - "attributes": { - "_id": 1, - "level": 3, - "test": true - }, - "relationships": { - "fourthLevel": { - "data": [] - }, - "badFourthLevel": { - "data": [] - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/1" - } - ] - } - } - }, - { - "id": "/related_dummies/2", - "type": "RelatedDummy", - "attributes": { - "_id": 2, - "name": "RelatedDummy #2", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/2" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - }, - { - "id": "/third_levels/2", - "type": "ThirdLevel", - "attributes": { - "_id": 2, - "level": 3, - "test": true - }, - "relationships": { - "fourthLevel": { - "data": [] - }, - "badFourthLevel": { - "data": [] - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/2" - } - ] - } - } - }, - { - "id": "/related_dummies/3", - "type": "RelatedDummy", - "attributes": { - "_id": 3, - "name": "RelatedDummy #3", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/3" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - }, - { - "id": "/third_levels/3", - "type": "ThirdLevel", - "attributes": { - "_id": 3, - "level": 3, - "test": true - }, - "relationships": { - "fourthLevel": { - "data": [] - }, - "badFourthLevel": { - "data": [] - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/3" - } - ] - } - } - } - ] - } - """ - - @createSchema - Scenario: Do not include the requested resource - Given there is a RelatedOwningDummy object with OneToOne relation - When I send a "GET" request to "/dummies/1?include=relatedOwningDummy.ownedDummy" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummies/1", - "type": "Dummy", - "attributes": { - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null, - "_id": 1, - "name": "plop", - "alias": null, - "foo": null - }, - "relationships": { - "relatedDummy": { - "data": [] - }, - "relatedDummies": { - "data": [] - }, - "relatedOwnedDummy": { - "data": [] - }, - "relatedOwningDummy": { - "data": { - "type": "RelatedOwningDummy", - "id": "/related_owning_dummies/1" - } - } - } - }, - "included": [ - { - "id": "/related_owning_dummies/1", - "type": "RelatedOwningDummy", - "attributes": { - "name": null, - "_id": 1 - }, - "relationships": { - "ownedDummy": { - "data": { - "type": "Dummy", - "id": "/dummies/1" - } - } - } - } - ] - } - """ - - @createSchema - Scenario: Do not include resources multiple times - Given there is a dummy object with 3 relatedDummies with same thirdLevel - When I send a "GET" request to "/dummies/1?include=relatedDummies.thirdLevel" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "data": { - "id": "/dummies/1", - "type": "Dummy", - "attributes": { - "_id": 1, - "name": "Dummy with relations", - "alias": null, - "foo": null, - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null - }, - "relationships": { - "relatedDummy": { - "data": [] - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/1" - }, - { - "type": "RelatedDummy", - "id": "/related_dummies/2" - }, - { - "type": "RelatedDummy", - "id": "/related_dummies/3" - } - ] - }, - "relatedOwnedDummy": { - "data": [] - }, - "relatedOwningDummy": { - "data": [] - } - } - }, - "included": [ - { - "id": "/related_dummies/1", - "type": "RelatedDummy", - "attributes": { - "_id": 1, - "name": "RelatedDummy #1", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - }, - { - "id": "/third_levels/1", - "type": "ThirdLevel", - "attributes": { - "_id": 1, - "level": 3, - "test": true - }, - "relationships": { - "fourthLevel": { - "data": [] - }, - "badFourthLevel": { - "data": [] - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/1" - }, - { - "type": "RelatedDummy", - "id": "/related_dummies/2" - }, - { - "type": "RelatedDummy", - "id": "/related_dummies/3" - } - ] - } - } - }, - { - "id": "/related_dummies/2", - "type": "RelatedDummy", - "attributes": { - "_id": 2, - "name": "RelatedDummy #2", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - }, - { - "id": "/related_dummies/3", - "type": "RelatedDummy", - "attributes": { - "_id": 3, - "name": "RelatedDummy #3", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - } - ] - } - """ - - - @createSchema - Scenario: Request inclusion of a related resources on collection - Given there are 3 dummy property objects - When I send a "GET" request to "/dummy_properties?include=group" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "links": { - "self": "/dummy_properties?include=group" - }, - "meta": { - "totalItems": 3, - "itemsPerPage": 3, - "currentPage": 1 - }, - "data": [ - { - "id": "/dummy_properties/1", - "type": "DummyProperty", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1", - "name_converted": "NameConverted #1" - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/1" - } - }, - "groups": { - "data": [] - } - } - }, - { - "id": "/dummy_properties/2", - "type": "DummyProperty", - "attributes": { - "_id": 2, - "foo": "Foo #2", - "bar": "Bar #2", - "baz": "Baz #2", - "name_converted": "NameConverted #2" - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/2" - } - }, - "groups": { - "data": [] - } - } - }, - { - "id": "/dummy_properties/3", - "type": "DummyProperty", - "attributes": { - "_id": 3, - "foo": "Foo #3", - "bar": "Bar #3", - "baz": "Baz #3", - "name_converted": "NameConverted #3" - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/3" - } - }, - "groups": { - "data": [] - } - } - } - ], - "included": [ - { - "id": "/dummy_groups/1", - "type": "DummyGroup", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1" - } - }, - { - "id": "/dummy_groups/2", - "type": "DummyGroup", - "attributes": { - "_id": 2, - "foo": "Foo #2", - "bar": "Bar #2", - "baz": "Baz #2" - } - }, - { - "id": "/dummy_groups/3", - "type": "DummyGroup", - "attributes": { - "_id": 3, - "foo": "Foo #3", - "bar": "Bar #3", - "baz": "Baz #3" - } - } - ] - } - """ - - @createSchema - Scenario: Request inclusion of a related resources on collection should not duplicated included object - Given there are 3 dummy property objects with a shared group - When I send a "GET" request to "/dummy_properties?include=group" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "links": { - "self": "/dummy_properties?include=group" - }, - "meta": { - "totalItems": 3, - "itemsPerPage": 3, - "currentPage": 1 - }, - "data": [ - { - "id": "/dummy_properties/1", - "type": "DummyProperty", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1", - "name_converted": null - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/1" - } - }, - "groups": { - "data": [] - } - } - }, - { - "id": "/dummy_properties/2", - "type": "DummyProperty", - "attributes": { - "_id": 2, - "foo": "Foo #2", - "bar": "Bar #2", - "baz": "Baz #2", - "name_converted": null - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/1" - } - }, - "groups": { - "data": [] - } - } - }, - { - "id": "/dummy_properties/3", - "type": "DummyProperty", - "attributes": { - "_id": 3, - "foo": "Foo #3", - "bar": "Bar #3", - "baz": "Baz #3", - "name_converted": null - }, - "relationships": { - "group": { - "data": { - "type": "DummyGroup", - "id": "/dummy_groups/1" - } - }, - "groups": { - "data": [] - } - } - } - ], - "included": [ - { - "id": "/dummy_groups/1", - "type": "DummyGroup", - "attributes": { - "_id": 1, - "foo": "Foo #shared", - "bar": "Bar #shared", - "baz": "Baz #shared" - } - } - ] - } - """ - - @createSchema - Scenario: Request inclusion of a related resources on collection should not duplicated included object - Given there are 2 dummy property objects with different number of related groups - When I send a "GET" request to "/dummy_properties?include=groups" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be a superset of: - """ - { - "links": { - "self": "/dummy_properties?include=groups" - }, - "meta": { - "totalItems": 2, - "itemsPerPage": 3, - "currentPage": 1 - }, - "data": [{ - "id": "/dummy_properties/1", - "type": "DummyProperty", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1", - "name_converted": null - }, - "relationships": { - "groups": { - "data": [{ - "type": "DummyGroup", - "id": "/dummy_groups/1" - }] - } - } - }, { - "id": "/dummy_properties/2", - "type": "DummyProperty", - "attributes": { - "_id": 2, - "foo": "Foo #2", - "bar": "Bar #2", - "baz": "Baz #2", - "name_converted": null - }, - "relationships": { - "groups": { - "data": [{ - "type": "DummyGroup", - "id": "/dummy_groups/1" - }, { - "type": "DummyGroup", - "id": "/dummy_groups/2" - }] - } - } - }], - "included": [{ - "id": "/dummy_groups/1", - "type": "DummyGroup", - "attributes": { - "_id": 1, - "foo": "Foo #1", - "bar": "Bar #1", - "baz": "Baz #1" - } - }, { - "id": "/dummy_groups/2", - "type": "DummyGroup", - "attributes": { - "_id": 2, - "foo": "Foo #2", - "bar": "Bar #2", - "baz": "Baz #2" - } - }] - } - """ - - @createSchema - Scenario: Request inclusion from path of resource with relation - Given there are 3 dummy objects with relatedDummy and its thirdLevel - When I send a "GET" request to "/dummies?include=relatedDummy.thirdLevel" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be valid according to the JSON API schema - And the JSON should be equal to: - """ - { - "links": { - "self": "/dummies?include=relatedDummy.thirdLevel" - }, - "meta": { - "totalItems": 3, - "itemsPerPage": 3, - "currentPage": 1 - }, - "data": [ - { - "id": "/dummies/1", - "type": "Dummy", - "attributes": { - "_id": 1, - "name": "Dummy #1", - "alias": "Alias #2", - "foo": null, - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null - }, - "relationships": { - "relatedDummy": { - "data": { - "type": "RelatedDummy", - "id": "/related_dummies/1" - } - }, - "relatedDummies": { - "data": [] - }, - "relatedOwnedDummy": { - "data": [] - }, - "relatedOwningDummy": { - "data": [] - } - } - }, - { - "id": "/dummies/2", - "type": "Dummy", - "attributes": { - "_id": 2, - "name": "Dummy #2", - "alias": "Alias #1", - "foo": null, - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null - }, - "relationships": { - "relatedDummy": { - "data": { - "type": "RelatedDummy", - "id": "/related_dummies/2" - } - }, - "relatedDummies": { - "data": [] - }, - "relatedOwnedDummy": { - "data": [] - }, - "relatedOwningDummy": { - "data": [] - } - } - }, - { - "id": "/dummies/3", - "type": "Dummy", - "attributes": { - "_id": 3, - "name": "Dummy #3", - "alias": "Alias #0", - "foo": null, - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "jsonData": [], - "arrayData": [], - "name_converted": null - }, - "relationships": { - "relatedDummy": { - "data": { - "type": "RelatedDummy", - "id": "/related_dummies/3" - } - }, - "relatedDummies": { - "data": [] - }, - "relatedOwnedDummy": { - "data": [] - }, - "relatedOwningDummy": { - "data": [] - } - } - } - ], - "included": [ - { - "id": "/related_dummies/1", - "type": "RelatedDummy", - "attributes": { - "_id": 1, - "name": "RelatedDummy #1", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/1" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - }, - { - "id": "/third_levels/1", - "type": "ThirdLevel", - "attributes": { - "_id": 1, - "level": 3, - "test": true - }, - "relationships": { - "fourthLevel": { - "data": [] - }, - "badFourthLevel": { - "data": [] - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/1" - } - ] - } - } - }, - { - "id": "/related_dummies/2", - "type": "RelatedDummy", - "attributes": { - "_id": 2, - "name": "RelatedDummy #2", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/2" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - }, - { - "id": "/third_levels/2", - "type": "ThirdLevel", - "attributes": { - "_id": 2, - "level": 3, - "test": true - }, - "relationships": { - "fourthLevel": { - "data": [] - }, - "badFourthLevel": { - "data": [] - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/2" - } - ] - } - } - }, - { - "id": "/related_dummies/3", - "type": "RelatedDummy", - "attributes": { - "_id": 3, - "name": "RelatedDummy #3", - "symfony": "symfony", - "dummyDate": null, - "dummyBoolean": null, - "embeddedDummy": { - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "age": null - }, - "relationships": { - "thirdLevel": { - "data": { - "type": "ThirdLevel", - "id": "/third_levels/3" - } - }, - "relatedToDummyFriend": { - "data": [] - } - } - }, - { - "id": "/third_levels/3", - "type": "ThirdLevel", - "attributes": { - "_id": 3, - "level": 3, - "test": true - }, - "relationships": { - "fourthLevel": { - "data": [] - }, - "badFourthLevel": { - "data": [] - }, - "relatedDummies": { - "data": [ - { - "type": "RelatedDummy", - "id": "/related_dummies/3" - } - ] - } - } - } - ] - } - """ diff --git a/features/jsonld/absolute_url.feature b/features/jsonld/absolute_url.feature deleted file mode 100644 index 9770e6bbb77..00000000000 --- a/features/jsonld/absolute_url.feature +++ /dev/null @@ -1,83 +0,0 @@ -Feature: IRI should contain Absolute URL - In order to add detail to IRIs - Include the absolute url - - @createSchema - Scenario: I should be able to GET a collection of Objects with Absolute Urls - Given there are 1 absoluteUrlDummy objects with a related absoluteUrlRelationDummy - And I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/absolute_url_dummies" - And the JSON should be equal to: - """ - { - "@context": "http://example.com/contexts/AbsoluteUrlDummy", - "@id": "http://example.com/absolute_url_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "http://example.com/absolute_url_dummies/1", - "@type": "AbsoluteUrlDummy", - "absoluteUrlRelationDummy": "http://example.com/absolute_url_relation_dummies/1", - "id": 1 - } - ], - "hydra:totalItems": 1 - } - - """ - - Scenario: I should be able to POST an object using an Absolute Url - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/absolute_url_relation_dummies" with body: - """ - { - "absolute_url_dummies": "http://example.com/absolute_url_dummies/1" - } - """ - Then the response status code should be 201 - And the JSON should be equal to: - """ - { - "@context": "http://example.com/contexts/AbsoluteUrlRelationDummy", - "@id": "http://example.com/absolute_url_relation_dummies/2", - "@type": "AbsoluteUrlRelationDummy", - "absoluteUrlDummies": [], - "id": 2 - } - """ - - Scenario: I should be able to GET an Item with Absolute Urls - Given I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/absolute_url_dummies/1" - And the JSON should be equal to: - """ - { - "@context": "http://example.com/contexts/AbsoluteUrlDummy", - "@id": "http://example.com/absolute_url_dummies/1", - "@type": "AbsoluteUrlDummy", - "absoluteUrlRelationDummy": "http://example.com/absolute_url_relation_dummies/1", - "id": 1 - } - """ - - Scenario: I should be able to GET resources with Absolute Urls - Given I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/absolute_url_relation_dummies/1/absolute_url_dummies" - And the JSON should be equal to: - """ - { - "@context": "http://example.com/contexts/AbsoluteUrlDummy", - "@id": "http://example.com/absolute_url_relation_dummies/1/absolute_url_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "http://example.com/absolute_url_dummies/1", - "@type": "AbsoluteUrlDummy", - "absoluteUrlRelationDummy": "http://example.com/absolute_url_relation_dummies/1", - "id": 1 - } - ], - "hydra:totalItems": 1 - } - """ diff --git a/features/jsonld/context.feature b/features/jsonld/context.feature deleted file mode 100644 index 49d76b7e3b6..00000000000 --- a/features/jsonld/context.feature +++ /dev/null @@ -1,88 +0,0 @@ -Feature: JSON-LD contexts generation - In order to have an hypermedia, Linked Data enabled API - As a client software developer - I need to access to a JSON-LD context describing data types - - Scenario: Retrieve Entrypoint context - When I send a "GET" request to "/contexts/Entrypoint" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "@context.@vocab" should be equal to "http://example.com/docs.jsonld#" - And the JSON node "@context.hydra" should be equal to "http://www.w3.org/ns/hydra/core#" - And the JSON node "@context.dummy.@id" should be equal to "Entrypoint/dummy" - And the JSON node "@context.dummy.@type" should be equal to "@id" - - Scenario: Retrieve Dummy context - When I send a "GET" request to "/contexts/Dummy" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "description": "https://schema.org/description", - "dummy": "Dummy/dummy", - "dummyBoolean": "Dummy/dummyBoolean", - "dummyDate": "https://schema.org/DateTime", - "dummyFloat": "Dummy/dummyFloat", - "dummyPrice": "Dummy/dummyPrice", - "relatedDummy": { - "@id": "Dummy/relatedDummy", - "@type": "@id" - }, - "relatedDummies": { - "@id": "Dummy/relatedDummies", - "@type": "@id" - }, - "jsonData": "Dummy/jsonData", - "arrayData": "Dummy/arrayData", - "name_converted": "Dummy/name_converted", - "name": "https://schema.org/name", - "alias": "https://schema.org/alternateName", - "foo": "Dummy/foo" - } - } - """ - - Scenario: Retrieve context of an object with an embed relation - When I send a "GET" request to "/contexts/RelationEmbedder" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "paris": "RelationEmbedder/paris", - "krondstadt": "RelationEmbedder/krondstadt", - "anotherRelated": "RelationEmbedder/anotherRelated", - "related": "RelationEmbedder/related" - } - } - """ - - Scenario: Retrieve Dummy with extended jsonld context - When I send a "GET" request to "/contexts/JsonldContextDummy" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "person": { - "@id": "https://example.com/id", - "@type": "@id", - "foo": "bar" - } - } - } - """ diff --git a/features/jsonld/disable_id_generation.feature b/features/jsonld/disable_id_generation.feature deleted file mode 100644 index 0e396ebe7ad..00000000000 --- a/features/jsonld/disable_id_generation.feature +++ /dev/null @@ -1,9 +0,0 @@ -Feature: Disable Id generation on anonymous resource collections - - @!mongodb - @createSchema - Scenario: Get embed collection without ids - When I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/disable_id_generation_collection" - Then the response status code should be 200 - Then the JSON node "disableIdGenerationItems[0].@id" should not exist diff --git a/features/jsonld/getter_setter_renaming.feature b/features/jsonld/getter_setter_renaming.feature deleted file mode 100644 index d5f876918b8..00000000000 --- a/features/jsonld/getter_setter_renaming.feature +++ /dev/null @@ -1,25 +0,0 @@ -Feature: Resource should contain one field for each property - In order to use API resource - As a developer - I need to have one field exposed for each property (which take getter/setter name) - - @createSchema - Scenario: I should be able to POST a new entity - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - When I send a "POST" request to "/entity_with_renamed_getter_and_setters" with body: - """ - { - "firstnameOnly": "Sarah" - } - """ - Then the response status code should be 201 - And the JSON should be equal to: - """ - { - "@context": "/contexts/EntityWithRenamedGetterAndSetter", - "@id": "/entity_with_renamed_getter_and_setters", - "@type": "EntityWithRenamedGetterAndSetter", - "firstnameOnly": "Sarah" - } - """ diff --git a/features/jsonld/inheritance.feature b/features/jsonld/inheritance.feature deleted file mode 100644 index 0008392cf54..00000000000 --- a/features/jsonld/inheritance.feature +++ /dev/null @@ -1,12 +0,0 @@ -Feature: Inheritance with correct IRIs - In order to fix (https://github.com/api-platform/core/issues/5438) - - Scenario: Get the collection of people with its employees - When I add "Accept" header equal to "application/json" - And I send a "GET" request to "/people_5438" - Then print last JSON response - - Scenario: Get the collection of people with its employees - When I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/people_5438" - Then print last JSON response diff --git a/features/jsonld/input_output.feature b/features/jsonld/input_output.feature deleted file mode 100644 index 121f3050061..00000000000 --- a/features/jsonld/input_output.feature +++ /dev/null @@ -1,454 +0,0 @@ -Feature: JSON-LD DTO input and output - In order to use a hypermedia API - As a client software developer - I need to be able to use DTOs on my resources as Input or Output objects. - - Background: - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - - @createSchema - Scenario: Create a resource with a custom Input - When I send a "POST" request to "/dummy_dto_customs" with body: - """ - { - "foo": "test", - "bar": 1 - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/DummyDtoCustom", - "@id": "/dummy_dto_customs/1", - "@type": "DummyDtoCustom", - "lorem": "test", - "ipsum": "1", - "id": 1 - } - """ - - @createSchema - Scenario: Get an item with a custom output - Given there is a DummyDtoCustom - When I send a "GET" request to "/dummy_dto_custom_output/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "foo": "CustomOutputDto/foo", - "bar": "CustomOutputDto/bar" - }, - "@type": "CustomOutputDto", - "foo": "test", - "bar": 1 - } - """ - - @createSchema - Scenario: Get a collection with a custom output - Given there are 2 DummyDtoCustom - When I send a "GET" request to "/dummy_dto_custom_output" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "@context": "/contexts/DummyDtoCustom", - "@id": "/dummy_dto_custom_output", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@type": "CustomOutputDto", - "foo": "test", - "bar": 1 - }, - { - "@type": "CustomOutputDto", - "foo": "test", - "bar": 2 - } - ], - "hydra:totalItems": 2 - } - """ - - @createSchema - Scenario: Create a DummyDtoCustom object without output - When I send a "POST" request to "/dummy_dto_custom_post_without_output" with body: - """ - { - "lorem": "test", - "ipsum": "1" - } - """ - Then the response status code should be 204 - And the response should be empty - - @createSchema - Scenario: Create and update a DummyInputOutput - When I send a "POST" request to "/dummy_dto_input_outputs" with body: - """ - { - "foo": "test", - "bar": 1 - } - """ - Then the response status code should be 201 - And the JSON should be a superset of: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "id": "OutputDto/id", - "baz": "OutputDto/baz", - "bat": "OutputDto/bat", - "relatedDummies": "OutputDto/relatedDummies" - }, - "@type": "OutputDto", - "id": 1, - "baz": 1, - "bat": "test", - "relatedDummies": [] - } - """ - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/dummy_dto_input_outputs/1" with body: - """ - { - "foo": "test", - "bar": 2 - } - """ - Then the response status code should be 200 - And the JSON should be a superset of: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "id": "OutputDto/id", - "baz": "OutputDto/baz", - "bat": "OutputDto/bat", - "relatedDummies": "OutputDto/relatedDummies" - }, - "@type": "OutputDto", - "id": 1, - "baz": 2, - "bat": "test", - "relatedDummies": [] - } - """ - - @!mongodb - @createSchema - Scenario: Use DTO with relations on User - When I send a "POST" request to "/users" with body: - """ - { - "username": "soyuka", - "plainPassword": "a real password", - "email": "soyuka@example.com" - } - """ - Then the response status code should be 201 - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/users/recover/1" with body: - """ - { - "user": "/users/1" - } - """ - Then the response status code should be 200 - And the JSON should be a superset of: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "dummy": "RecoverPasswordOutput/dummy" - }, - "@type": "RecoverPasswordOutput", - "dummy": "/dummies/1" - } - """ - - @createSchema - @controller - Scenario: Create a resource with no input - When I send a "POST" request to "/dummy_dto_no_inputs" - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "id": "OutputDto/id", - "baz": "OutputDto/baz", - "bat": "OutputDto/bat", - "relatedDummies": "OutputDto/relatedDummies" - }, - "@type": "OutputDto", - "id": 1, - "baz": 1, - "bat": "test", - "relatedDummies": [] - } - """ - - @controller - Scenario: Update a resource with no input - When I send a "POST" request to "/dummy_dto_no_inputs/1/double_bat" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "id": "OutputDto/id", - "baz": "OutputDto/baz", - "bat": "OutputDto/bat", - "relatedDummies": "OutputDto/relatedDummies" - }, - "@type": "OutputDto", - "id": 1, - "baz": 1, - "bat": "testtest", - "relatedDummies": [] - } - """ - - @!mongodb - Scenario: Use messenger with an input where the handler gives a synchronous result - When I send a "POST" request to "/messenger_with_inputs" with body: - """ - { - "var": "test" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/MessengerWithInput", - "@id": "/messenger_with_inputs/1", - "@type": "MessengerWithInput", - "id": 1, - "name": "test" - } - """ - - @!mongodb - Scenario: Use messenger with an input where the handler gives a synchronous Response result - When I send a "POST" request to "/messenger_with_responses" with body: - """ - { - "var": "test" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": 123 - } - """ - - @createSchema - Scenario: Initialize input data with a DataTransformerInitializer - Given there is an InitializeInput object with id 1 - When I send a "PUT" request to "/initialize_inputs/1" with body: - """ - { - "name": "La peste" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/InitializeInput", - "@id": "/initialize_inputs/1", - "@type": "InitializeInput", - "id": 1, - "manager": "Orwell", - "name": "La peste" - } - """ - - Scenario: Create a resource with a custom Input - When I send a "POST" request to "/dummy_dto_customs" with body: - """ - { - "foo": "test", - "bar": "test" - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the JSON node "detail" should be equal to "The input data is misformatted." - - @!mongodb - Scenario: Reset password through an input DTO without DataTransformer - When I send a "POST" request to "/user-reset-password" with body: - """ - { - "email": "user@example.com" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "email" should be equal to "user@example.com" - - @!mongodb - Scenario: Reset password with an invalid payload through an input DTO without DataTransformer - And I send a "POST" request to "/user-reset-password" with body: - """ - { - "email": "this is not an email" - } - """ - Then the response status code should be 422 - And the response should be in JSON - - @v3 - Scenario: Get a collection with a custom output and without item operations, from a resource without identifier - When I send a "GET" request to "/dummy_collection_dtos" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], - "properties": { - "@context": {"pattern": "^/contexts/DummyCollectionDto$"}, - "@id": {"pattern": "^/dummy_collection_dtos$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "uniqueItems": true, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "foo", "bar"], - "properties": { - "@id": {"pattern": "^/.well-known/genid/.+$"}, - "@type": {"pattern": "^DummyCollectionDtoOutput$"}, - "foo": {"type": "string"}, - "bar": {"type": "integer"} - } - } - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} - } - } - """ - - @v3 - Scenario: Get a collection with a custom output and itemUriTemplate, from a resource without identifier - When I send a "GET" request to "/dummy_foo_collection_dtos" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - Then print last JSON response - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], - "properties": { - "@context": {"pattern": "^/contexts/DummyFooCollectionDto$"}, - "@id": {"pattern": "^/dummy_foo_collection_dtos$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "uniqueItems": true, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "foo", "bar"], - "properties": { - "@id": {"pattern": "/dummy_foos/bar"}, - "@type": {"pattern": "^DummyFooCollectionDto$"}, - "foo": {"type": "string"}, - "bar": {"type": "integer"} - } - } - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} - } - } - """ - - @v3 - # Cannot generate proper IRI because DTO does not support output yet - # todo Change member IRI to `/dummy_id_collection_dtos/.+` once DTO support @ApiProperty - Scenario: Get a collection with a custom output and without item operations, from a resource with an identifier - When I send a "GET" request to "/dummy_id_collection_dtos" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], - "properties": { - "@context": {"pattern": "^/contexts/DummyIdCollectionDto$"}, - "@id": {"pattern": "^/dummy_id_collection_dtos$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "minItems": 2, - "maxItems": 2, - "uniqueItems": true, - "items": { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "id", "foo", "bar"], - "properties": { - "@id": {"pattern": "^/.well-known/genid/.+$"}, - "@type": {"pattern": "^DummyIdCollectionDtoOutput$"}, - "id": {"type": "integer"}, - "foo": {"type": "string"}, - "bar": {"type": "integer"} - } - } - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} - } - } - """ diff --git a/features/jsonld/interface_as_resource.feature b/features/jsonld/interface_as_resource.feature deleted file mode 100644 index 7236ace7ae7..00000000000 --- a/features/jsonld/interface_as_resource.feature +++ /dev/null @@ -1,57 +0,0 @@ -Feature: JSON-LD using interface as resource - In order to use interface as resource - As a developer - I should be able to serialize objects of an interface as API resource. - - Background: - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - - @createSchema - Scenario: Retrieve a taxon - Given there is the following taxon: - """ - { - "code": "WONDERFUL_TAXON" - } - """ - When I send a "GET" request to "/taxa/WONDERFUL_TAXON" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Taxon", - "@id": "/taxa/WONDERFUL_TAXON", - "@type": "Taxon", - "code": "WONDERFUL_TAXON" - } - """ - - Scenario: Retrieve a product with a main taxon - Given there is the following product: - """ - { - "code": "GREAT_PRODUCT", - "mainTaxon": "/taxa/WONDERFUL_TAXON" - } - """ - When I send a "GET" request to "/products/GREAT_PRODUCT" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Product", - "@id": "/products/GREAT_PRODUCT", - "@type": "Product", - "code": "GREAT_PRODUCT", - "mainTaxon": { - "@id": "/taxa/WONDERFUL_TAXON", - "@type": "Taxon", - "code": "WONDERFUL_TAXON" - } - } - """ diff --git a/features/jsonld/interface_dto_output.feature b/features/jsonld/interface_dto_output.feature deleted file mode 100644 index 9c56f8e57bb..00000000000 --- a/features/jsonld/interface_dto_output.feature +++ /dev/null @@ -1,11 +0,0 @@ -Feature: Resource should be able to take interface as output value - - @createSchema - Scenario: I should be able to GET a collection of objects - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/json" - When I send a "GET" request to "/entity_with_dto_outputs" - And the JSON node "hydra:member[0].name" should exist - And the JSON node "hydra:member[0].@type" should exist - And the JSON node "hydra:member[0].@id" should exist - And the JSON node "hydra:member[0].city" should not exist diff --git a/features/jsonld/iri_only.feature b/features/jsonld/iri_only.feature deleted file mode 100644 index 170e7f69f86..00000000000 --- a/features/jsonld/iri_only.feature +++ /dev/null @@ -1,95 +0,0 @@ -Feature: JSON-LD using iri_only parameter - In order to improve Vulcain support - As a Vulcain user and as a developer - I should be able to only get an IRI list when I ask a resource. - - Scenario Outline: Retrieve Dummy's resource context with iri_only - When I send a "GET" request to "" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "hydra:member": { - "@type": "@id" - } - } - } - """ - Examples: - | uri | - | /contexts/IriOnlyDummy | - | /contexts/IriOnlyDummy.jsonld | - - Scenario: Retrieve Dummy's resource context with invalid format returns an error - When I send a "GET" request to "/contexts/IriOnlyDummy.json" - Then the response status code should be 404 - - @createSchema - Scenario: Retrieve Dummies with iri_only and jsonld_embed_context - Given there are 3 iriOnlyDummies - When I send a "GET" request to "/iri_only_dummies" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "hydra:member": { - "@type": "@id" - } - }, - "@id": "/iri_only_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - "/iri_only_dummies/1", - "/iri_only_dummies/2", - "/iri_only_dummies/3" - ], - "hydra:totalItems": 3 - } - """ - - @createSchema - Scenario: Retrieve Resource with uriTemplate collection Property - Given there are propertyCollectionIriOnly with relations - When I send a "GET" request to "/property_collection_iri_onlies" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "hydra:member": [ - { - "@id": "/property_collection_iri_onlies/1", - "@type": "PropertyCollectionIriOnly", - "propertyCollectionIriOnlyRelation": "/property-collection-relations", - "iterableIri": "/parent/1/another-collection-operations", - "toOneRelation": "/parent/1/property-uri-template/one-to-ones/1" - } - ] - } - """ - When I send a "GET" request to "/property_collection_iri_onlies/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "@context": "/contexts/PropertyCollectionIriOnly", - "@id": "/property_collection_iri_onlies/1", - "@type": "PropertyCollectionIriOnly", - "propertyCollectionIriOnlyRelation": "/property-collection-relations", - "iterableIri": "/parent/1/another-collection-operations", - "toOneRelation": "/parent/1/property-uri-template/one-to-ones/1" - } - """ diff --git a/features/jsonld/json_serializable.feature b/features/jsonld/json_serializable.feature deleted file mode 100644 index 57cb57de510..00000000000 --- a/features/jsonld/json_serializable.feature +++ /dev/null @@ -1,72 +0,0 @@ -Feature: JSON-LD using JsonSerializable types - In order to use JsonSerializable in resource and non-resource types - As a developer - I should be able to serialize objects of JsonSerializable type. - - Background: - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - - @createSchema - Scenario: Create a Content - When I send a "POST" request to "/contents" with body: - """ - { - "contentType": "homepage", - "fields": [ - { - "name": "title", - "value": "Labore reprehenderit dolorem repellendus asperiores." - }, - { - "name": "content", - "value": "Minus sed repellendus corporis nemo. Aut aut veniam at aut aliquid. Architecto tempora quia neque numquam voluptas sint est delectus.\n\nUnde voluptatem animi non ut aut dicta. Omnis vero dolorum aliquid laudantium magni asperiores. Et tempora eveniet soluta modi occaecati.\n\nEa dolorum tenetur voluptatum temporibus illo fuga. Quibusdam et doloribus debitis omnis sed. Tempora in aperiam ullam non odit. Praesentium sunt accusantium dolorem commodi labore eum nostrum quia." - } - ] - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Content", - "@id": "/contents/1", - "@type": "Content", - "id": 1, - "contentType": "homepage", - "status": { - "key": "DRAFT", - "value": "draft" - }, - "fieldValues": { - "title": "Labore reprehenderit dolorem repellendus asperiores.", - "content": "Minus sed repellendus corporis nemo. Aut aut veniam at aut aliquid. Architecto tempora quia neque numquam voluptas sint est delectus.\n\nUnde voluptatem animi non ut aut dicta. Omnis vero dolorum aliquid laudantium magni asperiores. Et tempora eveniet soluta modi occaecati.\n\nEa dolorum tenetur voluptatum temporibus illo fuga. Quibusdam et doloribus debitis omnis sed. Tempora in aperiam ullam non odit. Praesentium sunt accusantium dolorem commodi labore eum nostrum quia." - } - } - """ - - Scenario: Retrieve a Content - When I send a "GET" request to "/contents/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Content", - "@id": "/contents/1", - "@type": "Content", - "id": 1, - "contentType": "homepage", - "status": { - "key": "DRAFT", - "value": "draft" - }, - "fieldValues": { - "title": "Labore reprehenderit dolorem repellendus asperiores.", - "content": "Minus sed repellendus corporis nemo. Aut aut veniam at aut aliquid. Architecto tempora quia neque numquam voluptas sint est delectus.\n\nUnde voluptatem animi non ut aut dicta. Omnis vero dolorum aliquid laudantium magni asperiores. Et tempora eveniet soluta modi occaecati.\n\nEa dolorum tenetur voluptatum temporibus illo fuga. Quibusdam et doloribus debitis omnis sed. Tempora in aperiam ullam non odit. Praesentium sunt accusantium dolorem commodi labore eum nostrum quia." - } - } - """ diff --git a/features/jsonld/max_depth.feature b/features/jsonld/max_depth.feature deleted file mode 100644 index 58c2e5c1249..00000000000 --- a/features/jsonld/max_depth.feature +++ /dev/null @@ -1,43 +0,0 @@ -Feature: Max depth handling - In order to handle MaxDepthDummy resources - As a developer - I need to be able to limit their depth with @maxDepth - - @createSchema - Scenario: Create a resource with 1 level of descendants - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/max_depth_eager_dummies" with body: - """ - { - "name": "level 1", - "child": { - "name": "level 2" - } - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - Then the JSON node "child" should exist - Then the JSON node "child.name" should be equal to "level 2" - - Scenario: Add a 2nd level of descendants - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/max_depth_eager_dummies" with body: - """ - { - "name": "level 1", - "child": { - "name": "level 2", - "child": { - "name": "level 3" - } - } - } - """ - And the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - Then the JSON node "child" should exist - Then the JSON node "child.name" should be equal to "level 2" - Then the JSON node "child.child" should not exist diff --git a/features/jsonld/network_path.feature b/features/jsonld/network_path.feature deleted file mode 100644 index 6d486390e3f..00000000000 --- a/features/jsonld/network_path.feature +++ /dev/null @@ -1,86 +0,0 @@ -Feature: IRI should contain network path - In order to add detail to IRIs - Include the network path - - @createSchema - Scenario: I should be able to GET a collection of objects with network paths - Given there are 1 networkPathDummy objects with a related networkPathRelationDummy - And I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/json" - And I send a "GET" request to "/network_path_dummies" - And the JSON should be equal to: - """ - { - "@context": "//example.com/contexts/NetworkPathDummy", - "@id": "//example.com/network_path_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "//example.com/network_path_dummies/1", - "@type": "NetworkPathDummy", - "networkPathRelationDummy": "//example.com/network_path_relation_dummies/1", - "id": 1 - } - ], - "hydra:totalItems": 1 - } - - """ - - Scenario: I should be able to POST an object using a network path - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/network_path_relation_dummies" with body: - """ - { - "network_path_dummies": "//example.com/network_path_dummies/1" - } - """ - Then the response status code should be 201 - And the JSON should be equal to: - """ - { - "@context": "//example.com/contexts/NetworkPathRelationDummy", - "@id": "//example.com/network_path_relation_dummies/2", - "@type": "NetworkPathRelationDummy", - "networkPathDummies": [], - "id": 2 - } - """ - - Scenario: I should be able to GET an Item with network paths - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/json" - And I send a "GET" request to "/network_path_dummies/1" - And the JSON should be equal to: - """ - { - "@context": "//example.com/contexts/NetworkPathDummy", - "@id": "//example.com/network_path_dummies/1", - "@type": "NetworkPathDummy", - "networkPathRelationDummy": "//example.com/network_path_relation_dummies/1", - "id": 1 - } - """ - - Scenario: I should be able to GET resources with network paths - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/json" - And I send a "GET" request to "/network_path_relation_dummies/1/network_path_dummies" - And the JSON should be equal to: - """ - { - "@context": "//example.com/contexts/NetworkPathDummy", - "@id": "//example.com/network_path_relation_dummies/1/network_path_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "//example.com/network_path_dummies/1", - "@type": "NetworkPathDummy", - "networkPathRelationDummy": "//example.com/network_path_relation_dummies/1", - "id": 1 - } - ], - "hydra:totalItems": 1 - } - """ diff --git a/features/jsonld/no_output.feature b/features/jsonld/no_output.feature deleted file mode 100644 index 09b7a41919a..00000000000 --- a/features/jsonld/no_output.feature +++ /dev/null @@ -1,10 +0,0 @@ -Feature: Disable Id generation on anonymous resource collections - - @!mongodb - Scenario: Post to an output false should not generate an IRI - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/no_iri_messages" with body: - """ - {} - """ - Then the response status code should be 202 diff --git a/features/jsonld/non_resource.feature b/features/jsonld/non_resource.feature deleted file mode 100644 index 083770c9a28..00000000000 --- a/features/jsonld/non_resource.feature +++ /dev/null @@ -1,145 +0,0 @@ -Feature: JSON-LD non-resource handling - In order to use non-resource types - As a developer - I should be able to serialize types not mapped to an API resource. - - Background: - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - - @createSchema - Scenario: Get a resource containing a raw object - When I send a "GET" request to "/contain_non_resources/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "@context": "/contexts/ContainNonResource", - "@id": "/contain_non_resources/1", - "@type": "ContainNonResource", - "id": 1, - "nested": { - "@id": "/contain_non_resources/1-nested", - "@type": "ContainNonResource", - "id": "1-nested", - "nested": null, - "notAResource": { - "@type": "NotAResource", - "foo": "f2", - "bar": "b2" - } - }, - "notAResource": { - "@type": "NotAResource", - "foo": "f1", - "bar": "b1" - } - } - """ - And the JSON node "notAResource.@id" should exist - - @createSchema - Scenario: Get a resource containing a raw object with selected properties - Given there are 1 dummy objects with relatedDummy and its thirdLevel - When I send a "GET" request to "/contain_non_resources/1?properties[]=id&properties[nested][notAResource][]=foo&properties[notAResource][]=bar" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "@context": "/contexts/ContainNonResource", - "@id": "/contain_non_resources/1", - "@type": "ContainNonResource", - "id": 1, - "nested": { - "@id": "/contain_non_resources/1-nested", - "@type": "ContainNonResource", - "notAResource": { - "@type": "NotAResource", - "foo": "f2" - } - }, - "notAResource": { - "@type": "NotAResource", - "bar": "b1" - } - } - """ - - @!mongodb - @createSchema - Scenario: Create a resource that has a non-resource relation. - When I send a "POST" request to "/non_relation_resources" with body: - """ - { - "relation": { - "foo": "test" - } - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "@context": "/contexts/NonRelationResource", - "@id": "/non_relation_resources/1", - "@type": "NonRelationResource", - "relation": { - "@type": "NonResourceClass", - "foo": "test" - }, - "id": 1 - } - """ - - @!mongodb - @createSchema - Scenario: Create a resource that contains a stdClass object. - When I send a "POST" request to "/plain_object_dummies" with body: - """ - { - "content": "{\"fields\":{\"title\":{\"value\":\"\"},\"images\":[{\"id\":0,\"categoryId\":0,\"uri\":\"/api/pictures\",\"resource\":\"{}\",\"description\":\"\",\"alt\":\"\",\"type\":\"picture\",\"text\":\"\",\"src\":\"\"}],\"alternativeAudio\":{},\"caption\":\"\"},\"showCaption\":false,\"alternativeContent\":false,\"alternativeAudioContent\":false,\"blockLayout\":\"default\"}" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "@context": "/contexts/PlainObjectDummy", - "@id": "/plain_object_dummies/1", - "@type": "PlainObjectDummy", - "data": { - "fields": [], - "showCaption": false, - "alternativeContent": false, - "alternativeAudioContent": false, - "blockLayout": "default" - }, - "id": 1 - } - """ - - @php8 - Scenario: Get a generated id - When I send a "GET" request to "/genids/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "totalPrice.@id" should not exist - - @!mongodb - @createSchema - Scenario: Get a resource using entityClass with a DateTime attribute - Given there is a resource using entityClass with a DateTime attribute - When I send a "GET" request to "/EntityClassWithDateTime/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "start" should exist diff --git a/tests/Fixtures/TestBundle/ApiResource/DtoOutput.php b/tests/Fixtures/TestBundle/ApiResource/DtoOutput.php new file mode 100644 index 00000000000..f91efee8fce --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/DtoOutput.php @@ -0,0 +1,21 @@ + + * + * 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; + +final class DtoOutput +{ + public function __construct(public readonly string $name = '') + { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/EntityWithDtoOutput.php b/tests/Fixtures/TestBundle/ApiResource/EntityWithDtoOutput.php deleted file mode 100644 index 77355d097c3..00000000000 --- a/tests/Fixtures/TestBundle/ApiResource/EntityWithDtoOutput.php +++ /dev/null @@ -1,71 +0,0 @@ - - * - * 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; - -use ApiPlatform\Metadata\ApiResource; -use ApiPlatform\Metadata\GetCollection; -use ApiPlatform\Metadata\Operation; - -#[ApiResource] -#[GetCollection(output: DtoInterface::class, provider: [self::class, 'provide'])] -class EntityWithDtoOutput -{ - private string $name; - - private string $city; - - public function getName(): string - { - return $this->name; - } - - public function setName(string $name): void - { - $this->name = $name; - } - - public function getCity(): string - { - return $this->city; - } - - public function setCity(string $city): void - { - $this->city = $city; - } - - public static function provide(Operation $operation, array $uriVariables = [], array $context = []): array - { - return [ - new DtoOutput('Sarah'), - ]; - } -} - -interface DtoInterface -{ - public function getName(): string; -} - -class DtoOutput implements DtoInterface -{ - public function __construct(private readonly string $name) - { - } - - public function getName(): string - { - return $this->name; - } -} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/AbsoluteUrlChild.php b/tests/Fixtures/TestBundle/ApiResource/Hal/AbsoluteUrlChild.php new file mode 100644 index 00000000000..c6cc558408f --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/AbsoluteUrlChild.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\Tests\Fixtures\TestBundle\ApiResource\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'HalAbsoluteUrlChild', + urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, + operations: [ + new GetCollection( + uriTemplate: '/hal_absolute_url_children', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/hal_absolute_url_children/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/hal_absolute_url_children', + processor: [self::class, 'process'], + ), + new GetCollection( + uriTemplate: '/hal_absolute_url_parents/{parentId}/children', + uriVariables: [ + 'parentId' => new Link(fromClass: AbsoluteUrlParent::class, identifiers: ['id']), + ], + provider: [self::class, 'provideCollection'], + ), + ], +)] +class AbsoluteUrlChild +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public ?AbsoluteUrlParent $parent = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->parent = AbsoluteUrlParent::provide($operation, ['id' => 1], $context); + + return $r; + } + + public static function provideCollection(): array + { + return [self::provide(new Get(), ['id' => 1], [])]; + } + + public static function process(self $data): self + { + $data->id = 2; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/AbsoluteUrlParent.php b/tests/Fixtures/TestBundle/ApiResource/Hal/AbsoluteUrlParent.php new file mode 100644 index 00000000000..1fabe052fc2 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/AbsoluteUrlParent.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\Tests\Fixtures\TestBundle\ApiResource\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'HalAbsoluteUrlParent', + urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, + operations: [ + new Get( + uriTemplate: '/hal_absolute_url_parents/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/hal_absolute_url_parents', + processor: [self::class, 'process'], + ), + ], +)] +class AbsoluteUrlParent +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + /** @var AbsoluteUrlChild[] */ + public array $children = []; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } + + public static function process(self $data): self + { + $data->id = 2; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/CollectionPagedResource.php b/tests/Fixtures/TestBundle/ApiResource/Hal/CollectionPagedResource.php new file mode 100644 index 00000000000..cf21afde041 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/CollectionPagedResource.php @@ -0,0 +1,122 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\ArrayPaginator; +use ApiPlatform\State\Pagination\HasNextPagePaginatorInterface; +use ApiPlatform\State\Pagination\PartialPaginatorInterface; + +#[ApiResource( + shortName: 'HalCollectionPaged', + paginationItemsPerPage: 3, + paginationClientItemsPerPage: true, + paginationClientEnabled: true, + paginationClientPartial: true, + operations: [ + new GetCollection( + uriTemplate: '/hal_collection_paged', + provider: [self::class, 'provideCollection'], + ), + ], +)] +class CollectionPagedResource +{ + #[ApiProperty(identifier: true)] + public int $id; + + public string $name = ''; + + public function __construct(int $id) + { + $this->id = $id; + $this->name = "Dummy #{$id}"; + } + + public static function provideCollection(Operation $operation, array $uriVariables = [], array $context = []): iterable + { + $items = array_map(static fn (int $i): self => new self($i), range(1, 10)); + $filters = $context['filters'] ?? []; + + if (isset($filters['id']) && '' !== $filters['id']) { + $needle = (string) $filters['id']; + $items = array_values(array_filter($items, static fn (self $r) => (string) $r->id === $needle || "/dummies/{$r->id}" === $needle)); + } + + if (isset($filters['name']) && '' !== $filters['name']) { + $needle = (string) $filters['name']; + $items = array_values(array_filter($items, static fn (self $r) => $r->name === $needle)); + } + + $page = (int) ($filters['page'] ?? 1); + if ($page < 1) { + $page = 1; + } + $itemsPerPage = (int) ($filters['itemsPerPage'] ?? 3); + if ($itemsPerPage < 0) { + $itemsPerPage = 3; + } + + $paginationDisabled = '0' === (string) ($filters['pagination'] ?? '1'); + if ($paginationDisabled) { + return new ArrayPaginator($items, 0, \count($items)); + } + + $partial = '1' === (string) ($filters['partial'] ?? ''); + if ($partial) { + return new HalCollectionPartialPaginator(\array_slice($items, ($page - 1) * $itemsPerPage, $itemsPerPage), $page, $itemsPerPage); + } + + return new ArrayPaginator($items, ($page - 1) * $itemsPerPage, $itemsPerPage); + } +} + +/** + * @internal + */ +final class HalCollectionPartialPaginator implements \IteratorAggregate, PartialPaginatorInterface, HasNextPagePaginatorInterface +{ + /** @param list $items */ + public function __construct(private readonly array $items, private readonly int $page, private readonly int $itemsPerPage) + { + } + + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->items); + } + + public function count(): int + { + return \count($this->items); + } + + public function getCurrentPage(): float + { + return (float) $this->page; + } + + public function getItemsPerPage(): float + { + return (float) $this->itemsPerPage; + } + + public function hasNextPage(): bool + { + return \count($this->items) === $this->itemsPerPage; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/CustomOutputResource.php b/tests/Fixtures/TestBundle/ApiResource/Hal/CustomOutputResource.php new file mode 100644 index 00000000000..d2ea8af0e7e --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/CustomOutputResource.php @@ -0,0 +1,65 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'HalCustomOutput', + operations: [ + new GetCollection( + uriTemplate: '/hal_custom_outputs', + output: CustomOutputDto::class, + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/hal_custom_outputs/{id}', + uriVariables: ['id'], + output: CustomOutputDto::class, + provider: [self::class, 'provide'], + ), + ], +)] +class CustomOutputResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $name = 'origin'; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): CustomOutputDto + { + return new CustomOutputDto(); + } + + public static function provideCollection(): array + { + $a = new CustomOutputDto(); + $b = new CustomOutputDto(); + $b->bar = 2; + + return [$a, $b]; + } +} + +final class CustomOutputDto +{ + public string $foo = 'test'; + + public int $bar = 1; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/HalRelatedResource.php b/tests/Fixtures/TestBundle/ApiResource/Hal/HalRelatedResource.php new file mode 100644 index 00000000000..3bbf5d2bfc7 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/HalRelatedResource.php @@ -0,0 +1,49 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'HalRelatedResource', + operations: [ + new Get( + uriTemplate: '/hal_related_resources/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class HalRelatedResource +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public string $symfony = 'symfony'; + + #[ApiProperty(readableLink: true)] + public ?HalThirdLevel $thirdLevel = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->thirdLevel = HalThirdLevel::provide(new Get(), ['id' => 1], $context); + + return $r; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/HalThirdLevel.php b/tests/Fixtures/TestBundle/ApiResource/Hal/HalThirdLevel.php new file mode 100644 index 00000000000..26273af698a --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/HalThirdLevel.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\Fixtures\TestBundle\ApiResource\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'HalThirdLevel', + operations: [ + new Get( + uriTemplate: '/hal_third_levels/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class HalThirdLevel +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public int $level = 3; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/MaxDepthResource.php b/tests/Fixtures/TestBundle/ApiResource/Hal/MaxDepthResource.php new file mode 100644 index 00000000000..723e7959196 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/MaxDepthResource.php @@ -0,0 +1,79 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; +use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Serializer\Attribute\MaxDepth; + +#[ApiResource( + shortName: 'HalMaxDepth', + normalizationContext: ['groups' => ['hal_max_depth'], 'enable_max_depth' => true], + denormalizationContext: ['groups' => ['hal_max_depth'], 'enable_max_depth' => true], + operations: [ + new Post( + uriTemplate: '/hal_max_depth_resources', + processor: [self::class, 'process'], + ), + new Put( + uriTemplate: '/hal_max_depth_resources/{id}', + uriVariables: ['id'], + extraProperties: ['standard_put' => false], + provider: [self::class, 'provide'], + processor: [self::class, 'process'], + ), + ], +)] +class MaxDepthResource +{ + #[ApiProperty(identifier: true)] + #[Groups(['hal_max_depth'])] + public ?int $id = null; + + #[Groups(['hal_max_depth'])] + public ?string $name = null; + + #[Groups(['hal_max_depth'])] + #[MaxDepth(1)] + public ?self $child = null; + + public static function process(self $data): self + { + $data->id = 1; + if ($data->child) { + $data->child->id = 2; + if ($data->child->child) { + $data->child->child->id = 3; + } + } + + return $data; + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $root = new self(); + $root->id = (int) ($uriVariables['id'] ?? 1); + $root->name = 'level 1'; + $root->child = new self(); + $root->child->id = 2; + $root->child->name = 'level 2'; + + return $root; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/NetworkPathParent.php b/tests/Fixtures/TestBundle/ApiResource/Hal/NetworkPathParent.php new file mode 100644 index 00000000000..2a493620098 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/NetworkPathParent.php @@ -0,0 +1,57 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'HalNetworkPathParent', + urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, + operations: [ + new Get( + uriTemplate: '/hal_network_path_parents/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/hal_network_path_parents', + processor: [self::class, 'process'], + ), + ], +)] +class NetworkPathParent +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } + + public static function process(self $data): self + { + $data->id = 2; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/NetworkPathResource.php b/tests/Fixtures/TestBundle/ApiResource/Hal/NetworkPathResource.php new file mode 100644 index 00000000000..6f9bdbc609c --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/NetworkPathResource.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\Tests\Fixtures\TestBundle\ApiResource\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'HalNetworkPathChild', + urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, + operations: [ + new GetCollection( + uriTemplate: '/hal_network_path_children', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/hal_network_path_children/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/hal_network_path_children', + processor: [self::class, 'process'], + ), + new GetCollection( + uriTemplate: '/hal_network_path_parents/{parentId}/children', + uriVariables: [ + 'parentId' => new Link(fromClass: NetworkPathParent::class, identifiers: ['id']), + ], + provider: [self::class, 'provideCollection'], + ), + ], +)] +class NetworkPathResource +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public ?NetworkPathParent $parent = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->parent = NetworkPathParent::provide($operation, ['id' => 1], $context); + + return $r; + } + + public static function provideCollection(): array + { + return [self::provide(new Get(), ['id' => 1], [])]; + } + + public static function process(self $data): self + { + $data->id = 2; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/NonResourceContainer.php b/tests/Fixtures/TestBundle/ApiResource/Hal/NonResourceContainer.php new file mode 100644 index 00000000000..3ed76c09814 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/NonResourceContainer.php @@ -0,0 +1,69 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'HalNonResourceContainer', + normalizationContext: ['groups' => ['hal_non_resource']], + operations: [ + new Get( + uriTemplate: '/hal_non_resource_containers/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class NonResourceContainer +{ + #[ApiProperty(identifier: true)] + #[Groups(['hal_non_resource'])] + public string $id; + + #[Groups(['hal_non_resource'])] + public ?self $nested = null; + + #[Groups(['hal_non_resource'])] + public ?NonResourceClass $notAResource = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $root = new self(); + $root->id = (string) ($uriVariables['id'] ?? '1'); + $root->notAResource = new NonResourceClass('f1', 'b1'); + + $nested = new self(); + $nested->id = $root->id.'-nested'; + $nested->notAResource = new NonResourceClass('f2', 'b2'); + $root->nested = $nested; + + return $root; + } +} + +final class NonResourceClass +{ + public function __construct( + #[Groups(['hal_non_resource'])] + public string $foo, + #[Groups(['hal_non_resource'])] + public string $bar, + ) { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/ProblemRelation.php b/tests/Fixtures/TestBundle/ApiResource/Hal/ProblemRelation.php new file mode 100644 index 00000000000..503b2684d8f --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/ProblemRelation.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\Fixtures\TestBundle\ApiResource\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'HalProblemRelation', + operations: [ + new Get( + uriTemplate: '/hal_problem_relations/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class ProblemRelation +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?string $name = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/ProblemResource.php b/tests/Fixtures/TestBundle/ApiResource/Hal/ProblemResource.php new file mode 100644 index 00000000000..119d0fbe407 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/ProblemResource.php @@ -0,0 +1,46 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + shortName: 'HalProblem', + operations: [ + new Post( + uriTemplate: '/hal_problems', + processor: [self::class, 'process'], + ), + ], +)] +class ProblemResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + #[Assert\NotBlank] + public ?string $name = null; + + public ?ProblemRelation $relatedDummy = null; + + public static function process(self $data): self + { + $data->id = 1; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/RelationEmbedder.php b/tests/Fixtures/TestBundle/ApiResource/Hal/RelationEmbedder.php new file mode 100644 index 00000000000..f9cf45bca0c --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/RelationEmbedder.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\Tests\Fixtures\TestBundle\ApiResource\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; + +#[ApiResource( + shortName: 'HalRelationEmbedder', + operations: [ + new GetCollection( + uriTemplate: '/hal_relation_embedders', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/hal_relation_embedders/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/hal_relation_embedders', + processor: [self::class, 'process'], + ), + new Put( + uriTemplate: '/hal_relation_embedders/{id}', + uriVariables: ['id'], + extraProperties: ['standard_put' => false], + provider: [self::class, 'provide'], + processor: [self::class, 'process'], + ), + new Patch( + uriTemplate: '/hal_relation_embedders/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + processor: [self::class, 'process'], + ), + ], +)] +class RelationEmbedder +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public string $krondstadt = 'Krondstadt'; + + #[ApiProperty(readableLink: true)] + public ?HalRelatedResource $related = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->related = HalRelatedResource::provide(new Get(), ['id' => 1], $context); + + return $r; + } + + public static function process(self $data): self + { + $data->id = 1; + + return $data; + } + + public static function provideCollection(): array + { + return [self::provide(new Get(), ['id' => 1], [])]; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/Hal/UriTemplateCar.php b/tests/Fixtures/TestBundle/ApiResource/Hal/UriTemplateCar.php new file mode 100644 index 00000000000..3554581ff88 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/Hal/UriTemplateCar.php @@ -0,0 +1,85 @@ + + * + * 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\Hal; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'HalUriTemplateCar', + operations: [ + new GetCollection( + uriTemplate: '/hal_uri_template_cars', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/hal_uri_template_cars/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/hal_uri_template_cars', + processor: [self::class, 'process'], + ), + new GetCollection( + uriTemplate: '/hal_uri_template_brands/renault/cars', + itemUriTemplate: '/hal_uri_template_brands/renault/cars/{id}', + provider: [self::class, 'provideCollection'], + ), + new Post( + uriTemplate: '/hal_uri_template_brands/renault/cars', + itemUriTemplate: '/hal_uri_template_brands/renault/cars/{id}', + processor: [self::class, 'process'], + ), + new Get( + uriTemplate: '/hal_uri_template_brands/renault/cars/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class UriTemplateCar +{ + #[ApiProperty(identifier: true)] + public string $id; + + public string $owner; + + public function __construct(string $id = '1', string $owner = 'Vincent') + { + $this->id = $id; + $this->owner = $owner; + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + return new self((string) ($uriVariables['id'] ?? '1'), 'Vincent'); + } + + public static function provideCollection(): array + { + return [new self('1'), new self('2')]; + } + + public static function process(self $data): self + { + $data->id = '42'; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/AbsoluteUrlDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/AbsoluteUrlDummy.php new file mode 100644 index 00000000000..eea9cfd9250 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/AbsoluteUrlDummy.php @@ -0,0 +1,68 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'JsonApiAbsoluteUrlDummy', + formats: ['jsonapi' => ['application/vnd.api+json']], + urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, + paginationItemsPerPage: 3, + operations: [ + new GetCollection( + uriTemplate: '/jsonapi_absolute_url_dummies', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonapi_absolute_url_dummies/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new GetCollection( + uriTemplate: '/jsonapi_absolute_url_relation_dummies/{relationId}/absolute_url_dummies', + uriVariables: [ + 'relationId' => new Link(fromClass: AbsoluteUrlRelationDummy::class, identifiers: ['id']), + ], + provider: [self::class, 'provideCollection'], + ), + ], +)] +class AbsoluteUrlDummy +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public ?AbsoluteUrlRelationDummy $absoluteUrlRelationDummy = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->absoluteUrlRelationDummy = AbsoluteUrlRelationDummy::provide($operation, ['id' => 1], $context); + + return $r; + } + + public static function provideCollection(): array + { + return [self::provide(new Get(), ['id' => 1], [])]; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/AbsoluteUrlRelationDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/AbsoluteUrlRelationDummy.php new file mode 100644 index 00000000000..4977228cfe0 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/AbsoluteUrlRelationDummy.php @@ -0,0 +1,61 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'JsonApiAbsoluteUrlRelationDummy', + formats: ['jsonapi' => ['application/vnd.api+json']], + urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, + operations: [ + new Get( + uriTemplate: '/jsonapi_absolute_url_relation_dummies/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonapi_absolute_url_relation_dummies', + processor: [self::class, 'process'], + ), + ], +)] +class AbsoluteUrlRelationDummy +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + /** @var AbsoluteUrlDummy[] */ + public array $absoluteUrlDummies = []; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } + + public static function process(self $data): self + { + $data->id = 2; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/CircularReference.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/CircularReference.php new file mode 100644 index 00000000000..710bf44d8a1 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/CircularReference.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\Tests\Fixtures\TestBundle\ApiResource\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonApiCircularReference', + formats: ['jsonapi' => ['application/vnd.api+json']], + operations: [ + new Get( + uriTemplate: '/jsonapi_circular_references/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class CircularReference +{ + #[ApiProperty(identifier: true)] + public int $id; + + public ?CircularReference $parent = null; + + /** @var CircularReference[] */ + public array $children = []; + + public function __construct(int $id = 1) + { + $this->id = $id; + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $first = new self(1); + $second = new self(2); + + $first->parent = $first; + $second->parent = $first; + $first->children = [$first, $second]; + + $id = (int) ($uriVariables['id'] ?? 1); + + return 2 === $id ? $second : $first; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/CustomOutputResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/CustomOutputResource.php new file mode 100644 index 00000000000..15abc2ac3d7 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/CustomOutputResource.php @@ -0,0 +1,66 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonApiCustomOutput', + formats: ['jsonapi' => ['application/vnd.api+json']], + operations: [ + new GetCollection( + uriTemplate: '/jsonapi_custom_outputs', + output: CustomOutputDto::class, + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonapi_custom_outputs/{id}', + uriVariables: ['id'], + output: CustomOutputDto::class, + provider: [self::class, 'provide'], + ), + ], +)] +class CustomOutputResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $name = 'origin'; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): CustomOutputDto + { + return new CustomOutputDto(); + } + + public static function provideCollection(): array + { + $a = new CustomOutputDto(); + $b = new CustomOutputDto(); + $b->bar = 2; + + return [$a, $b]; + } +} + +final class CustomOutputDto +{ + public string $foo = 'test'; + + public int $bar = 1; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/EntrypointDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/EntrypointDummy.php new file mode 100644 index 00000000000..569d11c03fa --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/EntrypointDummy.php @@ -0,0 +1,54 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonApiEntrypointDummy', + formats: ['jsonapi' => ['application/vnd.api+json']], + operations: [ + new GetCollection( + uriTemplate: '/jsonapi_entrypoint_dummies', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonapi_entrypoint_dummies/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class EntrypointDummy +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } + + public static function provideCollection(): array + { + return []; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/ErrorProblem.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/ErrorProblem.php new file mode 100644 index 00000000000..cd8e73e65b0 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/ErrorProblem.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\Tests\Fixtures\TestBundle\ApiResource\JsonApi; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Validator\Exception\ValidationException; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationList; + +#[ApiResource( + shortName: 'JsonApiErrorProblem', + formats: ['jsonapi' => ['application/vnd.api+json']], + operations: [ + new Post( + uriTemplate: '/jsonapi_validation_problem', + processor: [self::class, 'processValidation'], + ), + new Post( + uriTemplate: '/jsonapi_exception_problem', + processor: [self::class, 'processBadRequest'], + ), + ], +)] +class ErrorProblem +{ + public string $name = ''; + + public static function processValidation(): void + { + $root = new self(); + $violation = new ConstraintViolation( + 'This value should not be blank.', + null, + [], + $root, + 'name', + null, + ); + + throw new ValidationException(new ConstraintViolationList([$violation])); + } + + public static function processBadRequest(): void + { + throw new BadRequestHttpException(); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/FilteringDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/FilteringDummy.php new file mode 100644 index 00000000000..ae158bdd3a8 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/FilteringDummy.php @@ -0,0 +1,75 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\ArrayPaginator; + +#[ApiResource( + shortName: 'JsonApiFilteringDummy', + formats: ['jsonapi' => ['application/vnd.api+json']], + paginationItemsPerPage: 3, + operations: [ + new GetCollection( + uriTemplate: '/jsonapi_filtering_dummies', + provider: [self::class, 'provideCollection'], + ), + ], +)] +class FilteringDummy +{ + #[ApiProperty(identifier: true)] + public int $id; + + public string $name; + + public ?string $dummyDate; + + public function __construct(int $id, int $total = 30) + { + $this->id = $id; + $this->name = "Dummy #{$id}"; + // Last dummy has null date — match behat's thereAreDummyObjectsWithDummyDate. + $this->dummyDate = $id === $total ? null : \sprintf('2015-04-%02dT00:00:00+00:00', $id); + } + + public static function provideCollection(Operation $operation, array $uriVariables = [], array $context = []): iterable + { + $items = array_map(static fn (int $i): self => new self($i), range(1, 30)); + $filters = $context['filters'] ?? []; + + if (isset($filters['name']) && '' !== $filters['name']) { + $needle = strtolower((string) $filters['name']); + $items = array_values(array_filter($items, static fn (self $r): bool => str_contains(strtolower($r->name), $needle))); + } + + if (isset($filters['dummyDate']['after'])) { + $threshold = new \DateTimeImmutable((string) $filters['dummyDate']['after']); + $items = array_values(array_filter($items, static function (self $r) use ($threshold): bool { + return null !== $r->dummyDate && new \DateTimeImmutable($r->dummyDate) >= $threshold; + })); + } + + $page = (int) ($filters['page'] ?? 1); + if ($page < 1) { + $page = 1; + } + $itemsPerPage = 3; + + return new ArrayPaginator($items, ($page - 1) * $itemsPerPage, $itemsPerPage); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/FilteringProperty.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/FilteringProperty.php new file mode 100644 index 00000000000..23cd58a245a --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/FilteringProperty.php @@ -0,0 +1,56 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonApiFilteringProperty', + formats: ['jsonapi' => ['application/vnd.api+json']], + paginationEnabled: false, + operations: [ + new GetCollection( + uriTemplate: '/jsonapi_filtering_properties', + filters: ['dummy_property.property'], + provider: [self::class, 'provideCollection'], + ), + ], +)] +class FilteringProperty +{ + #[ApiProperty(identifier: true)] + public int $id; + + public string $foo; + + public string $bar; + + public string $group; + + public function __construct(int $id) + { + $this->id = $id; + $this->foo = "Foo #{$id}"; + $this->bar = "Bar #{$id}"; + $this->group = "Group #{$id}"; + } + + public static function provideCollection(Operation $operation, array $uriVariables = [], array $context = []): array + { + return [new self(1), new self(2)]; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/NetworkPathDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/NetworkPathDummy.php new file mode 100644 index 00000000000..0a9a1c9e60b --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/NetworkPathDummy.php @@ -0,0 +1,68 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'JsonApiNetworkPathDummy', + formats: ['jsonapi' => ['application/vnd.api+json']], + urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, + paginationItemsPerPage: 3, + operations: [ + new GetCollection( + uriTemplate: '/jsonapi_network_path_dummies', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonapi_network_path_dummies/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new GetCollection( + uriTemplate: '/jsonapi_network_path_relation_dummies/{relationId}/network_path_dummies', + uriVariables: [ + 'relationId' => new Link(fromClass: NetworkPathRelationDummy::class, identifiers: ['id']), + ], + provider: [self::class, 'provideCollection'], + ), + ], +)] +class NetworkPathDummy +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public ?NetworkPathRelationDummy $networkPathRelationDummy = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->networkPathRelationDummy = NetworkPathRelationDummy::provide($operation, ['id' => 1], $context); + + return $r; + } + + public static function provideCollection(): array + { + return [self::provide(new Get(), ['id' => 1], [])]; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/NetworkPathRelationDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/NetworkPathRelationDummy.php new file mode 100644 index 00000000000..a9b7de624bd --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/NetworkPathRelationDummy.php @@ -0,0 +1,61 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'JsonApiNetworkPathRelationDummy', + formats: ['jsonapi' => ['application/vnd.api+json']], + urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, + operations: [ + new Get( + uriTemplate: '/jsonapi_network_path_relation_dummies/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonapi_network_path_relation_dummies', + processor: [self::class, 'process'], + ), + ], +)] +class NetworkPathRelationDummy +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + /** @var NetworkPathDummy[] */ + public array $networkPathDummies = []; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } + + public static function process(self $data): self + { + $data->id = 2; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/NonRelationResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/NonRelationResource.php new file mode 100644 index 00000000000..807e668e524 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/NonRelationResource.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\Tests\Fixtures\TestBundle\ApiResource\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonApiNonRelationResource', + formats: ['jsonapi' => ['application/vnd.api+json']], + operations: [ + new Get( + uriTemplate: '/jsonapi_non_relation_resources/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonapi_non_relation_resources', + processor: [self::class, 'process'], + ), + ], +)] +class NonRelationResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?NonRelationPayload $relation = null; + + public static function provide(): self + { + return new self(); + } + + public static function process(self $data): self + { + $data->id = 1; + + return $data; + } +} + +final class NonRelationPayload +{ + public string $foo = ''; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/NonResourceContainer.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/NonResourceContainer.php new file mode 100644 index 00000000000..ffadad66a1d --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/NonResourceContainer.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\Tests\Fixtures\TestBundle\ApiResource\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'JsonApiNonResourceContainer', + formats: ['jsonapi' => ['application/vnd.api+json']], + normalizationContext: ['groups' => ['jsonapi_non_resource']], + operations: [ + new Get( + uriTemplate: '/jsonapi_non_resource_containers/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class NonResourceContainer +{ + #[ApiProperty(identifier: true)] + #[Groups(['jsonapi_non_resource'])] + public string $id; + + #[Groups(['jsonapi_non_resource'])] + public ?self $nested = null; + + #[Groups(['jsonapi_non_resource'])] + public ?NonResourceClass $notAResource = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $root = new self(); + $root->id = (string) ($uriVariables['id'] ?? '1'); + $root->notAResource = new NonResourceClass('f1', 'b1'); + + $nested = new self(); + $nested->id = $root->id.'-nested'; + $nested->notAResource = new NonResourceClass('f2', 'b2'); + $root->nested = $nested; + + return $root; + } +} + +final class NonResourceClass +{ + public function __construct( + #[Groups(['jsonapi_non_resource'])] + public string $foo, + #[Groups(['jsonapi_non_resource'])] + public string $bar, + ) { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/OrderingDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/OrderingDummy.php new file mode 100644 index 00000000000..c6dcfb9310b --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/OrderingDummy.php @@ -0,0 +1,75 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\ArrayPaginator; + +#[ApiResource( + shortName: 'JsonApiOrderingDummy', + formats: ['jsonapi' => ['application/vnd.api+json']], + paginationItemsPerPage: 30, + operations: [ + new GetCollection( + uriTemplate: '/jsonapi_ordering_dummies', + provider: [self::class, 'provideCollection'], + ), + ], +)] +class OrderingDummy +{ + #[ApiProperty(identifier: true)] + public int $id; + + public string $name; + + public string $description; + + public function __construct(int $id) + { + $this->id = $id; + $this->name = "Dummy #{$id}"; + // Even-id dummies share description "even"; odd dummies "odd". + // Sorting by description,-id puts evens first (desc within group): 30, 28, 26... + $this->description = 0 === $id % 2 ? 'even' : 'odd'; + } + + public static function provideCollection(Operation $operation, array $uriVariables = [], array $context = []): iterable + { + $items = array_map(static fn (int $i): self => new self($i), range(1, 30)); + $filters = $context['filters'] ?? []; + $order = $filters['order'] ?? []; + + if ($order) { + usort($items, static function (self $a, self $b) use ($order): int { + foreach ($order as $field => $direction) { + $cmp = $a->{$field} <=> $b->{$field}; + if ('desc' === strtolower((string) $direction)) { + $cmp = -$cmp; + } + if (0 !== $cmp) { + return $cmp; + } + } + + return 0; + }); + } + + return new ArrayPaginator($items, 0, \count($items)); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/PaginationDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/PaginationDummy.php new file mode 100644 index 00000000000..cbfb9870075 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/PaginationDummy.php @@ -0,0 +1,71 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\ArrayPaginator; + +#[ApiResource( + shortName: 'JsonApiPaginationDummy', + formats: ['jsonapi' => ['application/vnd.api+json']], + paginationItemsPerPage: 3, + paginationClientItemsPerPage: true, + operations: [ + new GetCollection( + uriTemplate: '/jsonapi_pagination_dummies', + provider: [self::class, 'provideCollection'], + ), + ], +)] +class PaginationDummy +{ + #[ApiProperty(identifier: true)] + public int $id; + + public function __construct(int $id) + { + $this->id = $id; + } + + public static function provideCollection(Operation $operation, array $uriVariables = [], array $context = []): iterable + { + $items = array_map(static fn (int $i): self => new self($i), range(1, 10)); + $filters = $context['filters'] ?? []; + + $rawPage = $filters['page'] ?? 1; + if (!is_numeric($rawPage)) { + throw new InvalidArgumentException('Page must be a positive integer.'); + } + $page = (int) $rawPage; + if ($page < 1) { + throw new InvalidArgumentException('Page must be a positive integer.'); + } + + $itemsPerPage = (int) ($filters['itemsPerPage'] ?? 3); + if ($itemsPerPage < 1) { + $itemsPerPage = 3; + } + + if ($page > intdiv(\PHP_INT_MAX, $itemsPerPage) + 1) { + throw new InvalidArgumentException('Page is out of range.'); + } + $offset = ($page - 1) * $itemsPerPage; + + return new ArrayPaginator($items, $offset, $itemsPerPage); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/PlainObjectResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/PlainObjectResource.php new file mode 100644 index 00000000000..07766fa2c0a --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/PlainObjectResource.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\Tests\Fixtures\TestBundle\ApiResource\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonApiPlainObjectResource', + formats: ['jsonapi' => ['application/vnd.api+json']], + operations: [ + new Get( + uriTemplate: '/jsonapi_plain_object_resources/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonapi_plain_object_resources', + processor: [self::class, 'process'], + ), + ], +)] +class PlainObjectResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?string $content = null; + + public ?\stdClass $data = null; + + public static function provide(): self + { + return new self(); + } + + public static function process(self $data): self + { + $data->id = 1; + if (null !== $data->content) { + $data->data = json_decode($data->content); + } + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonApi/UriTemplateCar.php b/tests/Fixtures/TestBundle/ApiResource/JsonApi/UriTemplateCar.php new file mode 100644 index 00000000000..3dc2accb6f0 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonApi/UriTemplateCar.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\Tests\Fixtures\TestBundle\ApiResource\JsonApi; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonApiUriTemplateCar', + formats: ['jsonapi' => ['application/vnd.api+json']], + operations: [ + new GetCollection( + uriTemplate: '/jsonapi_uri_template_cars', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonapi_uri_template_cars/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonapi_uri_template_cars', + processor: [self::class, 'process'], + ), + new GetCollection( + uriTemplate: '/jsonapi_uri_template_brands/renault/cars', + itemUriTemplate: '/jsonapi_uri_template_brands/renault/cars/{id}', + provider: [self::class, 'provideCollection'], + ), + new Post( + uriTemplate: '/jsonapi_uri_template_brands/renault/cars', + itemUriTemplate: '/jsonapi_uri_template_brands/renault/cars/{id}', + processor: [self::class, 'process'], + ), + new Get( + uriTemplate: '/jsonapi_uri_template_brands/renault/cars/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class UriTemplateCar +{ + #[ApiProperty(identifier: true)] + public string $id; + + public string $owner; + + public function __construct(string $id = '1', string $owner = 'Vincent') + { + $this->id = $id; + $this->owner = $owner; + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + return new self((string) ($uriVariables['id'] ?? '1'), 'Vincent'); + } + + public static function provideCollection(): array + { + return [new self('1'), new self('2')]; + } + + public static function process(self $data): self + { + $data->id = '42'; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsolutePagedResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsolutePagedResource.php new file mode 100644 index 00000000000..8b13c4ad818 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsolutePagedResource.php @@ -0,0 +1,51 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\State\Pagination\ArrayPaginator; + +#[ApiResource( + shortName: 'JsonLdAbsolutePaged', + urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, + paginationItemsPerPage: 3, + operations: [ + new GetCollection( + uriTemplate: '/jsonld_absolute_paged', + provider: [self::class, 'provideCollection'], + ), + ], +)] +class AbsolutePagedResource +{ + #[ApiProperty(identifier: true)] + public int $id; + + public function __construct(int $id) + { + $this->id = $id; + } + + public static function provideCollection(Operation $operation, array $uriVariables = [], array $context = []): ArrayPaginator + { + $page = (int) ($context['filters']['page'] ?? 1); + $items = array_map(static fn (int $i): self => new self($i), range(1, 30)); + + return new ArrayPaginator($items, ($page - 1) * 3, 3); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsoluteUrlChild.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsoluteUrlChild.php new file mode 100644 index 00000000000..e91632ec292 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsoluteUrlChild.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\Tests\Fixtures\TestBundle\ApiResource\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'JsonLdAbsoluteUrlChild', + urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, + operations: [ + new GetCollection( + uriTemplate: '/jsonld_absolute_url_children', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonld_absolute_url_children/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_absolute_url_children', + processor: [self::class, 'process'], + ), + new GetCollection( + uriTemplate: '/jsonld_absolute_url_parents/{parentId}/children', + uriVariables: [ + 'parentId' => new Link(fromClass: AbsoluteUrlParent::class, identifiers: ['id']), + ], + provider: [self::class, 'provideCollection'], + ), + ], +)] +class AbsoluteUrlChild +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public ?AbsoluteUrlParent $parent = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->parent = AbsoluteUrlParent::provide($operation, ['id' => 1], $context); + + return $r; + } + + public static function provideCollection(): array + { + return [self::provide(new Get(), ['id' => 1], [])]; + } + + public static function process(self $data): self + { + $data->id = 2; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsoluteUrlParent.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsoluteUrlParent.php new file mode 100644 index 00000000000..24d8c1ffde9 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/AbsoluteUrlParent.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\Tests\Fixtures\TestBundle\ApiResource\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'JsonLdAbsoluteUrlParent', + urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, + operations: [ + new Get( + uriTemplate: '/jsonld_absolute_url_parents/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_absolute_url_parents', + processor: [self::class, 'process'], + ), + ], +)] +class AbsoluteUrlParent +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + /** @var AbsoluteUrlChild[] */ + public array $children = []; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } + + public static function process(self $data): self + { + $data->id = 2; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/CollectionNoPrefix.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/CollectionNoPrefix.php new file mode 100644 index 00000000000..0cfb17e2080 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/CollectionNoPrefix.php @@ -0,0 +1,44 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; + +#[ApiResource( + shortName: 'JsonLdCollectionNoPrefix', + normalizationContext: ['hydra_prefix' => false], + operations: [ + new GetCollection( + uriTemplate: '/jsonld_collection_no_prefix', + provider: [self::class, 'provideCollection'], + ), + ], +)] +class CollectionNoPrefix +{ + #[ApiProperty(identifier: true)] + public int $id; + + public function __construct(int $id) + { + $this->id = $id; + } + + public static function provideCollection(): array + { + return [new self(1), new self(2)]; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/CollectionPagedResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/CollectionPagedResource.php new file mode 100644 index 00000000000..c6a3142f18c --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/CollectionPagedResource.php @@ -0,0 +1,124 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\ArrayPaginator; +use ApiPlatform\State\Pagination\HasNextPagePaginatorInterface; +use ApiPlatform\State\Pagination\PartialPaginatorInterface; + +#[ApiResource( + shortName: 'JsonLdCollectionPaged', + paginationItemsPerPage: 3, + paginationClientItemsPerPage: true, + paginationClientEnabled: true, + paginationClientPartial: true, + operations: [ + new GetCollection( + uriTemplate: '/jsonld_collection_paged', + provider: [self::class, 'provideCollection'], + ), + ], +)] +class CollectionPagedResource +{ + #[ApiProperty(identifier: true)] + public int $id; + + public string $name = ''; + + public function __construct(int $id) + { + $this->id = $id; + $this->name = "Dummy #{$id}"; + } + + public static function provideCollection(Operation $operation, array $uriVariables = [], array $context = []): iterable + { + $items = array_map(static fn (int $i): self => new self($i), range(1, 30)); + $filters = $context['filters'] ?? []; + + if (isset($filters['id']) && '' !== $filters['id']) { + $needle = (string) $filters['id']; + $items = array_values(array_filter($items, static fn (self $r) => (string) $r->id === $needle || "/dummies/{$r->id}" === $needle)); + } + + if (isset($filters['name']) && '' !== $filters['name']) { + $needle = (string) $filters['name']; + $items = array_values(array_filter($items, static fn (self $r) => $r->name === $needle)); + } + + $page = (int) ($filters['page'] ?? 1); + if ($page < 1) { + $page = 1; + } + $itemsPerPage = (int) ($filters['itemsPerPage'] ?? 3); + if ($itemsPerPage < 0) { + $itemsPerPage = 3; + } + + $paginationDisabled = '0' === (string) ($filters['pagination'] ?? '1'); + if ($paginationDisabled) { + return new ArrayPaginator($items, 0, \count($items)); + } + + $partial = '1' === (string) ($filters['partial'] ?? ''); + if ($partial) { + return new CollectionPartialPaginator(\array_slice($items, ($page - 1) * $itemsPerPage, $itemsPerPage), $page, $itemsPerPage); + } + + return new ArrayPaginator($items, ($page - 1) * $itemsPerPage, $itemsPerPage); + } +} + +/** + * Implements only the partial paginator contract so hydra:view drops first/last. + * + * @internal + */ +final class CollectionPartialPaginator implements \IteratorAggregate, PartialPaginatorInterface, HasNextPagePaginatorInterface +{ + /** @param list $items */ + public function __construct(private readonly array $items, private readonly int $page, private readonly int $itemsPerPage) + { + } + + public function getIterator(): \Traversable + { + return new \ArrayIterator($this->items); + } + + public function count(): int + { + return \count($this->items); + } + + public function getCurrentPage(): float + { + return (float) $this->page; + } + + public function getItemsPerPage(): float + { + return (float) $this->itemsPerPage; + } + + public function hasNextPage(): bool + { + return \count($this->items) === $this->itemsPerPage; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/CustomInputResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/CustomInputResource.php new file mode 100644 index 00000000000..89d6c9b4ee4 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/CustomInputResource.php @@ -0,0 +1,74 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + shortName: 'JsonLdCustomInput', + operations: [ + new Get( + uriTemplate: '/jsonld_custom_inputs/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_custom_inputs', + input: CustomInputDto::class, + processor: [self::class, 'process'], + ), + ], +)] +class CustomInputResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?string $lorem = null; + + public ?string $ipsum = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->lorem = 'test'; + $r->ipsum = '1'; + + return $r; + } + + public static function process(CustomInputDto $data): self + { + $r = new self(); + $r->id = 1; + $r->lorem = $data->foo; + $r->ipsum = (string) $data->bar; + + return $r; + } +} + +final class CustomInputDto +{ + public ?string $foo = null; + + #[Assert\Type('integer')] + public ?int $bar = null; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/CustomOutputResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/CustomOutputResource.php new file mode 100644 index 00000000000..d50170ac5fb --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/CustomOutputResource.php @@ -0,0 +1,65 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonLdCustomOutput', + operations: [ + new GetCollection( + uriTemplate: '/jsonld_custom_outputs', + output: CustomOutputDto::class, + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonld_custom_outputs/{id}', + uriVariables: ['id'], + output: CustomOutputDto::class, + provider: [self::class, 'provide'], + ), + ], +)] +class CustomOutputResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $name = 'origin'; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): CustomOutputDto + { + return new CustomOutputDto(); + } + + public static function provideCollection(): array + { + $a = new CustomOutputDto(); + $b = new CustomOutputDto(); + $b->bar = 2; + + return [$a, $b]; + } +} + +final class CustomOutputDto +{ + public string $foo = 'test'; + + public int $bar = 1; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/DateTimeOnlyResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/DateTimeOnlyResource.php new file mode 100644 index 00000000000..4c4fad27482 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/DateTimeOnlyResource.php @@ -0,0 +1,46 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonLdDateTimeResource', + operations: [ + new Get( + uriTemplate: '/jsonld_datetime_resources/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class DateTimeOnlyResource +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public ?\DateTimeInterface $start = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->start = new \DateTimeImmutable('2024-01-01T00:00:00+00:00'); + + return $r; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/DisableIdGenAnonymous.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/DisableIdGenAnonymous.php new file mode 100644 index 00000000000..fca70abf105 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/DisableIdGenAnonymous.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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; + +#[Get( + shortName: 'JsonLdDisableIdGenAnonymous', + uriTemplate: '/jsonld_disable_id_gen_anonymous', + provider: [self::class, 'provide'], +)] +class DisableIdGenAnonymous +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + /** @var array */ + #[ApiProperty(genId: false)] + public array $items; + + public static function provide(): self + { + $a = new self(); + $a->items = [new DisableIdGenItem('one'), new DisableIdGenItem('two')]; + + return $a; + } +} + +class DisableIdGenItem +{ + public function __construct(public string $title) + { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyCollectionDto.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyCollectionDto.php new file mode 100644 index 00000000000..b885400b3e9 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyCollectionDto.php @@ -0,0 +1,54 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; + +#[ApiResource( + shortName: 'JsonLdDummyCollectionDto', + operations: [ + new GetCollection( + uriTemplate: '/jsonld_dummy_collection_dtos', + output: DummyCollectionDtoOutput::class, + provider: [self::class, 'provideCollection'], + ), + ], +)] +class DummyCollectionDto +{ + public string $foo = ''; + + public int $bar = 0; + + public static function provideCollection(): array + { + $a = new DummyCollectionDtoOutput(); + $a->foo = 'foo'; + $a->bar = 1; + + $b = new DummyCollectionDtoOutput(); + $b->foo = 'foo'; + $b->bar = 2; + + return [$a, $b]; + } +} + +final class DummyCollectionDtoOutput +{ + public string $foo = ''; + + public int $bar = 0; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyFooCollectionDto.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyFooCollectionDto.php new file mode 100644 index 00000000000..a63be9bc25b --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyFooCollectionDto.php @@ -0,0 +1,69 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonLdDummyFooCollectionDto', + operations: [ + new GetCollection( + uriTemplate: '/jsonld_dummy_foo_collection_dtos', + itemUriTemplate: '/jsonld_dummy_foos/bar', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonld_dummy_foos/bar', + provider: [self::class, 'provide'], + ), + ], +)] +class DummyFooCollectionDto +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $foo = ''; + + public int $bar = 0; + + public static function provideCollection(): array + { + $a = new self(); + $a->id = 1; + $a->foo = 'foo'; + $a->bar = 1; + + $b = new self(); + $b->id = 2; + $b->foo = 'foo'; + $b->bar = 2; + + return [$a, $b]; + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = 1; + $r->foo = 'foo'; + $r->bar = 1; + + return $r; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyIdCollectionDto.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyIdCollectionDto.php new file mode 100644 index 00000000000..3538035ef9b --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/DummyIdCollectionDto.php @@ -0,0 +1,62 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; + +#[ApiResource( + shortName: 'JsonLdDummyIdCollectionDto', + operations: [ + new GetCollection( + uriTemplate: '/jsonld_dummy_id_collection_dtos', + output: DummyIdCollectionDtoOutput::class, + provider: [self::class, 'provideCollection'], + ), + ], +)] +class DummyIdCollectionDto +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $foo = ''; + + public int $bar = 0; + + public static function provideCollection(): array + { + $a = new DummyIdCollectionDtoOutput(); + $a->id = 1; + $a->foo = 'foo'; + $a->bar = 1; + + $b = new DummyIdCollectionDtoOutput(); + $b->id = 2; + $b->foo = 'foo'; + $b->bar = 2; + + return [$a, $b]; + } +} + +final class DummyIdCollectionDtoOutput +{ + public ?int $id = null; + + public string $foo = ''; + + public int $bar = 0; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/GenIdFalseProperty.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/GenIdFalseProperty.php new file mode 100644 index 00000000000..402acc72c31 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/GenIdFalseProperty.php @@ -0,0 +1,58 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonLdGenIdFalseProperty', + operations: [ + new Get( + uriTemplate: '/jsonld_genid_false_properties/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class GenIdFalseProperty +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + #[ApiProperty(genId: false)] + public GenIdMonetaryAmount $totalPrice; + + public function __construct() + { + $this->totalPrice = new GenIdMonetaryAmount(42); + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } +} + +final class GenIdMonetaryAmount +{ + public function __construct(public readonly float $value) + { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsDeprecated.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsDeprecated.php new file mode 100644 index 00000000000..0e2e42fc5d3 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsDeprecated.php @@ -0,0 +1,50 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonLdHydraDocsDeprecated', + deprecationReason: 'This resource is deprecated.', + operations: [ + new GetCollection(uriTemplate: '/jsonld_hydra_docs_deprecated', provider: [self::class, 'provideCollection']), + new Get(uriTemplate: '/jsonld_hydra_docs_deprecated/{id}', uriVariables: ['id'], provider: [self::class, 'provide']), + ], +)] +class HydraDocsDeprecated +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + #[ApiProperty(deprecationReason: 'This field is deprecated.')] + public ?string $deprecatedField = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } + + public static function provideCollection(): array + { + return []; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsRelated.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsRelated.php new file mode 100644 index 00000000000..9ca3c8df240 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsRelated.php @@ -0,0 +1,49 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonLdHydraDocsRelated', + types: ['https://schema.org/Product'], + operations: [ + new GetCollection(uriTemplate: '/jsonld_hydra_docs_related', provider: [self::class, 'provideCollection']), + new Get(uriTemplate: '/jsonld_hydra_docs_related/{id}', uriVariables: ['id'], provider: [self::class, 'provide']), + ], +)] +class HydraDocsRelated +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $name = ''; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } + + public static function provideCollection(): array + { + return []; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsResource.php new file mode 100644 index 00000000000..5390c2cde92 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraDocsResource.php @@ -0,0 +1,71 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Put; +use Symfony\Component\Validator\Constraints as Assert; + +/** + * A docs sample. + */ +#[ApiResource( + shortName: 'JsonLdHydraDocs', + operations: [ + new Get(uriTemplate: '/jsonld_hydra_docs/{id}', uriVariables: ['id'], provider: [self::class, 'provide']), + new GetCollection(uriTemplate: '/jsonld_hydra_docs', provider: [self::class, 'provideCollection']), + new Put(uriTemplate: '/jsonld_hydra_docs/{id}', uriVariables: ['id'], processor: [self::class, 'process']), + new Delete(uriTemplate: '/jsonld_hydra_docs/{id}', uriVariables: ['id'], processor: [self::class, 'process']), + ], +)] +class HydraDocsResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + /** + * The doc resource name. + */ + #[ApiProperty(iris: ['https://schema.org/name'])] + #[Assert\NotBlank] + public string $name = ''; + + public ?HydraDocsRelated $related = null; + + /** @var array */ + public array $relateds = []; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } + + public static function provideCollection(): array + { + return []; + } + + public static function process(mixed $data): mixed + { + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraErrorResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraErrorResource.php new file mode 100644 index 00000000000..fafb888c1f0 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/HydraErrorResource.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\Tests\Fixtures\TestBundle\ApiResource\JsonLd; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Validator\Exception\ValidationException; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationList; + +#[ApiResource( + shortName: 'JsonLdHydraError', + operations: [ + new Post( + uriTemplate: '/jsonld_hydra_errors_bad_request', + processor: [self::class, 'throwBadRequest'], + ), + new Post( + uriTemplate: '/jsonld_hydra_errors_validation', + processor: [self::class, 'throwValidation'], + ), + new Post( + uriTemplate: '/jsonld_hydra_errors_no_prefix', + normalizationContext: ['hydra_prefix' => false], + processor: [self::class, 'throwBadRequest'], + ), + new Patch( + uriTemplate: '/jsonld_hydra_errors_patch_only', + processor: [self::class, 'throwBadRequest'], + ), + ], +)] +class HydraErrorResource +{ + public static function throwBadRequest(): void + { + throw new BadRequestHttpException(); + } + + public static function throwValidation(): void + { + $list = new ConstraintViolationList([ + new ConstraintViolation( + 'This value should not be blank.', + null, + [], + null, + 'name', + null, + null, + 'c1051bb4-d103-4f74-8988-acbcafc7fdc3', + ), + ]); + + throw new ValidationException($list); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/InputOutputResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/InputOutputResource.php new file mode 100644 index 00000000000..77ec9d1061d --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/InputOutputResource.php @@ -0,0 +1,117 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; + +#[ApiResource( + shortName: 'JsonLdInputOutputResource', + operations: [ + new GetCollection( + uriTemplate: '/jsonld_input_outputs', + output: InputOutputDto::class, + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonld_input_outputs/{id}', + uriVariables: ['id'], + output: InputOutputDto::class, + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_input_outputs', + input: InputOutputInputDto::class, + output: InputOutputDto::class, + processor: [self::class, 'process'], + ), + new Put( + uriTemplate: '/jsonld_input_outputs/{id}', + uriVariables: ['id'], + input: InputOutputInputDto::class, + output: InputOutputDto::class, + provider: [self::class, 'provide'], + processor: [self::class, 'process'], + ), + ], +)] +class InputOutputResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?string $foo = null; + + public ?int $bar = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->foo = 'test'; + $r->bar = 1; + + return $r; + } + + public static function provideCollection(): array + { + $a = new self(); + $a->id = 1; + $a->foo = 'test'; + $a->bar = 1; + $b = new self(); + $b->id = 2; + $b->foo = 'test'; + $b->bar = 2; + + return [$a, $b]; + } + + public static function process(InputOutputInputDto $data, Operation $operation, array $uriVariables = [], array $context = []): InputOutputDto + { + $out = new InputOutputDto(); + $out->id = (int) ($uriVariables['id'] ?? 1); + $out->bat = $data->foo; + $out->baz = $data->bar; + $out->relatedDummies = []; + + return $out; + } +} + +final class InputOutputInputDto +{ + public ?string $foo = null; + + public ?int $bar = null; +} + +final class InputOutputDto +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?int $baz = null; + + public ?string $bat = null; + + /** @var list */ + public array $relatedDummies = []; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceDtoOutputResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceDtoOutputResource.php new file mode 100644 index 00000000000..d51e2f82856 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceDtoOutputResource.php @@ -0,0 +1,63 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; + +#[ApiResource( + shortName: 'JsonLdInterfaceDtoOutput', + operations: [ + new GetCollection( + uriTemplate: '/jsonld_interface_dto_outputs', + output: InterfaceDtoOutputDto::class, + provider: [self::class, 'provide'], + ), + ], +)] +final class InterfaceDtoOutputResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $name = ''; + + public string $city = ''; + + public static function provide(): array + { + return [new InterfaceDtoOutputImpl(1, 'Sarah')]; + } +} + +interface InterfaceDtoOutputDto +{ + public function getName(): string; +} + +final class InterfaceDtoOutputImpl implements InterfaceDtoOutputDto +{ + public function __construct( + #[ApiProperty(identifier: true)] + public readonly int $id, + private readonly string $name, + ) { + } + + public function getName(): string + { + return $this->name; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceTaxon.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceTaxon.php new file mode 100644 index 00000000000..823314be202 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceTaxon.php @@ -0,0 +1,55 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'JsonLdInterfaceTaxon', + normalizationContext: ['groups' => ['jsonld_taxon_read']], + operations: [ + new Get( + uriTemplate: '/jsonld_interface_taxa/{code}', + uriVariables: ['code'], + provider: [InterfaceTaxonImpl::class, 'provideTaxon'], + ), + ], +)] +interface InterfaceTaxon +{ + #[ApiProperty(identifier: true)] + #[Groups(['jsonld_taxon_read', 'jsonld_product_read'])] + public function getCode(): ?string; +} + +final class InterfaceTaxonImpl implements InterfaceTaxon +{ + public function __construct(public string $code = '') + { + } + + public function getCode(): string + { + return $this->code; + } + + public static function provideTaxon(Operation $operation, array $uriVariables = [], array $context = []): self + { + return new self($uriVariables['code'] ?? 'WONDERFUL_TAXON'); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceTaxonProduct.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceTaxonProduct.php new file mode 100644 index 00000000000..be7f062fdb3 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/InterfaceTaxonProduct.php @@ -0,0 +1,50 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'JsonLdInterfaceTaxonProduct', + normalizationContext: ['groups' => ['jsonld_product_read']], + operations: [ + new Get( + uriTemplate: '/jsonld_interface_taxon_products/{code}', + uriVariables: ['code'], + provider: [self::class, 'provide'], + ), + ], +)] +final class InterfaceTaxonProduct +{ + #[ApiProperty(identifier: true)] + #[Groups(['jsonld_product_read'])] + public string $code; + + #[Groups(['jsonld_product_read'])] + public ?InterfaceTaxon $mainTaxon = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $product = new self(); + $product->code = $uriVariables['code'] ?? 'GREAT_PRODUCT'; + $product->mainTaxon = new InterfaceTaxonImpl('WONDERFUL_TAXON'); + + return $product; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/IriOnlyResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/IriOnlyResource.php new file mode 100644 index 00000000000..eda110387fa --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/IriOnlyResource.php @@ -0,0 +1,63 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + shortName: 'JsonLdIriOnlyResource', + normalizationContext: ['iri_only' => true, 'jsonld_embed_context' => true], + operations: [ + new GetCollection( + uriTemplate: '/jsonld_iri_only_resources', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonld_iri_only_resources/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class IriOnlyResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public string $foo = ''; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->foo = "foo {$r->id}"; + + return $r; + } + + public static function provideCollection(): array + { + return array_map(static function (int $i): self { + $r = new self(); + $r->id = $i; + $r->foo = "foo {$i}"; + + return $r; + }, [1, 2, 3]); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextDummy.php new file mode 100644 index 00000000000..18cd539f17a --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextDummy.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\Tests\Fixtures\TestBundle\ApiResource\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; + +#[ApiResource( + shortName: 'JsonLdContextDummy', + provider: [self::class, 'provide'], + processor: [self::class, 'process'], +)] +class JsonLdContextDummy +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + #[ApiProperty(iris: ['https://schema.org/name'])] + public ?string $name = null; + + #[ApiProperty(iris: ['https://schema.org/alternateName'])] + public ?string $alias = null; + + #[ApiProperty(jsonldContext: ['@id' => 'https://example.com/id', '@type' => '@id', 'foo' => 'bar'])] + public ?string $person = null; + + public ?JsonLdContextRelation $related = null; + + /** + * Exercises the collection-valued relation context mapping. + * + * @var JsonLdContextRelation[] + */ + public array $relatedCollection = []; + + #[ApiProperty(readableLink: true)] + public ?JsonLdContextRelation $embedded = null; + + #[ApiProperty(iris: ['https://schema.org/DateTime'])] + public ?\DateTimeInterface $dummyDate = null; + + public ?array $arrayData = null; + + public mixed $jsonData = null; + + public ?string $nameConverted = null; + + public static function provide(): array + { + return []; + } + + public static function process(self $data): self + { + $data->id = 1; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextRelation.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextRelation.php new file mode 100644 index 00000000000..406baf1d149 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextRelation.php @@ -0,0 +1,37 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; + +#[ApiResource( + shortName: 'JsonLdContextRelation', + operations: [ + new GetCollection(uriTemplate: '/jsonld_context_relations', provider: [self::class, 'provide']), + ], +)] +class JsonLdContextRelation +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?string $name = null; + + public static function provide(): array + { + return []; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonSerializableResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonSerializableResource.php new file mode 100644 index 00000000000..545990ce665 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonSerializableResource.php @@ -0,0 +1,103 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'JsonLdJsonSerializable', + normalizationContext: ['groups' => ['jsonld_jss']], + operations: [ + new Get( + uriTemplate: '/jsonld_json_serializables/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_json_serializables', + provider: [self::class, 'provideNew'], + processor: [self::class, 'process'], + ), + ], +)] +class JsonSerializableResource implements \JsonSerializable +{ + #[ApiProperty(identifier: true)] + #[Groups(['jsonld_jss'])] + public ?int $id = null; + + #[Groups(['jsonld_jss'])] + public string $contentType = ''; + + /** @var array */ + #[Groups(['jsonld_jss'])] + public array $fieldValues = []; + + #[Groups(['jsonld_jss'])] + public JsonSerializableStatus $status; + + public function __construct() + { + $this->status = new JsonSerializableStatus('DRAFT', 'draft'); + } + + public function jsonSerialize(): array + { + return [ + 'id' => $this->id, + 'contentType' => $this->contentType, + 'status' => $this->status, + 'fieldValues' => $this->fieldValues, + ]; + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->contentType = 'homepage'; + $r->fieldValues = ['title' => 'hello']; + + return $r; + } + + public static function provideNew(): self + { + return new self(); + } + + public static function process(self $data): self + { + $data->id = 1; + + return $data; + } +} + +final class JsonSerializableStatus implements \JsonSerializable +{ + public function __construct(public readonly string $key, public readonly string $value) + { + } + + public function jsonSerialize(): array + { + return ['key' => $this->key, 'value' => $this->value]; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/MaxDepthResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/MaxDepthResource.php new file mode 100644 index 00000000000..1db06db1e0c --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/MaxDepthResource.php @@ -0,0 +1,58 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; +use Symfony\Component\Serializer\Attribute\Groups; +use Symfony\Component\Serializer\Attribute\MaxDepth; + +#[ApiResource( + shortName: 'JsonLdMaxDepth', + normalizationContext: ['groups' => ['jsonld_max_depth'], 'enable_max_depth' => true], + denormalizationContext: ['groups' => ['jsonld_max_depth'], 'enable_max_depth' => true], + operations: [ + new Post( + uriTemplate: '/jsonld_max_depth_resources', + processor: [self::class, 'process'], + ), + ], +)] +class MaxDepthResource +{ + #[ApiProperty(identifier: true)] + #[Groups(['jsonld_max_depth'])] + public ?int $id = null; + + #[Groups(['jsonld_max_depth'])] + public ?string $name = null; + + #[Groups(['jsonld_max_depth'])] + #[MaxDepth(1)] + public ?self $child = null; + + public static function process(self $data): self + { + $data->id = 1; + if ($data->child) { + $data->child->id = 2; + if ($data->child->child) { + $data->child->child->id = 3; + } + } + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/NetworkPathParent.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NetworkPathParent.php new file mode 100644 index 00000000000..10bc1f369bc --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NetworkPathParent.php @@ -0,0 +1,57 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'JsonLdNetworkPathParent', + urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, + operations: [ + new Get( + uriTemplate: '/jsonld_network_path_parents/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_network_path_parents', + processor: [self::class, 'process'], + ), + ], +)] +class NetworkPathParent +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + + return $r; + } + + public static function process(self $data): self + { + $data->id = 2; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/NetworkPathResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NetworkPathResource.php new file mode 100644 index 00000000000..d30ec739fb0 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NetworkPathResource.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\Tests\Fixtures\TestBundle\ApiResource\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\UrlGeneratorInterface; + +#[ApiResource( + shortName: 'JsonLdNetworkPathChild', + urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, + operations: [ + new GetCollection( + uriTemplate: '/jsonld_network_path_children', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonld_network_path_children/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_network_path_children', + processor: [self::class, 'process'], + ), + new GetCollection( + uriTemplate: '/jsonld_network_path_parents/{parentId}/children', + uriVariables: [ + 'parentId' => new Link(fromClass: NetworkPathParent::class, identifiers: ['id']), + ], + provider: [self::class, 'provideCollection'], + ), + ], +)] +class NetworkPathResource +{ + #[ApiProperty(identifier: true)] + public int $id = 1; + + public ?NetworkPathParent $parent = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->parent = NetworkPathParent::provide($operation, ['id' => 1], $context); + + return $r; + } + + public static function provideCollection(): array + { + return [self::provide(new Get(), ['id' => 1], [])]; + } + + public static function process(self $data): self + { + $data->id = 2; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/NoInputResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NoInputResource.php new file mode 100644 index 00000000000..03bc78e0fab --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NoInputResource.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\Tests\Fixtures\TestBundle\ApiResource\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonLdNoInput', + operations: [ + new Get( + uriTemplate: '/jsonld_no_inputs/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_no_inputs', + input: false, + processor: [self::class, 'create'], + ), + new Post( + uriTemplate: '/jsonld_no_inputs/{id}/double_bat', + uriVariables: ['id'], + input: false, + status: 200, + read: true, + provider: [self::class, 'provide'], + processor: [self::class, 'doubleBat'], + ), + ], +)] +class NoInputResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?int $baz = null; + + public ?string $bat = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $r = new self(); + $r->id = (int) ($uriVariables['id'] ?? 1); + $r->baz = 1; + $r->bat = 'test'; + + return $r; + } + + public static function create(): self + { + $r = new self(); + $r->id = 1; + $r->baz = 1; + $r->bat = 'test'; + + return $r; + } + + public static function doubleBat(self $data): self + { + $data->bat = (string) $data->bat.$data->bat; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/NoOutputMessage.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NoOutputMessage.php new file mode 100644 index 00000000000..d35a04b998f --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NoOutputMessage.php @@ -0,0 +1,40 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\NotExposed; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonLdNoOutputMessage', + operations: [ + new NotExposed(uriTemplate: '/jsonld_no_output_messages/{id}'), + new Post( + uriTemplate: '/jsonld_no_output_messages', + status: 202, + output: false, + processor: [self::class, 'process'], + ), + ], +)] +class NoOutputMessage +{ + public ?int $id = null; + + public static function process(mixed $data): mixed + { + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/NonRelationResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NonRelationResource.php new file mode 100644 index 00000000000..e60af1ab9c1 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NonRelationResource.php @@ -0,0 +1,58 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonLdNonRelationResource', + operations: [ + new Get( + uriTemplate: '/jsonld_non_relation_resources/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_non_relation_resources', + processor: [self::class, 'process'], + ), + ], +)] +class NonRelationResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?NonRelationPayload $relation = null; + + public static function provide(): self + { + return new self(); + } + + public static function process(self $data): self + { + $data->id = 1; + + return $data; + } +} + +final class NonRelationPayload +{ + public string $foo = ''; +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/NonResourceContainer.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NonResourceContainer.php new file mode 100644 index 00000000000..076f8752819 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NonResourceContainer.php @@ -0,0 +1,72 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Serializer\Filter\PropertyFilter; +use Symfony\Component\Serializer\Attribute\Groups; + +#[ApiResource( + shortName: 'JsonLdNonResourceContainer', + normalizationContext: ['groups' => ['jsonld_non_resource']], + operations: [ + new Get( + uriTemplate: '/jsonld_non_resource_containers/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +#[ApiFilter(PropertyFilter::class)] +class NonResourceContainer +{ + #[ApiProperty(identifier: true)] + #[Groups(['jsonld_non_resource'])] + public string $id; + + #[Groups(['jsonld_non_resource'])] + public ?self $nested = null; + + #[Groups(['jsonld_non_resource'])] + public ?NonResourceClass $notAResource = null; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + $root = new self(); + $root->id = (string) ($uriVariables['id'] ?? '1'); + $root->notAResource = new NonResourceClass('f1', 'b1'); + + $nested = new self(); + $nested->id = $root->id.'-nested'; + $nested->notAResource = new NonResourceClass('f2', 'b2'); + $root->nested = $nested; + + return $root; + } +} + +final class NonResourceClass +{ + public function __construct( + #[Groups(['jsonld_non_resource'])] + public string $foo, + #[Groups(['jsonld_non_resource'])] + public string $bar, + ) { + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/PaginationCapped.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/PaginationCapped.php new file mode 100644 index 00000000000..0b755a3cf5a --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/PaginationCapped.php @@ -0,0 +1,43 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\State\JsonLdPaginationCappedProvider; + +#[ApiResource( + shortName: 'JsonLdPaginationCapped', + paginationItemsPerPage: 3, + paginationMaximumItemsPerPage: 30, + paginationClientItemsPerPage: true, + paginationClientEnabled: true, + operations: [ + new GetCollection( + uriTemplate: '/jsonld_pagination_capped', + provider: JsonLdPaginationCappedProvider::class, + ), + ], +)] +class PaginationCapped +{ + #[ApiProperty(identifier: true)] + public int $id; + + public function __construct(int $id) + { + $this->id = $id; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/PlainObjectResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/PlainObjectResource.php new file mode 100644 index 00000000000..ce2f5358d64 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/PlainObjectResource.php @@ -0,0 +1,58 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonLdPlainObjectResource', + operations: [ + new Get( + uriTemplate: '/jsonld_plain_object_resources/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_plain_object_resources', + processor: [self::class, 'process'], + ), + ], +)] +class PlainObjectResource +{ + #[ApiProperty(identifier: true)] + public ?int $id = null; + + public ?string $content = null; + + public ?\stdClass $data = null; + + public static function provide(): self + { + return new self(); + } + + public static function process(self $data): self + { + $data->id = 1; + if (null !== $data->content) { + $data->data = json_decode($data->content); + } + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/PostNoOutputResource.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/PostNoOutputResource.php new file mode 100644 index 00000000000..279af7d2348 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/PostNoOutputResource.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\Tests\Fixtures\TestBundle\ApiResource\JsonLd; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonLdPostNoOutput', + operations: [ + new Post( + uriTemplate: '/jsonld_post_no_output', + output: false, + processor: [self::class, 'process'], + ), + ], +)] +class PostNoOutputResource +{ + public ?string $lorem = null; + + public ?string $ipsum = null; + + public static function process(self $data): self + { + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/EntityWithRenamedGetterAndSetter.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/RenamedGetterSetter.php similarity index 63% rename from tests/Fixtures/TestBundle/ApiResource/EntityWithRenamedGetterAndSetter.php rename to tests/Fixtures/TestBundle/ApiResource/JsonLd/RenamedGetterSetter.php index 251453d2477..335f5aece95 100644 --- a/tests/Fixtures/TestBundle/ApiResource/EntityWithRenamedGetterAndSetter.php +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/RenamedGetterSetter.php @@ -11,15 +11,19 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource; +namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Operation; -#[ApiResource(provider: [self::class, 'provide'])] -class EntityWithRenamedGetterAndSetter +#[ApiResource( + shortName: 'JsonLdRenamedGetterSetter', + provider: [self::class, 'provide'], + processor: [self::class, 'process'], +)] +class RenamedGetterSetter { - private string $name; + private string $name = ''; public function getFirstnameOnly(): string { @@ -33,6 +37,11 @@ public function setFirstnameOnly(string $name): void public static function provide(Operation $operation, array $uriVariables = [], array $context = []): array { - return $context; + return []; + } + + public static function process(mixed $data): mixed + { + return $data; } } diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/UriTemplateCar.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/UriTemplateCar.php new file mode 100644 index 00000000000..c5ed575dfc9 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/UriTemplateCar.php @@ -0,0 +1,85 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; + +#[ApiResource( + shortName: 'JsonLdUriTemplateCar', + operations: [ + new GetCollection( + uriTemplate: '/jsonld_uri_template_cars', + provider: [self::class, 'provideCollection'], + ), + new Get( + uriTemplate: '/jsonld_uri_template_cars/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + new Post( + uriTemplate: '/jsonld_uri_template_cars', + processor: [self::class, 'process'], + ), + new GetCollection( + uriTemplate: '/jsonld_uri_template_brands/renault/cars', + itemUriTemplate: '/jsonld_uri_template_brands/renault/cars/{id}', + provider: [self::class, 'provideCollection'], + ), + new Post( + uriTemplate: '/jsonld_uri_template_brands/renault/cars', + itemUriTemplate: '/jsonld_uri_template_brands/renault/cars/{id}', + processor: [self::class, 'process'], + ), + new Get( + uriTemplate: '/jsonld_uri_template_brands/renault/cars/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +class UriTemplateCar +{ + #[ApiProperty(identifier: true)] + public string $id; + + public string $owner; + + public function __construct(string $id = '1', string $owner = 'Vincent') + { + $this->id = $id; + $this->owner = $owner; + } + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): self + { + return new self((string) ($uriVariables['id'] ?? '1'), 'Vincent'); + } + + public static function provideCollection(): array + { + return [new self('1'), new self('2')]; + } + + public static function process(self $data): self + { + $data->id = '42'; + + return $data; + } +} diff --git a/tests/Fixtures/TestBundle/Document/JsonldContextDummy.php b/tests/Fixtures/TestBundle/Document/JsonldContextDummy.php deleted file mode 100644 index ac8509cadf7..00000000000 --- a/tests/Fixtures/TestBundle/Document/JsonldContextDummy.php +++ /dev/null @@ -1,56 +0,0 @@ - - * - * 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\Document; - -use ApiPlatform\Metadata\ApiProperty; -use ApiPlatform\Metadata\ApiResource; -use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; - -/** - * Jsonld Context Dummy. - */ -#[ApiResource] -#[ODM\Document] -class JsonldContextDummy -{ - /** - * @var int The id - */ - #[ApiProperty(identifier: true)] - #[ODM\Id(strategy: 'INCREMENT', type: 'int')] - private ?int $id = null; - - /** - * @var string The dummy person - */ - #[ApiProperty( - jsonldContext: ['@id' => 'https://example.com/id', '@type' => '@id', 'foo' => 'bar'] - )] - private $person; - - public function getId(): ?int - { - return $this->id; - } - - public function setPerson($person): void - { - $this->person = $person; - } - - public function getPerson() - { - return $this->person; - } -} diff --git a/tests/Fixtures/TestBundle/Document/MaxDepthEagerDummy.php b/tests/Fixtures/TestBundle/Document/MaxDepthEagerDummy.php deleted file mode 100644 index 6aa501f3adc..00000000000 --- a/tests/Fixtures/TestBundle/Document/MaxDepthEagerDummy.php +++ /dev/null @@ -1,43 +0,0 @@ - - * - * 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\Document; - -use ApiPlatform\Metadata\ApiResource; -use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; -use Symfony\Component\Serializer\Attribute\Groups; -use Symfony\Component\Serializer\Attribute\MaxDepth; - -/** - * @author Brian Fox - */ -#[ApiResource(normalizationContext: ['groups' => ['default'], 'enable_max_depth' => true], denormalizationContext: ['groups' => ['default'], 'enable_max_depth' => true], graphQlOperations: [])] -#[ODM\Document] -class MaxDepthEagerDummy -{ - #[Groups(['default'])] - #[ODM\Id(strategy: 'INCREMENT', type: 'int')] - private $id; - #[Groups(['default'])] - #[ODM\Field(name: 'name', type: 'string')] - public $name; - #[Groups(['default'])] - #[MaxDepth(1)] - #[ODM\ReferenceOne(targetDocument: self::class, cascade: ['persist'])] - public $child; - - public function getId() - { - return $this->id; - } -} diff --git a/tests/Fixtures/TestBundle/Document/RelatedLinkedDummy.php b/tests/Fixtures/TestBundle/Document/RelatedLinkedDummy.php index e45799155a6..5e2f25793db 100644 --- a/tests/Fixtures/TestBundle/Document/RelatedLinkedDummy.php +++ b/tests/Fixtures/TestBundle/Document/RelatedLinkedDummy.php @@ -19,8 +19,9 @@ use ApiPlatform\Metadata\Link; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; -#[ApiResource()] +#[ApiResource] #[ApiResource( + shortName: 'RelatedLinkedDummyToFrom', uriTemplate: '/secured_dummies/{securedDummyId}/to_from', operations: [new GetCollection()], uriVariables: [ @@ -28,6 +29,7 @@ ] )] #[ApiResource( + shortName: 'RelatedLinkedDummyWithName', uriTemplate: '/secured_dummies/{securedDummyId}/with_name', operations: [new GetCollection()], uriVariables: [ @@ -35,6 +37,7 @@ ] )] #[ApiResource( + shortName: 'RelatedLinkedDummyMultiLink', uriTemplate: '/secured_dummies/{securedDummyId}/related/{id}', operations: [new GetCollection()], uriVariables: [ diff --git a/tests/Fixtures/TestBundle/Entity/DummyProblem.php b/tests/Fixtures/TestBundle/Entity/DummyProblem.php index a6bf31b5dc9..e7f745f14ab 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyProblem.php +++ b/tests/Fixtures/TestBundle/Entity/DummyProblem.php @@ -22,7 +22,6 @@ /** * DummyProblem. - * Tests features/hal/problem.feature. * * @author Kévin Dunglas */ diff --git a/tests/Fixtures/TestBundle/Entity/JsonldContextDummy.php b/tests/Fixtures/TestBundle/Entity/JsonldContextDummy.php deleted file mode 100644 index bbd39fcea31..00000000000 --- a/tests/Fixtures/TestBundle/Entity/JsonldContextDummy.php +++ /dev/null @@ -1,58 +0,0 @@ - - * - * 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\Entity; - -use ApiPlatform\Metadata\ApiProperty; -use ApiPlatform\Metadata\ApiResource; -use Doctrine\ORM\Mapping as ORM; - -/** - * Jsonld Context Dummy. - */ -#[ApiResource] -#[ORM\Entity] -class JsonldContextDummy -{ - /** - * @var int The id - */ - #[ApiProperty(identifier: true)] - #[ORM\Column(type: 'integer')] - #[ORM\Id] - #[ORM\GeneratedValue(strategy: 'AUTO')] - private ?int $id = null; - - /** - * @var string The dummy person - */ - #[ApiProperty( - jsonldContext: ['@id' => 'https://example.com/id', '@type' => '@id', 'foo' => 'bar'] - )] - private $person; - - public function getId(): ?int - { - return $this->id; - } - - public function setPerson($person): void - { - $this->person = $person; - } - - public function getPerson() - { - return $this->person; - } -} diff --git a/tests/Fixtures/TestBundle/Entity/MaxDepthEagerDummy.php b/tests/Fixtures/TestBundle/Entity/MaxDepthEagerDummy.php deleted file mode 100644 index 3a796b31542..00000000000 --- a/tests/Fixtures/TestBundle/Entity/MaxDepthEagerDummy.php +++ /dev/null @@ -1,45 +0,0 @@ - - * - * 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\Entity; - -use ApiPlatform\Metadata\ApiResource; -use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\Serializer\Attribute\Groups; -use Symfony\Component\Serializer\Attribute\MaxDepth; - -/** - * @author Brian Fox - */ -#[ApiResource(normalizationContext: ['groups' => ['default'], 'enable_max_depth' => true], denormalizationContext: ['groups' => ['default'], 'enable_max_depth' => true], graphQlOperations: [])] -#[ORM\Entity] -class MaxDepthEagerDummy -{ - #[ORM\Column(type: 'integer')] - #[ORM\Id] - #[ORM\GeneratedValue(strategy: 'AUTO')] - #[Groups(['default'])] - private $id; - #[ORM\Column(name: 'name', type: 'string', length: 30)] - #[Groups(['default'])] - public $name; - #[ORM\ManyToOne(targetEntity: self::class, cascade: ['persist'])] - #[Groups(['default'])] - #[MaxDepth(1)] - public $child; - - public function getId() - { - return $this->id; - } -} diff --git a/tests/Fixtures/TestBundle/Entity/RelatedLinkedDummy.php b/tests/Fixtures/TestBundle/Entity/RelatedLinkedDummy.php index 6715a842f79..c02722fce37 100644 --- a/tests/Fixtures/TestBundle/Entity/RelatedLinkedDummy.php +++ b/tests/Fixtures/TestBundle/Entity/RelatedLinkedDummy.php @@ -19,8 +19,9 @@ use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping\Entity; -#[ApiResource()] +#[ApiResource] #[ApiResource( + shortName: 'RelatedLinkedDummyToFrom', uriTemplate: '/secured_dummies/{securedDummyId}/to_from', operations: [new GetCollection()], uriVariables: [ @@ -28,6 +29,7 @@ ] )] #[ApiResource( + shortName: 'RelatedLinkedDummyWithName', uriTemplate: '/secured_dummies/{securedDummyId}/with_name', operations: [new GetCollection()], uriVariables: [ @@ -35,6 +37,7 @@ ] )] #[ApiResource( + shortName: 'RelatedLinkedDummyMultiLink', uriTemplate: '/secured_dummies/{securedDummyId}/related/{id}', operations: [new GetCollection()], uriVariables: [ diff --git a/tests/Fixtures/TestBundle/State/JsonLdPaginationCappedProvider.php b/tests/Fixtures/TestBundle/State/JsonLdPaginationCappedProvider.php new file mode 100644 index 00000000000..2d00adb40e0 --- /dev/null +++ b/tests/Fixtures/TestBundle/State/JsonLdPaginationCappedProvider.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\Tests\Fixtures\TestBundle\State; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\Pagination\ArrayPaginator; +use ApiPlatform\State\Pagination\Pagination; +use ApiPlatform\State\ProviderInterface; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\PaginationCapped; + +/** + * Exercises the framework's Pagination service so cap and validation rules apply. + */ +final class JsonLdPaginationCappedProvider implements ProviderInterface +{ + public function __construct(private readonly Pagination $pagination) + { + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable + { + $items = array_map(static fn (int $i): PaginationCapped => new PaginationCapped($i), range(1, 80)); + + [, $offset, $limit] = $this->pagination->getPagination($operation, $context); + + return new ArrayPaginator($items, $offset, $limit); + } +} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 1320c1e2637..77b2d7cbc1a 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -179,6 +179,12 @@ services: tags: - name: 'api_platform.state_provider' + ApiPlatform\Tests\Fixtures\TestBundle\State\JsonLdPaginationCappedProvider: + class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\JsonLdPaginationCappedProvider' + arguments: ['@api_platform.pagination'] + tags: + - name: 'api_platform.state_provider' + ApiPlatform\Tests\Fixtures\TestBundle\State\CarProcessor: class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\CarProcessor' tags: diff --git a/tests/Functional/Authorization/DenyTest.php b/tests/Functional/Authorization/DenyTest.php new file mode 100644 index 00000000000..838de50670b --- /dev/null +++ b/tests/Functional/Authorization/DenyTest.php @@ -0,0 +1,573 @@ + + * + * 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\Authorization; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6446\SecurityPostValidation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedLinkedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummyWithPropertiesDependingOnThemselves; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\Security\Core\User\InMemoryUser; + +final class DenyTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + SecuredDummy::class, + SecuredDummyWithPropertiesDependingOnThemselves::class, + RelatedLinkedDummy::class, + SecurityPostValidation::class, + ]; + } + + public function testAnonymousGetCollectionReturns401(): void + { + self::createClient()->request('GET', '/secured_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(401); + } + + public function testAuthenticatedUserGetCollectionReturns200(): void + { + $this->recreateSchema([SecuredDummy::class]); + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', '/secured_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testCustomDataProviderGeneratorReturns200(): void + { + $this->recreateSchema([SecuredDummy::class]); + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', '/custom_data_provider_generator', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + } + + public function testStandardUserCannotCreate(): void + { + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('POST', '/secured_dummies', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['title' => 'Title', 'description' => 'Description', 'owner' => 'foo'], + ]); + $this->assertResponseStatusCodeSame(403); + } + + public function testAdminCanCreate(): void + { + $this->recreateSchema([SecuredDummy::class]); + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $client->request('POST', '/secured_dummies', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['title' => 'Title', 'description' => 'Description', 'owner' => 'someone'], + ]); + $this->assertResponseStatusCodeSame(201); + } + + public function testUserCannotGetItemTheyDontOwn(): void + { + $this->recreateSchema([SecuredDummy::class]); + $iri = $this->createSecuredDummy(owner: 'someone'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', $iri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(403); + } + + public function testUserCanGetItemTheyOwn(): void + { + $this->recreateSchema([SecuredDummy::class]); + $iri = $this->createSecuredDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', $iri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + } + + public function testOwnerSeesOwnerOnlyAndAttributeBasedProperties(): void + { + $this->recreateSchema([SecuredDummy::class]); + $iri = $this->createSecuredDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $response = $client->request('GET', $iri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertArrayHasKey('ownerOnlyProperty', $body); + $this->assertNotNull($body['ownerOnlyProperty']); + $this->assertArrayHasKey('attributeBasedProperty', $body); + $this->assertNotNull($body['attributeBasedProperty']); + } + + public function testAdminCanCreateWithPropertiesDependingOnThemselves(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([SecuredDummyWithPropertiesDependingOnThemselves::class]); + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $client->request('POST', '/secured_dummy_with_properties_depending_on_themselves', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['canUpdateProperty' => false, 'property' => false], + ]); + $this->assertResponseStatusCodeSame(201); + } + + public function testCannotPatchSecuredPropertyIfNotGranted(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([SecuredDummyWithPropertiesDependingOnThemselves::class]); + $admin = self::createClient(); + $admin->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $admin->request('POST', '/secured_dummy_with_properties_depending_on_themselves', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['canUpdateProperty' => false, 'property' => false], + ]); + $this->assertResponseStatusCodeSame(201); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $response = $client->request('PATCH', '/secured_dummy_with_properties_depending_on_themselves/1', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['canUpdateProperty' => true, 'property' => true], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertTrue($body['canUpdateProperty']); + $this->assertFalse($body['property']); + } + + public function testAdminCannotSeeOwnerOnlyPropertiesOnOthersItems(): void + { + $this->recreateSchema([SecuredDummy::class]); + $admin1 = self::createClient(); + $admin1->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $admin1->request('POST', '/secured_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['title' => '#1', 'owner' => 'someone'], + ]); + $this->assertResponseStatusCodeSame(201); + $admin2 = self::createClient(); + $admin2->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $admin2->request('POST', '/secured_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['title' => '#2', 'owner' => 'dunglas'], + ]); + $this->assertResponseStatusCodeSame(201); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $response = $client->request('GET', '/secured_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertStringNotContainsString('ownerOnlyProperty', $response->getContent()); + $this->assertStringNotContainsString('attributeBasedProperty', $response->getContent()); + } + + public function testUserCannotReassignItem(): void + { + $this->recreateSchema([SecuredDummy::class]); + $iri = $this->createSecuredDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $client->request('PUT', $iri, [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['owner' => 'kitten'], + ]); + $this->assertResponseStatusCodeSame(403); + } + + public function testUserCanTransferItemTheyOwn(): void + { + $this->recreateSchema([SecuredDummy::class]); + $iri = $this->createSecuredDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('PUT', $iri, [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['owner' => 'vincent'], + ]); + $this->assertResponseIsSuccessful(); + } + + public function testAdminSeesAdminOnlyProperty(): void + { + $this->recreateSchema([SecuredDummy::class]); + $admin = self::createClient(); + $admin->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $admin->request('POST', '/secured_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['title' => '#1', 'owner' => 'dunglas', 'adminOnlyProperty' => 'secret'], + ]); + $this->assertResponseStatusCodeSame(201); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $response = $client->request('GET', '/secured_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertStringContainsString('adminOnlyProperty', $response->getContent()); + } + + public function testUserDoesNotSeeAdminOnlyProperty(): void + { + $this->recreateSchema([SecuredDummy::class]); + $admin = self::createClient(); + $admin->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $admin->request('POST', '/secured_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['title' => '#1', 'owner' => 'someone', 'adminOnlyProperty' => 'secret'], + ]); + $this->assertResponseStatusCodeSame(201); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $response = $client->request('GET', '/secured_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertStringNotContainsString('adminOnlyProperty', $response->getContent()); + } + + public function testAdminCanCreateWithAdminOnlyProperty(): void + { + $this->recreateSchema([SecuredDummy::class]); + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $response = $client->request('POST', '/secured_dummies', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => [ + 'title' => 'Common Title', + 'description' => 'Description', + 'owner' => 'dunglas', + 'adminOnlyProperty' => 'Is it safe?', + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertStringContainsString('adminOnlyProperty', $response->getContent()); + $this->assertSame('Is it safe?', $body['adminOnlyProperty']); + } + + public function testUserCannotUpdateAdminOnlyProperty(): void + { + $this->recreateSchema([SecuredDummy::class]); + $iri = $this->createSecuredDummy( + owner: 'dunglas', + extra: [ + 'title' => 'Common Title', + 'description' => 'Description', + 'adminOnlyProperty' => 'Is it safe?', + ], + ); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $response = $client->request('PUT', $iri, [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['adminOnlyProperty' => 'Yes it is!'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertStringNotContainsString('adminOnlyProperty', $response->getContent()); + + $adminClient = self::createClient(); + $adminClient->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $listResponse = $adminClient->request('GET', '/secured_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $listBody = $listResponse->toArray(); + $this->assertSame('Is it safe?', $listBody['hydra:member'][0]['adminOnlyProperty']); + } + + public function testUserCanUpdateOwnerOnlyAndAttributeBasedProperties(): void + { + $this->recreateSchema([SecuredDummy::class]); + $iri = $this->createSecuredDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $response = $client->request('PUT', $iri, [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['ownerOnlyProperty' => 'updated', 'attributeBasedProperty' => 'updated'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertStringContainsString('ownerOnlyProperty', $response->getContent()); + $this->assertSame('updated', $body['ownerOnlyProperty']); + $this->assertSame('updated', $body['attributeBasedProperty']); + } + + public function testLinkSecurityNotFoundReturns404(): void + { + $this->recreateSchema([SecuredDummy::class, RelatedLinkedDummy::class]); + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', '/secured_dummies/40000/to_from', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(404); + } + + public function testLinkSecurityToFromAuthorized(): void + { + [$securedId, $linkedId] = $this->seedLinkedDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $response = $client->request('GET', "/secured_dummies/{$securedId}/to_from", [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertStringContainsString('securedDummy', $response->getContent()); + $this->assertSame($linkedId, $body['hydra:member'][0]['id']); + } + + public function testLinkSecurityWithNameAuthorized(): void + { + [$securedId, $linkedId] = $this->seedLinkedDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $response = $client->request('GET', "/secured_dummies/{$securedId}/with_name", [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertStringContainsString('securedDummy', $response->getContent()); + $this->assertSame($linkedId, $body['hydra:member'][0]['id']); + } + + public function testLinkSecurityFromFromAuthorized(): void + { + [$securedId, $linkedId] = $this->seedLinkedDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $response = $client->request('GET', "/related_linked_dummies/{$linkedId}/from_from", [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertStringContainsString('id', $response->getContent()); + // The /related_linked_dummies/{relatedDummyId}/from_from operation + // returns the linked SecuredDummy collection, not the relation itself. + $this->assertSame($securedId, $body['hydra:member'][0]['id']); + } + + public function testLinkSecurityMultipleLinksAuthorized(): void + { + [$securedId, $linkedId] = $this->seedLinkedDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $response = $client->request('GET', "/secured_dummies/{$securedId}/related/{$linkedId}", [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertStringContainsString('id', $response->getContent()); + $this->assertSame($linkedId, $body['hydra:member'][0]['id']); + } + + public function testLinkSecurityToFromUnauthorized(): void + { + [$securedId] = $this->seedLinkedDummy(owner: 'someone'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', "/secured_dummies/{$securedId}/to_from", [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(403); + } + + public function testLinkSecurityWithNameUnauthorized(): void + { + [$securedId] = $this->seedLinkedDummy(owner: 'someone'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', "/secured_dummies/{$securedId}/with_name", [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(403); + } + + public function testLinkSecurityFromFromUnauthorized(): void + { + [, $linkedId] = $this->seedLinkedDummy(owner: 'someone'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', "/related_linked_dummies/{$linkedId}/from_from", [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(403); + } + + public function testLinkSecurityMultipleLinksUnauthorized(): void + { + [$securedId, $linkedId] = $this->seedLinkedDummy(owner: 'someone'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', "/secured_dummies/{$securedId}/related/{$linkedId}", [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(403); + } + + /** + * Admin-POSTs a SecuredDummy and returns its IRI. Avoids hard-coding id=1, + * which is flaky on MongoDB ODM (INCREMENT counter survives collection drops). + */ + private function createSecuredDummy(string $owner, array $extra = []): string + { + $admin = self::createClient(); + $admin->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $response = $admin->request('POST', '/secured_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['title' => '#1', 'owner' => $owner] + $extra, + ]); + $this->assertResponseStatusCodeSame(201); + + return $response->toArray()['@id']; + } + + /** + * Seeds one SecuredDummy + one RelatedLinkedDummy via the API so the same + * helper works against either ORM or ODM persistence, and returns the + * generated ids (parsed from the IRIs). Hard-coding id=1 is flaky. + * + * @return array{0:int, 1:int} + */ + private function seedLinkedDummy(string $owner): array + { + $this->recreateSchema([SecuredDummy::class, RelatedLinkedDummy::class]); + + $admin = self::createClient(); + $admin->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $dummyResponse = $admin->request('POST', '/secured_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['title' => '#1', 'owner' => $owner], + ]); + $this->assertResponseStatusCodeSame(201); + $securedIri = $dummyResponse->toArray()['@id']; + + $linkedResponse = $admin->request('POST', '/related_linked_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['securedDummy' => $securedIri], + ]); + $this->assertResponseStatusCodeSame(201); + $linkedId = $linkedResponse->toArray()['id']; + + $securedId = (int) basename($securedIri); + + return [$securedId, (int) $linkedId]; + } + + public function testUserSeesOwnerOnlyPropertyWithJsonFormat(): void + { + $this->recreateSchema([SecuredDummy::class]); + $admin = self::createClient(); + $admin->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $admin->request('POST', '/secured_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['title' => '#1', 'owner' => 'dunglas'], + ]); + $this->assertResponseStatusCodeSame(201); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $response = $client->request('GET', '/secured_dummies', [ + 'headers' => ['Accept' => 'application/json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertStringContainsString('ownerOnlyProperty', $response->getContent()); + $this->assertStringContainsString('attributeBasedProperty', $response->getContent()); + } + + public function testSecurityPostValidation(): void + { + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('POST', '/issue_6446', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['title' => ''], + ]); + $this->assertResponseStatusCodeSame(403); + } +} diff --git a/tests/Functional/Authorization/LegacyDenyTest.php b/tests/Functional/Authorization/LegacyDenyTest.php new file mode 100644 index 00000000000..d93149724a5 --- /dev/null +++ b/tests/Functional/Authorization/LegacyDenyTest.php @@ -0,0 +1,159 @@ + + * + * 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\Authorization; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\LegacySecuredDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\Security\Core\User\InMemoryUser; + +final class LegacyDenyTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [LegacySecuredDummy::class]; + } + + public function testAnonymousGetCollectionReturns401(): void + { + self::createClient()->request('GET', '/legacy_secured_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(401); + } + + public function testAuthenticatedUserGetCollectionReturns200(): void + { + $this->recreateSchema([LegacySecuredDummy::class]); + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', '/legacy_secured_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testStandardUserCannotCreate(): void + { + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('POST', '/legacy_secured_dummies', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['title' => 'Title', 'description' => 'Description', 'owner' => 'foo'], + ]); + $this->assertResponseStatusCodeSame(403); + } + + public function testAdminCanCreate(): void + { + $this->recreateSchema([LegacySecuredDummy::class]); + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $client->request('POST', '/legacy_secured_dummies', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['title' => 'Title', 'description' => 'Description', 'owner' => 'someone'], + ]); + $this->assertResponseStatusCodeSame(201); + } + + public function testUserCannotGetItemTheyDontOwn(): void + { + $this->recreateSchema([LegacySecuredDummy::class]); + $iri = $this->createLegacySecuredDummy(owner: 'someone'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', $iri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(403); + } + + public function testUserCanGetItemTheyOwn(): void + { + $this->recreateSchema([LegacySecuredDummy::class]); + $iri = $this->createLegacySecuredDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('GET', $iri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + } + + public function testUserCannotReassignItem(): void + { + $this->recreateSchema([LegacySecuredDummy::class]); + $iri = $this->createLegacySecuredDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $client->request('PUT', $iri, [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['owner' => 'kitten'], + ]); + $this->assertResponseStatusCodeSame(403); + } + + public function testUserCanTransferItemTheyOwn(): void + { + $this->recreateSchema([LegacySecuredDummy::class]); + $iri = $this->createLegacySecuredDummy(owner: 'dunglas'); + + $client = self::createClient(); + $client->loginUser(new InMemoryUser('dunglas', 'kevin', ['ROLE_USER'])); + $client->request('PUT', $iri, [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['owner' => 'vincent'], + ]); + $this->assertResponseIsSuccessful(); + } + + /** + * Avoids hard-coding id=1, which is flaky on MongoDB ODM (INCREMENT counter + * survives collection drops). + */ + private function createLegacySecuredDummy(string $owner): string + { + $admin = self::createClient(); + $admin->loginUser(new InMemoryUser('admin', 'kitten', ['ROLE_ADMIN'])); + $response = $admin->request('POST', '/legacy_secured_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['title' => '#1', 'description' => '', 'owner' => $owner], + ]); + $this->assertResponseStatusCodeSame(201); + + return $response->toArray()['@id']; + } +} diff --git a/tests/Functional/Hal/AbsoluteUrlTest.php b/tests/Functional/Hal/AbsoluteUrlTest.php new file mode 100644 index 00000000000..ee61a90757c --- /dev/null +++ b/tests/Functional/Hal/AbsoluteUrlTest.php @@ -0,0 +1,85 @@ + + * + * 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\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\AbsoluteUrlChild; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\AbsoluteUrlParent; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class AbsoluteUrlTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [AbsoluteUrlChild::class, AbsoluteUrlParent::class]; + } + + public function testCollectionLinksUseAbsoluteUrls(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/hal_absolute_url_children', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('http://example.com/hal_absolute_url_children', $body['_links']['self']['href']); + $this->assertSame('http://example.com/hal_absolute_url_children/1', $body['_links']['item'][0]['href']); + $this->assertSame('http://example.com/hal_absolute_url_children/1', $body['_embedded']['item'][0]['_links']['self']['href']); + $this->assertSame('http://example.com/hal_absolute_url_parents/1', $body['_embedded']['item'][0]['_links']['parent']['href']); + } + + public function testItemLinksUseAbsoluteUrls(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/hal_absolute_url_children/1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('http://example.com/hal_absolute_url_children/1', $body['_links']['self']['href']); + $this->assertSame('http://example.com/hal_absolute_url_parents/1', $body['_links']['parent']['href']); + } + + public function testPostAcceptsAbsoluteUrlInPayload(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('POST', '/hal_absolute_url_children', [ + 'headers' => [ + 'Accept' => 'application/hal+json', + 'Content-Type' => 'application/json', + ], + 'json' => ['parent' => 'http://example.com/hal_absolute_url_parents/1'], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('http://example.com/hal_absolute_url_children/2', $body['_links']['self']['href']); + $this->assertSame('http://example.com/hal_absolute_url_parents/1', $body['_links']['parent']['href']); + } + + public function testSubresourceCollectionUsesAbsoluteUrls(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/hal_absolute_url_parents/1/children', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('http://example.com/hal_absolute_url_parents/1/children', $body['_links']['self']['href']); + $this->assertSame('http://example.com/hal_absolute_url_children/1', $body['_links']['item'][0]['href']); + } +} diff --git a/tests/Functional/Hal/CollectionTest.php b/tests/Functional/Hal/CollectionTest.php new file mode 100644 index 00000000000..fa338eb8f08 --- /dev/null +++ b/tests/Functional/Hal/CollectionTest.php @@ -0,0 +1,167 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\CollectionPagedResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CollectionTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [CollectionPagedResource::class]; + } + + public function testFirstPageHasFirstThreeItemsAndNextLink(): void + { + $response = self::createClient()->request('GET', '/hal_collection_paged', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/hal+json; charset=utf-8'); + $body = $response->toArray(); + + $this->assertSame('/hal_collection_paged?page=1', $body['_links']['self']['href']); + $this->assertSame('/hal_collection_paged?page=1', $body['_links']['first']['href']); + $this->assertSame('/hal_collection_paged?page=4', $body['_links']['last']['href']); + $this->assertSame('/hal_collection_paged?page=2', $body['_links']['next']['href']); + $this->assertCount(3, $body['_links']['item']); + $this->assertSame(10, $body['totalItems']); + $this->assertSame(3, $body['itemsPerPage']); + $this->assertSame([1, 2, 3], array_column($body['_embedded']['item'], 'id')); + } + + public function testMiddlePageHasPrevAndNext(): void + { + $response = self::createClient()->request('GET', '/hal_collection_paged?page=3', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/hal_collection_paged?page=3', $body['_links']['self']['href']); + $this->assertSame('/hal_collection_paged?page=2', $body['_links']['prev']['href']); + $this->assertSame('/hal_collection_paged?page=4', $body['_links']['next']['href']); + $this->assertSame([7, 8, 9], array_column($body['_embedded']['item'], 'id')); + } + + public function testLastPageOmitsNext(): void + { + $response = self::createClient()->request('GET', '/hal_collection_paged?page=4', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/hal_collection_paged?page=4', $body['_links']['self']['href']); + $this->assertSame('/hal_collection_paged?page=3', $body['_links']['prev']['href']); + $this->assertArrayNotHasKey('next', $body['_links']); + $this->assertSame([10], array_column($body['_embedded']['item'], 'id')); + } + + public function testPartialPaginationDropsFirstAndLast(): void + { + $response = self::createClient()->request('GET', '/hal_collection_paged?page=2&partial=1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertArrayNotHasKey('first', $body['_links']); + $this->assertArrayNotHasKey('last', $body['_links']); + $this->assertArrayHasKey('prev', $body['_links']); + $this->assertArrayHasKey('next', $body['_links']); + $this->assertArrayNotHasKey('totalItems', $body); + $this->assertSame(3, $body['itemsPerPage']); + $this->assertSame([4, 5, 6], array_column($body['_embedded']['item'], 'id')); + } + + public function testPaginationDisabledExposesAllItems(): void + { + $response = self::createClient()->request('GET', '/hal_collection_paged?pagination=0', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/hal_collection_paged?pagination=0', $body['_links']['self']['href']); + $this->assertCount(10, $body['_links']['item']); + $this->assertCount(10, $body['_embedded']['item']); + $this->assertSame(10, $body['totalItems']); + } + + public function testItemsPerPageOverridesDefault(): void + { + $response = self::createClient()->request('GET', '/hal_collection_paged?page=2&itemsPerPage=1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/hal_collection_paged?itemsPerPage=1&page=2', $body['_links']['self']['href']); + $this->assertSame('/hal_collection_paged?itemsPerPage=1&page=1', $body['_links']['first']['href']); + $this->assertSame('/hal_collection_paged?itemsPerPage=1&page=10', $body['_links']['last']['href']); + $this->assertSame(1, $body['itemsPerPage']); + $this->assertSame([2], array_column($body['_embedded']['item'], 'id')); + } + + public function testFilterByEncodedIriPreservedInLinks(): void + { + $response = self::createClient()->request('GET', '/hal_collection_paged?id=%2fdummies%2f8', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/hal_collection_paged?id=%2Fdummies%2F8', $body['_links']['self']['href']); + $this->assertSame(1, $body['totalItems']); + $this->assertSame([8], array_column($body['_embedded']['item'], 'id')); + } + + public function testFilterByEncodedNamePreservedInLinks(): void + { + $response = self::createClient()->request('GET', '/hal_collection_paged?name=Dummy%20%238', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/hal_collection_paged?name=Dummy%20%238', $body['_links']['self']['href']); + $this->assertSame(1, $body['totalItems']); + $this->assertSame([8], array_column($body['_embedded']['item'], 'id')); + } + + public function testItemsPerPageZeroReturnsEmptyEmbeddedItems(): void + { + $response = self::createClient()->request('GET', '/hal_collection_paged?itemsPerPage=0', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/hal_collection_paged?itemsPerPage=0', $body['_links']['self']['href']); + $this->assertSame(10, $body['totalItems']); + $this->assertSame(0, $body['itemsPerPage']); + $this->assertArrayNotHasKey('item', $body['_links']); + } + + public function testEmptyCollectionExposesNoItems(): void + { + $response = self::createClient()->request('GET', '/hal_collection_paged?id=999', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame(0, $body['totalItems']); + $this->assertSame(3, $body['itemsPerPage']); + $this->assertArrayNotHasKey('item', $body['_links']); + } +} diff --git a/tests/Functional/Hal/HalTest.php b/tests/Functional/Hal/HalTest.php new file mode 100644 index 00000000000..bf1fbc77072 --- /dev/null +++ b/tests/Functional/Hal/HalTest.php @@ -0,0 +1,120 @@ + + * + * 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\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\HalRelatedResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\HalThirdLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\RelationEmbedder; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class HalTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [RelationEmbedder::class, HalRelatedResource::class, HalThirdLevel::class]; + } + + public function testEntrypointListsResourcesAsHalLinks(): void + { + $response = self::createClient()->request('GET', '/', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/hal+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame('/', $body['_links']['self']['href']); + $hrefs = array_column($body['_links'], 'href'); + $this->assertContains('/hal_relation_embedders', $hrefs); + } + + public function testGetEmbedsRelatedResourceAndItsRelation(): void + { + $response = self::createClient()->request('GET', '/hal_relation_embedders/1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/hal+json; charset=utf-8'); + $body = $response->toArray(); + + $this->assertSame('/hal_relation_embedders/1', $body['_links']['self']['href']); + $this->assertSame('/hal_related_resources/1', $body['_links']['related']['href']); + $this->assertSame('Krondstadt', $body['krondstadt']); + + $related = $body['_embedded']['related']; + $this->assertSame('/hal_related_resources/1', $related['_links']['self']['href']); + $this->assertSame('/hal_third_levels/1', $related['_links']['thirdLevel']['href']); + $this->assertSame('symfony', $related['symfony']); + + $thirdLevel = $related['_embedded']['thirdLevel']; + $this->assertSame('/hal_third_levels/1', $thirdLevel['_links']['self']['href']); + $this->assertSame(3, $thirdLevel['level']); + } + + public function testPostAcceptsIriRelationAndReturnsHalPayload(): void + { + $response = self::createClient()->request('POST', '/hal_relation_embedders', [ + 'headers' => [ + 'Accept' => 'application/hal+json', + 'Content-Type' => 'application/json', + ], + 'json' => ['related' => '/hal_related_resources/1'], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('/hal_relation_embedders/1', $body['_links']['self']['href']); + $this->assertSame('/hal_related_resources/1', $body['_links']['related']['href']); + $this->assertSame('Krondstadt', $body['krondstadt']); + } + + public function testPutReturnsHalPayloadAndKeepsPreviousRelation(): void + { + $response = self::createClient()->request('PUT', '/hal_relation_embedders/1', [ + 'headers' => [ + 'Accept' => 'application/hal+json', + 'Content-Type' => 'application/json', + ], + 'json' => ['krondstadt' => 'Updated'], + ]); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/hal+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame('/hal_relation_embedders/1', $body['_links']['self']['href']); + $this->assertSame('/hal_related_resources/1', $body['_links']['related']['href']); + $this->assertSame('Updated', $body['krondstadt']); + $this->assertSame('/hal_related_resources/1', $body['_embedded']['related']['_links']['self']['href']); + } + + public function testPatchReturnsHalPayloadWithMergePatch(): void + { + $response = self::createClient()->request('PATCH', '/hal_relation_embedders/1', [ + 'headers' => [ + 'Accept' => 'application/hal+json', + 'Content-Type' => 'application/merge-patch+json', + ], + 'json' => ['krondstadt' => 'Patched'], + ]); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/hal+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame('/hal_relation_embedders/1', $body['_links']['self']['href']); + $this->assertSame('/hal_related_resources/1', $body['_links']['related']['href']); + $this->assertSame('Patched', $body['krondstadt']); + $this->assertSame('/hal_related_resources/1', $body['_embedded']['related']['_links']['self']['href']); + } +} diff --git a/tests/Functional/Hal/InputOutputDtoTest.php b/tests/Functional/Hal/InputOutputDtoTest.php new file mode 100644 index 00000000000..6ae2f29bb1f --- /dev/null +++ b/tests/Functional/Hal/InputOutputDtoTest.php @@ -0,0 +1,56 @@ + + * + * 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\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\CustomOutputResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class InputOutputDtoTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [CustomOutputResource::class]; + } + + public function testItemReturnsCustomOutput(): void + { + $response = self::createClient()->request('GET', '/hal_custom_outputs/1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/hal+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame('test', $body['foo']); + $this->assertSame(1, $body['bar']); + } + + public function testCollectionEmbedsCustomOutputItems(): void + { + $response = self::createClient()->request('GET', '/hal_custom_outputs', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(2, $body['_embedded']['item']); + $this->assertSame('test', $body['_embedded']['item'][0]['foo']); + $this->assertSame(1, $body['_embedded']['item'][0]['bar']); + $this->assertSame('test', $body['_embedded']['item'][1]['foo']); + $this->assertSame(2, $body['_embedded']['item'][1]['bar']); + } +} diff --git a/tests/Functional/Hal/ItemUriTemplateTest.php b/tests/Functional/Hal/ItemUriTemplateTest.php new file mode 100644 index 00000000000..2196881211c --- /dev/null +++ b/tests/Functional/Hal/ItemUriTemplateTest.php @@ -0,0 +1,69 @@ + + * + * 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\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\UriTemplateCar; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ItemUriTemplateTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [UriTemplateCar::class]; + } + + public function testCollectionWithoutItemUriTemplateUsesFirstGetOperation(): void + { + $response = self::createClient()->request('GET', '/hal_uri_template_cars', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + + $this->assertSame('/hal_uri_template_cars', $body['_links']['self']['href']); + $this->assertCount(2, $body['_links']['item']); + foreach ($body['_links']['item'] as $link) { + $this->assertMatchesRegularExpression('#^/hal_uri_template_cars/.+$#', $link['href']); + } + $this->assertCount(2, $body['_embedded']['item']); + foreach ($body['_embedded']['item'] as $item) { + $this->assertMatchesRegularExpression('#^/hal_uri_template_cars/.+$#', $item['_links']['self']['href']); + $this->assertSame('Vincent', $item['owner']); + } + } + + public function testCollectionWithItemUriTemplateGeneratesIriFromTargetOperation(): void + { + $response = self::createClient()->request('GET', '/hal_uri_template_brands/renault/cars', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + + $this->assertSame('/hal_uri_template_brands/renault/cars', $body['_links']['self']['href']); + $this->assertCount(2, $body['_links']['item']); + foreach ($body['_links']['item'] as $link) { + $this->assertMatchesRegularExpression('#^/hal_uri_template_brands/renault/cars/.+$#', $link['href']); + } + $this->assertCount(2, $body['_embedded']['item']); + foreach ($body['_embedded']['item'] as $item) { + $this->assertMatchesRegularExpression('#^/hal_uri_template_brands/renault/cars/.+$#', $item['_links']['self']['href']); + } + } +} diff --git a/tests/Functional/Hal/MaxDepthTest.php b/tests/Functional/Hal/MaxDepthTest.php new file mode 100644 index 00000000000..007aa270b34 --- /dev/null +++ b/tests/Functional/Hal/MaxDepthTest.php @@ -0,0 +1,94 @@ + + * + * 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\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\MaxDepthResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class MaxDepthTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [MaxDepthResource::class]; + } + + public function testFirstLevelChildIsEmbedded(): void + { + $response = self::createClient()->request('POST', '/hal_max_depth_resources', [ + 'headers' => [ + 'Accept' => 'application/hal+json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'name' => 'level 1', + 'child' => ['name' => 'level 2'], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertArrayHasKey('_embedded', $body); + $this->assertArrayHasKey('child', $body['_embedded']); + $this->assertSame('level 2', $body['_embedded']['child']['name']); + $this->assertArrayNotHasKey('_embedded', $body['_embedded']['child']); + } + + public function testSecondLevelChildIsTruncatedByMaxDepth(): void + { + $response = self::createClient()->request('POST', '/hal_max_depth_resources', [ + 'headers' => [ + 'Accept' => 'application/hal+json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'name' => 'level 1', + 'child' => [ + 'name' => 'level 2', + 'child' => ['name' => 'level 3'], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertArrayHasKey('_embedded', $body); + $this->assertArrayHasKey('child', $body['_embedded']); + $this->assertSame('level 2', $body['_embedded']['child']['name']); + $this->assertArrayNotHasKey('_embedded', $body['_embedded']['child']); + } + + public function testPutTruncatesSecondLevelChildByMaxDepth(): void + { + $response = self::createClient()->request('PUT', '/hal_max_depth_resources/1', [ + 'headers' => [ + 'Accept' => 'application/hal+json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'child' => [ + 'child' => ['name' => 'level 3'], + ], + ], + ]); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/hal+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertArrayHasKey('_embedded', $body); + $this->assertArrayHasKey('child', $body['_embedded']); + $this->assertArrayNotHasKey('_embedded', $body['_embedded']['child']); + } +} diff --git a/tests/Functional/Hal/NetworkPathTest.php b/tests/Functional/Hal/NetworkPathTest.php new file mode 100644 index 00000000000..d3dafe1bbd8 --- /dev/null +++ b/tests/Functional/Hal/NetworkPathTest.php @@ -0,0 +1,84 @@ + + * + * 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\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\NetworkPathParent; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\NetworkPathResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NetworkPathTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [NetworkPathResource::class, NetworkPathParent::class]; + } + + public function testCollectionLinksUseNetworkPaths(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/hal_network_path_children', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('//example.com/hal_network_path_children', $body['_links']['self']['href']); + $this->assertSame('//example.com/hal_network_path_children/1', $body['_links']['item'][0]['href']); + $this->assertSame('//example.com/hal_network_path_parents/1', $body['_embedded']['item'][0]['_links']['parent']['href']); + } + + public function testItemLinksUseNetworkPaths(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/hal_network_path_children/1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('//example.com/hal_network_path_children/1', $body['_links']['self']['href']); + $this->assertSame('//example.com/hal_network_path_parents/1', $body['_links']['parent']['href']); + } + + public function testPostAcceptsNetworkPathInPayload(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('POST', '/hal_network_path_children', [ + 'headers' => [ + 'Accept' => 'application/hal+json', + 'Content-Type' => 'application/json', + ], + 'json' => ['parent' => '//example.com/hal_network_path_parents/1'], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('//example.com/hal_network_path_children/2', $body['_links']['self']['href']); + $this->assertSame('//example.com/hal_network_path_parents/1', $body['_links']['parent']['href']); + } + + public function testSubresourceCollectionUsesNetworkPaths(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/hal_network_path_parents/1/children', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('//example.com/hal_network_path_parents/1/children', $body['_links']['self']['href']); + $this->assertSame('//example.com/hal_network_path_children/1', $body['_links']['item'][0]['href']); + } +} diff --git a/tests/Functional/Hal/NonResourceTest.php b/tests/Functional/Hal/NonResourceTest.php new file mode 100644 index 00000000000..8292a16ec69 --- /dev/null +++ b/tests/Functional/Hal/NonResourceTest.php @@ -0,0 +1,50 @@ + + * + * 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\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\NonResourceContainer; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NonResourceTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [NonResourceContainer::class]; + } + + public function testNestedResourceIsEmbeddedAndRawObjectIsInlined(): void + { + $response = self::createClient()->request('GET', '/hal_non_resource_containers/1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/hal+json; charset=utf-8'); + $body = $response->toArray(); + + $this->assertSame('/hal_non_resource_containers/1', $body['_links']['self']['href']); + $this->assertSame('/hal_non_resource_containers/1-nested', $body['_links']['nested']['href']); + $this->assertSame('1', $body['id']); + $this->assertSame(['foo' => 'f1', 'bar' => 'b1'], $body['notAResource']); + + $nested = $body['_embedded']['nested']; + $this->assertSame('/hal_non_resource_containers/1-nested', $nested['_links']['self']['href']); + $this->assertSame('1-nested', $nested['id']); + $this->assertSame(['foo' => 'f2', 'bar' => 'b2'], $nested['notAResource']); + } +} diff --git a/tests/Functional/Hal/ProblemTest.php b/tests/Functional/Hal/ProblemTest.php new file mode 100644 index 00000000000..4c5e5136763 --- /dev/null +++ b/tests/Functional/Hal/ProblemTest.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\Functional\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\ProblemRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Hal\ProblemResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ProblemTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ProblemResource::class, ProblemRelation::class]; + } + + public function testValidationErrorIsReturnedAsProblemJson(): void + { + $response = self::createClient()->request('POST', '/hal_problems', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [], + ]); + $this->assertResponseStatusCodeSame(422); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $body = $response->toArray(false); + $this->assertSame('/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3', $body['type']); + $this->assertSame('An error occurred', $body['title']); + $this->assertSame('name: This value should not be blank.', $body['detail']); + $this->assertSame(422, $body['status']); + $this->assertSame([ + [ + 'propertyPath' => 'name', + 'message' => 'This value should not be blank.', + 'code' => 'c1051bb4-d103-4f74-8988-acbcafc7fdc3', + ], + ], $body['violations']); + } + + public function testNestedRelationDocumentReturns400Problem(): void + { + $response = self::createClient()->request('POST', '/hal_problems', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'name' => 'Foo', + 'relatedDummy' => ['name' => 'bar'], + ], + ]); + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $body = $response->toArray(false); + $this->assertSame('/errors/400', $body['type']); + $this->assertSame('An error occurred', $body['title']); + $this->assertSame('Nested documents for attribute "relatedDummy" are not allowed. Use IRIs instead.', $body['detail']); + $this->assertArrayHasKey('trace', $body); + } +} diff --git a/tests/Functional/Hal/PropertyCollectionIriOnlyTest.php b/tests/Functional/Hal/PropertyCollectionIriOnlyTest.php new file mode 100644 index 00000000000..dd92836af17 --- /dev/null +++ b/tests/Functional/Hal/PropertyCollectionIriOnlyTest.php @@ -0,0 +1,89 @@ + + * + * 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\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnly; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelationSecondLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyUriTemplateOneToOneRelation; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class PropertyCollectionIriOnlyTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + PropertyCollectionIriOnly::class, + PropertyCollectionIriOnlyRelation::class, + PropertyCollectionIriOnlyRelationSecondLevel::class, + PropertyUriTemplateOneToOneRelation::class, + ]; + } + + public function testPropertyUriTemplatesRenderAsLinks(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([ + PropertyCollectionIriOnly::class, + PropertyCollectionIriOnlyRelation::class, + PropertyCollectionIriOnlyRelationSecondLevel::class, + PropertyUriTemplateOneToOneRelation::class, + ]); + + $manager = $this->getManager(); + $rel1 = new PropertyCollectionIriOnlyRelation(); + $rel1->name = 'asb1'; + $rel2 = new PropertyCollectionIriOnlyRelation(); + $rel2->name = 'asb2'; + $toOne = new PropertyUriTemplateOneToOneRelation(); + $toOne->name = 'xarguš'; + $parent = new PropertyCollectionIriOnly(); + $parent->addPropertyCollectionIriOnlyRelation($rel1); + $parent->addPropertyCollectionIriOnlyRelation($rel2); + $parent->setToOneRelation($toOne); + $manager->persist($parent); + $manager->persist($rel1); + $manager->persist($rel2); + $manager->persist($toOne); + $manager->flush(); + + $response = self::createClient()->request('GET', '/property_collection_iri_onlies/1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + + $this->assertSame('/property_collection_iri_onlies/1', $body['_links']['self']['href']); + $this->assertSame('/property-collection-relations', $body['_links']['propertyCollectionIriOnlyRelation']['href']); + $this->assertSame('/parent/1/another-collection-operations', $body['_links']['iterableIri']['href']); + $this->assertSame('/parent/1/property-uri-template/one-to-ones/1', $body['_links']['toOneRelation']['href']); + + $embedded = $body['_embedded']; + $this->assertCount(2, $embedded['propertyCollectionIriOnlyRelation']); + $this->assertSame('asb1', $embedded['propertyCollectionIriOnlyRelation'][0]['name']); + $this->assertSame('asb2', $embedded['propertyCollectionIriOnlyRelation'][1]['name']); + $this->assertSame('xarguš', $embedded['toOneRelation']['name']); + $this->assertSame('/parent/1/property-uri-template/one-to-ones/1', $embedded['toOneRelation']['_links']['self']['href']); + } +} diff --git a/tests/Functional/Hal/TableInheritanceTest.php b/tests/Functional/Hal/TableInheritanceTest.php new file mode 100644 index 00000000000..d5c1f89e86e --- /dev/null +++ b/tests/Functional/Hal/TableInheritanceTest.php @@ -0,0 +1,165 @@ + + * + * 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\Hal; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritance; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceChild; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceDifferentChild; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceNotApiResourceChild; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceRelated; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class TableInheritanceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + DummyTableInheritance::class, + DummyTableInheritanceChild::class, + DummyTableInheritanceRelated::class, + DummyTableInheritanceNotApiResourceChild::class, + ]; + } + + public function testCreateChildExposesParentAndChildFields(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([ + DummyTableInheritance::class, + DummyTableInheritanceChild::class, + DummyTableInheritanceDifferentChild::class, + DummyTableInheritanceRelated::class, + DummyTableInheritanceNotApiResourceChild::class, + ]); + + $response = self::createClient()->request('POST', '/dummy_table_inheritance_children', [ + 'headers' => [ + 'Accept' => 'application/hal+json', + 'Content-Type' => 'application/json', + ], + 'json' => ['name' => 'foo', 'nickname' => 'bar'], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/hal+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame([ + '_links' => ['self' => ['href' => '/dummy_table_inheritance_children/1']], + 'nickname' => 'bar', + 'id' => 1, + 'name' => 'foo', + ], $body); + } + + public function testParentCollectionMixesChildAndParent(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([ + DummyTableInheritance::class, + DummyTableInheritanceChild::class, + DummyTableInheritanceDifferentChild::class, + DummyTableInheritanceRelated::class, + DummyTableInheritanceNotApiResourceChild::class, + ]); + + $manager = $this->getManager(); + $child = new DummyTableInheritanceChild(); + $child->setName('foo'); + $child->setNickname('bar'); + $manager->persist($child); + $parent = new DummyTableInheritance(); + $parent->setName('Foobarbaz inheritance'); + $manager->persist($parent); + $manager->flush(); + + $response = self::createClient()->request('GET', '/dummy_table_inheritances', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + + $this->assertSame('/dummy_table_inheritances', $body['_links']['self']['href']); + $this->assertSame(2, $body['totalItems']); + $this->assertSame('/dummy_table_inheritance_children/1', $body['_links']['item'][0]['href']); + $this->assertSame('/dummy_table_inheritances/2', $body['_links']['item'][1]['href']); + + $this->assertSame('bar', $body['_embedded']['item'][0]['nickname']); + $this->assertSame('foo', $body['_embedded']['item'][0]['name']); + $this->assertArrayNotHasKey('nickname', $body['_embedded']['item'][1]); + $this->assertSame('Foobarbaz inheritance', $body['_embedded']['item'][1]['name']); + } + + public function testRelatedEntityWithMixedChildren(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([ + DummyTableInheritance::class, + DummyTableInheritanceChild::class, + DummyTableInheritanceDifferentChild::class, + DummyTableInheritanceRelated::class, + DummyTableInheritanceNotApiResourceChild::class, + ]); + + $manager = $this->getManager(); + $child = new DummyTableInheritanceChild(); + $child->setName('foo'); + $child->setNickname('bar'); + $manager->persist($child); + $parent = new DummyTableInheritance(); + $parent->setName('Foobarbaz inheritance'); + $manager->persist($parent); + $manager->flush(); + + $response = self::createClient()->request('POST', '/dummy_table_inheritance_relateds', [ + 'headers' => [ + 'Accept' => 'application/hal+json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'children' => [ + '/dummy_table_inheritance_children/1', + '/dummy_table_inheritances/2', + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('/dummy_table_inheritance_relateds/1', $body['_links']['self']['href']); + $this->assertSame('/dummy_table_inheritance_children/1', $body['_links']['children'][0]['href']); + $this->assertSame('/dummy_table_inheritances/2', $body['_links']['children'][1]['href']); + + $children = $body['_embedded']['children']; + $this->assertSame('/dummy_table_inheritance_children/1', $children[0]['_links']['self']['href']); + $this->assertSame('bar', $children[0]['nickname']); + $this->assertSame('foo', $children[0]['name']); + $this->assertSame('/dummy_table_inheritances/2', $children[1]['_links']['self']['href']); + $this->assertSame('Foobarbaz inheritance', $children[1]['name']); + $this->assertArrayNotHasKey('nickname', $children[1]); + } +} diff --git a/tests/Functional/JsonApi/AbsoluteUrlTest.php b/tests/Functional/JsonApi/AbsoluteUrlTest.php new file mode 100644 index 00000000000..1566f03534c --- /dev/null +++ b/tests/Functional/JsonApi/AbsoluteUrlTest.php @@ -0,0 +1,100 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\AbsoluteUrlDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\AbsoluteUrlRelationDummy; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class AbsoluteUrlTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [AbsoluteUrlDummy::class, AbsoluteUrlRelationDummy::class]; + } + + public function testCollectionUsesAbsoluteUrls(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonapi_absolute_url_dummies', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame('http://example.com/jsonapi_absolute_url_dummies', $body['links']['self']); + $this->assertSame('http://example.com/jsonapi_absolute_url_dummies/1', $body['data'][0]['id']); + $this->assertSame('JsonApiAbsoluteUrlDummy', $body['data'][0]['type']); + $this->assertSame( + 'http://example.com/jsonapi_absolute_url_relation_dummies/1', + $body['data'][0]['relationships']['absoluteUrlRelationDummy']['data']['id'], + ); + } + + public function testItemUsesAbsoluteUrls(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonapi_absolute_url_dummies/1', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('http://example.com/jsonapi_absolute_url_dummies/1', $body['data']['id']); + $this->assertSame('JsonApiAbsoluteUrlDummy', $body['data']['type']); + $this->assertSame( + 'http://example.com/jsonapi_absolute_url_relation_dummies/1', + $body['data']['relationships']['absoluteUrlRelationDummy']['data']['id'], + ); + } + + public function testPostReturnsAbsoluteUrl(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('POST', '/jsonapi_absolute_url_relation_dummies', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => ['data' => ['type' => 'JsonApiAbsoluteUrlRelationDummy']], + ]); + + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('http://example.com/jsonapi_absolute_url_relation_dummies/2', $body['data']['id']); + $this->assertSame('JsonApiAbsoluteUrlRelationDummy', $body['data']['type']); + } + + public function testSubresourceCollectionUsesAbsoluteUrls(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonapi_absolute_url_relation_dummies/1/absolute_url_dummies', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame( + 'http://example.com/jsonapi_absolute_url_relation_dummies/1/absolute_url_dummies', + $body['links']['self'], + ); + $this->assertSame('http://example.com/jsonapi_absolute_url_dummies/1', $body['data'][0]['id']); + } +} diff --git a/tests/Functional/JsonApi/CollectionAttributesTest.php b/tests/Functional/JsonApi/CollectionAttributesTest.php new file mode 100644 index 00000000000..d19b84fa882 --- /dev/null +++ b/tests/Functional/JsonApi/CollectionAttributesTest.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\Functional\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\CircularReference; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CollectionAttributesTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [CircularReference::class]; + } + + public function testCollectionAttributeSerializesAsRelationshipArray(): void + { + $response = self::createClient()->request('GET', '/jsonapi_circular_references/1', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame('/jsonapi_circular_references/1', $body['data']['id']); + $this->assertSame('/jsonapi_circular_references/1', $body['data']['relationships']['parent']['data']['id']); + $this->assertCount(2, $body['data']['relationships']['children']['data']); + foreach ($body['data']['relationships']['children']['data'] as $child) { + $this->assertMatchesRegularExpression('#^/jsonapi_circular_references/(1|2)$#', $child['id']); + } + } +} diff --git a/tests/Functional/JsonApi/CollectionUriTemplateTest.php b/tests/Functional/JsonApi/CollectionUriTemplateTest.php new file mode 100644 index 00000000000..8541a7de3a7 --- /dev/null +++ b/tests/Functional/JsonApi/CollectionUriTemplateTest.php @@ -0,0 +1,108 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnly; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelationSecondLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyUriTemplateOneToOneRelation; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CollectionUriTemplateTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + PropertyCollectionIriOnly::class, + PropertyCollectionIriOnlyRelation::class, + PropertyCollectionIriOnlyRelationSecondLevel::class, + PropertyUriTemplateOneToOneRelation::class, + ]; + } + + public function testPropertyUriTemplatesRenderInJsonApi(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([ + PropertyCollectionIriOnly::class, + PropertyCollectionIriOnlyRelation::class, + PropertyCollectionIriOnlyRelationSecondLevel::class, + PropertyUriTemplateOneToOneRelation::class, + ]); + + $manager = $this->getManager(); + $rel1 = new PropertyCollectionIriOnlyRelation(); + $rel1->name = 'asb1'; + $rel2 = new PropertyCollectionIriOnlyRelation(); + $rel2->name = 'asb2'; + $toOne = new PropertyUriTemplateOneToOneRelation(); + $toOne->name = 'xarguš'; + $parent = new PropertyCollectionIriOnly(); + $parent->addPropertyCollectionIriOnlyRelation($rel1); + $parent->addPropertyCollectionIriOnlyRelation($rel2); + $parent->setToOneRelation($toOne); + $manager->persist($parent); + $manager->persist($rel1); + $manager->persist($rel2); + $manager->persist($toOne); + $manager->flush(); + + $response = self::createClient()->request('GET', '/property_collection_iri_onlies/1', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'links' => [ + 'propertyCollectionIriOnlyRelation' => '/property-collection-relations', + 'iterableIri' => '/parent/1/another-collection-operations', + 'toOneRelation' => '/parent/1/property-uri-template/one-to-ones/1', + ], + 'data' => [ + 'id' => '/property_collection_iri_onlies/1', + 'type' => 'PropertyCollectionIriOnly', + 'relationships' => [ + 'propertyCollectionIriOnlyRelation' => [ + 'data' => [ + ['type' => 'PropertyCollectionIriOnlyRelation', 'id' => '/property_collection_iri_only_relations/1'], + ['type' => 'PropertyCollectionIriOnlyRelation', 'id' => '/property_collection_iri_only_relations/2'], + ], + ], + 'iterableIri' => [ + 'data' => [ + ['type' => 'PropertyCollectionIriOnlyRelation', 'id' => '/property_collection_iri_only_relations/9999'], + ], + ], + 'toOneRelation' => [ + 'data' => [ + 'type' => 'PropertyUriTemplateOneToOneRelation', + 'id' => '/parent/1/property-uri-template/one-to-ones/1', + ], + ], + ], + ], + ]); + } +} diff --git a/tests/Functional/JsonApi/CrudTest.php b/tests/Functional/JsonApi/CrudTest.php new file mode 100644 index 00000000000..fb759b9b4af --- /dev/null +++ b/tests/Functional/JsonApi/CrudTest.php @@ -0,0 +1,362 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CrudTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + ThirdLevel::class, + RelatedDummy::class, + Dummy::class, + RelationEmbedder::class, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + $this->recreateSchema(self::getResources()); + } + + public function testCreateThirdLevel(): void + { + $response = self::createClient()->request('POST', '/third_levels', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'third-level', + 'attributes' => ['level' => 3], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('/third_levels/1', $body['data']['id']); + $this->assertSame('ThirdLevel', $body['data']['type']); + } + + public function testGetThirdLevelCollection(): void + { + $this->seedThirdLevel(); + + $response = self::createClient()->request('GET', '/third_levels', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertCount(1, $body['data']); + } + + public function testGetThirdLevelItem(): void + { + $this->seedThirdLevel(); + + $response = self::createClient()->request('GET', '/third_levels/1', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/third_levels/1', $body['data']['id']); + } + + public function testCreateRelatedDummyWithThirdLevelRelation(): void + { + $this->seedThirdLevel(); + + $response = self::createClient()->request('POST', '/related_dummies', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'related-dummy', + 'attributes' => ['name' => 'John Doe', 'age' => 23], + 'relationships' => [ + 'thirdLevel' => [ + 'data' => ['type' => 'third-level', 'id' => '/third_levels/1'], + ], + ], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('/related_dummies/1', $body['data']['id']); + $this->assertSame('John Doe', $body['data']['attributes']['name']); + $this->assertSame(23, $body['data']['attributes']['age']); + } + + public function testCreateRelatedDummyWithEmptyThirdLevel(): void + { + $response = self::createClient()->request('POST', '/related_dummies', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'related-dummy', + 'attributes' => ['name' => 'John Doe'], + 'relationships' => [ + 'thirdLevel' => ['data' => null], + ], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + } + + public function testCreateDummyWithRelations(): void + { + $this->seedRelatedDummies(2); + + $response = self::createClient()->request('POST', '/dummies', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'dummy', + 'attributes' => [ + 'name' => 'Dummy with relations', + 'dummyDate' => '2015-03-01T10:00:00+00:00', + ], + 'relationships' => [ + 'relatedDummy' => [ + 'data' => ['type' => 'related-dummy', 'id' => '/related_dummies/2'], + ], + 'relatedDummies' => [ + 'data' => [ + ['type' => 'related-dummy', 'id' => '/related_dummies/1'], + ['type' => 'related-dummy', 'id' => '/related_dummies/2'], + ], + ], + ], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertCount(2, $body['data']['relationships']['relatedDummies']['data']); + $this->assertSame( + '/related_dummies/2', + $body['data']['relationships']['relatedDummy']['data']['id'], + ); + } + + public function testPatchDummyManyToMany(): void + { + $this->seedRelatedDummies(2); + $this->seedDummyWithTwoRelatedDummies(); + + $response = self::createClient()->request('PATCH', '/dummies/1', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'dummy', + 'relationships' => [ + 'relatedDummy' => [ + 'data' => ['type' => 'related-dummy', 'id' => '/related_dummies/1'], + ], + 'relatedDummies' => [ + 'data' => [ + ['type' => 'related-dummy', 'id' => '/related_dummies/2'], + ], + ], + ], + ], + ], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(1, $body['data']['relationships']['relatedDummies']['data']); + $this->assertSame( + '/related_dummies/1', + $body['data']['relationships']['relatedDummy']['data']['id'], + ); + } + + public function testGetCollectionRelatedDummiesExposesRelationships(): void + { + $this->seedRelatedDummiesWithThirdLevel(1); + + $response = self::createClient()->request('GET', '/related_dummies', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame( + '/third_levels/1', + $body['data'][0]['relationships']['thirdLevel']['data']['id'], + ); + } + + public function testGetRelatedDummyFullBody(): void + { + $this->seedRelatedDummiesWithThirdLevel(1); + + $response = self::createClient()->request('GET', '/related_dummies/1', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'data' => [ + 'id' => '/related_dummies/1', + 'type' => 'RelatedDummy', + 'attributes' => [ + '_id' => 1, + 'name' => 'John Doe', + 'symfony' => 'symfony', + 'age' => 23, + ], + 'relationships' => [ + 'thirdLevel' => [ + 'data' => ['type' => 'ThirdLevel', 'id' => '/third_levels/1'], + ], + ], + ], + ]); + } + + public function testPatchRelatedDummyName(): void + { + $this->seedRelatedDummiesWithThirdLevel(1); + + $response = self::createClient()->request('PATCH', '/related_dummies/1', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'related-dummy', + 'attributes' => ['name' => 'Jane Doe'], + ], + ], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('Jane Doe', $body['data']['attributes']['name']); + $this->assertSame(23, $body['data']['attributes']['age']); + } + + public function testCreateRelationEmbedder(): void + { + $this->seedRelatedDummies(1); + + $response = self::createClient()->request('POST', '/relation_embedders', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'relationships' => [ + 'related' => [ + 'data' => ['type' => 'related-dummy', 'id' => '/related_dummies/1'], + ], + ], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('Krondstadt', $body['data']['attributes']['krondstadt']); + $this->assertSame( + '/related_dummies/1', + $body['data']['relationships']['related']['data']['id'], + ); + } + + private function seedThirdLevel(): void + { + $manager = $this->getManager(); + $thirdLevel = new ThirdLevel(); + $thirdLevel->setLevel(3); + $manager->persist($thirdLevel); + $manager->flush(); + $manager->clear(); + } + + private function seedRelatedDummies(int $nb): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName("RelatedDummy #{$i}"); + $manager->persist($relatedDummy); + } + $manager->flush(); + $manager->clear(); + } + + private function seedRelatedDummiesWithThirdLevel(int $nb): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $thirdLevel = new ThirdLevel(); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName('John Doe'); + $relatedDummy->setAge(23); + $relatedDummy->thirdLevel = $thirdLevel; + $manager->persist($thirdLevel); + $manager->persist($relatedDummy); + } + $manager->flush(); + $manager->clear(); + } + + private function seedDummyWithTwoRelatedDummies(): void + { + $manager = $this->getManager(); + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + $relatedDummies = $manager->getRepository(RelatedDummy::class)->findBy([], ['id' => 'ASC']); + if (\count($relatedDummies) >= 2) { + $dummy->setRelatedDummy($relatedDummies[1]); + $dummy->addRelatedDummy($relatedDummies[0]); + $dummy->addRelatedDummy($relatedDummies[1]); + } + $manager->persist($dummy); + $manager->flush(); + $manager->clear(); + } +} diff --git a/tests/Functional/JsonApi/EntrypointTest.php b/tests/Functional/JsonApi/EntrypointTest.php new file mode 100644 index 00000000000..ddc220247a5 --- /dev/null +++ b/tests/Functional/JsonApi/EntrypointTest.php @@ -0,0 +1,56 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\EntrypointDummy; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class EntrypointTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [EntrypointDummy::class]; + } + + public function testEntrypointHasSelfAndResourceLinks(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame('http://example.com/', $body['links']['self']); + $this->assertSame('http://example.com/jsonapi_entrypoint_dummies', $body['links']['jsonApiEntrypointDummy']); + } + + public function testEmptyCollectionRendersEmptyDataArray(): void + { + $response = self::createClient()->request('GET', '/jsonapi_entrypoint_dummies', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame([], $body['data']); + } +} diff --git a/tests/Functional/JsonApi/ErrorTest.php b/tests/Functional/JsonApi/ErrorTest.php new file mode 100644 index 00000000000..96a813c1d0b --- /dev/null +++ b/tests/Functional/JsonApi/ErrorTest.php @@ -0,0 +1,117 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\ErrorProblem; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiErrorTestResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class ErrorTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [JsonApiErrorTestResource::class, ErrorProblem::class]; + } + + public function testErrorResourceRendersInJsonApiFormat(): void + { + self::createClient()->request('GET', '/jsonapi_error_test/nonexistent', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'errors' => [ + [ + // TODO: change this to '400' in 5.x + 'status' => 400, + 'detail' => 'Resource "nonexistent" not found.', + ], + ], + ]); + } + + public function testValidationErrorRendersJsonApiPointer(): void + { + self::createClient()->request('POST', '/jsonapi_validation_problem', [ + 'headers' => [ + 'accept' => 'application/vnd.api+json', + 'content-type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'JsonApiErrorProblem', + 'attributes' => new \stdClass(), + ], + ], + ]); + + $this->assertResponseStatusCodeSame(422); + $this->assertJsonEquals([ + 'errors' => [ + [ + 'detail' => 'This value should not be blank.', + 'source' => ['pointer' => 'data/attributes/name'], + ], + ], + ]); + } + + public function testRfc7807ErrorRendersJsonApiFormat(): void + { + $response = self::createClient()->request('POST', '/jsonapi_exception_problem', [ + 'headers' => [ + 'accept' => 'application/vnd.api+json', + 'content-type' => 'application/vnd.api+json', + ], + 'json' => new \stdClass(), + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $body = $response->toArray(false); + $this->assertSame('An error occurred', $body['errors'][0]['title']); + $this->assertSame(400, $body['errors'][0]['status']); + $this->assertArrayHasKey('detail', $body['errors'][0]); + $this->assertArrayHasKey('type', $body['errors'][0]); + } + + public function testNotFoundRouteRendersJsonApiFormat(): void + { + $response = self::createClient()->request('POST', '/does_not_exist', [ + 'headers' => [ + 'accept' => 'application/vnd.api+json', + 'content-type' => 'application/vnd.api+json', + ], + 'json' => new \stdClass(), + ]); + + $this->assertResponseStatusCodeSame(404); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $body = $response->toArray(false); + $this->assertSame('An error occurred', $body['errors'][0]['title']); + $this->assertSame(404, $body['errors'][0]['status']); + $this->assertArrayHasKey('detail', $body['errors'][0]); + $this->assertArrayHasKey('type', $body['errors'][0]); + } +} diff --git a/tests/Functional/JsonApi/FilteringTest.php b/tests/Functional/JsonApi/FilteringTest.php new file mode 100644 index 00000000000..b44cb080998 --- /dev/null +++ b/tests/Functional/JsonApi/FilteringTest.php @@ -0,0 +1,89 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\FilteringDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\FilteringProperty; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class FilteringTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [FilteringDummy::class, FilteringProperty::class]; + } + + public function testFilterMatchesPaginatesToThree(): void + { + $response = self::createClient()->request('GET', '/jsonapi_filtering_dummies?filter[name]=my', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(3, $body['data']); + } + + public function testFilterNoMatch(): void + { + $response = self::createClient()->request('GET', '/jsonapi_filtering_dummies?filter[name]=foo', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(0, $body['data']); + } + + public function testFilterAndPaginationCombined(): void + { + $response = self::createClient()->request('GET', '/jsonapi_filtering_dummies?filter[name]=foo&page[page]=2', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame(2, $body['meta']['currentPage']); + } + + public function testSparseFieldsetWithFields(): void + { + $response = self::createClient()->request( + 'GET', + '/jsonapi_filtering_properties?fields[JsonApiFilteringProperty]=id,foo,bar', + ['headers' => ['Accept' => 'application/vnd.api+json']], + ); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(2, $body['data']); + $this->assertSame('1', (string) $body['data'][0]['attributes']['_id']); + $this->assertSame('Foo #1', $body['data'][0]['attributes']['foo']); + $this->assertSame('Bar #1', $body['data'][0]['attributes']['bar']); + $this->assertArrayNotHasKey('group', $body['data'][0]['attributes']); + } + + public function testFilterDateAfter(): void + { + $response = self::createClient()->request( + 'GET', + '/jsonapi_filtering_dummies?filter[dummyDate][after]=2015-04-28', + ['headers' => ['Accept' => 'application/vnd.api+json']], + ); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(2, $body['data']); + } +} diff --git a/tests/Functional/JsonApi/IdentifierModeTest.php b/tests/Functional/JsonApi/IdentifierModeTest.php new file mode 100644 index 00000000000..2f34c41ea5a --- /dev/null +++ b/tests/Functional/JsonApi/IdentifierModeTest.php @@ -0,0 +1,149 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiNotExposedRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiRelatedDummy; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class IdentifierModeTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + JsonApiDummy::class, + JsonApiRelatedDummy::class, + JsonApiNotExposedRelation::class, + ]; + } + + public function testGetSingleResourceIdentifierMode(): void + { + $this->bootJsonApiKernel(); + self::createClient()->request('GET', '/jsonapi_dummies/10', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'data' => [ + 'id' => '10', + 'type' => 'JsonApiDummy', + 'links' => [ + 'self' => '/jsonapi_dummies/10', + ], + 'attributes' => [ + 'name' => 'Dummy #10', + ], + ], + ]); + } + + public function testGetCollectionIdentifierMode(): void + { + $this->bootJsonApiKernel(); + self::createClient()->request('GET', '/jsonapi_dummies', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'data' => [ + [ + 'id' => '1', + 'type' => 'JsonApiDummy', + 'links' => [ + 'self' => '/jsonapi_dummies/1', + ], + ], + [ + 'id' => '2', + 'type' => 'JsonApiDummy', + 'links' => [ + 'self' => '/jsonapi_dummies/2', + ], + ], + ], + ]); + } + + public function testRelationWithNotExposedOperationIdentifierMode(): void + { + $this->bootJsonApiKernel(); + self::createClient()->request('GET', '/jsonapi_dummies/10', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'data' => [ + 'id' => '10', + 'type' => 'JsonApiDummy', + 'relationships' => [ + 'notExposedRelation' => [ + 'data' => [ + 'id' => '5', + 'type' => 'JsonApiNotExposedRelation', + ], + ], + ], + ], + ]); + } + + public function testSubresourceNotExposedIdentifierMode(): void + { + $this->bootJsonApiKernel(); + self::createClient()->request('GET', '/jsonapi_dummies/10/not_exposed_relation', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'data' => [ + 'id' => '5', + 'type' => 'JsonApiNotExposedRelation', + // links.self uses the subresource URI — the only publicly accessible route + 'links' => ['self' => '/jsonapi_dummies/10/not_exposed_relation'], + ], + ]); + } + + private function bootJsonApiKernel(): void + { + $baseEnv = $_SERVER['APP_ENV'] ?? 'test'; + $jsonApiEnv = 'mongodb' === $baseEnv ? 'jsonapi_mongodb' : 'jsonapi'; + + // AppKernel overrides environment with $_SERVER['APP_ENV'] (behat compat), + // so we must temporarily set it to our target environment. + $_SERVER['APP_ENV'] = $jsonApiEnv; + + try { + self::bootKernel(['environment' => $jsonApiEnv]); + } finally { + $_SERVER['APP_ENV'] = $baseEnv; + } + } +} diff --git a/tests/Functional/JsonApi/InputDtoTest.php b/tests/Functional/JsonApi/InputDtoTest.php new file mode 100644 index 00000000000..b42bab554ff --- /dev/null +++ b/tests/Functional/JsonApi/InputDtoTest.php @@ -0,0 +1,100 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiInputResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiRequiredFieldsResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class InputDtoTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [JsonApiInputResource::class, JsonApiRequiredFieldsResource::class]; + } + + /** + * Without the JSON:API ItemNormalizer guarding against double unwrapping, + * the second pass reads $data['data']['attributes'] from already-flat data + * and gets null, which nulls every DTO property. + */ + public function testPostWithInputDtoPreservesAttributes(): void + { + $response = self::createClient()->request('POST', '/jsonapi_input_test', [ + 'headers' => [ + 'accept' => 'application/vnd.api+json', + 'content-type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'JsonApiInputResource', + 'attributes' => [ + 'title' => 'Hello from JSON:API', + 'body' => 'This should not be nulled.', + ], + ], + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'data' => [ + 'attributes' => [ + 'title' => 'Hello from JSON:API', + 'body' => 'This should not be nulled.', + ], + ], + ]); + } + + public function testPostWithRequiredConstructorArgsInputDto(): void + { + $response = self::createClient()->request('POST', '/jsonapi_required_fields_test', [ + 'headers' => [ + 'accept' => 'application/vnd.api+json', + 'content-type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'JsonApiRequiredFieldsResource', + 'attributes' => [ + 'title' => 'Great review', + 'rating' => 5, + 'comment' => 'Loved it.', + ], + ], + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'data' => [ + 'attributes' => [ + 'title' => 'Great review', + 'rating' => 5, + 'comment' => 'Loved it.', + ], + ], + ]); + } +} diff --git a/tests/Functional/JsonApi/InputOutputTest.php b/tests/Functional/JsonApi/InputOutputTest.php new file mode 100644 index 00000000000..7cb8c5aa4bb --- /dev/null +++ b/tests/Functional/JsonApi/InputOutputTest.php @@ -0,0 +1,65 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\CustomOutputResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class InputOutputTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [CustomOutputResource::class]; + } + + public function testItemUsesCustomOutputDto(): void + { + $response = self::createClient()->request('GET', '/jsonapi_custom_outputs/1', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'data' => [ + 'type' => 'CustomOutputDto', + 'attributes' => [ + 'foo' => 'test', + 'bar' => 1, + ], + ], + ]); + } + + public function testCollectionUsesCustomOutputDto(): void + { + $response = self::createClient()->request('GET', '/jsonapi_custom_outputs', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'data' => [ + ['type' => 'CustomOutputDto', 'attributes' => ['foo' => 'test', 'bar' => 1]], + ['type' => 'CustomOutputDto', 'attributes' => ['foo' => 'test', 'bar' => 2]], + ], + ]); + } +} diff --git a/tests/Functional/JsonApi/IriModeTest.php b/tests/Functional/JsonApi/IriModeTest.php new file mode 100644 index 00000000000..429748ad16d --- /dev/null +++ b/tests/Functional/JsonApi/IriModeTest.php @@ -0,0 +1,52 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiDummy; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class IriModeTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [JsonApiDummy::class]; + } + + public function testGetSingleResourceDefaultIriMode(): void + { + // Default mode (use_iri_as_id: true) — id is the IRI, no links.self + self::createClient()->request('GET', '/jsonapi_dummies/10', [ + 'headers' => ['accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'data' => [ + 'id' => '/jsonapi_dummies/10', + 'type' => 'JsonApiDummy', + ], + ]); + + $json = json_decode(self::getClient()->getResponse()->getContent(), true); + $this->assertArrayNotHasKey('links', $json['data']); + } +} diff --git a/tests/Functional/JsonApi/ItemUriTemplateTest.php b/tests/Functional/JsonApi/ItemUriTemplateTest.php new file mode 100644 index 00000000000..d80ed9965b7 --- /dev/null +++ b/tests/Functional/JsonApi/ItemUriTemplateTest.php @@ -0,0 +1,98 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\UriTemplateCar; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ItemUriTemplateTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [UriTemplateCar::class]; + } + + public function testGetCollectionDerivesItemIriFromFirstGetOperation(): void + { + $response = self::createClient()->request('GET', '/jsonapi_uri_template_cars', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame('/jsonapi_uri_template_cars', $body['links']['self']); + $this->assertCount(2, $body['data']); + foreach ($body['data'] as $member) { + $this->assertMatchesRegularExpression('#^/jsonapi_uri_template_cars/.+$#', $member['id']); + $this->assertSame('JsonApiUriTemplateCar', $member['type']); + } + } + + public function testGetCollectionWithItemUriTemplateUsesIt(): void + { + $response = self::createClient()->request('GET', '/jsonapi_uri_template_brands/renault/cars', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/jsonapi_uri_template_brands/renault/cars', $body['links']['self']); + foreach ($body['data'] as $member) { + $this->assertMatchesRegularExpression('#^/jsonapi_uri_template_brands/renault/cars/.+$#', $member['id']); + } + } + + public function testPostWithoutItemUriTemplateUsesFirstGetOperation(): void + { + $response = self::createClient()->request('POST', '/jsonapi_uri_template_cars', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'JsonApiUriTemplateCar', + 'attributes' => ['owner' => 'Vincent'], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertMatchesRegularExpression('#^/jsonapi_uri_template_cars/.+$#', $body['data']['id']); + $this->assertSame('JsonApiUriTemplateCar', $body['data']['type']); + } + + public function testPostWithItemUriTemplateUsesIt(): void + { + $response = self::createClient()->request('POST', '/jsonapi_uri_template_brands/renault/cars', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'JsonApiUriTemplateCar', + 'attributes' => ['owner' => 'Vincent'], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertMatchesRegularExpression('#^/jsonapi_uri_template_brands/renault/cars/.+$#', $body['data']['id']); + } +} diff --git a/tests/Functional/JsonApi/NetworkPathTest.php b/tests/Functional/JsonApi/NetworkPathTest.php new file mode 100644 index 00000000000..d8b35fed8cb --- /dev/null +++ b/tests/Functional/JsonApi/NetworkPathTest.php @@ -0,0 +1,98 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\NetworkPathDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\NetworkPathRelationDummy; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NetworkPathTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [NetworkPathDummy::class, NetworkPathRelationDummy::class]; + } + + public function testCollectionUsesNetworkPaths(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonapi_network_path_dummies', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('//example.com/jsonapi_network_path_dummies', $body['links']['self']); + $this->assertSame('//example.com/jsonapi_network_path_dummies/1', $body['data'][0]['id']); + $this->assertSame('JsonApiNetworkPathDummy', $body['data'][0]['type']); + $this->assertSame( + '//example.com/jsonapi_network_path_relation_dummies/1', + $body['data'][0]['relationships']['networkPathRelationDummy']['data']['id'], + ); + } + + public function testItemUsesNetworkPaths(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonapi_network_path_dummies/1', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('//example.com/jsonapi_network_path_dummies/1', $body['data']['id']); + $this->assertSame('JsonApiNetworkPathDummy', $body['data']['type']); + $this->assertSame( + '//example.com/jsonapi_network_path_relation_dummies/1', + $body['data']['relationships']['networkPathRelationDummy']['data']['id'], + ); + } + + public function testPostReturnsNetworkPath(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('POST', '/jsonapi_network_path_relation_dummies', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => ['data' => ['type' => 'JsonApiNetworkPathRelationDummy']], + ]); + + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('//example.com/jsonapi_network_path_relation_dummies/2', $body['data']['id']); + } + + public function testSubresourceCollectionUsesNetworkPaths(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonapi_network_path_relation_dummies/1/network_path_dummies', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame( + '//example.com/jsonapi_network_path_relation_dummies/1/network_path_dummies', + $body['links']['self'], + ); + $this->assertSame('//example.com/jsonapi_network_path_dummies/1', $body['data'][0]['id']); + } +} diff --git a/tests/Functional/JsonApi/NonResourceTest.php b/tests/Functional/JsonApi/NonResourceTest.php new file mode 100644 index 00000000000..9f925752f6d --- /dev/null +++ b/tests/Functional/JsonApi/NonResourceTest.php @@ -0,0 +1,132 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\NonRelationResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\NonResourceContainer; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\PlainObjectResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NonResourceTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [NonResourceContainer::class, NonRelationResource::class, PlainObjectResource::class]; + } + + public function testNonResourceObjectIsEmbeddedAsRelationship(): void + { + $response = self::createClient()->request('GET', '/jsonapi_non_resource_containers/1?include=nested', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'data' => [ + 'id' => '/jsonapi_non_resource_containers/1', + 'type' => 'JsonApiNonResourceContainer', + 'attributes' => [ + '_id' => '1', + 'notAResource' => ['foo' => 'f1', 'bar' => 'b1'], + ], + 'relationships' => [ + 'nested' => [ + 'data' => [ + 'id' => '/jsonapi_non_resource_containers/1-nested', + 'type' => 'JsonApiNonResourceContainer', + ], + ], + ], + ], + 'included' => [ + [ + 'id' => '/jsonapi_non_resource_containers/1-nested', + 'type' => 'JsonApiNonResourceContainer', + 'attributes' => [ + '_id' => '1-nested', + 'notAResource' => ['foo' => 'f2', 'bar' => 'b2'], + ], + ], + ], + ]); + } + + public function testCreateResourceWithNonResourceRelation(): void + { + $response = self::createClient()->request('POST', '/jsonapi_non_relation_resources', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'JsonApiNonRelationResource', + 'attributes' => ['relation' => ['foo' => 'test']], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonContains([ + 'data' => [ + 'id' => '/jsonapi_non_relation_resources/1', + 'type' => 'JsonApiNonRelationResource', + 'attributes' => [ + '_id' => 1, + 'relation' => ['foo' => 'test'], + ], + ], + ]); + } + + public function testCreateResourceWithStdClass(): void + { + $payload = json_encode([ + 'fields' => [ + 'title' => ['value' => ''], + 'images' => [], + 'alternativeAudio' => new \stdClass(), + 'caption' => '', + ], + 'showCaption' => false, + 'alternativeContent' => false, + 'alternativeAudioContent' => false, + 'blockLayout' => 'default', + ]); + + $response = self::createClient()->request('POST', '/jsonapi_plain_object_resources', [ + 'headers' => [ + 'Accept' => 'application/vnd.api+json', + 'Content-Type' => 'application/vnd.api+json', + ], + 'json' => [ + 'data' => [ + 'type' => 'JsonApiPlainObjectResource', + 'attributes' => ['content' => $payload], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('/jsonapi_plain_object_resources/1', $body['data']['id']); + $this->assertSame('JsonApiPlainObjectResource', $body['data']['type']); + $this->assertFalse($body['data']['attributes']['data']['showCaption']); + $this->assertSame('default', $body['data']['attributes']['data']['blockLayout']); + } +} diff --git a/tests/Functional/JsonApi/OrderingTest.php b/tests/Functional/JsonApi/OrderingTest.php new file mode 100644 index 00000000000..dbd6ca7480b --- /dev/null +++ b/tests/Functional/JsonApi/OrderingTest.php @@ -0,0 +1,63 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\OrderingDummy; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class OrderingTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [OrderingDummy::class]; + } + + public function testSortAscendingOnSingleField(): void + { + $response = self::createClient()->request('GET', '/jsonapi_ordering_dummies?sort=id', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $ids = array_map(static fn (array $d): int => (int) $d['attributes']['_id'], $body['data']); + $this->assertSame([1, 2, 3], \array_slice($ids, 0, 3)); + } + + public function testSortDescendingOnSingleField(): void + { + $response = self::createClient()->request('GET', '/jsonapi_ordering_dummies?sort=-id', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $ids = array_map(static fn (array $d): int => (int) $d['attributes']['_id'], $body['data']); + $this->assertSame([30, 29, 28], \array_slice($ids, 0, 3)); + } + + public function testSortMultipleFields(): void + { + $response = self::createClient()->request('GET', '/jsonapi_ordering_dummies?sort=description,-id', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $ids = array_map(static fn (array $d): int => (int) $d['attributes']['_id'], $body['data']); + $this->assertSame([30, 28, 26], \array_slice($ids, 0, 3)); + } +} diff --git a/tests/Functional/JsonApi/PaginationTest.php b/tests/Functional/JsonApi/PaginationTest.php new file mode 100644 index 00000000000..a57eaff9368 --- /dev/null +++ b/tests/Functional/JsonApi/PaginationTest.php @@ -0,0 +1,83 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApi\PaginationDummy; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class PaginationTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [PaginationDummy::class]; + } + + public function testFirstPageDefaults(): void + { + $response = self::createClient()->request('GET', '/jsonapi_pagination_dummies', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(3, $body['data']); + $this->assertSame(10, $body['meta']['totalItems']); + $this->assertSame(3, $body['meta']['itemsPerPage']); + $this->assertSame(1, $body['meta']['currentPage']); + } + + public function testFourthPage(): void + { + $response = self::createClient()->request('GET', '/jsonapi_pagination_dummies?page[page]=4', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(1, $body['data']); + $this->assertSame(4, $body['meta']['currentPage']); + } + + public function testCustomItemsPerPage(): void + { + $response = self::createClient()->request('GET', '/jsonapi_pagination_dummies?page[itemsPerPage]=15', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(10, $body['data']); + $this->assertSame(10, $body['meta']['totalItems']); + $this->assertSame(15, $body['meta']['itemsPerPage']); + $this->assertSame(1, $body['meta']['currentPage']); + } + + public function testInvalidPageNumberZero(): void + { + self::createClient()->request('GET', '/jsonapi_pagination_dummies?page[page]=0', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseStatusCodeSame(400); + } + + public function testTooLargePageNumber(): void + { + self::createClient()->request('GET', '/jsonapi_pagination_dummies?page[page]=9223372036854775807', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseStatusCodeSame(400); + } +} diff --git a/tests/Functional/JsonApi/RelatedResourcesInclusionTest.php b/tests/Functional/JsonApi/RelatedResourcesInclusionTest.php new file mode 100644 index 00000000000..b12ab711bfe --- /dev/null +++ b/tests/Functional/JsonApi/RelatedResourcesInclusionTest.php @@ -0,0 +1,591 @@ + + * + * 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\JsonApi; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProperty; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FourthLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedOwnedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedOwningDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class RelatedResourcesInclusionTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + Dummy::class, + DummyProperty::class, + DummyGroup::class, + RelatedDummy::class, + ThirdLevel::class, + FourthLevel::class, + RelatedOwningDummy::class, + RelatedOwnedDummy::class, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + $this->recreateSchema(self::getResources()); + } + + public function testIncludeManyToOneRelation(): void + { + $this->seedDummyPropertyObjects(3); + + $response = self::createClient()->request('GET', '/dummy_properties/1?include=group', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); + $this->assertJsonEquals([ + 'data' => [ + 'id' => '/dummy_properties/1', + 'type' => 'DummyProperty', + 'attributes' => [ + '_id' => 1, + 'foo' => 'Foo #1', + 'bar' => 'Bar #1', + 'baz' => 'Baz #1', + 'name_converted' => 'NameConverted #1', + ], + 'relationships' => [ + 'group' => [ + 'data' => ['type' => 'DummyGroup', 'id' => '/dummy_groups/1'], + ], + 'groups' => ['data' => []], + ], + ], + 'included' => [ + [ + 'id' => '/dummy_groups/1', + 'type' => 'DummyGroup', + 'attributes' => [ + '_id' => 1, + 'foo' => 'Foo #1', + 'bar' => 'Bar #1', + 'baz' => 'Baz #1', + ], + ], + ], + ]); + } + + public function testIncludeNonExistingRelation(): void + { + $this->seedDummyPropertyObjects(3); + + $response = self::createClient()->request('GET', '/dummy_properties/1?include=foo', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/dummy_properties/1', $body['data']['id']); + $this->assertArrayNotHasKey('included', $body); + } + + public function testIncludeKeepsMainAttributesUnfiltered(): void + { + $this->seedDummyPropertyObjects(3); + + $response = self::createClient()->request( + 'GET', + '/dummy_properties/1?include=group&fields[group]=id,foo&fields[DummyProperty]=bar,baz', + ['headers' => ['Accept' => 'application/vnd.api+json']], + ); + + $this->assertResponseIsSuccessful(); + $this->assertJsonEquals([ + 'data' => [ + 'id' => '/dummy_properties/1', + 'type' => 'DummyProperty', + 'attributes' => ['bar' => 'Bar #1', 'baz' => 'Baz #1'], + 'relationships' => [ + 'group' => ['data' => ['type' => 'DummyGroup', 'id' => '/dummy_groups/1']], + ], + ], + 'included' => [ + [ + 'id' => '/dummy_groups/1', + 'type' => 'DummyGroup', + 'attributes' => ['_id' => 1, 'foo' => 'Foo #1'], + ], + ], + ]); + } + + public function testIncludeWithSparseFieldsForRelationOnly(): void + { + $this->seedDummyPropertyObjects(3); + + $response = self::createClient()->request( + 'GET', + '/dummy_properties/1?include=group&fields[group]=id,foo', + ['headers' => ['Accept' => 'application/vnd.api+json']], + ); + + $this->assertResponseIsSuccessful(); + $this->assertJsonEquals([ + 'data' => [ + 'id' => '/dummy_properties/1', + 'type' => 'DummyProperty', + 'relationships' => [ + 'group' => ['data' => ['type' => 'DummyGroup', 'id' => '/dummy_groups/1']], + ], + ], + 'included' => [ + [ + 'id' => '/dummy_groups/1', + 'type' => 'DummyGroup', + 'attributes' => ['_id' => 1, 'foo' => 'Foo #1'], + ], + ], + ]); + } + + public function testIncludeManyToMany(): void + { + $this->seedDummyPropertyObjectsWithGroups(1, 3); + + $response = self::createClient()->request('GET', '/dummy_properties/1?include=groups', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(3, $body['data']['relationships']['groups']['data']); + $this->assertCount(3, $body['included']); + $includedIds = array_column($body['included'], 'id'); + $this->assertContains('/dummy_groups/2', $includedIds); + $this->assertContains('/dummy_groups/3', $includedIds); + $this->assertContains('/dummy_groups/4', $includedIds); + } + + public function testIncludeManyToManyAndManyToOne(): void + { + $this->seedDummyPropertyObjectsWithGroups(1, 3); + + $response = self::createClient()->request('GET', '/dummy_properties/1?include=groups,group', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + // 1 group (manyToOne) + 3 groups (manyToMany) = 4 included. + $this->assertCount(4, $body['included']); + $includedIds = array_column($body['included'], 'id'); + $this->assertContains('/dummy_groups/1', $includedIds); + $this->assertContains('/dummy_groups/2', $includedIds); + $this->assertContains('/dummy_groups/3', $includedIds); + $this->assertContains('/dummy_groups/4', $includedIds); + } + + public function testIncludeRelatedDummyAndItsThirdLevel(): void + { + $this->seedDummiesWithRelatedDummyAndThirdLevel(1); + + $response = self::createClient()->request('GET', '/dummies/1?include=relatedDummy', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/dummies/1', $body['data']['id']); + $this->assertSame('Dummy', $body['data']['type']); + $this->assertSame( + '/related_dummies/1', + $body['data']['relationships']['relatedDummy']['data']['id'], + ); + $this->assertCount(1, $body['included']); + $this->assertSame('/related_dummies/1', $body['included'][0]['id']); + $this->assertSame('RelatedDummy', $body['included'][0]['type']); + $this->assertSame('RelatedDummy #1', $body['included'][0]['attributes']['name']); + $this->assertSame( + '/third_levels/1', + $body['included'][0]['relationships']['thirdLevel']['data']['id'], + ); + } + + public function testIncludeFromPath(): void + { + $this->seedDummyWithFourthLevel(); + + $response = self::createClient()->request( + 'GET', + '/dummies/1?include=relatedDummy.thirdLevel.fourthLevel', + ['headers' => ['Accept' => 'application/vnd.api+json']], + ); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/dummies/1', $body['data']['id']); + $includedIds = array_column($body['included'], 'id'); + // relatedDummy + thirdLevel + fourthLevel + $this->assertContains('/related_dummies/1', $includedIds); + $this->assertContains('/third_levels/1', $includedIds); + $this->assertContains('/fourth_levels/1', $includedIds); + $this->assertCount(3, $body['included']); + } + + public function testIncludeFromPathWithCollection(): void + { + $this->seedDummyWithRelatedDummiesAndTheirThirdLevel(3); + + $response = self::createClient()->request( + 'GET', + '/dummies/1?include=relatedDummies.thirdLevel', + ['headers' => ['Accept' => 'application/vnd.api+json']], + ); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(3, $body['data']['relationships']['relatedDummies']['data']); + $includedIds = array_column($body['included'], 'id'); + // 3 related_dummies + 3 third_levels (each its own) = 6 + $this->assertCount(6, $body['included']); + $this->assertContains('/related_dummies/1', $includedIds); + $this->assertContains('/related_dummies/2', $includedIds); + $this->assertContains('/related_dummies/3', $includedIds); + $this->assertContains('/third_levels/1', $includedIds); + $this->assertContains('/third_levels/2', $includedIds); + $this->assertContains('/third_levels/3', $includedIds); + } + + public function testIncludeDoesNotIncludeRequestedResource(): void + { + $this->seedRelatedOwningDummyOneToOne(); + + $response = self::createClient()->request( + 'GET', + '/dummies/1?include=relatedOwningDummy.ownedDummy', + ['headers' => ['Accept' => 'application/vnd.api+json']], + ); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/dummies/1', $body['data']['id']); + $this->assertSame( + '/related_owning_dummies/1', + $body['data']['relationships']['relatedOwningDummy']['data']['id'], + ); + // Path leads back to the requested resource — only RelatedOwningDummy stays in included, not Dummy itself. + $includedIds = array_column($body['included'], 'id'); + $this->assertCount(1, $body['included']); + $this->assertSame('/related_owning_dummies/1', $body['included'][0]['id']); + $this->assertNotContains('/dummies/1', $includedIds); + } + + public function testIncludeDoesNotDuplicateSharedThirdLevel(): void + { + $this->seedDummyWithRelatedDummiesSharingThirdLevel(3); + + $response = self::createClient()->request( + 'GET', + '/dummies/1?include=relatedDummies.thirdLevel', + ['headers' => ['Accept' => 'application/vnd.api+json']], + ); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(3, $body['data']['relationships']['relatedDummies']['data']); + $includedIds = array_column($body['included'], 'id'); + // 3 related_dummies + 1 shared third_level = 4 included entries. + $this->assertCount(4, $body['included']); + $thirdLevelEntries = array_filter($body['included'], static fn (array $e): bool => 'ThirdLevel' === $e['type']); + $this->assertCount(1, $thirdLevelEntries); + } + + public function testIncludeRelationOnCollection(): void + { + $this->seedDummyPropertyObjects(3); + + $response = self::createClient()->request('GET', '/dummy_properties?include=group', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(3, $body['data']); + // Each property has its own group → 3 included. + $this->assertCount(3, $body['included']); + $includedIds = array_column($body['included'], 'id'); + $this->assertContains('/dummy_groups/1', $includedIds); + $this->assertContains('/dummy_groups/2', $includedIds); + $this->assertContains('/dummy_groups/3', $includedIds); + } + + public function testIncludeOnCollectionDeduplicatesSharedRelation(): void + { + $this->seedDummyPropertyObjectsWithSharedGroup(3); + + $response = self::createClient()->request('GET', '/dummy_properties?include=group', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(3, $body['data']); + // All 3 properties share 1 group → only 1 included entry. + $this->assertCount(1, $body['included']); + $this->assertSame('/dummy_groups/1', $body['included'][0]['id']); + } + + public function testIncludeOnCollectionWithDifferingNumberOfGroupsDeduplicates(): void + { + $this->seedDummyPropertyObjectsWithDifferentNumberOfRelatedGroups(2); + + $response = self::createClient()->request('GET', '/dummy_properties?include=groups', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(2, $body['data']); + // Property 1 has [group1]; property 2 has [group1, group2]. Dedup → 2 unique groups. + $this->assertCount(2, $body['included']); + $includedIds = array_column($body['included'], 'id'); + $this->assertContains('/dummy_groups/1', $includedIds); + $this->assertContains('/dummy_groups/2', $includedIds); + } + + public function testIncludeFromPathOnCollection(): void + { + $this->seedDummiesWithRelatedDummyAndThirdLevel(3); + + $response = self::createClient()->request( + 'GET', + '/dummies?include=relatedDummy.thirdLevel', + ['headers' => ['Accept' => 'application/vnd.api+json']], + ); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(3, $body['data']); + $includedIds = array_column($body['included'], 'id'); + // 3 related dummies + 3 distinct thirdLevels = 6. + $this->assertCount(6, $body['included']); + $this->assertContains('/related_dummies/1', $includedIds); + $this->assertContains('/third_levels/1', $includedIds); + } + + private function seedDummyPropertyObjects(int $nb): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummyProperty = new DummyProperty(); + $dummyGroup = new DummyGroup(); + foreach (['foo', 'bar', 'baz'] as $property) { + $dummyProperty->{$property} = $dummyGroup->{$property} = ucfirst($property)." #{$i}"; + } + $dummyProperty->nameConverted = "NameConverted #{$i}"; + $dummyProperty->group = $dummyGroup; + $manager->persist($dummyGroup); + $manager->persist($dummyProperty); + } + $manager->flush(); + } + + private function seedDummyPropertyObjectsWithGroups(int $nb, int $nb2): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummyProperty = new DummyProperty(); + $dummyGroup = new DummyGroup(); + foreach (['foo', 'bar', 'baz'] as $property) { + $dummyProperty->{$property} = $dummyGroup->{$property} = ucfirst($property)." #{$i}"; + } + $dummyProperty->group = $dummyGroup; + $manager->persist($dummyGroup); + + $dummyProperty->groups = []; + for ($j = 1; $j <= $nb2; ++$j) { + $extraGroup = new DummyGroup(); + foreach (['foo', 'bar', 'baz'] as $property) { + $extraGroup->{$property} = ucfirst($property).' #'.$i.$j; + } + $dummyProperty->groups[] = $extraGroup; + $manager->persist($extraGroup); + } + $manager->persist($dummyProperty); + } + $manager->flush(); + } + + private function seedDummyPropertyObjectsWithSharedGroup(int $nb): void + { + $manager = $this->getManager(); + $dummyGroup = new DummyGroup(); + foreach (['foo', 'bar', 'baz'] as $property) { + $dummyGroup->{$property} = ucfirst($property).' #shared'; + } + $manager->persist($dummyGroup); + + for ($i = 1; $i <= $nb; ++$i) { + $dummyProperty = new DummyProperty(); + foreach (['foo', 'bar', 'baz'] as $property) { + $dummyProperty->{$property} = ucfirst($property)." #{$i}"; + } + $dummyProperty->group = $dummyGroup; + $manager->persist($dummyProperty); + } + $manager->flush(); + } + + private function seedDummyPropertyObjectsWithDifferentNumberOfRelatedGroups(int $nb): void + { + $manager = $this->getManager(); + $dummyGroups = []; + for ($i = 1; $i <= $nb; ++$i) { + $dummyGroup = new DummyGroup(); + $dummyProperty = new DummyProperty(); + foreach (['foo', 'bar', 'baz'] as $property) { + $dummyProperty->{$property} = $dummyGroup->{$property} = ucfirst($property)." #{$i}"; + } + $manager->persist($dummyGroup); + $dummyGroups[$i] = $dummyGroup; + + $dummyProperty->groups = []; + for ($j = 1; $j <= $i; ++$j) { + $dummyProperty->groups[] = $dummyGroups[$j]; + } + $manager->persist($dummyProperty); + } + $manager->flush(); + } + + private function seedDummiesWithRelatedDummyAndThirdLevel(int $nb): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $thirdLevel = new ThirdLevel(); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName("RelatedDummy #{$i}"); + $relatedDummy->thirdLevel = $thirdLevel; + + $dummy = new Dummy(); + $dummy->setName("Dummy #{$i}"); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setRelatedDummy($relatedDummy); + + $manager->persist($thirdLevel); + $manager->persist($relatedDummy); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummyWithFourthLevel(): void + { + $manager = $this->getManager(); + + $fourthLevel = new FourthLevel(); + $fourthLevel->setLevel(4); + $manager->persist($fourthLevel); + + $thirdLevel = new ThirdLevel(); + $thirdLevel->setLevel(3); + $thirdLevel->setFourthLevel($fourthLevel); + $manager->persist($thirdLevel); + + $namedRelatedDummy = new RelatedDummy(); + $namedRelatedDummy->setName('Hello'); + $namedRelatedDummy->thirdLevel = $thirdLevel; + $manager->persist($namedRelatedDummy); + + $relatedDummy = new RelatedDummy(); + $relatedDummy->thirdLevel = $thirdLevel; + $manager->persist($relatedDummy); + + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + $dummy->setRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($relatedDummy); + $manager->persist($dummy); + + $manager->flush(); + // Detach so the request side hydrates from DB instead of seeing in-memory + // FourthLevel.badThirdLevel left at its declared null default. + $manager->clear(); + } + + private function seedDummyWithRelatedDummiesAndTheirThirdLevel(int $nb): void + { + $manager = $this->getManager(); + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + + for ($i = 1; $i <= $nb; ++$i) { + $thirdLevel = new ThirdLevel(); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName("RelatedDummy #{$i}"); + $relatedDummy->thirdLevel = $thirdLevel; + $dummy->addRelatedDummy($relatedDummy); + + $manager->persist($thirdLevel); + $manager->persist($relatedDummy); + } + $manager->persist($dummy); + $manager->flush(); + } + + private function seedDummyWithRelatedDummiesSharingThirdLevel(int $nb): void + { + $manager = $this->getManager(); + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + $thirdLevel = new ThirdLevel(); + + for ($i = 1; $i <= $nb; ++$i) { + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName("RelatedDummy #{$i}"); + $relatedDummy->thirdLevel = $thirdLevel; + $dummy->addRelatedDummy($relatedDummy); + $manager->persist($relatedDummy); + } + $manager->persist($thirdLevel); + $manager->persist($dummy); + $manager->flush(); + } + + private function seedRelatedOwningDummyOneToOne(): void + { + $manager = $this->getManager(); + $dummy = new Dummy(); + $dummy->setName('plop'); + $manager->persist($dummy); + + $relatedOwningDummy = new RelatedOwningDummy(); + $relatedOwningDummy->setOwnedDummy($dummy); + $manager->persist($relatedOwningDummy); + $manager->flush(); + } +} diff --git a/tests/Functional/JsonApiTest.php b/tests/Functional/JsonApiTest.php deleted file mode 100644 index 66d35696b97..00000000000 --- a/tests/Functional/JsonApiTest.php +++ /dev/null @@ -1,270 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Functional; - -use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiErrorTestResource; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiInputResource; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiNotExposedRelation; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiRelatedDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonApiRequiredFieldsResource; -use ApiPlatform\Tests\SetupClassResourcesTrait; - -class JsonApiTest extends ApiTestCase -{ - use SetupClassResourcesTrait; - protected static ?bool $alwaysBootKernel = false; - - /** - * @return class-string[] - */ - public static function getResources(): array - { - return [ - JsonApiErrorTestResource::class, - JsonApiDummy::class, - JsonApiRelatedDummy::class, - JsonApiNotExposedRelation::class, - JsonApiInputResource::class, - JsonApiRequiredFieldsResource::class, - ]; - } - - public function testError(): void - { - self::createClient()->request('GET', '/jsonapi_error_test/nonexistent', [ - 'headers' => ['accept' => 'application/vnd.api+json'], - ]); - - $this->assertResponseStatusCodeSame(400); - $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); - $this->assertJsonContains([ - 'errors' => [ - [ - // TODO: change this to '400' in 5.x - 'status' => 400, - 'detail' => 'Resource "nonexistent" not found.', - ], - ], - ]); - } - - public function testGetSingleResourceIdentifierMode(): void - { - $this->bootJsonApiKernel(); - self::createClient()->request('GET', '/jsonapi_dummies/10', [ - 'headers' => ['accept' => 'application/vnd.api+json'], - ]); - - $this->assertResponseIsSuccessful(); - $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); - $this->assertJsonContains([ - 'data' => [ - 'id' => '10', - 'type' => 'JsonApiDummy', - 'links' => [ - 'self' => '/jsonapi_dummies/10', - ], - 'attributes' => [ - 'name' => 'Dummy #10', - ], - ], - ]); - } - - public function testGetCollectionIdentifierMode(): void - { - $this->bootJsonApiKernel(); - self::createClient()->request('GET', '/jsonapi_dummies', [ - 'headers' => ['accept' => 'application/vnd.api+json'], - ]); - - $this->assertResponseIsSuccessful(); - $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); - $this->assertJsonContains([ - 'data' => [ - [ - 'id' => '1', - 'type' => 'JsonApiDummy', - 'links' => [ - 'self' => '/jsonapi_dummies/1', - ], - ], - [ - 'id' => '2', - 'type' => 'JsonApiDummy', - 'links' => [ - 'self' => '/jsonapi_dummies/2', - ], - ], - ], - ]); - } - - public function testRelationWithNotExposedOperationIdentifierMode(): void - { - $this->bootJsonApiKernel(); - self::createClient()->request('GET', '/jsonapi_dummies/10', [ - 'headers' => ['accept' => 'application/vnd.api+json'], - ]); - - $this->assertResponseIsSuccessful(); - $this->assertJsonContains([ - 'data' => [ - 'id' => '10', - 'type' => 'JsonApiDummy', - 'relationships' => [ - 'notExposedRelation' => [ - 'data' => [ - 'id' => '5', - 'type' => 'JsonApiNotExposedRelation', - ], - ], - ], - ], - ]); - } - - public function testSubresourceNotExposedIdentifierMode(): void - { - $this->bootJsonApiKernel(); - self::createClient()->request('GET', '/jsonapi_dummies/10/not_exposed_relation', [ - 'headers' => ['accept' => 'application/vnd.api+json'], - ]); - - $this->assertResponseIsSuccessful(); - $this->assertJsonContains([ - 'data' => [ - 'id' => '5', - 'type' => 'JsonApiNotExposedRelation', - // links.self uses the subresource URI — the only publicly accessible route - 'links' => ['self' => '/jsonapi_dummies/10/not_exposed_relation'], - ], - ]); - } - - public function testGetSingleResourceDefaultIriMode(): void - { - // Default mode (use_iri_as_id: true) — id should be the IRI, no links.self - self::createClient()->request('GET', '/jsonapi_dummies/10', [ - 'headers' => ['accept' => 'application/vnd.api+json'], - ]); - - $this->assertResponseIsSuccessful(); - $this->assertJsonContains([ - 'data' => [ - 'id' => '/jsonapi_dummies/10', - 'type' => 'JsonApiDummy', - ], - ]); - - // Verify no links.self is present on the data object - $json = json_decode(self::getClient()->getResponse()->getContent(), true); - $this->assertArrayNotHasKey('links', $json['data']); - } - - /** - * Reproducer for https://github.com/api-platform/core/issues/7794. - * - * When using an input DTO with JSON:API format, the JsonApi\ItemNormalizer - * must not unwrap data.attributes twice. Without the fix, the second pass - * reads $data['data']['attributes'] from already-flat data and gets null, - * which nulls every DTO property. - */ - public function testPostWithInputDtoPreservesAttributes(): void - { - $response = self::createClient()->request('POST', '/jsonapi_input_test', [ - 'headers' => [ - 'accept' => 'application/vnd.api+json', - 'content-type' => 'application/vnd.api+json', - ], - 'json' => [ - 'data' => [ - 'type' => 'JsonApiInputResource', - 'attributes' => [ - 'title' => 'Hello from JSON:API', - 'body' => 'This should not be nulled.', - ], - ], - ], - ]); - - $this->assertResponseIsSuccessful(); - $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); - $this->assertJsonContains([ - 'data' => [ - 'attributes' => [ - 'title' => 'Hello from JSON:API', - 'body' => 'This should not be nulled.', - ], - ], - ]); - } - - /** - * Verify that a JSON:API POST with all required fields on an input DTO - * with constructor arguments works correctly end-to-end. - * - * Related to Sylius test failures caused by a missing `continue` in - * AbstractItemNormalizer::instantiateObject() — only the first missing - * constructor argument was reported instead of all of them. - */ - public function testPostWithRequiredConstructorArgsInputDto(): void - { - $response = self::createClient()->request('POST', '/jsonapi_required_fields_test', [ - 'headers' => [ - 'accept' => 'application/vnd.api+json', - 'content-type' => 'application/vnd.api+json', - ], - 'json' => [ - 'data' => [ - 'type' => 'JsonApiRequiredFieldsResource', - 'attributes' => [ - 'title' => 'Great review', - 'rating' => 5, - 'comment' => 'Loved it.', - ], - ], - ], - ]); - - $this->assertResponseIsSuccessful(); - $this->assertJsonContains([ - 'data' => [ - 'attributes' => [ - 'title' => 'Great review', - 'rating' => 5, - 'comment' => 'Loved it.', - ], - ], - ]); - } - - private function bootJsonApiKernel(): void - { - $baseEnv = $_SERVER['APP_ENV'] ?? 'test'; - $jsonApiEnv = 'mongodb' === $baseEnv ? 'jsonapi_mongodb' : 'jsonapi'; - - // AppKernel overrides environment with $_SERVER['APP_ENV'] (behat compat), - // so we must temporarily set it to our target environment. - $_SERVER['APP_ENV'] = $jsonApiEnv; - - try { - self::bootKernel(['environment' => $jsonApiEnv]); - } finally { - $_SERVER['APP_ENV'] = $baseEnv; - } - } -} diff --git a/tests/Functional/JsonLd/AbsolutePaginationTest.php b/tests/Functional/JsonLd/AbsolutePaginationTest.php new file mode 100644 index 00000000000..d117647a7e0 --- /dev/null +++ b/tests/Functional/JsonLd/AbsolutePaginationTest.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\Tests\Functional\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\AbsolutePagedResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class AbsolutePaginationTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [AbsolutePagedResource::class]; + } + + public function testHydraViewUrlsAreAbsolute(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonld_absolute_paged?page=3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame([ + '@id' => 'http://example.com/jsonld_absolute_paged?page=3', + '@type' => 'hydra:PartialCollectionView', + 'hydra:first' => 'http://example.com/jsonld_absolute_paged?page=1', + 'hydra:last' => 'http://example.com/jsonld_absolute_paged?page=10', + 'hydra:previous' => 'http://example.com/jsonld_absolute_paged?page=2', + 'hydra:next' => 'http://example.com/jsonld_absolute_paged?page=4', + ], $body['hydra:view']); + } +} diff --git a/tests/Functional/JsonLd/AbsoluteUrlTest.php b/tests/Functional/JsonLd/AbsoluteUrlTest.php new file mode 100644 index 00000000000..e94c2e5ce35 --- /dev/null +++ b/tests/Functional/JsonLd/AbsoluteUrlTest.php @@ -0,0 +1,89 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\AbsoluteUrlChild; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\AbsoluteUrlParent; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class AbsoluteUrlTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [AbsoluteUrlChild::class, AbsoluteUrlParent::class]; + } + + public function testCollectionUsesAbsoluteUrls(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonld_absolute_url_children', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('http://example.com/contexts/JsonLdAbsoluteUrlChild', $body['@context']); + $this->assertSame('http://example.com/jsonld_absolute_url_children', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertSame('http://example.com/jsonld_absolute_url_children/1', $body['hydra:member'][0]['@id']); + } + + public function testItemUsesAbsoluteUrls(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonld_absolute_url_children/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('http://example.com/contexts/JsonLdAbsoluteUrlChild', $body['@context']); + $this->assertSame('http://example.com/jsonld_absolute_url_children/1', $body['@id']); + $this->assertSame('JsonLdAbsoluteUrlChild', $body['@type']); + $this->assertSame('http://example.com/jsonld_absolute_url_parents/1', $body['parent']); + } + + public function testPostAcceptsAbsoluteUrlInPayload(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('POST', '/jsonld_absolute_url_children', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['parent' => 'http://example.com/jsonld_absolute_url_parents/1'], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('http://example.com/jsonld_absolute_url_children/2', $body['@id']); + $this->assertSame('JsonLdAbsoluteUrlChild', $body['@type']); + $this->assertSame('http://example.com/jsonld_absolute_url_parents/1', $body['parent']); + } + + public function testSubresourceCollectionUsesAbsoluteUrls(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonld_absolute_url_parents/1/children', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('http://example.com/contexts/JsonLdAbsoluteUrlChild', $body['@context']); + $this->assertSame('http://example.com/jsonld_absolute_url_parents/1/children', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + } +} diff --git a/tests/Functional/JsonLd/ContextOutputTest.php b/tests/Functional/JsonLd/ContextOutputTest.php new file mode 100644 index 00000000000..07a6f4f9c82 --- /dev/null +++ b/tests/Functional/JsonLd/ContextOutputTest.php @@ -0,0 +1,51 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\GenIdFalse; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6810\JsonLdContextOutput; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class ContextOutputTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [JsonLdContextOutput::class, GenIdFalse::class]; + } + + public function testContextOnOutputDtoMatchesDeclaredVocabulary(): void + { + $response = self::createClient()->request('GET', '/json_ld_context_output'); + $res = $response->toArray(); + $this->assertEquals($res['@context'], [ + '@vocab' => 'http://localhost/docs.jsonld#', + 'hydra' => 'http://www.w3.org/ns/hydra/core#', + 'foo' => 'Output/foo', + ]); + } + + public function testIgnoredPropertyIsExcludedFromResourceContext(): void + { + $r = self::createClient()->request('GET', '/contexts/GenIdFalse'); + $this->assertArrayNotHasKey('shouldBeIgnored', $r->toArray()['@context']); + } +} diff --git a/tests/Functional/JsonLd/ContextTest.php b/tests/Functional/JsonLd/ContextTest.php new file mode 100644 index 00000000000..dd768a9cf55 --- /dev/null +++ b/tests/Functional/JsonLd/ContextTest.php @@ -0,0 +1,115 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\JsonLdContextDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\JsonLdContextRelation; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ContextTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [JsonLdContextDummy::class, JsonLdContextRelation::class]; + } + + public function testEntrypointContextListsResources(): void + { + $response = self::createClient()->request('GET', '/contexts/Entrypoint'); + $this->assertResponseIsSuccessful(); + $this->assertSame('application/ld+json; charset=utf-8', $response->getHeaders()['content-type'][0]); + $body = $response->toArray(); + $this->assertSame('http://localhost/docs.jsonld#', $body['@context']['@vocab']); + $this->assertSame('http://www.w3.org/ns/hydra/core#', $body['@context']['hydra']); + $this->assertSame(['@id' => 'Entrypoint/jsonLdContextDummy', '@type' => '@id'], $body['@context']['jsonLdContextDummy']); + $this->assertSame(['@id' => 'Entrypoint/jsonLdContextRelation', '@type' => '@id'], $body['@context']['jsonLdContextRelation']); + } + + public function testResourceContextExposesPropertyMappings(): void + { + $response = self::createClient()->request('GET', '/contexts/JsonLdContextDummy'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('http://localhost/docs.jsonld#', $body['@context']['@vocab']); + $this->assertSame('http://www.w3.org/ns/hydra/core#', $body['@context']['hydra']); + $this->assertSame('https://schema.org/name', $body['@context']['name']); + $this->assertSame('https://schema.org/alternateName', $body['@context']['alias']); + $this->assertSame([ + '@id' => 'https://example.com/id', + '@type' => '@id', + 'foo' => 'bar', + ], $body['@context']['person']); + } + + public function testRelatedResourceMappingHasIdReference(): void + { + $response = self::createClient()->request('GET', '/contexts/JsonLdContextDummy'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame([ + '@id' => 'JsonLdContextDummy/related', + '@type' => '@id', + ], $body['@context']['related']); + } + + public function testRelatedCollectionMappingHasIdReference(): void + { + $response = self::createClient()->request('GET', '/contexts/JsonLdContextDummy'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame([ + '@id' => 'JsonLdContextDummy/relatedCollection', + '@type' => '@id', + ], $body['@context']['relatedCollection']); + } + + public function testDateTimePropertyExposesSchemaOrgDateTime(): void + { + $response = self::createClient()->request('GET', '/contexts/JsonLdContextDummy'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('https://schema.org/DateTime', $body['@context']['dummyDate']); + } + + public function testNameConvertedPropertyKeyIsNormalized(): void + { + $response = self::createClient()->request('GET', '/contexts/JsonLdContextDummy'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertArrayHasKey('name_converted', $body['@context']); + $this->assertArrayNotHasKey('nameConverted', $body['@context']); + } + + public function testJsonAndArrayDataAreExposed(): void + { + $response = self::createClient()->request('GET', '/contexts/JsonLdContextDummy'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertArrayHasKey('jsonData', $body['@context']); + $this->assertArrayHasKey('arrayData', $body['@context']); + } + + public function testEmbeddedRelationMappingIsPlainString(): void + { + $response = self::createClient()->request('GET', '/contexts/JsonLdContextDummy'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('JsonLdContextDummy/embedded', $body['@context']['embedded']); + } +} diff --git a/tests/Functional/JsonLd/CursorPaginationTest.php b/tests/Functional/JsonLd/CursorPaginationTest.php new file mode 100644 index 00000000000..dfa41f81198 --- /dev/null +++ b/tests/Functional/JsonLd/CursorPaginationTest.php @@ -0,0 +1,104 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SoMany; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CursorPaginationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [SoMany::class]; + } + + public function testEmptyCollectionWithCursorPagination(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([SoMany::class]); + + $response = self::createClient()->request('GET', '/so_manies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/contexts/SoMany', $body['@context']); + $this->assertSame('/so_manies', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertSame('/so_manies', $body['hydra:view']['@id']); + $this->assertSame('hydra:PartialCollectionView', $body['hydra:view']['@type']); + $this->assertCount(0, $body['hydra:member']); + } + + public function testRangedItemsWithCursorPagination(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([SoMany::class]); + $manager = $this->getManager(); + for ($i = 1; $i <= 10; ++$i) { + $s = new SoMany(); + $s->content = "row $i"; + $manager->persist($s); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/so_manies?order[id]=desc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/so_manies?order%5Bid%5D=desc', $body['hydra:view']['@id']); + $this->assertSame('/so_manies?order%5Bid%5D=desc&id%5Bgt%5D=10', $body['hydra:view']['hydra:previous']); + $this->assertSame('/so_manies?order%5Bid%5D=desc&id%5Blt%5D=8', $body['hydra:view']['hydra:next']); + $this->assertGreaterThanOrEqual(3, \count($body['hydra:member'])); + } + + public function testRangeFilteredItemsWithCursorPagination(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([SoMany::class]); + $manager = $this->getManager(); + for ($i = 1; $i <= 10; ++$i) { + $s = new SoMany(); + $s->content = "row $i"; + $manager->persist($s); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/so_manies?order[id]=desc&id[gt]=10', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(0, $body['hydra:member']); + $this->assertSame('/so_manies?id%5Bgt%5D=10&order%5Bid%5D=desc', $body['hydra:view']['@id']); + $this->assertSame('hydra:PartialCollectionView', $body['hydra:view']['@type']); + } +} diff --git a/tests/Functional/JsonLd/DisableIdGenerationTest.php b/tests/Functional/JsonLd/DisableIdGenerationTest.php new file mode 100644 index 00000000000..d0d9759bc27 --- /dev/null +++ b/tests/Functional/JsonLd/DisableIdGenerationTest.php @@ -0,0 +1,41 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\DisableIdGenAnonymous; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class DisableIdGenerationTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [DisableIdGenAnonymous::class]; + } + + public function testNestedAnonymousResourceHasNoIri(): void + { + $response = self::createClient()->request('GET', '/jsonld_disable_id_gen_anonymous', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $items = $response->toArray()['items']; + $this->assertArrayNotHasKey('@id', $items[0]); + $this->assertArrayNotHasKey('@id', $items[1]); + } +} diff --git a/tests/Functional/JsonLd/EntityClassWithDateTimeTest.php b/tests/Functional/JsonLd/EntityClassWithDateTimeTest.php new file mode 100644 index 00000000000..5163f098908 --- /dev/null +++ b/tests/Functional/JsonLd/EntityClassWithDateTimeTest.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\Tests\Functional\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\EntityClassWithDateTime; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EntityClassWithDateTime as EntityClassWithDateTimeEntity; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class EntityClassWithDateTimeTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [EntityClassWithDateTime::class]; + } + + public function testGetExposesDateTimeProperty(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([EntityClassWithDateTimeEntity::class]); + + $manager = $this->getManager(); + $entity = new EntityClassWithDateTimeEntity(); + $entity->setStart(new \DateTime('2024-05-12T10:00:00+00:00')); + $manager->persist($entity); + $manager->flush(); + + $response = self::createClient()->request('GET', '/EntityClassWithDateTime/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertSame('application/ld+json; charset=utf-8', $response->getHeaders()['content-type'][0]); + $body = $response->toArray(); + $this->assertSame('/EntityClassWithDateTime/1', $body['@id']); + $this->assertSame('EntityClassWithDateTime', $body['@type']); + $this->assertArrayHasKey('start', $body); + $this->assertNotEmpty($body['start']); + } +} diff --git a/tests/Functional/JsonLd/EntrypointTest.php b/tests/Functional/JsonLd/EntrypointTest.php new file mode 100644 index 00000000000..502f464557a --- /dev/null +++ b/tests/Functional/JsonLd/EntrypointTest.php @@ -0,0 +1,46 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\JsonLdContextDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\JsonLdContextRelation; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class EntrypointTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [JsonLdContextDummy::class, JsonLdContextRelation::class]; + } + + public function testEntrypointListsRegisteredResources(): void + { + $response = self::createClient()->request('GET', '/', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertSame('application/ld+json; charset=utf-8', $response->getHeaders()['content-type'][0]); + $body = $response->toArray(); + $this->assertSame('/contexts/Entrypoint', $body['@context']); + $this->assertSame('/', $body['@id']); + $this->assertSame('Entrypoint', $body['@type']); + $this->assertSame('/jsonld_context_relations', $body['jsonLdContextRelation']); + $this->assertArrayHasKey('jsonLdContextDummy', $body); + } +} diff --git a/tests/Functional/JsonLd/GenIdFalseTest.php b/tests/Functional/JsonLd/GenIdFalseTest.php new file mode 100644 index 00000000000..7cedc762669 --- /dev/null +++ b/tests/Functional/JsonLd/GenIdFalseTest.php @@ -0,0 +1,53 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\AggregateRating; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\GenIdFalse; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\LevelFirst; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\LevelThird; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class GenIdFalseTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [GenIdFalse::class, AggregateRating::class, LevelFirst::class, LevelThird::class]; + } + + public function testNestedResourceWithGenIdFalseHasNoIdProperty(): void + { + $r = self::createClient()->request('GET', '/gen_id_falsy'); + $this->assertJsonContains([ + 'aggregateRating' => ['ratingValue' => 2, 'ratingCount' => 3], + ]); + $this->assertArrayNotHasKey('@id', $r->toArray()['aggregateRating']); + } + + public function testGenIdFalseAppliesOnlyToConfiguredLevel(): void + { + $r = self::createClient()->request('GET', '/levelfirst/1'); + $res = $r->toArray(); + $this->assertArrayNotHasKey('@id', $res['levelSecond']); + $this->assertArrayHasKey('@id', $res['levelSecond'][0]['levelThird']); + } +} diff --git a/tests/Functional/JsonLd/HydraCollectionTest.php b/tests/Functional/JsonLd/HydraCollectionTest.php new file mode 100644 index 00000000000..e97add70b24 --- /dev/null +++ b/tests/Functional/JsonLd/HydraCollectionTest.php @@ -0,0 +1,205 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\CollectionNoPrefix; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\CollectionPagedResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\PaginationCapped; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class HydraCollectionTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [CollectionPagedResource::class, CollectionNoPrefix::class, PaginationCapped::class]; + } + + public function testFirstPageHasFirstThreeItemsAndNextLink(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/contexts/JsonLdCollectionPaged', $body['@context']); + $this->assertSame('/jsonld_collection_paged', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertSame(30, $body['hydra:totalItems']); + $this->assertCount(3, $body['hydra:member']); + $this->assertSame([1, 2, 3], array_column($body['hydra:member'], 'id')); + $this->assertSame('/jsonld_collection_paged?page=1', $body['hydra:view']['@id']); + $this->assertSame('hydra:PartialCollectionView', $body['hydra:view']['@type']); + $this->assertSame('/jsonld_collection_paged?page=1', $body['hydra:view']['hydra:first']); + $this->assertSame('/jsonld_collection_paged?page=10', $body['hydra:view']['hydra:last']); + $this->assertSame('/jsonld_collection_paged?page=2', $body['hydra:view']['hydra:next']); + } + + public function testMiddlePageHasPreviousAndNext(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged?page=7', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(3, $body['hydra:member']); + $this->assertSame([19, 20, 21], array_column($body['hydra:member'], 'id')); + $this->assertSame('/jsonld_collection_paged?page=6', $body['hydra:view']['hydra:previous']); + $this->assertSame('/jsonld_collection_paged?page=8', $body['hydra:view']['hydra:next']); + } + + public function testLastPageOmitsNext(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged?page=10', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame([28, 29, 30], array_column($body['hydra:member'], 'id')); + $this->assertSame('/jsonld_collection_paged?page=9', $body['hydra:view']['hydra:previous']); + $this->assertArrayNotHasKey('hydra:next', $body['hydra:view']); + } + + public function testPaginationDisabledExposesAllItems(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged?pagination=0', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame(30, $body['hydra:totalItems']); + $this->assertCount(30, $body['hydra:member']); + } + + public function testItemsPerPageOverridesDefault(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged?page=2&itemsPerPage=10', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(10, $body['hydra:member']); + $this->assertSame('/jsonld_collection_paged?itemsPerPage=10&page=1', $body['hydra:view']['hydra:first']); + $this->assertSame('/jsonld_collection_paged?itemsPerPage=10&page=3', $body['hydra:view']['hydra:last']); + $this->assertSame('/jsonld_collection_paged?itemsPerPage=10&page=1', $body['hydra:view']['hydra:previous']); + $this->assertSame('/jsonld_collection_paged?itemsPerPage=10&page=3', $body['hydra:view']['hydra:next']); + } + + public function testItemsPerPageZeroReturnsEmptyMember(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged?itemsPerPage=0', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(0, $body['hydra:member']); + } + + public function testFilterExactMatchByIdPreservesViewQueryString(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged?id=8', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(1, $body['hydra:member']); + $this->assertSame(8, $body['hydra:member'][0]['id']); + $this->assertSame('/jsonld_collection_paged?id=8', $body['hydra:view']['@id']); + } + + public function testFilterUrlEncodedValuePreservedInView(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged?id=%2Fdummies%2F8', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/jsonld_collection_paged?id=%2Fdummies%2F8', $body['hydra:view']['@id']); + } + + public function testFilterByEncodedNameValuePreservedInView(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged?name=Dummy%20%238', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(1, $body['hydra:member']); + $this->assertSame(8, $body['hydra:member'][0]['id']); + $this->assertSame('/jsonld_collection_paged?name=Dummy%20%238', $body['hydra:view']['@id']); + } + + public function testEmptyResultExposesEmptyMember(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged?id=999', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertSame(0, $body['hydra:totalItems']); + $this->assertCount(0, $body['hydra:member']); + } + + public function testPartialPaginationDropsFirstAndLast(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_paged?page=7&partial=1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('hydra:PartialCollectionView', $body['hydra:view']['@type']); + $this->assertArrayNotHasKey('hydra:first', $body['hydra:view']); + $this->assertArrayNotHasKey('hydra:last', $body['hydra:view']); + $this->assertArrayHasKey('hydra:next', $body['hydra:view']); + $this->assertArrayHasKey('hydra:previous', $body['hydra:view']); + } + + public function testCollectionWithoutHydraPrefix(): void + { + $response = self::createClient()->request('GET', '/jsonld_collection_no_prefix', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertArrayHasKey('totalItems', $body); + $this->assertArrayHasKey('member', $body); + $this->assertArrayNotHasKey('hydra:totalItems', $body); + $this->assertArrayNotHasKey('hydra:member', $body); + } + + public function testItemsPerPageZeroAndPageGreaterThanOneReturns400(): void + { + $response = self::createClient()->request('GET', '/jsonld_pagination_capped?itemsPerPage=0&page=2', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(400); + $body = $response->toArray(false); + $this->assertSame('Page should not be greater than 1 if limit is equal to 0', $body['detail']); + } + + public function testPaginationMaximumItemsPerPageCapsClientItemsPerPage(): void + { + $response = self::createClient()->request('GET', '/jsonld_pagination_capped?itemsPerPage=40', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertCount(30, $body['hydra:member']); + } +} diff --git a/tests/Functional/JsonLd/HydraDocsTest.php b/tests/Functional/JsonLd/HydraDocsTest.php new file mode 100644 index 00000000000..92bcd766405 --- /dev/null +++ b/tests/Functional/JsonLd/HydraDocsTest.php @@ -0,0 +1,210 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\HydraDocsDeprecated; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\HydraDocsRelated; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\HydraDocsResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class HydraDocsTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [HydraDocsResource::class, HydraDocsRelated::class, HydraDocsDeprecated::class]; + } + + public function testDocumentationLinkHeader(): void + { + $response = self::createClient()->request('GET', '/'); + $link = $response->getHeaders()['link'][0] ?? ''; + $this->assertStringContainsString('rel="http://www.w3.org/ns/hydra/core#apiDocumentation"', $link); + } + + public function testApiVocabularyShape(): void + { + $response = self::createClient()->request('GET', '/docs.jsonld'); + $this->assertResponseIsSuccessful(); + $this->assertSame('application/ld+json; charset=utf-8', $response->getHeaders()['content-type'][0]); + $body = $response->toArray(); + + $this->assertIsArray($body['@context']); + $vocab = $body['@context'][1] ?? null; + $this->assertIsArray($vocab); + $this->assertSame('http://localhost/docs.jsonld#', $vocab['@vocab']); + $this->assertSame(['@id' => 'rdfs:domain', '@type' => '@id'], $vocab['domain']); + $this->assertSame(['@id' => 'rdfs:range', '@type' => '@id'], $vocab['range']); + $this->assertSame(['@id' => 'rdfs:subClassOf', '@type' => '@id'], $vocab['subClassOf']); + + $this->assertSame('/docs.jsonld', $body['@id']); + $this->assertNotEmpty($body['hydra:title']); + $this->assertNotEmpty($body['hydra:description']); + $this->assertSame('/', $body['hydra:entrypoint']); + } + + public function testSupportedClassesIncludeRegisteredAndOmitNonResources(): void + { + $response = self::createClient()->request('GET', '/docs.jsonld'); + $body = $response->toArray(); + $titles = array_column($body['hydra:supportedClass'], 'hydra:title'); + $this->assertContains('Entrypoint', $titles); + $this->assertContains('JsonLdHydraDocs', $titles); + $this->assertContains('JsonLdHydraDocsRelated', $titles); + $this->assertNotContains('UnknownDummy', $titles); + $this->assertNotContains('HydraDocsResource', $titles, 'class FQCN should not leak when shortName is set'); + } + + public function testResourceClassMetadata(): void + { + $body = self::createClient()->request('GET', '/docs.jsonld')->toArray(); + $resource = $this->findClass($body['hydra:supportedClass'], 'JsonLdHydraDocs'); + $this->assertNotNull($resource); + $this->assertSame('#JsonLdHydraDocs', $resource['@id']); + $this->assertSame('hydra:Class', $resource['@type']); + $this->assertSame('JsonLdHydraDocs', $resource['hydra:title']); + $this->assertSame('A docs sample.', $resource['hydra:description']); + } + + public function testSubClassOfFromTypes(): void + { + $body = self::createClient()->request('GET', '/docs.jsonld')->toArray(); + $related = $this->findClass($body['hydra:supportedClass'], 'JsonLdHydraDocsRelated'); + $this->assertNotNull($related); + $this->assertSame('https://schema.org/Product', $related['subClassOf']); + } + + public function testPropertyMetadataReadableWritableRequired(): void + { + $body = self::createClient()->request('GET', '/docs.jsonld')->toArray(); + $resource = $this->findClass($body['hydra:supportedClass'], 'JsonLdHydraDocs'); + $name = $this->findProperty($resource, 'name'); + $this->assertNotNull($name); + $this->assertSame('hydra:SupportedProperty', $name['@type']); + $this->assertTrue($name['hydra:readable']); + $this->assertSame('https://schema.org/name', $name['hydra:property']['@id']); + $this->assertSame('rdf:Property', $name['hydra:property']['@type']); + $this->assertSame('name', $name['hydra:property']['label']); + $this->assertSame('#JsonLdHydraDocs', $name['hydra:property']['domain']); + $this->assertSame('xsd:string', $name['hydra:property']['range']); + $this->assertSame('name', $name['hydra:title']); + $this->assertSame('The doc resource name.', $name['hydra:description']); + } + + public function testRelationPropertyRangeAndCardinality(): void + { + $body = self::createClient()->request('GET', '/docs.jsonld')->toArray(); + $resource = $this->findClass($body['hydra:supportedClass'], 'JsonLdHydraDocs'); + + $related = $this->findProperty($resource, 'related'); + $this->assertNotNull($related); + $this->assertSame('#JsonLdHydraDocsRelated', $related['hydra:property']['range']); + $this->assertSame(1, $related['hydra:property']['owl:maxCardinality']); + + $relateds = $this->findProperty($resource, 'relateds'); + $this->assertNotNull($relateds); + $this->assertSame('#JsonLdHydraDocsRelated', $relateds['hydra:property']['range']); + $this->assertArrayNotHasKey('owl:maxCardinality', $relateds['hydra:property']); + } + + public function testOperationMetadata(): void + { + $body = self::createClient()->request('GET', '/docs.jsonld')->toArray(); + $resource = $this->findClass($body['hydra:supportedClass'], 'JsonLdHydraDocs'); + + $get = $this->findOperation($resource, 'GET'); + $this->assertNotNull($get); + $this->assertContains('hydra:Operation', (array) $get['@type']); + $this->assertContains('schema:FindAction', (array) $get['@type']); + $this->assertSame('GET', $get['hydra:method']); + $this->assertSame('getJsonLdHydraDocs', $get['hydra:title']); + $this->assertSame('Retrieves a JsonLdHydraDocs resource.', $get['hydra:description']); + $this->assertSame('JsonLdHydraDocs', $get['returns']); + + $put = $this->findOperation($resource, 'PUT'); + $this->assertNotNull($put); + $this->assertSame('putJsonLdHydraDocs', $put['hydra:title']); + $this->assertSame('Replaces the JsonLdHydraDocs resource.', $put['hydra:description']); + + $delete = $this->findOperation($resource, 'DELETE'); + $this->assertNotNull($delete); + $this->assertSame('deleteJsonLdHydraDocs', $delete['hydra:title']); + $this->assertSame('Deletes the JsonLdHydraDocs resource.', $delete['hydra:description']); + $this->assertSame('owl:Nothing', $delete['returns']); + } + + public function testDeprecationOnResourceAndProperty(): void + { + $body = self::createClient()->request('GET', '/docs.jsonld')->toArray(); + $deprecated = $this->findClass($body['hydra:supportedClass'], 'JsonLdHydraDocsDeprecated'); + $this->assertNotNull($deprecated); + $this->assertTrue($deprecated['owl:deprecated']); + + $deprecatedField = $this->findProperty($deprecated, 'deprecatedField'); + $this->assertNotNull($deprecatedField); + $this->assertTrue($deprecatedField['hydra:property']['owl:deprecated']); + + $entrypoint = $this->findClass($body['hydra:supportedClass'], 'Entrypoint'); + $this->assertNotNull($entrypoint); + $deprecatedEntrypointProp = $this->findProperty($entrypoint, 'getJsonLdHydraDocsDeprecatedCollection'); + $this->assertNotNull($deprecatedEntrypointProp, 'deprecation on resource must propagate to entrypoint property'); + $this->assertTrue($deprecatedEntrypointProp['owl:deprecated']); + } + + /** + * @param list> $supportedClass + */ + private function findClass(array $supportedClass, string $title): ?array + { + foreach ($supportedClass as $cls) { + if (($cls['hydra:title'] ?? null) === $title) { + return $cls; + } + } + + return null; + } + + /** + * @param array $resource + */ + private function findProperty(array $resource, string $name): ?array + { + foreach ($resource['hydra:supportedProperty'] ?? [] as $prop) { + if (($prop['hydra:title'] ?? null) === $name) { + return $prop; + } + } + + return null; + } + + /** + * @param array $resource + */ + private function findOperation(array $resource, string $method): ?array + { + foreach ($resource['hydra:supportedOperation'] ?? [] as $op) { + if (($op['hydra:method'] ?? null) === $method) { + return $op; + } + } + + return null; + } +} diff --git a/tests/Functional/JsonLd/HydraErrorTest.php b/tests/Functional/JsonLd/HydraErrorTest.php new file mode 100644 index 00000000000..aadfdf41f6e --- /dev/null +++ b/tests/Functional/JsonLd/HydraErrorTest.php @@ -0,0 +1,158 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\HydraErrorResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class HydraErrorTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [HydraErrorResource::class]; + } + + public function testBadRequestErrorIsRfc7807AndHydraCompliant(): void + { + $response = self::createClient()->request('POST', '/jsonld_hydra_errors_bad_request', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => new \stdClass(), + ]); + $this->assertResponseStatusCodeSame(400); + $headers = $response->getHeaders(false); + $this->assertSame('application/problem+json; charset=utf-8', $headers['content-type'][0]); + $this->assertStringContainsString( + '; rel="http://www.w3.org/ns/json-ld#error"', + implode(',', $headers['link']), + ); + $body = $response->toArray(false); + $this->assertArrayHasKey('@context', $body); + $this->assertArrayHasKey('type', $body); + $this->assertSame('An error occurred', $body['hydra:title']); + $this->assertArrayHasKey('detail', $body); + $this->assertArrayHasKey('hydra:description', $body); + $this->assertArrayHasKey('trace', $body); + $this->assertArrayHasKey('status', $body); + $this->assertArrayNotHasKey('title', $body); + $this->assertArrayNotHasKey('description', $body); + } + + public function testValidationErrorReturnsConstraintViolationList(): void + { + $response = self::createClient()->request('POST', '/jsonld_hydra_errors_validation', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => new \stdClass(), + ]); + $this->assertResponseStatusCodeSame(422); + $this->assertSame('application/problem+json; charset=utf-8', $response->getHeaders(false)['content-type'][0]); + $this->assertJsonContains([ + '@context' => '/contexts/ConstraintViolation', + '@id' => '/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3', + '@type' => 'ConstraintViolation', + 'status' => 422, + 'violations' => [ + [ + 'propertyPath' => 'name', + 'message' => 'This value should not be blank.', + 'code' => 'c1051bb4-d103-4f74-8988-acbcafc7fdc3', + ], + ], + 'detail' => 'name: This value should not be blank.', + 'hydra:title' => 'An error occurred', + 'hydra:description' => 'name: This value should not be blank.', + 'type' => '/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3', + ]); + } + + public function testNotFoundReturnsHydraError(): void + { + $response = self::createClient()->request('POST', '/does_not_exist', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(404); + $headers = $response->getHeaders(false); + $this->assertSame('application/problem+json; charset=utf-8', $headers['content-type'][0]); + $this->assertStringContainsString( + '; rel="http://www.w3.org/ns/json-ld#error"', + implode(',', $headers['link']), + ); + $body = $response->toArray(false); + $this->assertArrayHasKey('@context', $body); + $this->assertArrayHasKey('type', $body); + $this->assertSame('An error occurred', $body['hydra:title']); + $this->assertArrayHasKey('detail', $body); + $this->assertArrayNotHasKey('description', $body); + } + + public function testMethodNotAllowedReturnsHydraError(): void + { + $response = self::createClient()->request('POST', '/jsonld_hydra_errors_patch_only', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => new \stdClass(), + ]); + $this->assertResponseStatusCodeSame(405); + $headers = $response->getHeaders(false); + $this->assertSame('application/problem+json; charset=utf-8', $headers['content-type'][0]); + $this->assertStringContainsString( + '; rel="http://www.w3.org/ns/json-ld#error"', + implode(',', $headers['link']), + ); + $body = $response->toArray(false); + $this->assertArrayHasKey('@context', $body); + $this->assertArrayHasKey('type', $body); + $this->assertSame('An error occurred', $body['hydra:title']); + $this->assertArrayHasKey('detail', $body); + $this->assertArrayNotHasKey('description', $body); + } + + public function testNoHydraPrefixWhenDisabled(): void + { + $response = self::createClient()->request('POST', '/jsonld_hydra_errors_no_prefix', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => new \stdClass(), + ]); + $this->assertResponseStatusCodeSame(400); + $headers = $response->getHeaders(false); + $this->assertSame('application/problem+json; charset=utf-8', $headers['content-type'][0]); + $this->assertStringContainsString( + '; rel="http://www.w3.org/ns/json-ld#error"', + implode(',', $headers['link']), + ); + $body = $response->toArray(false); + $this->assertArrayHasKey('@context', $body); + $this->assertArrayHasKey('type', $body); + $this->assertSame('An error occurred', $body['hydra:title']); + $this->assertArrayHasKey('detail', $body); + $this->assertArrayHasKey('trace', $body); + $this->assertArrayHasKey('status', $body); + $this->assertArrayNotHasKey('description', $body); + } +} diff --git a/tests/Functional/HydraTest.php b/tests/Functional/JsonLd/HydraHideFromDocsTest.php similarity index 88% rename from tests/Functional/HydraTest.php rename to tests/Functional/JsonLd/HydraHideFromDocsTest.php index 5fc068d23dc..c8806f883dd 100644 --- a/tests/Functional/HydraTest.php +++ b/tests/Functional/JsonLd/HydraHideFromDocsTest.php @@ -11,14 +11,14 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Functional; +namespace ApiPlatform\Tests\Functional\JsonLd; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\HideHydraClass; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\HideHydraOperation; use ApiPlatform\Tests\SetupClassResourcesTrait; -class HydraTest extends ApiTestCase +class HydraHideFromDocsTest extends ApiTestCase { use SetupClassResourcesTrait; @@ -32,10 +32,7 @@ public static function getResources(): array return [HideHydraOperation::class, HideHydraClass::class]; } - /** - * The input DTO denormalizes an existing Doctrine entity. - */ - public function testIssue6465(): void + public function testHideHydraClassAndOperationFromDocsAndEntrypoint(): void { $response = self::createClient()->request('GET', 'docs', [ 'headers' => ['accept' => 'application/ld+json'], diff --git a/tests/Functional/JsonLd/InheritanceIriTest.php b/tests/Functional/JsonLd/InheritanceIriTest.php new file mode 100644 index 00000000000..1e1e415d610 --- /dev/null +++ b/tests/Functional/JsonLd/InheritanceIriTest.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5438\Contractor; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5438\Employee; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5438\Person; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class InheritanceIriTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Person::class, Contractor::class, Employee::class]; + } + + public function testCollectionItemsUseConcreteSubtypeIris(): void + { + $response = self::createClient()->request('GET', '/people_5438', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/contexts/People5438', $body['@context']); + $this->assertSame('/people_5438', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertSame(2, $body['hydra:totalItems']); + + $this->assertSame([ + [ + '@id' => '/contractor_5438/1', + '@type' => 'Contractor', + 'id' => 1, + 'name' => 'a', + ], + [ + '@id' => '/employee_5438/2', + '@type' => 'Employee', + 'id' => 2, + 'name' => 'b', + ], + ], $body['hydra:member']); + } +} diff --git a/tests/Functional/JsonLd/InitializeInputTest.php b/tests/Functional/JsonLd/InitializeInputTest.php new file mode 100644 index 00000000000..50f3b374a8d --- /dev/null +++ b/tests/Functional/JsonLd/InitializeInputTest.php @@ -0,0 +1,65 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\InitializeInput; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class InitializeInputTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [InitializeInput::class]; + } + + public function testPutPreservesManagerFromPreviousData(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([InitializeInput::class]); + + $manager = $this->getManager(); + $entity = new InitializeInput(); + $entity->id = 1; + $entity->manager = 'Orwell'; + $entity->name = '1984'; + $manager->persist($entity); + $manager->flush(); + + $response = self::createClient()->request('PUT', '/initialize_inputs/1', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['name' => 'La peste'], + ]); + $this->assertResponseStatusCodeSame(200); + $body = $response->toArray(); + $this->assertSame('/contexts/InitializeInput', $body['@context']); + $this->assertSame('/initialize_inputs/1', $body['@id']); + $this->assertSame('InitializeInput', $body['@type']); + $this->assertSame(1, $body['id']); + $this->assertSame('Orwell', $body['manager']); + $this->assertSame('La peste', $body['name']); + } +} diff --git a/tests/Functional/JsonLd/InputDtoIriDenormalizationTest.php b/tests/Functional/JsonLd/InputDtoIriDenormalizationTest.php new file mode 100644 index 00000000000..4d31a832a83 --- /dev/null +++ b/tests/Functional/JsonLd/InputDtoIriDenormalizationTest.php @@ -0,0 +1,69 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6465\Bar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6465\Foo; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class InputDtoIriDenormalizationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Foo::class, Bar::class]; + } + + protected function setUp(): void + { + self::bootKernel(); + + if ($this->isMongoDB()) { + $this->markTestSkipped('This test uses Doctrine ORM entities without MongoDB equivalents.'); + } + + $this->recreateSchema([Foo::class, Bar::class]); + + $manager = $this->getManager(); + $foo = new Foo(); + $foo->title = 'Foo'; + $manager->persist($foo); + $bar = new Bar(); + $bar->title = 'Bar one'; + $manager->persist($bar); + $bar2 = new Bar(); + $bar2->title = 'Bar two'; + $manager->persist($bar2); + $manager->flush(); + } + + public function testInputDtoDenormalizesEntityFromIri(): void + { + $response = self::createClient()->request('POST', '/foo/1/validate', [ + 'json' => ['bar' => '/bar6465s/2'], + ]); + + $res = $response->toArray(); + $this->assertEquals('Bar two', $res['title']); + } +} diff --git a/tests/Functional/JsonLd/InputOutputDtoTest.php b/tests/Functional/JsonLd/InputOutputDtoTest.php new file mode 100644 index 00000000000..e52474d0f82 --- /dev/null +++ b/tests/Functional/JsonLd/InputOutputDtoTest.php @@ -0,0 +1,277 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\CustomInputResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\CustomOutputResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\DummyCollectionDto; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\DummyFooCollectionDto; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\DummyIdCollectionDto; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\InputOutputResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\NoInputResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\PostNoOutputResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UserResource; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class InputOutputDtoTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + CustomInputResource::class, + CustomOutputResource::class, + InputOutputResource::class, + NoInputResource::class, + PostNoOutputResource::class, + DummyCollectionDto::class, + DummyFooCollectionDto::class, + DummyIdCollectionDto::class, + UserResource::class, + ]; + } + + public function testCreateResourceWithCustomInput(): void + { + $response = self::createClient()->request('POST', '/jsonld_custom_inputs', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['foo' => 'test', 'bar' => 1], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertSame('application/ld+json; charset=utf-8', $response->getHeaders()['content-type'][0]); + $this->assertJsonContains([ + '@context' => '/contexts/JsonLdCustomInput', + '@id' => '/jsonld_custom_inputs/1', + '@type' => 'JsonLdCustomInput', + 'lorem' => 'test', + 'ipsum' => '1', + 'id' => 1, + ]); + } + + public function testCustomInputRejectsBadType(): void + { + $response = self::createClient()->request('POST', '/jsonld_custom_inputs', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['foo' => 'test', 'bar' => 'not-an-int'], + ]); + $this->assertResponseStatusCodeSame(400); + $body = $response->toArray(false); + $this->assertSame('The input data is misformatted.', $body['detail']); + } + + public function testItemWithCustomOutput(): void + { + $response = self::createClient()->request('GET', '/jsonld_custom_outputs/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('CustomOutputDto', $body['@type']); + $this->assertSame('test', $body['foo']); + $this->assertSame(1, $body['bar']); + $this->assertArrayHasKey('@context', $body); + } + + public function testCollectionWithCustomOutput(): void + { + $response = self::createClient()->request('GET', '/jsonld_custom_outputs', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/contexts/JsonLdCustomOutput', $body['@context']); + $this->assertSame('/jsonld_custom_outputs', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertSame(2, $body['hydra:totalItems']); + $this->assertCount(2, $body['hydra:member']); + $this->assertSame('CustomOutputDto', $body['hydra:member'][0]['@type']); + } + + public function testPostWithoutOutputReturns204(): void + { + $response = self::createClient()->request('POST', '/jsonld_post_no_output', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['lorem' => 'a', 'ipsum' => 'b'], + ]); + $this->assertResponseStatusCodeSame(204); + $this->assertEmpty($response->getContent()); + } + + public function testInputOutputCycle(): void + { + $response = self::createClient()->request('POST', '/jsonld_input_outputs', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['foo' => 'test', 'bar' => 1], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('InputOutputDto', $body['@type']); + $this->assertSame(1, $body['id']); + $this->assertSame(1, $body['baz']); + $this->assertSame('test', $body['bat']); + $this->assertSame([], $body['relatedDummies']); + + $response = self::createClient()->request('PUT', '/jsonld_input_outputs/1', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['foo' => 'test', 'bar' => 2], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('InputOutputDto', $body['@type']); + $this->assertSame(1, $body['id']); + $this->assertSame(2, $body['baz']); + $this->assertSame('test', $body['bat']); + } + + public function testCreateNoInputResource(): void + { + if ($_SERVER['USE_SYMFONY_LISTENERS'] ?? false) { + $this->markTestSkipped('PlaceholderAction cannot resolve $data when input:false in event-listener mode.'); + } + + $response = self::createClient()->request('POST', '/jsonld_no_inputs', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertSame('application/ld+json; charset=utf-8', $response->getHeaders()['content-type'][0]); + $body = $response->toArray(); + $this->assertSame('JsonLdNoInput', $body['@type']); + $this->assertSame(1, $body['id']); + $this->assertSame(1, $body['baz']); + $this->assertSame('test', $body['bat']); + } + + public function testUpdateNoInputResource(): void + { + $response = self::createClient()->request('POST', '/jsonld_no_inputs/1/double_bat', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('JsonLdNoInput', $body['@type']); + $this->assertSame('testtest', $body['bat']); + } + + public function testCollectionWithCustomOutputAndNoIdentifierUsesGenid(): void + { + $response = self::createClient()->request('GET', '/jsonld_dummy_collection_dtos', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/contexts/JsonLdDummyCollectionDto', $body['@context']); + $this->assertSame('/jsonld_dummy_collection_dtos', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertCount(2, $body['hydra:member']); + $this->assertSame(2, $body['hydra:totalItems']); + foreach ($body['hydra:member'] as $member) { + $this->assertStringStartsWith('/.well-known/genid/', $member['@id']); + $this->assertSame('DummyCollectionDtoOutput', $member['@type']); + $this->assertSame('foo', $member['foo']); + $this->assertIsInt($member['bar']); + } + } + + public function testCollectionWithItemUriTemplateUsesIt(): void + { + $response = self::createClient()->request('GET', '/jsonld_dummy_foo_collection_dtos', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/contexts/JsonLdDummyFooCollectionDto', $body['@context']); + $this->assertSame('/jsonld_dummy_foo_collection_dtos', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertCount(2, $body['hydra:member']); + foreach ($body['hydra:member'] as $member) { + $this->assertStringContainsString('/jsonld_dummy_foos/bar', $member['@id']); + $this->assertSame('JsonLdDummyFooCollectionDto', $member['@type']); + } + } + + public function testCollectionWithCustomOutputResourceWithIdentifierUsesGenid(): void + { + $response = self::createClient()->request('GET', '/jsonld_dummy_id_collection_dtos', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/contexts/JsonLdDummyIdCollectionDto', $body['@context']); + $this->assertCount(2, $body['hydra:member']); + foreach ($body['hydra:member'] as $member) { + $this->assertStringStartsWith('/.well-known/genid/', $member['@id']); + $this->assertSame('DummyIdCollectionDtoOutput', $member['@type']); + $this->assertArrayHasKey('id', $member); + $this->assertArrayHasKey('foo', $member); + $this->assertArrayHasKey('bar', $member); + } + } + + public function testResetPasswordViaInputDto(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $response = self::createClient()->request('POST', '/user-reset-password', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['email' => 'user@example.com'], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertSame('application/ld+json; charset=utf-8', $response->getHeaders()['content-type'][0]); + $body = $response->toArray(); + $this->assertSame('user@example.com', $body['email']); + } + + public function testResetPasswordWithInvalidEmailReturns422(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $response = self::createClient()->request('POST', '/user-reset-password', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['email' => 'this is not an email'], + ]); + $this->assertResponseStatusCodeSame(422); + } +} diff --git a/tests/Functional/JsonLd/InterfaceAsResourceTest.php b/tests/Functional/JsonLd/InterfaceAsResourceTest.php new file mode 100644 index 00000000000..2875224c575 --- /dev/null +++ b/tests/Functional/JsonLd/InterfaceAsResourceTest.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\Tests\Functional\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\InterfaceTaxon; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\InterfaceTaxonProduct; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class InterfaceAsResourceTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [InterfaceTaxon::class, InterfaceTaxonProduct::class]; + } + + public function testRetrieveTaxonViaInterface(): void + { + $response = self::createClient()->request('GET', '/jsonld_interface_taxa/WONDERFUL_TAXON', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertSame([ + '@context' => '/contexts/JsonLdInterfaceTaxon', + '@id' => '/jsonld_interface_taxa/WONDERFUL_TAXON', + '@type' => 'JsonLdInterfaceTaxon', + 'code' => 'WONDERFUL_TAXON', + ], $response->toArray()); + } + + public function testRetrieveProductWithMainTaxonReferencesInterfaceResource(): void + { + $response = self::createClient()->request('GET', '/jsonld_interface_taxon_products/GREAT_PRODUCT', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('JsonLdInterfaceTaxonProduct', $body['@type']); + $this->assertSame('GREAT_PRODUCT', $body['code']); + $this->assertIsArray($body['mainTaxon']); + $this->assertSame('/jsonld_interface_taxa/WONDERFUL_TAXON', $body['mainTaxon']['@id']); + $this->assertSame('JsonLdInterfaceTaxon', $body['mainTaxon']['@type']); + $this->assertSame('WONDERFUL_TAXON', $body['mainTaxon']['code']); + } +} diff --git a/tests/Functional/JsonLd/InterfaceDtoOutputTest.php b/tests/Functional/JsonLd/InterfaceDtoOutputTest.php new file mode 100644 index 00000000000..b34cb9a602d --- /dev/null +++ b/tests/Functional/JsonLd/InterfaceDtoOutputTest.php @@ -0,0 +1,44 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\InterfaceDtoOutputResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class InterfaceDtoOutputTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [InterfaceDtoOutputResource::class]; + } + + public function testCollectionExposesOnlyInterfaceProperties(): void + { + $response = self::createClient()->request('GET', '/jsonld_interface_dto_outputs', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $member = $body['hydra:member'] ?? $body['member']; + $this->assertArrayHasKey('@id', $member[0]); + $this->assertArrayHasKey('@type', $member[0]); + $this->assertArrayHasKey('name', $member[0]); + $this->assertArrayNotHasKey('city', $member[0]); + } +} diff --git a/tests/Functional/JsonLd/IriOnlyTest.php b/tests/Functional/JsonLd/IriOnlyTest.php new file mode 100644 index 00000000000..723d24b3d16 --- /dev/null +++ b/tests/Functional/JsonLd/IriOnlyTest.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\Functional\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\IriOnlyResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; + +final class IriOnlyTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [IriOnlyResource::class]; + } + + #[DataProvider('contextUris')] + public function testContextEndpointReturnsIriOnlyContext(string $uri): void + { + $response = self::createClient()->request('GET', $uri); + $this->assertResponseIsSuccessful(); + $this->assertSame('application/ld+json; charset=utf-8', $response->getHeaders()['content-type'][0]); + $this->assertSame([ + '@context' => [ + '@vocab' => 'http://localhost/docs.jsonld#', + 'hydra' => 'http://www.w3.org/ns/hydra/core#', + 'hydra:member' => ['@type' => '@id'], + ], + ], $response->toArray()); + } + + public static function contextUris(): array + { + return [ + ['/contexts/JsonLdIriOnlyResource'], + ['/contexts/JsonLdIriOnlyResource.jsonld'], + ]; + } + + public function testContextEndpointWithJsonExtensionReturns404(): void + { + self::createClient()->request('GET', '/contexts/JsonLdIriOnlyResource.json'); + $this->assertResponseStatusCodeSame(404); + } + + public function testCollectionReturnsIriOnlyMembers(): void + { + $response = self::createClient()->request('GET', '/jsonld_iri_only_resources'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame([ + '@vocab' => 'http://localhost/docs.jsonld#', + 'hydra' => 'http://www.w3.org/ns/hydra/core#', + 'hydra:member' => ['@type' => '@id'], + ], $body['@context']); + $this->assertSame('/jsonld_iri_only_resources', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertSame([ + '/jsonld_iri_only_resources/1', + '/jsonld_iri_only_resources/2', + '/jsonld_iri_only_resources/3', + ], $body['hydra:member']); + $this->assertSame(3, $body['hydra:totalItems']); + } +} diff --git a/tests/Functional/JsonLd/ItemUriTemplateCollectionTest.php b/tests/Functional/JsonLd/ItemUriTemplateCollectionTest.php new file mode 100644 index 00000000000..0f6f4df5ae1 --- /dev/null +++ b/tests/Functional/JsonLd/ItemUriTemplateCollectionTest.php @@ -0,0 +1,112 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ItemUriTemplateWithCollection\Recipe; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ItemUriTemplateWithCollection\RecipeCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Recipe as EntityRecipe; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class ItemUriTemplateCollectionTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Recipe::class, RecipeCollection::class]; + } + + public function testCollectionItemsExposeItemUriTemplateAsId(): void + { + self::createClient()->request('GET', '/item_uri_template_recipes'); + $this->assertResponseIsSuccessful(); + + $this->assertJsonContains([ + 'member' => [ + [ + '@type' => 'Recipe', + '@id' => '/item_uri_template_recipes/1', + 'name' => 'Dummy Recipe', + ], + [ + '@type' => 'Recipe', + '@id' => '/item_uri_template_recipes/2', + 'name' => 'Dummy Recipe 2', + ], + ], + ]); + } + + public function testItemUriTemplateAppliesWhenSourceIsStateOption(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([EntityRecipe::class]); + + $manager = $this->getManager(); + for ($i = 0; $i < 10; ++$i) { + $recipe = new EntityRecipe(); + $recipe->name = "Recipe $i"; + $recipe->description = "Description of recipe $i"; + $recipe->author = "Author $i"; + $recipe->recipeIngredient = [ + "Ingredient 1 for recipe $i", + "Ingredient 2 for recipe $i", + ]; + $recipe->recipeInstructions = "Instructions for recipe $i"; + $recipe->prepTime = '10 minutes'; + $recipe->cookTime = '20 minutes'; + $recipe->totalTime = '30 minutes'; + $recipe->recipeCategory = "Category $i"; + $recipe->recipeCuisine = "Cuisine $i"; + $recipe->suitableForDiet = "Diet $i"; + + $manager->persist($recipe); + } + $manager->flush(); + + self::createClient()->request('GET', '/item_uri_template_recipes_state_option'); + $this->assertResponseIsSuccessful(); + + $this->assertJsonContains([ + 'member' => [ + [ + '@type' => 'Recipe', + '@id' => '/item_uri_template_recipes_state_option/1', + 'name' => 'Recipe 0', + ], + [ + '@type' => 'Recipe', + '@id' => '/item_uri_template_recipes_state_option/2', + 'name' => 'Recipe 1', + ], + [ + '@type' => 'Recipe', + '@id' => '/item_uri_template_recipes_state_option/3', + 'name' => 'Recipe 2', + ], + ], + ]); + } +} diff --git a/tests/Functional/JsonLd/ItemUriTemplateHydraTest.php b/tests/Functional/JsonLd/ItemUriTemplateHydraTest.php new file mode 100644 index 00000000000..3646a55397e --- /dev/null +++ b/tests/Functional/JsonLd/ItemUriTemplateHydraTest.php @@ -0,0 +1,176 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\UriTemplateCar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CollectionReferencingItem; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5662\Book; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5662\Review; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ItemReferencedInCollection; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ItemUriTemplateHydraTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + UriTemplateCar::class, + CollectionReferencingItem::class, + ItemReferencedInCollection::class, + Book::class, + Review::class, + ]; + } + + public function testGetCollectionDerivesItemIriFromFirstGetOperation(): void + { + $response = self::createClient()->request('GET', '/jsonld_uri_template_cars', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/contexts/JsonLdUriTemplateCar', $body['@context']); + $this->assertSame('/jsonld_uri_template_cars', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertCount(2, $body['hydra:member']); + foreach ($body['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('#^/jsonld_uri_template_cars/.+$#', $member['@id']); + $this->assertSame('JsonLdUriTemplateCar', $member['@type']); + } + } + + public function testGetCollectionWithItemUriTemplateUsesIt(): void + { + $response = self::createClient()->request('GET', '/jsonld_uri_template_brands/renault/cars', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/jsonld_uri_template_brands/renault/cars', $body['@id']); + foreach ($body['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('#^/jsonld_uri_template_brands/renault/cars/.+$#', $member['@id']); + } + } + + public function testPostWithoutItemUriTemplateUsesFirstGetOperation(): void + { + $response = self::createClient()->request('POST', '/jsonld_uri_template_cars', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/json', + ], + 'json' => ['owner' => 'Vincent'], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertMatchesRegularExpression('#^/jsonld_uri_template_cars/.+$#', $body['@id']); + $this->assertSame('JsonLdUriTemplateCar', $body['@type']); + } + + public function testPostWithItemUriTemplateUsesIt(): void + { + $response = self::createClient()->request('POST', '/jsonld_uri_template_brands/renault/cars', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/json', + ], + 'json' => ['owner' => 'Vincent'], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertMatchesRegularExpression('#^/jsonld_uri_template_brands/renault/cars/.+$#', $body['@id']); + } + + public function testCollectionReferencingAnotherResource(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $response = self::createClient()->request('GET', '/item_referenced_in_collection', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@context' => '/contexts/CollectionReferencingItem', + '@id' => '/item_referenced_in_collection', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + ['@id' => '/item_referenced_in_collection/a', '@type' => 'ItemReferencedInCollection', 'id' => 'a', 'name' => 'hello'], + ['@id' => '/item_referenced_in_collection/b', '@type' => 'ItemReferencedInCollection', 'id' => 'b', 'name' => 'you'], + ], + 'hydra:totalItems' => 2, + ]); + } + + public function testCollectionReferencingItemUriTemplate(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $response = self::createClient()->request('GET', '/issue5662/books/a/reviews', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/contexts/Review', $body['@context']); + $this->assertSame('/issue5662/books/a/reviews', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertSame(2, $body['hydra:totalItems']); + $this->assertSame('/issue5662/books/a/reviews/1', $body['hydra:member'][0]['@id']); + $this->assertSame('/issue5662/books/b/reviews/2', $body['hydra:member'][1]['@id']); + } + + public function testCollectionReferencingInvalidItemUriTemplateFallsBackToCollectionUri(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $response = self::createClient()->request('GET', '/issue5662/admin/reviews', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/issue5662/admin/reviews', $body['@id']); + $this->assertSame('/issue5662/admin/reviews/1', $body['hydra:member'][0]['@id']); + $this->assertSame('/issue5662/admin/reviews/2', $body['hydra:member'][1]['@id']); + } + + public function testPostWithItemUriTemplateGeneratesIriFromTemplate(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $response = self::createClient()->request('POST', '/issue5662/books/a/reviews', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['body' => 'Good book'], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('/issue5662/books/a/reviews/0', $body['@id']); + } +} diff --git a/tests/Functional/ItemUriTemplateTest.php b/tests/Functional/JsonLd/ItemUriTemplateNotFoundTest.php similarity index 84% rename from tests/Functional/ItemUriTemplateTest.php rename to tests/Functional/JsonLd/ItemUriTemplateNotFoundTest.php index ac4a6ea563f..b40907860c1 100644 --- a/tests/Functional/ItemUriTemplateTest.php +++ b/tests/Functional/JsonLd/ItemUriTemplateNotFoundTest.php @@ -11,13 +11,13 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Functional; +namespace ApiPlatform\Tests\Functional\JsonLd; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6718\Organization; use ApiPlatform\Tests\SetupClassResourcesTrait; -class ItemUriTemplateTest extends ApiTestCase +class ItemUriTemplateNotFoundTest extends ApiTestCase { use SetupClassResourcesTrait; @@ -31,7 +31,7 @@ public static function getResources(): array return [Organization::class]; } - public function testIssue6718(): void + public function testNotFoundOnInvalidItemUriTemplateRelation(): void { self::createClient()->request('GET', '/6718_users/1/organisation', [ 'headers' => ['accept' => 'application/ld+json'], diff --git a/tests/Functional/JsonLd/JsonSerializableTest.php b/tests/Functional/JsonLd/JsonSerializableTest.php new file mode 100644 index 00000000000..f859f9a562a --- /dev/null +++ b/tests/Functional/JsonLd/JsonSerializableTest.php @@ -0,0 +1,67 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\JsonSerializableResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class JsonSerializableTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [JsonSerializableResource::class]; + } + + public function testCreateJsonSerializableResource(): void + { + $response = self::createClient()->request('POST', '/jsonld_json_serializables', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => [ + 'contentType' => 'homepage', + 'fieldValues' => ['title' => 'Sample title'], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertSame([ + '@context' => '/contexts/JsonLdJsonSerializable', + '@id' => '/jsonld_json_serializables/1', + '@type' => 'JsonLdJsonSerializable', + 'id' => 1, + 'contentType' => 'homepage', + 'fieldValues' => ['title' => 'Sample title'], + 'status' => ['key' => 'DRAFT', 'value' => 'draft'], + ], $response->toArray()); + } + + public function testGetJsonSerializableResource(): void + { + $response = self::createClient()->request('GET', '/jsonld_json_serializables/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('/contexts/JsonLdJsonSerializable', $body['@context']); + $this->assertSame('/jsonld_json_serializables/1', $body['@id']); + $this->assertSame('JsonLdJsonSerializable', $body['@type']); + $this->assertSame(['key' => 'DRAFT', 'value' => 'draft'], $body['status']); + } +} diff --git a/tests/Functional/LinkedDataPlatformTest.php b/tests/Functional/JsonLd/LinkedDataPlatformTest.php similarity index 98% rename from tests/Functional/LinkedDataPlatformTest.php rename to tests/Functional/JsonLd/LinkedDataPlatformTest.php index 7a05b2cfddb..5ad112b61c3 100644 --- a/tests/Functional/LinkedDataPlatformTest.php +++ b/tests/Functional/JsonLd/LinkedDataPlatformTest.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Functional; +namespace ApiPlatform\Tests\Functional\JsonLd; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\DummyGetPostDeleteOperation; diff --git a/tests/Functional/JsonLd/MaxDepthTest.php b/tests/Functional/JsonLd/MaxDepthTest.php new file mode 100644 index 00000000000..c5b0501475e --- /dev/null +++ b/tests/Functional/JsonLd/MaxDepthTest.php @@ -0,0 +1,69 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\MaxDepthResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class MaxDepthTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [MaxDepthResource::class]; + } + + public function testFirstLevelChildIsExposed(): void + { + $response = self::createClient()->request('POST', '/jsonld_max_depth_resources', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => [ + 'name' => 'level 1', + 'child' => ['name' => 'level 2'], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertArrayHasKey('child', $body); + $this->assertSame('level 2', $body['child']['name']); + } + + public function testSecondLevelChildIsTruncatedByMaxDepth(): void + { + $response = self::createClient()->request('POST', '/jsonld_max_depth_resources', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => [ + 'name' => 'level 1', + 'child' => [ + 'name' => 'level 2', + 'child' => ['name' => 'level 3'], + ], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('level 2', $body['child']['name']); + $this->assertArrayNotHasKey('child', $body['child']); + } +} diff --git a/tests/Functional/JsonLd/MessengerTest.php b/tests/Functional/JsonLd/MessengerTest.php new file mode 100644 index 00000000000..0a3f0c7e560 --- /dev/null +++ b/tests/Functional/JsonLd/MessengerTest.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\Functional\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MessengerWithInput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MessengerWithResponse; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class MessengerTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [MessengerWithInput::class, MessengerWithResponse::class]; + } + + public function testPostMessengerWithSynchronousResultReturnsLdPayload(): void + { + $response = self::createClient()->request('POST', '/messenger_with_inputs', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['var' => 'test'], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $body = $response->toArray(); + $this->assertSame('/contexts/MessengerWithInput', $body['@context']); + $this->assertSame('/messenger_with_inputs/1', $body['@id']); + $this->assertSame('MessengerWithInput', $body['@type']); + $this->assertSame(1, $body['id']); + $this->assertSame('test', $body['name']); + } + + public function testPostMessengerWithResponseHandlerReturnsRawResponse(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $response = self::createClient()->request('POST', '/messenger_with_responses', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['var' => 'test'], + ]); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + $this->assertSame(['data' => 123], json_decode($response->getContent(), true)); + } + + private function isMongoDB(): bool + { + return 'mongodb' === static::getContainer()->getParameter('kernel.environment'); + } +} diff --git a/tests/Functional/JsonLd/MultiResourceContextTest.php b/tests/Functional/JsonLd/MultiResourceContextTest.php new file mode 100644 index 00000000000..ed729866cb2 --- /dev/null +++ b/tests/Functional/JsonLd/MultiResourceContextTest.php @@ -0,0 +1,67 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiResourceEntity; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class MultiResourceContextTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [MultiResourceEntity::class]; + } + + protected function setUp(): void + { + self::bootKernel(); + + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([MultiResourceEntity::class]); + + $manager = $this->getManager(); + $multi = new MultiResourceEntity(); + $multi->title = 'Multi Resource'; + $manager->persist($multi); + $manager->flush(); + } + + public function testContextUsesShortNameForCurrentResourceVariant(): void + { + $response = self::createClient()->request('GET', '/multi_resources'); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@context' => '/contexts/MultiResource', + ]); + + $response = self::createClient()->request('GET', '/admin/multi_resources'); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@context' => '/contexts/AdminMultiResource', + ]); + } +} diff --git a/tests/Functional/JsonLd/NetworkPathTest.php b/tests/Functional/JsonLd/NetworkPathTest.php new file mode 100644 index 00000000000..b8b946115ec --- /dev/null +++ b/tests/Functional/JsonLd/NetworkPathTest.php @@ -0,0 +1,104 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\NetworkPathParent; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\NetworkPathResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NetworkPathTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [NetworkPathResource::class, NetworkPathParent::class]; + } + + public function testCollectionUsesNetworkPaths(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonld_network_path_children', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('//example.com/contexts/JsonLdNetworkPathChild', $body['@context']); + $this->assertSame('//example.com/jsonld_network_path_children', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + $this->assertSame('//example.com/jsonld_network_path_children/1', $body['hydra:member'][0]['@id']); + } + + public function testItemUsesNetworkPaths(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonld_network_path_children/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('//example.com/contexts/JsonLdNetworkPathChild', $body['@context']); + $this->assertSame('//example.com/jsonld_network_path_children/1', $body['@id']); + $this->assertSame('JsonLdNetworkPathChild', $body['@type']); + $this->assertSame('//example.com/jsonld_network_path_parents/1', $body['parent']); + } + + public function testPostReturnsNetworkPath(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('POST', '/jsonld_network_path_parents', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/json', + ], + 'json' => new \stdClass(), + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('//example.com/jsonld_network_path_parents/2', $body['@id']); + } + + public function testPostAcceptsNetworkPathInPayload(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('POST', '/jsonld_network_path_children', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['parent' => '//example.com/jsonld_network_path_parents/1'], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('//example.com/jsonld_network_path_children/2', $body['@id']); + $this->assertSame('JsonLdNetworkPathChild', $body['@type']); + $this->assertSame('//example.com/jsonld_network_path_parents/1', $body['parent']); + } + + public function testSubresourceCollectionUsesNetworkPaths(): void + { + $client = self::createClient([], ['base_uri' => 'http://example.com']); + $response = $client->request('GET', '/jsonld_network_path_parents/1/children', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('//example.com/contexts/JsonLdNetworkPathChild', $body['@context']); + $this->assertSame('//example.com/jsonld_network_path_parents/1/children', $body['@id']); + $this->assertSame('hydra:Collection', $body['@type']); + } +} diff --git a/tests/Functional/JsonLd/NoOutputTest.php b/tests/Functional/JsonLd/NoOutputTest.php new file mode 100644 index 00000000000..c9bff3750e8 --- /dev/null +++ b/tests/Functional/JsonLd/NoOutputTest.php @@ -0,0 +1,44 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\NoOutputMessage; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NoOutputTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [NoOutputMessage::class]; + } + + public function testPostWithOutputFalseReturns202AndEmptyBody(): void + { + $response = self::createClient()->request('POST', '/jsonld_no_output_messages', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => new \stdClass(), + ]); + + $this->assertResponseStatusCodeSame(202); + $this->assertEmpty($response->getContent()); + } +} diff --git a/tests/Functional/JsonLd/NonResourceTest.php b/tests/Functional/JsonLd/NonResourceTest.php new file mode 100644 index 00000000000..941a1065e9e --- /dev/null +++ b/tests/Functional/JsonLd/NonResourceTest.php @@ -0,0 +1,152 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\DateTimeOnlyResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\GenIdFalseProperty; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\NonRelationResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\NonResourceContainer; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\PlainObjectResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NonResourceTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + NonResourceContainer::class, + NonRelationResource::class, + PlainObjectResource::class, + GenIdFalseProperty::class, + DateTimeOnlyResource::class, + ]; + } + + public function testNonResourceObjectHasGenidAndType(): void + { + $response = self::createClient()->request('GET', '/jsonld_non_resource_containers/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@context' => '/contexts/JsonLdNonResourceContainer', + '@id' => '/jsonld_non_resource_containers/1', + '@type' => 'JsonLdNonResourceContainer', + 'id' => '1', + 'nested' => [ + '@id' => '/jsonld_non_resource_containers/1-nested', + '@type' => 'JsonLdNonResourceContainer', + 'id' => '1-nested', + 'notAResource' => [ + '@type' => 'NonResourceClass', + 'foo' => 'f2', + 'bar' => 'b2', + ], + ], + 'notAResource' => [ + '@type' => 'NonResourceClass', + 'foo' => 'f1', + 'bar' => 'b1', + ], + ]); + $body = $response->toArray(); + $this->assertArrayHasKey('@id', $body['notAResource']); + $this->assertStringStartsWith('/.well-known/genid/', $body['notAResource']['@id']); + } + + public function testCreateResourceWithNonResourceRelation(): void + { + $response = self::createClient()->request('POST', '/jsonld_non_relation_resources', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['relation' => ['foo' => 'test']], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertJsonContains([ + '@context' => '/contexts/JsonLdNonRelationResource', + '@id' => '/jsonld_non_relation_resources/1', + '@type' => 'JsonLdNonRelationResource', + 'relation' => [ + '@type' => 'NonRelationPayload', + 'foo' => 'test', + ], + 'id' => 1, + ]); + } + + public function testCreateResourceWithStdClass(): void + { + $response = self::createClient()->request('POST', '/jsonld_plain_object_resources', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => [ + 'content' => '{"emptyObject":{},"showCaption":false,"alternativeContent":false,"blockLayout":"default"}', + ], + ]); + $this->assertResponseStatusCodeSame(201); + $body = $response->toArray(); + $this->assertSame('/jsonld_plain_object_resources/1', $body['@id']); + $this->assertSame('JsonLdPlainObjectResource', $body['@type']); + $this->assertSame([], $body['data']['emptyObject']); + $this->assertFalse($body['data']['showCaption']); + $this->assertFalse($body['data']['alternativeContent']); + $this->assertSame('default', $body['data']['blockLayout']); + } + + public function testGenIdFalsePropertyOmitsAtId(): void + { + $response = self::createClient()->request('GET', '/jsonld_genid_false_properties/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertArrayNotHasKey('@id', $body['totalPrice']); + } + + public function testResourceWithDateTimeProperty(): void + { + $response = self::createClient()->request('GET', '/jsonld_datetime_resources/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertArrayHasKey('start', $body); + $this->assertNotEmpty($body['start']); + } + + public function testSparseFieldsetOnNonResourceObject(): void + { + $response = self::createClient()->request( + 'GET', + '/jsonld_non_resource_containers/1?properties[]=id&properties[nested][notAResource][]=foo&properties[notAResource][]=bar', + ['headers' => ['Accept' => 'application/ld+json']], + ); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('1', $body['id']); + $this->assertSame('f2', $body['nested']['notAResource']['foo']); + $this->assertSame('b1', $body['notAResource']['bar']); + $this->assertArrayNotHasKey('bar', $body['nested']['notAResource']); + $this->assertArrayNotHasKey('foo', $body['notAResource']); + } +} diff --git a/tests/Functional/JsonLd/PolymorphicResourceCollectionTest.php b/tests/Functional/JsonLd/PolymorphicResourceCollectionTest.php new file mode 100644 index 00000000000..d1f28bb4437 --- /dev/null +++ b/tests/Functional/JsonLd/PolymorphicResourceCollectionTest.php @@ -0,0 +1,55 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7298\ImageModuleResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7298\PageResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7298\TitleModuleResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +class PolymorphicResourceCollectionTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [PageResource::class, TitleModuleResource::class, ImageModuleResource::class]; + } + + public function testPolymorphicCollectionPropertyExposesPerItemTypes(): void + { + self::createClient()->request('GET', '/page_resources/page-1'); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'modules' => [ + [ + '@type' => 'TitleModuleResource', + 'id' => 'title-module-1', + 'title' => 'My Title', + ], + [ + '@type' => 'ImageModule', + 'id' => 'image-module-1', + 'url' => 'http://example.com/image.jpg', + ], + ], + ]); + } +} diff --git a/tests/Functional/JsonLd/PropertyCollectionIriOnlyTest.php b/tests/Functional/JsonLd/PropertyCollectionIriOnlyTest.php new file mode 100644 index 00000000000..ec5f3dce0e7 --- /dev/null +++ b/tests/Functional/JsonLd/PropertyCollectionIriOnlyTest.php @@ -0,0 +1,98 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnly; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelationSecondLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyUriTemplateOneToOneRelation; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class PropertyCollectionIriOnlyTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + PropertyCollectionIriOnly::class, + PropertyCollectionIriOnlyRelation::class, + PropertyCollectionIriOnlyRelationSecondLevel::class, + PropertyUriTemplateOneToOneRelation::class, + ]; + } + + public function testPropertyUriTemplatesRenderAsIris(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([ + PropertyCollectionIriOnly::class, + PropertyCollectionIriOnlyRelation::class, + PropertyCollectionIriOnlyRelationSecondLevel::class, + PropertyUriTemplateOneToOneRelation::class, + ]); + + $manager = $this->getManager(); + $rel1 = new PropertyCollectionIriOnlyRelation(); + $rel1->name = 'asb1'; + $rel2 = new PropertyCollectionIriOnlyRelation(); + $rel2->name = 'asb2'; + $toOne = new PropertyUriTemplateOneToOneRelation(); + $toOne->name = 'xarguš'; + $parent = new PropertyCollectionIriOnly(); + $parent->addPropertyCollectionIriOnlyRelation($rel1); + $parent->addPropertyCollectionIriOnlyRelation($rel2); + $parent->setToOneRelation($toOne); + $manager->persist($parent); + $manager->persist($rel1); + $manager->persist($rel2); + $manager->persist($toOne); + $manager->flush(); + + $response = self::createClient()->request('GET', '/property_collection_iri_onlies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'hydra:member' => [[ + '@id' => '/property_collection_iri_onlies/1', + '@type' => 'PropertyCollectionIriOnly', + 'propertyCollectionIriOnlyRelation' => '/property-collection-relations', + 'iterableIri' => '/parent/1/another-collection-operations', + 'toOneRelation' => '/parent/1/property-uri-template/one-to-ones/1', + ]], + ]); + + $response = self::createClient()->request('GET', '/property_collection_iri_onlies/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@context' => '/contexts/PropertyCollectionIriOnly', + '@id' => '/property_collection_iri_onlies/1', + '@type' => 'PropertyCollectionIriOnly', + 'propertyCollectionIriOnlyRelation' => '/property-collection-relations', + 'iterableIri' => '/parent/1/another-collection-operations', + 'toOneRelation' => '/parent/1/property-uri-template/one-to-ones/1', + ]); + } +} diff --git a/tests/Functional/JsonLd/RenamedGetterSetterTest.php b/tests/Functional/JsonLd/RenamedGetterSetterTest.php new file mode 100644 index 00000000000..1f08a5818cb --- /dev/null +++ b/tests/Functional/JsonLd/RenamedGetterSetterTest.php @@ -0,0 +1,49 @@ + + * + * 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\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\RenamedGetterSetter; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class RenamedGetterSetterTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [RenamedGetterSetter::class]; + } + + public function testPostExposesRenamedField(): void + { + $response = self::createClient()->request('POST', '/json_ld_renamed_getter_setters', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['firstnameOnly' => 'Sarah'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertSame([ + '@context' => '/contexts/JsonLdRenamedGetterSetter', + '@id' => '/json_ld_renamed_getter_setters', + '@type' => 'JsonLdRenamedGetterSetter', + 'firstnameOnly' => 'Sarah', + ], $response->toArray()); + } +} diff --git a/tests/Functional/JsonLdTest.php b/tests/Functional/JsonLdTest.php deleted file mode 100644 index d72176f5856..00000000000 --- a/tests/Functional/JsonLdTest.php +++ /dev/null @@ -1,283 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Tests\Functional; - -use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\AggregateRating; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\GenIdFalse; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\LevelFirst; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\GenIdFalse\LevelThird; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6810\JsonLdContextOutput; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7298\ImageModuleResource; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7298\PageResource; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue7298\TitleModuleResource; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ItemUriTemplateWithCollection\Recipe; -use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ItemUriTemplateWithCollection\RecipeCollection; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6465\Bar; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6465\Foo; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiResourceEntity; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Recipe as EntityRecipe; -use ApiPlatform\Tests\RecreateSchemaTrait; -use ApiPlatform\Tests\SetupClassResourcesTrait; - -class JsonLdTest extends ApiTestCase -{ - use RecreateSchemaTrait; - use SetupClassResourcesTrait; - - protected static ?bool $alwaysBootKernel = false; - - /** - * @return class-string[] - */ - public static function getResources(): array - { - return [ - Foo::class, - Bar::class, - JsonLdContextOutput::class, - GenIdFalse::class, - AggregateRating::class, - LevelFirst::class, - LevelThird::class, - PageResource::class, - TitleModuleResource::class, - ImageModuleResource::class, - Recipe::class, - RecipeCollection::class, - MultiResourceEntity::class, - ]; - } - - /** - * The input DTO denormalizes an existing Doctrine entity. - */ - public function testIssue6465(): void - { - $container = static::getContainer(); - if ('mongodb' === $container->getParameter('kernel.environment')) { - $this->markTestSkipped(); - } - - $response = self::createClient()->request('POST', '/foo/1/validate', [ - 'json' => ['bar' => '/bar6465s/2'], - ]); - - $res = $response->toArray(); - $this->assertEquals('Bar two', $res['title']); - } - - public function testContextWithOutput(): void - { - $response = self::createClient()->request( - 'GET', - '/json_ld_context_output', - ); - $res = $response->toArray(); - $this->assertEquals($res['@context'], [ - '@vocab' => 'http://localhost/docs.jsonld#', - 'hydra' => 'http://www.w3.org/ns/hydra/core#', - 'foo' => 'Output/foo', - ]); - } - - public function testGenIdFalseOnResource(): void - { - $r = self::createClient()->request( - 'GET', - '/gen_id_falsy', - ); - $this->assertJsonContains([ - 'aggregateRating' => ['ratingValue' => 2, 'ratingCount' => 3], - ]); - $this->assertArrayNotHasKey('@id', $r->toArray()['aggregateRating']); - } - - public function testGenIdFalseOnNestedResource(): void - { - $r = self::createClient()->request( - 'GET', - '/levelfirst/1', - ); - $res = $r->toArray(); - $this->assertArrayNotHasKey('@id', $res['levelSecond']); - $this->assertArrayHasKey('@id', $res['levelSecond'][0]['levelThird']); - } - - public function testShouldIgnoreProperty(): void - { - $r = self::createClient()->request( - 'GET', - '/contexts/GenIdFalse', - ); - $this->assertArrayNotHasKey('shouldBeIgnored', $r->toArray()['@context']); - } - - public function testIssue7298(): void - { - self::createClient()->request( - 'GET', - '/page_resources/page-1', - ); - $this->assertResponseIsSuccessful(); - $this->assertJsonContains([ - 'modules' => [ - [ - '@type' => 'TitleModuleResource', - 'id' => 'title-module-1', - 'title' => 'My Title', - ], - [ - '@type' => 'ImageModule', - 'id' => 'image-module-1', - 'url' => 'http://example.com/image.jpg', - ], - ], - ]); - } - - public function testItemUriTemplate(): void - { - self::createClient()->request( - 'GET', - '/item_uri_template_recipes', - ); - $this->assertResponseIsSuccessful(); - - $this->assertJsonContains([ - 'member' => [ - [ - '@type' => 'Recipe', - '@id' => '/item_uri_template_recipes/1', - 'name' => 'Dummy Recipe', - ], - [ - '@type' => 'Recipe', - '@id' => '/item_uri_template_recipes/2', - 'name' => 'Dummy Recipe 2', - ], - ], - ]); - } - - public function testItemUriTemplateWithStateOption(): void - { - $container = static::getContainer(); - if ('mongodb' === $container->getParameter('kernel.environment')) { - $this->markTestSkipped(); - } - - $registry = $container->get('doctrine'); - $manager = $registry->getManager(); - for ($i = 0; $i < 10; ++$i) { - $recipe = new EntityRecipe(); - $recipe->name = "Recipe $i"; - $recipe->description = "Description of recipe $i"; - $recipe->author = "Author $i"; - $recipe->recipeIngredient = [ - "Ingredient 1 for recipe $i", - "Ingredient 2 for recipe $i", - ]; - $recipe->recipeInstructions = "Instructions for recipe $i"; - $recipe->prepTime = '10 minutes'; - $recipe->cookTime = '20 minutes'; - $recipe->totalTime = '30 minutes'; - $recipe->recipeCategory = "Category $i"; - $recipe->recipeCuisine = "Cuisine $i"; - $recipe->suitableForDiet = "Diet $i"; - - $manager->persist($recipe); - } - $manager->flush(); - - self::createClient()->request( - 'GET', - '/item_uri_template_recipes_state_option', - ); - $this->assertResponseIsSuccessful(); - - $this->assertJsonContains([ - 'member' => [ - [ - '@type' => 'Recipe', - '@id' => '/item_uri_template_recipes_state_option/1', - 'name' => 'Recipe 0', - ], - [ - '@type' => 'Recipe', - '@id' => '/item_uri_template_recipes_state_option/2', - 'name' => 'Recipe 1', - ], - [ - '@type' => 'Recipe', - '@id' => '/item_uri_template_recipes_state_option/3', - 'name' => 'Recipe 2', - ], - ], - ]); - } - - /** - * Tests that @context uses the correct shortName when an entity has multiple ApiResource attributes. - */ - public function testMultiResourceContextUsesCorrectShortName(): void - { - if ($this->isMongoDB()) { - $this->markTestSkipped(); - } - - // Test the second declared ApiResource (shortName: 'MultiResource') - $response = self::createClient()->request('GET', '/multi_resources'); - $this->assertResponseIsSuccessful(); - $this->assertJsonContains([ - '@context' => '/contexts/MultiResource', - ]); - - // Test the first declared ApiResource (shortName: 'AdminMultiResource') - $response = self::createClient()->request('GET', '/admin/multi_resources'); - $this->assertResponseIsSuccessful(); - $this->assertJsonContains([ - '@context' => '/contexts/AdminMultiResource', - ]); - } - - protected function setUp(): void - { - self::bootKernel(); - - if ($this->isMongoDB()) { - $this->markTestSkipped('This test uses Doctrine ORM entities without MongoDB equivalents.'); - } - - $this->recreateSchema([Foo::class, Bar::class, EntityRecipe::class, MultiResourceEntity::class]); - - $manager = $this->getManager(); - $foo = new Foo(); - $foo->title = 'Foo'; - $manager->persist($foo); - $foo1 = new Foo(); - $foo1->title = 'Foo1'; - $manager->persist($foo1); - $bar = new Bar(); - $bar->title = 'Bar one'; - $manager->persist($bar); - $bar2 = new Bar(); - $bar2->title = 'Bar two'; - $manager->persist($bar2); - $multi = new MultiResourceEntity(); - $multi->title = 'Multi Resource'; - $manager->persist($multi); - $manager->flush(); - } -} From 98dc77ba734d4fb9dfd46d76885a706aed3b6405 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 21 May 2026 11:55:10 +0200 Subject: [PATCH 10/84] feat(doctrine): state options repositoryMethod for query builder (#7115) --- docs/guides/computed-field.php | 67 ++++++++++++------- src/Doctrine/Common/State/Options.php | 14 ++++ src/Doctrine/Odm/State/CollectionProvider.php | 15 ++++- src/Doctrine/Odm/State/ItemProvider.php | 15 ++++- src/Doctrine/Odm/State/Options.php | 3 +- src/Doctrine/Orm/State/CollectionProvider.php | 21 +++++- src/Doctrine/Orm/State/ItemProvider.php | 21 +++++- src/Doctrine/Orm/State/Options.php | 3 +- src/State/Util/StateOptionsTrait.php | 16 +++++ tests/Fixtures/TestBundle/Entity/Cart.php | 16 +---- .../TestBundle/Repository/CartRepository.php | 34 ++++++++++ 11 files changed, 179 insertions(+), 46 deletions(-) create mode 100644 tests/Fixtures/TestBundle/Repository/CartRepository.php diff --git a/docs/guides/computed-field.php b/docs/guides/computed-field.php index 24b86bacee3..5eb987ccc65 100644 --- a/docs/guides/computed-field.php +++ b/docs/guides/computed-field.php @@ -1,4 +1,15 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); // --- // slug: computed-field // name: Compute a field @@ -12,6 +23,7 @@ // by modifying the SQL query (via `stateOptions`/`handleLinks`), mapping the computed value // to the entity object (via `processor`/`process`), and optionally enabling sorting on it // using a custom filter configured via `parameters`. + namespace App\Filter { use ApiPlatform\Doctrine\Orm\Filter\FilterInterface; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; @@ -44,7 +56,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q */ // Defines the OpenAPI/Swagger schema for this filter parameter. // Tells API Platform documentation generators that 'sort[totalQuantity]' expects 'asc' or 'desc'. - // This also add constraint violations to the parameter that will reject any wrong values. + // This also add constraint violations to the parameter that will reject any wrong values. public function getSchema(Parameter $parameter): array { return ['type' => 'string', 'enum' => ['asc', 'desc']]; @@ -59,29 +71,28 @@ public function getDescription(string $resourceClass): array namespace App\Entity { use ApiPlatform\Doctrine\Orm\State\Options; - use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\NotExposed; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\QueryParameter; use App\Filter\SortComputedFieldFilter; + use App\Repository\CartRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; - use Doctrine\ORM\QueryBuilder; - #[ORM\Entity] + #[ORM\Entity(repositoryClass: CartRepository::class)] // Defines the GetCollection operation for Cart, including computed 'totalQuantity'. // Recipe involves: - // 1. handleLinks (modify query) - // 2. process (map result) - // 3. parameters (filters) + // 1. setup the repository method (modify query) + // 2. process (map result) + // 3. parameters (filters) #[GetCollection( normalizationContext: ['hydra_prefix' => false], paginationItemsPerPage: 3, paginationPartial: false, - // stateOptions: Uses handleLinks to modify the query *before* fetching. - stateOptions: new Options(handleLinks: [self::class, 'handleLinks']), + // stateOptions: Uses repositoryMethod to modify the query *before* fetching. See App\Repository\CartRepository. + stateOptions: new Options(repositoryMethod: 'getCartsWithTotalQuantity'), // processor: Uses process to map the result *after* fetching, *before* serialization. processor: [self::class, 'process'], write: true, @@ -99,20 +110,6 @@ public function getDescription(string $resourceClass): array )] class Cart { - // Handles links/joins and modifications to the QueryBuilder *before* data is fetched (via stateOptions). - // Adds SQL logic (JOIN, SELECT aggregate, GROUP BY) to calculate 'totalQuantity' at the database level. - // The alias 'totalQuantity' created here is crucial for the filter and processor. - public static function handleLinks(QueryBuilder $queryBuilder, array $uriVariables, QueryNameGeneratorInterface $queryNameGenerator, array $context): void - { - // Get the alias for the root entity (Cart), usually 'o'. - $rootAlias = $queryBuilder->getRootAliases()[0] ?? 'o'; - // Generate a unique alias for the joined 'items' relation to avoid conflicts. - $itemsAlias = $queryNameGenerator->generateParameterName('items'); - $queryBuilder->leftJoin(\sprintf('%s.items', $rootAlias), $itemsAlias) - ->addSelect(\sprintf('COALESCE(SUM(%s.quantity), 0) AS totalQuantity', $itemsAlias)) - ->addGroupBy(\sprintf('%s.id', $rootAlias)); - } - // Processor function called *after* fetching data, *before* serialization. // Maps the raw 'totalQuantity' from Doctrine result onto the Cart entity's property. // Handles Doctrine's array result structure: [0 => Entity, 'alias' => computedValue]. @@ -238,6 +235,30 @@ public function setQuantity(int $quantity): self } } +namespace App\Repository { + use Doctrine\ORM\EntityRepository; + use Doctrine\ORM\QueryBuilder; + + /** + * @extends EntityRepository + */ + class CartRepository extends EntityRepository + { + // This repository method is used via stateOptions to alter the QueryBuilder *before* data is fetched. + // Adds SQL logic (JOIN, SELECT aggregate, GROUP BY) to calculate 'totalQuantity' at the database level. + // The alias 'totalQuantity' created here is crucial for the filter and processor. + public function getCartsWithTotalQuantity(): QueryBuilder + { + $queryBuilder = $this->createQueryBuilder('o'); + $queryBuilder->leftJoin('o.items', 'items') + ->addSelect('COALESCE(SUM(items.quantity), 0) AS totalQuantity') + ->addGroupBy('o.id'); + + return $queryBuilder; + } + } +} + namespace App\Playground { use Symfony\Component\HttpFoundation\Request; diff --git a/src/Doctrine/Common/State/Options.php b/src/Doctrine/Common/State/Options.php index df42fc6cb84..a5815332498 100644 --- a/src/Doctrine/Common/State/Options.php +++ b/src/Doctrine/Common/State/Options.php @@ -22,6 +22,7 @@ class Options implements OptionsInterface */ public function __construct( protected mixed $handleLinks = null, + protected ?string $repositoryMethod = null, ) { } @@ -37,4 +38,17 @@ public function withHandleLinks(mixed $handleLinks): self return $self; } + + public function getRepositoryMethod(): ?string + { + return $this->repositoryMethod; + } + + public function withRepositoryMethod(?string $repositoryMethod): self + { + $self = clone $this; + $self->repositoryMethod = $repositoryMethod; + + return $self; + } } diff --git a/src/Doctrine/Odm/State/CollectionProvider.php b/src/Doctrine/Odm/State/CollectionProvider.php index 6c68b663f3e..e28d2ca7ef1 100644 --- a/src/Doctrine/Odm/State/CollectionProvider.php +++ b/src/Doctrine/Odm/State/CollectionProvider.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\StateOptionsTrait; +use Doctrine\ODM\MongoDB\Aggregation\Builder as AggregationBuilder; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; use Doctrine\Persistence\ManagerRegistry; @@ -57,7 +58,19 @@ public function provide(Operation $operation, array $uriVariables = [], array $c throw new RuntimeException(\sprintf('The repository for "%s" must be an instance of "%s".', $documentClass, DocumentRepository::class)); } - $aggregationBuilder = $repository->createAggregationBuilder(); + if ($method = $this->getStateOptionsRepositoryMethod($operation)) { + if (!method_exists($repository, $method)) { + throw new RuntimeException(\sprintf('The repository method "%s::%s" does not exist.', $repository::class, $method)); + } + + $aggregationBuilder = $repository->{$method}(); + + if (!$aggregationBuilder instanceof AggregationBuilder) { + throw new RuntimeException(\sprintf('The repository method "%s" must return a %s instance.', $method, AggregationBuilder::class)); + } + } else { + $aggregationBuilder = $repository->createAggregationBuilder(); + } if ($handleLinks = $this->getLinksHandler($operation)) { $handleLinks($aggregationBuilder, $uriVariables, ['documentClass' => $documentClass, 'operation' => $operation] + $context); diff --git a/src/Doctrine/Odm/State/ItemProvider.php b/src/Doctrine/Odm/State/ItemProvider.php index 50fc50f6253..95d78dd2262 100644 --- a/src/Doctrine/Odm/State/ItemProvider.php +++ b/src/Doctrine/Odm/State/ItemProvider.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\StateOptionsTrait; +use Doctrine\ODM\MongoDB\Aggregation\Builder as AggregationBuilder; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; use Doctrine\Persistence\ManagerRegistry; @@ -71,7 +72,19 @@ public function provide(Operation $operation, array $uriVariables = [], array $c throw new RuntimeException(\sprintf('The repository for "%s" must be an instance of "%s".', $documentClass, DocumentRepository::class)); } - $aggregationBuilder = $repository->createAggregationBuilder(); + if ($method = $this->getStateOptionsRepositoryMethod($operation)) { + if (!method_exists($repository, $method)) { + throw new RuntimeException(\sprintf('The repository method "%s::%s" does not exist.', $repository::class, $method)); + } + + $aggregationBuilder = $repository->{$method}(); + + if (!$aggregationBuilder instanceof AggregationBuilder) { + throw new RuntimeException(\sprintf('The repository method "%s" must return a %s instance.', $method, AggregationBuilder::class)); + } + } else { + $aggregationBuilder = $repository->createAggregationBuilder(); + } if ($handleLinks = $this->getLinksHandler($operation)) { $handleLinks($aggregationBuilder, $uriVariables, ['documentClass' => $documentClass, 'operation' => $operation] + $context); diff --git a/src/Doctrine/Odm/State/Options.php b/src/Doctrine/Odm/State/Options.php index 459d6bc49ec..00650e5de32 100644 --- a/src/Doctrine/Odm/State/Options.php +++ b/src/Doctrine/Odm/State/Options.php @@ -26,8 +26,9 @@ class Options extends CommonOptions implements OptionsInterface public function __construct( protected ?string $documentClass = null, mixed $handleLinks = null, + ?string $repositoryMethod = null, ) { - parent::__construct(handleLinks: $handleLinks); + parent::__construct(handleLinks: $handleLinks, repositoryMethod: $repositoryMethod); } public function getDocumentClass(): ?string diff --git a/src/Doctrine/Orm/State/CollectionProvider.php b/src/Doctrine/Orm/State/CollectionProvider.php index 3815447a8d3..5bc181abb57 100644 --- a/src/Doctrine/Orm/State/CollectionProvider.php +++ b/src/Doctrine/Orm/State/CollectionProvider.php @@ -23,6 +23,7 @@ use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\StateOptionsTrait; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; use Psr\Container\ContainerInterface; @@ -56,11 +57,25 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $manager = $this->managerRegistry->getManagerForClass($entityClass); $repository = $manager->getRepository($entityClass); - if (!method_exists($repository, 'createQueryBuilder')) { - throw new RuntimeException('The repository class must have a "createQueryBuilder" method.'); + + if ($method = $this->getStateOptionsRepositoryMethod($operation)) { + if (!method_exists($repository, $method)) { + throw new RuntimeException(\sprintf('The repository method "%s::%s" does not exist.', $repository::class, $method)); + } + + $queryBuilder = $repository->{$method}(); + + if (!$queryBuilder instanceof QueryBuilder) { + throw new RuntimeException(\sprintf('The repository method "%s" must return a %s instance.', $method, QueryBuilder::class)); + } + } else { + if (!method_exists($repository, 'createQueryBuilder')) { + throw new RuntimeException('The repository class must have a "createQueryBuilder" method.'); + } + + $queryBuilder = $repository->createQueryBuilder('o'); } - $queryBuilder = $repository->createQueryBuilder('o'); $queryNameGenerator = new QueryNameGenerator(); if ($handleLinks = $this->getLinksHandler($operation)) { diff --git a/src/Doctrine/Orm/State/ItemProvider.php b/src/Doctrine/Orm/State/ItemProvider.php index b201d03b7d0..ba3f06b592f 100644 --- a/src/Doctrine/Orm/State/ItemProvider.php +++ b/src/Doctrine/Orm/State/ItemProvider.php @@ -23,6 +23,7 @@ use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\Util\StateOptionsTrait; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; use Psr\Container\ContainerInterface; @@ -65,11 +66,25 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } $repository = $manager->getRepository($entityClass); - if (!method_exists($repository, 'createQueryBuilder')) { - throw new RuntimeException('The repository class must have a "createQueryBuilder" method.'); + + if ($method = $this->getStateOptionsRepositoryMethod($operation)) { + if (!method_exists($repository, $method)) { + throw new RuntimeException(\sprintf('The repository method "%s::%s" does not exist.', $repository::class, $method)); + } + + $queryBuilder = $repository->{$method}(); + + if (!$queryBuilder instanceof QueryBuilder) { + throw new RuntimeException(\sprintf('The repository method "%s" must return a %s instance.', $method, QueryBuilder::class)); + } + } else { + if (!method_exists($repository, 'createQueryBuilder')) { + throw new RuntimeException('The repository class must have a "createQueryBuilder" method.'); + } + + $queryBuilder = $repository->createQueryBuilder('o'); } - $queryBuilder = $repository->createQueryBuilder('o'); $queryNameGenerator = new QueryNameGenerator(); if ($handleLinks = $this->getLinksHandler($operation)) { diff --git a/src/Doctrine/Orm/State/Options.php b/src/Doctrine/Orm/State/Options.php index 3a9a46c3825..00f791da563 100644 --- a/src/Doctrine/Orm/State/Options.php +++ b/src/Doctrine/Orm/State/Options.php @@ -26,8 +26,9 @@ class Options extends CommonOptions implements OptionsInterface public function __construct( protected ?string $entityClass = null, mixed $handleLinks = null, + ?string $repositoryMethod = null, ) { - parent::__construct(handleLinks: $handleLinks); + parent::__construct(handleLinks: $handleLinks, repositoryMethod: $repositoryMethod); } public function getEntityClass(): ?string diff --git a/src/State/Util/StateOptionsTrait.php b/src/State/Util/StateOptionsTrait.php index 1b27c5534f7..5017cb8ace5 100644 --- a/src/State/Util/StateOptionsTrait.php +++ b/src/State/Util/StateOptionsTrait.php @@ -55,4 +55,20 @@ public function getStateOptionsClass(Operation $operation, ?string $defaultClass return $defaultClass; } + + public function getStateOptionsRepositoryMethod(Operation $operation): ?string + { + if (!$options = $operation->getStateOptions()) { + return null; + } + + if ( + (class_exists(Options::class) && $options instanceof Options) + || (class_exists(ODMOptions::class) && $options instanceof ODMOptions) + ) { + return $options->getRepositoryMethod(); + } + + return null; + } } diff --git a/tests/Fixtures/TestBundle/Entity/Cart.php b/tests/Fixtures/TestBundle/Entity/Cart.php index 1adf0cad569..d5cc8d0d77c 100644 --- a/tests/Fixtures/TestBundle/Entity/Cart.php +++ b/tests/Fixtures/TestBundle/Entity/Cart.php @@ -14,22 +14,21 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Doctrine\Orm\State\Options; -use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SortComputedFieldFilter; +use ApiPlatform\Tests\Fixtures\TestBundle\Repository\CartRepository; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; -use Doctrine\ORM\QueryBuilder; -#[ORM\Entity] +#[ORM\Entity(repositoryClass: CartRepository::class)] #[GetCollection( normalizationContext: ['hydra_prefix' => false], paginationItemsPerPage: 3, paginationPartial: false, - stateOptions: new Options(handleLinks: [self::class, 'handleLinks']), + stateOptions: new Options(repositoryMethod: 'getCartsWithTotalQuantity'), processor: [self::class, 'process'], write: true, parameters: [ @@ -53,15 +52,6 @@ public static function process(mixed $data, Operation $operation, array $uriVari return $data; } - public static function handleLinks(QueryBuilder $queryBuilder, array $uriVariables, QueryNameGeneratorInterface $queryNameGenerator, array $context): void - { - $rootAlias = $queryBuilder->getRootAliases()[0] ?? 'o'; - $itemsAlias = $queryNameGenerator->generateParameterName('items'); - $queryBuilder->leftJoin(\sprintf('%s.items', $rootAlias), $itemsAlias) - ->addSelect(\sprintf('COALESCE(SUM(%s.quantity), 0) AS totalQuantity', $itemsAlias)) - ->addGroupBy(\sprintf('%s.id', $rootAlias)); - } - public int|string|null $totalQuantity; #[ORM\Id] diff --git a/tests/Fixtures/TestBundle/Repository/CartRepository.php b/tests/Fixtures/TestBundle/Repository/CartRepository.php new file mode 100644 index 00000000000..5f5ba5b4e87 --- /dev/null +++ b/tests/Fixtures/TestBundle/Repository/CartRepository.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\Tests\Fixtures\TestBundle\Repository; + +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Cart; +use Doctrine\ORM\EntityRepository; +use Doctrine\ORM\QueryBuilder; + +/** + * @extends EntityRepository + */ +class CartRepository extends EntityRepository +{ + public function getCartsWithTotalQuantity(): QueryBuilder + { + $queryBuilder = $this->createQueryBuilder('o'); + $queryBuilder->leftJoin('o.items', 'items') + ->addSelect('COALESCE(SUM(items.quantity), 0) AS totalQuantity') + ->addGroupBy('o.id'); + + return $queryBuilder; + } +} From 2ff386bd854fbf0192d521be641fb7304b65688d Mon Sep 17 00:00:00 2001 From: cay89 Date: Fri, 22 May 2026 14:58:02 +0200 Subject: [PATCH 11/84] feat(symfony,laravel): `withCredentials` option to Swagger UI (#8197) --- src/Laravel/ApiPlatformProvider.php | 3 +- src/Laravel/State/SwaggerUiProcessor.php | 1 + src/Laravel/Tests/DocsTest.php | 6 +++ src/Laravel/Tests/DocsWithCredentialsTest.php | 40 +++++++++++++++++++ src/Laravel/config/api-platform.php | 2 + src/Laravel/public/init-swagger-ui.js | 16 +++++++- src/OpenApi/Options.php | 6 +++ .../ApiPlatformExtension.php | 1 + .../DependencyInjection/Configuration.php | 1 + .../Bundle/Resources/config/openapi.php | 1 + .../Resources/public/init-swagger-ui.js | 13 +++++- .../Bundle/SwaggerUi/SwaggerUiProcessor.php | 1 + .../DependencyInjection/ConfigurationTest.php | 1 + 13 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 src/Laravel/Tests/DocsWithCredentialsTest.php diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 12c176f54b5..9477d5c8a15 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -767,7 +767,8 @@ public function register(): void httpAuth: $config->get('api-platform.swagger_ui.http_auth', []), tags: $config->get('api-platform.openapi.tags', []), errorResourceClass: Error::class, - validationErrorResourceClass: ValidationError::class + validationErrorResourceClass: ValidationError::class, + withCredentials: $config->get('api-platform.swagger_ui.with_credentials', false), ); }); diff --git a/src/Laravel/State/SwaggerUiProcessor.php b/src/Laravel/State/SwaggerUiProcessor.php index 7ba643cb80d..a29ea79a8c9 100644 --- a/src/Laravel/State/SwaggerUiProcessor.php +++ b/src/Laravel/State/SwaggerUiProcessor.php @@ -83,6 +83,7 @@ public function process(mixed $openApi, Operation $operation, array $uriVariable 'clientSecret' => $this->oauthClientSecret, 'pkce' => $this->oauthPkce, ], + 'withCredentials' => $this->openApiOptions->getWithCredentials(), ]; $status = 200; diff --git a/src/Laravel/Tests/DocsTest.php b/src/Laravel/Tests/DocsTest.php index f8449f80adb..8ceb4e0aae9 100644 --- a/src/Laravel/Tests/DocsTest.php +++ b/src/Laravel/Tests/DocsTest.php @@ -85,4 +85,10 @@ public function testHtmlDocsRendersScalarWithoutFooterWhenRequested(): void $this->assertStringContainsString('init-scalar-ui.js', $content); $this->assertStringNotContainsString('id="formats"', $content); } + + public function testSwaggerDataDoesNotContainWithCredentialsByDefault(): void + { + $res = $this->get('/api/docs', headers: ['accept' => 'text/html']); + $this->assertStringNotContainsString('"withCredentials":true', (string) $res->getContent()); + } } diff --git a/src/Laravel/Tests/DocsWithCredentialsTest.php b/src/Laravel/Tests/DocsWithCredentialsTest.php new file mode 100644 index 00000000000..a3d52e939c0 --- /dev/null +++ b/src/Laravel/Tests/DocsWithCredentialsTest.php @@ -0,0 +1,40 @@ + + * + * 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\Laravel\Tests; + +use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait; +use Illuminate\Config\Repository; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; + +class DocsWithCredentialsTest extends TestCase +{ + use ApiTestAssertionsTrait; + use WithWorkbench; + + protected function defineEnvironment($app): void + { + tap($app['config'], static function (Repository $config): void { + $config->set('api-platform.swagger_ui.with_credentials', true); + }); + } + + public function testSwaggerDataContainsWithCredentialsTrueWhenEnabled(): void + { + $res = $this->get('/api/docs', headers: ['accept' => 'text/html']); + $res->assertOk(); + $content = (string) $res->getContent(); + $this->assertStringContainsString('"withCredentials":true', $content); + } +} diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 2db701be663..d07dbadaa86 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -143,6 +143,8 @@ // 'bearerFormat' => 'JWT', // ], // ], + // + // 'with_credentials' => true, ], // 'openapi' => [ diff --git a/src/Laravel/public/init-swagger-ui.js b/src/Laravel/public/init-swagger-ui.js index 101d4fc83b2..794b86ae340 100644 --- a/src/Laravel/public/init-swagger-ui.js +++ b/src/Laravel/public/init-swagger-ui.js @@ -41,7 +41,8 @@ window.onload = function() { }).observe(document, {childList: true, subtree: true}); const data = JSON.parse(document.getElementById('swagger-data').innerText); - const ui = SwaggerUIBundle(Object.assign({ + + const config = { spec: data.spec, dom_id: '#swagger-ui', validatorUrl: null, @@ -55,7 +56,18 @@ window.onload = function() { SwaggerUIBundle.plugins.DownloadUrl, ], layout: 'StandaloneLayout', - }, data.extraConfiguration)); + }; + + if (data.withCredentials) { + // Cloudflare Access fix: ensure cookies are sent on token / CORS calls + config.requestInterceptor = (req) => { + req.credentials = 'include'; + return req; + }; + } + + const withExtraConfig = Object.assign(config, data.extraConfiguration); + const ui = SwaggerUIBundle(withExtraConfig); if (data.oauth.enabled) { ui.initOAuth({ diff --git a/src/OpenApi/Options.php b/src/OpenApi/Options.php index e91976aa929..a22904bd15c 100644 --- a/src/OpenApi/Options.php +++ b/src/OpenApi/Options.php @@ -47,6 +47,7 @@ public function __construct( private ?string $errorResourceClass = null, private ?string $validationErrorResourceClass = null, private ?string $licenseIdentifier = null, + private bool $withCredentials = false, ) { } @@ -178,4 +179,9 @@ public function getLicenseIdentifier(): ?string { return $this->licenseIdentifier; } + + public function getWithCredentials(): bool + { + return $this->withCredentials; + } } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 0164d273aa6..abbdd83ec95 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -684,6 +684,7 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.enable_scalar', $config['enable_scalar']); $container->setParameter('api_platform.swagger.api_keys', $config['swagger']['api_keys']); $container->setParameter('api_platform.swagger.persist_authorization', $config['swagger']['persist_authorization']); + $container->setParameter('api_platform.swagger.with_credentials', $config['swagger']['with_credentials']); $container->setParameter('api_platform.swagger.http_auth', $config['swagger']['http_auth']); if ($config['openapi']['swagger_ui_extra_configuration'] && $config['swagger']['swagger_ui_extra_configuration']) { throw new RuntimeException('You can not set "swagger_ui_extra_configuration" twice - in "openapi" and "swagger" section.'); diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 911de422fdb..7e330737963 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -324,6 +324,7 @@ private function addSwaggerSection(ArrayNodeDefinition $rootNode): void ->addDefaultsIfNotSet() ->children() ->booleanNode('persist_authorization')->defaultValue(false)->info('Persist the SwaggerUI Authorization in the localStorage.')->end() + ->booleanNode('with_credentials')->defaultValue(false)->info('Send credentials (cookies, authorization headers) on Swagger UI cross-origin requests (e.g. when running behind Cloudflare Access).')->end() ->arrayNode('versions') ->info('The active versions of OpenAPI to be exported or used in Swagger UI. The first value is the default.') ->defaultValue($supportedVersions) diff --git a/src/Symfony/Bundle/Resources/config/openapi.php b/src/Symfony/Bundle/Resources/config/openapi.php index b68eb55ed4b..d5b9517a204 100644 --- a/src/Symfony/Bundle/Resources/config/openapi.php +++ b/src/Symfony/Bundle/Resources/config/openapi.php @@ -70,6 +70,7 @@ '%api_platform.openapi.errorResourceClass%', '%api_platform.openapi.validationErrorResourceClass%', '%api_platform.openapi.license.identifier%', + '%api_platform.swagger.with_credentials%', ]); $services->alias(Options::class, 'api_platform.openapi.options'); diff --git a/src/Symfony/Bundle/Resources/public/init-swagger-ui.js b/src/Symfony/Bundle/Resources/public/init-swagger-ui.js index bdf9bb3a8c0..0e8059f7d7a 100644 --- a/src/Symfony/Bundle/Resources/public/init-swagger-ui.js +++ b/src/Symfony/Bundle/Resources/public/init-swagger-ui.js @@ -41,7 +41,7 @@ window.onload = function() { }).observe(document, {childList: true, subtree: true}); const data = JSON.parse(document.getElementById('swagger-data').innerText); - const ui = SwaggerUIBundle(Object.assign({ + const config = { spec: data.spec, dom_id: '#swagger-ui', validatorUrl: null, @@ -56,7 +56,16 @@ window.onload = function() { SwaggerUIBundle.plugins.DownloadUrl, ], layout: 'StandaloneLayout', - }, data.extraConfiguration)); + }; + + if (data.withCredentials) { + config.requestInterceptor = (req) => { + req.credentials = 'include'; + return req; + }; + } + + const ui = SwaggerUIBundle(Object.assign(config, data.extraConfiguration)); if (data.oauth.enabled) { ui.initOAuth({ diff --git a/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php b/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php index eba9d89fed8..065ada1ea14 100644 --- a/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php +++ b/src/Symfony/Bundle/SwaggerUi/SwaggerUiProcessor.php @@ -64,6 +64,7 @@ public function process(mixed $openApi, Operation $operation, array $uriVariable 'url' => $this->urlGenerator->generate('api_doc', ['format' => 'json']), 'spec' => $this->normalizer->normalize($openApi, 'json', []), 'persistAuthorization' => $this->openApiOptions->hasPersistAuthorization(), + 'withCredentials' => $this->openApiOptions->getWithCredentials(), 'oauth' => [ 'enabled' => $this->openApiOptions->getOAuthEnabled(), 'type' => $this->openApiOptions->getOAuthType(), diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 9004ecb6654..dc392b2c1d7 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -161,6 +161,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'http_auth' => [], 'swagger_ui_extra_configuration' => [], 'persist_authorization' => false, + 'with_credentials' => false, ], 'eager_loading' => [ 'enabled' => true, From e4684ef13c5e800bfe17e1b235d5884757e0263f Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 22 May 2026 16:15:49 +0200 Subject: [PATCH 12/84] test: migrate remaining trivial behat features to ApiTestCase (#7971) Co-authored-by: Claude Opus 4.7 (1M context) --- features/filter/filter_validation.feature | 109 ------- features/filter/property_filter.feature | 28 -- features/http_cache/headers.feature | 12 - .../http_cache/tag_collector_service.feature | 268 ------------------ features/http_cache/tags.feature | 142 ---------- features/issues/5926.feature | 36 --- features/json/input_output.feature | 40 --- features/json/relation.feature | 229 --------------- features/mercure/discover.feature | 13 - features/mercure/publish.feature | 60 ---- features/push_relations/push.feature | 17 -- .../sub_resources/multiple_relation.feature | 61 ---- features/xml/deserialization.feature | 92 ------ .../PropertyFilter/SparseFieldsetChild.php | 44 +++ .../PropertyFilter/SparseFieldsetParent.php | 57 ++++ ...SparseFieldsetParentWithQueryParameter.php | 54 ++++ .../Filter/FilterValidationTest.php | 147 ++++++++++ .../Functional/Filter/PropertyFilterTest.php | 100 +++++++ tests/Functional/HttpCache/CacheTagsTest.php | 201 +++++++++++++ tests/Functional/HttpCache/HeadersTest.php | 48 ++++ .../HttpCache/PushRelationsTest.php | 88 ++++++ .../Functional/HttpCache/TagCollectorTest.php | 227 +++++++++++++++ tests/Functional/Issue5926Test.php | 62 ++++ tests/Functional/Json/InputOutputTest.php | 67 +++++ tests/Functional/Json/RelationTest.php | 184 +++++++++++- tests/Functional/Mercure/MercureTest.php | 149 ++++++++++ .../SubResource/MultipleRelationTest.php | 94 ++++++ tests/Functional/Xml/DeserializationTest.php | 173 +++++++++++ tests/RecreateSchemaTrait.php | 7 + 29 files changed, 1693 insertions(+), 1116 deletions(-) delete mode 100644 features/filter/filter_validation.feature delete mode 100644 features/filter/property_filter.feature delete mode 100644 features/http_cache/headers.feature delete mode 100644 features/http_cache/tag_collector_service.feature delete mode 100644 features/http_cache/tags.feature delete mode 100644 features/issues/5926.feature delete mode 100644 features/json/input_output.feature delete mode 100644 features/json/relation.feature delete mode 100644 features/mercure/discover.feature delete mode 100644 features/mercure/publish.feature delete mode 100644 features/push_relations/push.feature delete mode 100644 features/sub_resources/multiple_relation.feature delete mode 100644 features/xml/deserialization.feature create mode 100644 tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetChild.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParent.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParentWithQueryParameter.php create mode 100644 tests/Functional/Filter/FilterValidationTest.php create mode 100644 tests/Functional/Filter/PropertyFilterTest.php create mode 100644 tests/Functional/HttpCache/CacheTagsTest.php create mode 100644 tests/Functional/HttpCache/HeadersTest.php create mode 100644 tests/Functional/HttpCache/PushRelationsTest.php create mode 100644 tests/Functional/HttpCache/TagCollectorTest.php create mode 100644 tests/Functional/Issue5926Test.php create mode 100644 tests/Functional/Json/InputOutputTest.php create mode 100644 tests/Functional/Mercure/MercureTest.php create mode 100644 tests/Functional/SubResource/MultipleRelationTest.php create mode 100644 tests/Functional/Xml/DeserializationTest.php diff --git a/features/filter/filter_validation.feature b/features/filter/filter_validation.feature deleted file mode 100644 index 5119643e553..00000000000 --- a/features/filter/filter_validation.feature +++ /dev/null @@ -1,109 +0,0 @@ -Feature: Validate filters based upon filter description - - Background: - Given I add "Accept" header equal to "application/json" - - @createSchema - Scenario: Required filter should not throw an error if set - When I am on "/filter_validators?required=foo&required-allow-empty=&arrayRequired[foo]=" - Then the response status code should be 200 - - Scenario: Required filter that does not allow empty value should throw an error if empty - When I am on "/filter_validators?required=&required-allow-empty=&arrayRequired[foo]=" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'required: This value should not be blank.' - - Scenario: Required filter should throw an error if not set - When I am on "/filter_validators" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'required: This value should not be blank.\nrequired-allow-empty: This value should not be null.' - - Scenario: Required filter should not throw an error if set - When I am on "/array_filter_validators?arrayRequired[]=foo&indexedArrayRequired[foo]=foo" - Then the response status code should be 200 - - Scenario: Required filter should throw an error if not set - When I am on "/array_filter_validators" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'arrayRequired[]: This value should not be blank.\nindexedArrayRequired[foo]: This value should not be blank.' - - When I am on "/array_filter_validators?arrayRequired[foo]=foo" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'indexedArrayRequired[foo]: This value should not be blank.' - - When I am on "/array_filter_validators?arrayRequired[]=foo" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'indexedArrayRequired[foo]: This value should not be blank.' - - Scenario: Test filter bounds: maximum - When I am on "/filter_validators?required=foo&required-allow-empty&maximum=10" - Then the response status code should be 200 - - When I am on "/filter_validators?required=foo&required-allow-empty&maximum=11" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'maximum: This value should be less than or equal to 10.' - - Scenario: Test filter bounds: exclusiveMaximum - When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMaximum=9" - Then the response status code should be 200 - - When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMaximum=10" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'exclusiveMaximum: This value should be less than 10.' - - Scenario: Test filter bounds: minimum - When I am on "/filter_validators?required=foo&required-allow-empty&minimum=5" - Then the response status code should be 200 - - When I am on "/filter_validators?required=foo&required-allow-empty&minimum=0" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'minimum: This value should be greater than or equal to 5.' - - Scenario: Test filter bounds: exclusiveMinimum - When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMinimum=6" - Then the response status code should be 200 - - When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMinimum=5" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'exclusiveMinimum: This value should be greater than 5.' - - Scenario: Test filter bounds: max length - When I am on "/filter_validators?required=foo&required-allow-empty&max-length-3=123" - Then the response status code should be 200 - - When I am on "/filter_validators?required=foo&required-allow-empty&max-length-3=1234" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'max-length-3: This value is too long. It should have 3 characters or less.' - - Scenario: Test filter bounds: min length - When I am on "/filter_validators?required=foo&required-allow-empty&min-length-3=123" - Then the response status code should be 200 - - When I am on "/filter_validators?required=foo&required-allow-empty&min-length-3=12" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'min-length-3: This value is too short. It should have 3 characters or more.' - - Scenario: Test filter pattern - When I am on "/filter_validators?required=foo&required-allow-empty&pattern=pattern" - When I am on "/filter_validators?required=foo&required-allow-empty&pattern=nrettap" - Then the response status code should be 200 - - When I am on "/filter_validators?required=foo&required-allow-empty&pattern=not-pattern" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'pattern: This value is not valid.' - - Scenario: Test filter enum - When I am on "/filter_validators?required=foo&required-allow-empty&enum=in-enum" - Then the response status code should be 200 - - When I am on "/filter_validators?required=foo&required-allow-empty&enum=not-in-enum" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'enum: The value you selected is not a valid choice.' - - Scenario: Test filter multipleOf - When I am on "/filter_validators?required=foo&required-allow-empty&multiple-of=4" - Then the response status code should be 200 - - When I am on "/filter_validators?required=foo&required-allow-empty&multiple-of=3" - Then the response status code should be 422 - And the JSON node "detail" should be equal to 'multiple-of: This value should be a multiple of 2.' diff --git a/features/filter/property_filter.feature b/features/filter/property_filter.feature deleted file mode 100644 index 3b225793d3f..00000000000 --- a/features/filter/property_filter.feature +++ /dev/null @@ -1,28 +0,0 @@ -Feature: Set properties to include - In order to select specific properties from a resource - As a client software developer - I need to select attributes to retrieve - - @createSchema - Scenario: Test properties filter - Given there are 1 dummy objects with relatedDummy and its thirdLevel - When I send a "GET" request to "/dummies/1?properties[]=name&properties[]=alias&properties[]=relatedDummy&properties[]=name_converted" - Then the JSON node "name" should be equal to "Dummy #1" - And the JSON node "alias" should be equal to "Alias #0" - And the JSON node "relatedDummies" should not exist - And the JSON node "name_converted" should exist - - Scenario: Test relation embedding - When I send a "GET" request to "/dummies/1?properties[]=name&properties[]=alias&properties[relatedDummy][]=name" - Then the JSON node "name" should be equal to "Dummy #1" - And the JSON node "alias" should be equal to "Alias #0" - And the JSON node "relatedDummy.name" should be equal to "RelatedDummy #1" - And the JSON node "relatedDummies" should not exist - - Scenario: Test property filter on not resource relations - When I send a "GET" request to "/dummy-with-array-of-objects/1?properties[notResourceObject][]=foo&properties[arrayOfNotResourceObjects][]=bar" - Then the JSON node "notResourceObject.foo" should be equal to "foo" - And the JSON node "notResourceObject.bar" should not exist - And the JSON node "arrayOfNotResourceObjects[0].foo" should not exist - And the JSON node "arrayOfNotResourceObjects[0].bar" should be equal to "bar" - And the JSON node "id" should not exist diff --git a/features/http_cache/headers.feature b/features/http_cache/headers.feature deleted file mode 100644 index 7c000f79b05..00000000000 --- a/features/http_cache/headers.feature +++ /dev/null @@ -1,12 +0,0 @@ -Feature: Default values of HTTP cache headers - In order to make API responses cacheable - As an API software developer - I need to be able to set default cache headers values - - @createSchema - Scenario: Cache headers default value - When I send a "GET" request to "/relation_embedders" - Then the response status code should be 200 - And the header "Etag" should be equal to '"032297ac74d75a50"' - And the header "Cache-Control" should be equal to "max-age=60, public, s-maxage=3600" - And the header "Vary" should be equal to "Accept, Cookie, Accept-Language" diff --git a/features/http_cache/tag_collector_service.feature b/features/http_cache/tag_collector_service.feature deleted file mode 100644 index ed994aadb7e..00000000000 --- a/features/http_cache/tag_collector_service.feature +++ /dev/null @@ -1,268 +0,0 @@ -@sqlite -@customTagCollector -@disableForSymfonyLowest -Feature: Cache invalidation through HTTP Cache tags (custom TagCollector service) - In order to have a fast API - As an API software developer - I need to store API responses in a cache - - @createSchema - Scenario: Create a dummy resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - } - """ - Then the response status code should be 201 - And the header "Cache-Tags" should not exist - - Scenario: TagCollector can identify $object (IRI is overridden with custom logic) - When I send a "GET" request to "/relation_embedders/1" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/RE/1#anotherRelated,/RE/1#related,/RE/1" - - Scenario: Create some embedded resources - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "anotherRelated": { - "name": "Related" - } - } - """ - Then the response status code should be 201 - And the header "Cache-Tags" should not exist - - Scenario: TagCollector can add cache tags for relations (JSON-LD format) - When I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/relation_embedders/2" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/related_dummies/1#thirdLevel,/related_dummies/1,/RE/2#anotherRelated,/RE/2#related,/RE/2" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelationEmbedder", - "@id": "/relation_embedders/2", - "@type": "RelationEmbedder", - "krondstadt": "Krondstadt", - "anotherRelated": { - "@id": "/related_dummies/1", - "@type": "https://schema.org/Product", - "symfony": "symfony", - "thirdLevel": null - }, - "related": null - } - """ - - Scenario: TagCollector can add cache tags for relations (HAL format) - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/relation_embedders/2" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/RE/2,/related_dummies/1,/related_dummies/1#thirdLevel,/RE/2#anotherRelated,/RE/2#related" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/relation_embedders/2" - }, - "anotherRelated": { - "href": "/related_dummies/1" - } - }, - "_embedded": { - "anotherRelated": { - "_links": { - "self": { - "href": "/related_dummies/1" - } - }, - "symfony": "symfony" - } - }, - "krondstadt": "Krondstadt" - } - """ - - Scenario: TagCollector can add cache tags for relations (JSONAPI format) - When I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/relation_embedders/2" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/RE/2,/RE/2#anotherRelated,/RE/2#related" - And the JSON should be equal to: - """ - { - "data": { - "id": "/relation_embedders/2", - "type": "RelationEmbedder", - "attributes": { - "krondstadt": "Krondstadt" - }, - "relationships": { - "anotherRelated": { - "data": { - "type": "RelatedDummy", - "id": "/related_dummies/1" - } - }, - "related": { - "data": [] - } - } - } - } - """ - - Scenario: Create resource with extraProperties on ApiProperty - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/extra_properties_on_properties" with body: - """ - { - } - """ - Then the response status code should be 201 - And the header "Cache-Tags" should not exist - - Scenario: TagCollector can read propertyMetadata (tag is overridden with data from extraProperties) - When I send a "GET" request to "/extra_properties_on_properties/1" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/extra_properties_on_properties/1#overrideRelationTag,/extra_properties_on_properties/1" - - Scenario: Create two Relation2 - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation2s" with body: - """ - { - } - """ - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation2s" with body: - """ - { - } - """ - Then the response status code should be 201 - - Scenario: Create a Relation3 with many to many - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation3s" with body: - """ - { - "relation2s": ["/relation2s/1", "/relation2s/2"] - } - """ - Then the response status code should be 201 - - Scenario: Get a Relation3 (test collection of links; JSON-LD format) - When I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/relation3s" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/relation3s/1#relation2s,/relation3s/1,/relation3s" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Relation3", - "@id": "/relation3s", - "@type": "hydra:Collection", - "hydra:totalItems": 1, - "hydra:member": [ - { - "@id": "/relation3s/1", - "@type": "Relation3", - "id": 1, - "relation2s": [ - "/relation2s/1", - "/relation2s/2" - ] - } - ] - } - """ - - Scenario: Get a Relation3 (test collection of links; HAL format) - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/relation3s" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/relation3s/1,/relation3s/1#relation2s,/relation3s" - And the JSON should be equal to: - """ - { - "_links": { - "self": { - "href": "/relation3s" - }, - "item": [ - { - "href": "/relation3s/1" - } - ] - }, - "totalItems": 1, - "itemsPerPage": 3, - "_embedded": { - "item": [ - { - "_links": { - "self": { - "href": "/relation3s/1" - }, - "relation2s": [ - { - "href": "/relation2s/1" - }, - { - "href": "/relation2s/2" - } - ] - }, - "id": 1 - } - ] - } - } - """ - - Scenario: Get a Relation3 (test collection of links; HAL format) - When I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/relation3s" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/relation3s/1,/relation3s/1#relation2s,/relation3s" - And the JSON should be equal to: - """ - { - "links": { - "self": "/relation3s" - }, - "meta": { - "totalItems": 1, - "itemsPerPage": 3, - "currentPage": 1 - }, - "data": [ - { - "id": "/relation3s/1", - "type": "Relation3", - "attributes": { - "_id": 1 - }, - "relationships": { - "relation2s": { - "data": [ - { - "type": "Relation2", - "id": "/relation2s/1" - }, - { - "type": "Relation2", - "id": "/relation2s/2" - } - ] - } - } - } - ] - } - """ diff --git a/features/http_cache/tags.feature b/features/http_cache/tags.feature deleted file mode 100644 index bcc5ed9370c..00000000000 --- a/features/http_cache/tags.feature +++ /dev/null @@ -1,142 +0,0 @@ -@sqlite -Feature: Cache invalidation through HTTP Cache tags - In order to have a fast API - As an API software developer - I need to store API responses in a cache - - @createSchema - Scenario: Create some embedded resources - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "anotherRelated": { - "name": "Related", - "thirdLevel": {} - } - } - """ - Then the response status code should be 201 - And the header "Cache-Tags" should not exist - And "/relation_embedders,/related_dummies,/third_levels" IRIs should be purged - - Scenario: Tags must be set for items - When I send a "GET" request to "/relation_embedders/1" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/third_levels/1,/related_dummies/1,/relation_embedders/1" - - Scenario: Create some more resources - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "anotherRelated": { - "name": "Another Related", - "thirdLevel": {} - } - } - """ - Then the response status code should be 201 - And the header "Cache-Tags" should not exist - - Scenario: Tags must be set for collections - When I send a "GET" request to "/relation_embedders" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/third_levels/1,/related_dummies/1,/relation_embedders/1,/third_levels/2,/related_dummies/2,/relation_embedders/2,/relation_embedders" - - Scenario: Purge item on update - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/relation_embedders/1" with body: - """ - { - "paris": "France" - } - """ - Then the response status code should be 200 - And the header "Cache-Tags" should not exist - And "/relation_embedders,/relation_embedders/1,/related_dummies/1" IRIs should be purged - - Scenario: Purge item and the related collection on update - When I add "Content-Type" header equal to "application/ld+json" - And I send a "DELETE" request to "/relation_embedders/1" - Then the response status code should be 204 - And the header "Cache-Tags" should not exist - And "/relation_embedders,/relation_embedders/1,/related_dummies/1" IRIs should be purged - - Scenario: Create two Relation2 - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation2s" with body: - """ - { - } - """ - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation2s" with body: - """ - { - } - """ - Then the response status code should be 201 - - Scenario: Embedded collection must be listed in cache tags - When I send a "GET" request to "/relation2s/1" - Then the header "Cache-Tags" should be equal to "/relation2s/1" - - Scenario: Create a Relation1 - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation1s" with body: - """ - { - "relation2": "/relation2s/1" - } - """ - Then the response status code should be 201 - And "/relation1s,/relation2s/1" IRIs should be purged - - Scenario: Update a Relation1 - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/relation1s/1" with body: - """ - { - "relation2": "/relation2s/2" - } - """ - Then the response status code should be 200 - And "/relation1s,/relation1s/1,/relation2s/2,/relation2s/1" IRIs should be purged - - Scenario: Create a Relation3 with many to many - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation3s" with body: - """ - { - "relation2s": ["/relation2s/1", "/relation2s/2"] - } - """ - Then the response status code should be 201 - And "/relation3s,/relation2s/1,/relation2s/2" IRIs should be purged - - Scenario: Get a Relation3 - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/relation3s" - Then the response status code should be 200 - And the header "Cache-Tags" should be equal to "/relation2s/1,/relation2s/2,/relation3s/1,/relation3s" - - Scenario: Update a collection member only (legacy non-standard PUT) - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/relation3s/1" with body: - """ - { - "relation2s": ["/relation2s/2"] - } - """ - Then the response status code should be 200 - And the header "Cache-Tags" should not exist - And "/relation3s,/relation3s/1,/relation2s/2,/relation2s,/relation2s/1" IRIs should be purged - - Scenario: Delete the collection owner - When I add "Content-Type" header equal to "application/ld+json" - And I send a "DELETE" request to "/relation3s/1" - Then the response status code should be 204 - And the header "Cache-Tags" should not exist - And "/relation3s,/relation3s/1,/relation2s/2" IRIs should be purged - diff --git a/features/issues/5926.feature b/features/issues/5926.feature deleted file mode 100644 index 640d8a1bc6c..00000000000 --- a/features/issues/5926.feature +++ /dev/null @@ -1,36 +0,0 @@ -Feature: Issue 5926 - In order to reproduce the issue at https://github.com/api-platform/core/issues/5926 - As a client software developer - I need to be able to use every operation on a resource with non-resources embed objects - - @!mongodb - Scenario: Create and retrieve a WriteResource - When I add "Accept" header equal to "application/json" - And I send a "GET" request to "/test_issue5926s/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json; charset=utf-8" - - @!mongodb - Scenario: Create and retrieve a JSON:API WriteResource - When I add "Accept" header equal to "application/vnd.api+json" - And I send a "GET" request to "/test_issue5926s/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/vnd.api+json; charset=utf-8" - - @!mongodb - Scenario: Create and retrieve a LD+JSON WriteResource - When I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/test_issue5926s/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - @!mongodb - Scenario: Create and retrieve a HAL WriteResource - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/test_issue5926s/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/hal+json; charset=utf-8" diff --git a/features/json/input_output.feature b/features/json/input_output.feature deleted file mode 100644 index 2a5a1143162..00000000000 --- a/features/json/input_output.feature +++ /dev/null @@ -1,40 +0,0 @@ -Feature: JSON DTO input and output - In order to use the API - As a client software developer - I need to be able to use DTOs on my resources as Input or Output objects. - - Background: - Given I add "Accept" header equal to "application/json" - And I add "Content-Type" header equal to "application/json" - - @createSchema - Scenario: Request a password reset - And I send a "POST" request to "/users_reset/password_reset_request" with body: - """ - { - "email": "user@example.com" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json; charset=utf-8" - And the JSON should be equal to: - """ - { - "emailSentAt": "2019-07-05T15:44:00+00:00" - } - """ - - @createSchema - Scenario: Request a password reset for a non-existent user - And I send a "POST" request to "/users_reset/password_reset_request" with body: - """ - { - "email": "does-not-exist@example.com" - } - """ - Then the response status code should be 404 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "detail" should be equal to "User does not exist." - diff --git a/features/json/relation.feature b/features/json/relation.feature deleted file mode 100644 index 3455d9d4123..00000000000 --- a/features/json/relation.feature +++ /dev/null @@ -1,229 +0,0 @@ -Feature: JSON relations support - In order to use a hypermedia API - As a client software developer - I need to be able to update relations between resources - - @createSchema - Scenario: Create a third level - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/third_levels" with body: - """ - { - "level": 3 - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/ThirdLevel", - "@id": "/third_levels/1", - "@type": "ThirdLevel", - "fourthLevel": null, - "badFourthLevel": null, - "id": 1, - "level": 3, - "test": true, - "relatedDummies": [] - } - """ - - Scenario: Create a new relation - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "anotherRelated": { - "symfony": "laravel" - } - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelationEmbedder", - "@id": "/relation_embedders/1", - "@type": "RelationEmbedder", - "krondstadt": "Krondstadt", - "anotherRelated": { - "@id": "/related_dummies/1", - "@type": "https://schema.org/Product", - "symfony": "laravel", - "thirdLevel": null - }, - "related": null - } - """ - - Scenario: Update the relation with a new one - When I add "Content-Type" header equal to "application/json" - And I send a "PUT" request to "/relation_embedders/1" with body: - """ - { - "anotherRelated": { - "symfony": "laravel2" - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelationEmbedder", - "@id": "/relation_embedders/1", - "@type": "RelationEmbedder", - "krondstadt": "Krondstadt", - "anotherRelated": { - "@id": "/related_dummies/2", - "@type": "https://schema.org/Product", - "symfony": "laravel2", - "thirdLevel": null - }, - "related": null - } - """ - - Scenario: Update an embedded relation using an IRI - When I add "Content-Type" header equal to "application/json" - And I send a "PUT" request to "/relation_embedders/1" with body: - """ - { - "anotherRelated": { - "id": "/related_dummies/1", - "symfony": "API Platform" - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelationEmbedder", - "@id": "/relation_embedders/1", - "@type": "RelationEmbedder", - "krondstadt": "Krondstadt", - "anotherRelated": { - "@id": "/related_dummies/1", - "@type": "https://schema.org/Product", - "symfony": "API Platform", - "thirdLevel": null - }, - "related": null - } - """ - - Scenario: Update an embedded relation - When I add "Content-Type" header equal to "application/json" - And I send a "PUT" request to "/relation_embedders/1" with body: - """ - { - "anotherRelated": { - "id": 1, - "symfony": "API Platform 2" - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelationEmbedder", - "@id": "/relation_embedders/1", - "@type": "RelationEmbedder", - "krondstadt": "Krondstadt", - "anotherRelated": { - "@id": "/related_dummies/1", - "@type": "https://schema.org/Product", - "symfony": "API Platform 2", - "thirdLevel": null - }, - "related": null - } - """ - - Scenario: Create a related dummy with a relation using plain identifiers - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/related_dummies" with body: - """ - { - "thirdLevel": "1" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelatedDummy", - "@id": "/related_dummies/3", - "@type": "https://schema.org/Product", - "id": 3, - "name": null, - "symfony": "symfony", - "dummyDate": null, - "thirdLevel": { - "@id": "/third_levels/1", - "@type": "ThirdLevel", - "fourthLevel": null - }, - "relatedToDummyFriend": [], - "dummyBoolean": null, - "embeddedDummy": [], - "age": null - } - """ - - Scenario: Passing a (valid) plain identifier on a relation - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/dummies" with body: - """ - { - "relatedDummy": "1", - "relatedDummies": [ - "1" - ], - "name": "Dummy with plain relations" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Dummy", - "@id": "/dummies/1", - "@type": "Dummy", - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "relatedDummy": "/related_dummies/1", - "relatedDummies": [ - "/related_dummies/1" - ], - "jsonData": [], - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": null, - "relatedOwningDummy": null, - "id": 1, - "name": "Dummy with plain relations", - "alias": null, - "foo": null - } - """ diff --git a/features/mercure/discover.feature b/features/mercure/discover.feature deleted file mode 100644 index fecc057506f..00000000000 --- a/features/mercure/discover.feature +++ /dev/null @@ -1,13 +0,0 @@ -Feature: Mercure discovery support - In order to let the client discovering the Mercure hub - As a client software developer - I need to retrieve the hub URL through a Link HTTP header - - @createSchema - Scenario: Checks that the Mercure Link is added - Given I send a "GET" request to "/dummy_mercures" - Then the header "Link" should contain '; rel="mercure"' - - Scenario: Checks that the Mercure Link is not added on endpoints where updates are not dispatched - Given I send a "GET" request to "/" - Then the header "Link" should not contain '; rel="mercure"' diff --git a/features/mercure/publish.feature b/features/mercure/publish.feature deleted file mode 100644 index ac0c27fa7ed..00000000000 --- a/features/mercure/publish.feature +++ /dev/null @@ -1,60 +0,0 @@ -Feature: Mercure publish support - In order to publish an Update to the Mercure hub - As a developer - I need to specify which topics I want to send the Update on - - @createSchema - # see https://github.com/api-platform/core/issues/5074 - Scenario: Checks that Mercure Updates are dispatched properly - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - When I send a "POST" request to "/issue5074/mercure_with_topics" with body: - """ - { - "name": "Hello World!", - "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit." - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - Then 1 Mercure update should have been sent - And the Mercure update should have topics: - | http://example.com/issue5074/mercure_with_topics/1 | - And the Mercure update should have data: - """ - { - "@context": "/contexts/MercureWithTopics", - "@id": "/issue5074/mercure_with_topics/1", - "@type": "MercureWithTopics", - "id": 1, - "name": "Hello World!" - } - """ - - Scenario: Checks that Mercure Updates are dispatched following topics configured with expression language - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - When I send a "POST" request to "/mercure_with_topics_and_get_operations" with body: - """ - { - "name": "Hello World!" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - Then 1 Mercure update should have been sent - And the Mercure update should have topics: - | http://example.com/mercure_with_topics_and_get_operations/1 | - | http://example.com/custom_resource/mercure_with_topics_and_get_operations/1 | - And the Mercure update should have data: - """ - { - "@context": "/contexts/MercureWithTopicsAndGetOperation", - "@id": "/mercure_with_topics_and_get_operations/1", - "@type": "MercureWithTopicsAndGetOperation", - "id": 1, - "name": "Hello World!" - } - """ diff --git a/features/push_relations/push.feature b/features/push_relations/push.feature deleted file mode 100644 index fe4a7d1de75..00000000000 --- a/features/push_relations/push.feature +++ /dev/null @@ -1,17 +0,0 @@ -@sqlite -Feature: Push relations using HTTP/2 - In order to have a fast API - As an API software developer - I need to push relations using HTTP/2 - - @createSchema - Scenario: Push the relations of a collection of items - Given there are 2 dummy objects with relatedDummy - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/dummies" - Then the header "Link" should be equal to '; rel="preload"; as="fetch",; rel="preload"; as="fetch",; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"' - - Scenario: Push the relations of an item - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/dummies/1" - Then the header "Link" should be equal to '; rel="preload"; as="fetch",; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"' diff --git a/features/sub_resources/multiple_relation.feature b/features/sub_resources/multiple_relation.feature deleted file mode 100644 index c774257aa37..00000000000 --- a/features/sub_resources/multiple_relation.feature +++ /dev/null @@ -1,61 +0,0 @@ -Feature: JSON-LD multi relation - In order to use non-resource types - As a developer - I should be able to serialize types not mapped to an API resource. - - Background: - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - - @createSchema - @!mongodb - Scenario: Get a multiple relation between to object - Given there is a relationMultiple object - When I send a "GET" request to "/dummy/1/relations/2" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelationMultiple", - "@id": "/dummy/1/relations/2", - "@type": "RelationMultiple", - "id": 1, - "first": "/dummies/1", - "second": "/dummies/2" - } - """ - - @!mongodb - Scenario: Get all multiple relation of an object - Given there is a dummy object with many multiple relation - When I send a "GET" request to "/dummy/1/relations" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelationMultiple", - "@id": "/dummy/1/relations", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/dummy/1/relations/2", - "@type": "RelationMultiple", - "id": 1, - "first": "/dummies/1", - "second": "/dummies/2" - }, - { - "@id": "/dummy/1/relations/3", - "@type": "RelationMultiple", - "id": 2, - "first": "/dummies/1", - "second": "/dummies/3" - } - ], - "hydra:totalItems": 2 - } - """ diff --git a/features/xml/deserialization.feature b/features/xml/deserialization.feature deleted file mode 100644 index ae2d2ef66ab..00000000000 --- a/features/xml/deserialization.feature +++ /dev/null @@ -1,92 +0,0 @@ -Feature: XML Deserialization - In order to use the API with XML - As a client software developer - I need to be able to deserialize XML data - - Background: - Given I add "Accept" header equal to "application/xml" - And I add "Content-Type" header equal to "application/xml" - - @createSchema - Scenario: Posting an XML resource with a string value - When I send a "POST" request to "/resource_with_strings" with body: - """ - - - string - - """ - Then the response status code should be 201 - And the response should be in XML - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - - Scenario Outline: Posting an XML resource with a boolean value - When I send a "POST" request to "/resource_with_booleans" with body: - """ - - - - - """ - Then the response status code should be 201 - And the response should be in XML - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - Examples: - | value | - | true | - | false | - | 1 | - | 0 | - - Scenario Outline: Posting an XML resource with an integer value - When I send a "POST" request to "/resource_with_integers" with body: - """ - - - - - """ - Then the response status code should be 201 - And the response should be in XML - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - Examples: - | value | - | 42 | - | -6 | - | 1 | - | 0 | - - @!mysql - Scenario Outline: Posting an XML resource with a float value - When I send a "POST" request to "/resource_with_floats" with body: - """ - - - - - """ - Then the response status code should be 201 - And the response should be in XML - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - Examples: - | value | - | 3.14 | - | NaN | - | INF | - | -INF | - - Scenario: Posting an XML resource with a collection with only one element - When I send a "POST" request to "/dummy_properties" with body: - """ - - - - - bar - - - - """ - Then the response status code should be 201 - And the response should be in XML - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" diff --git a/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetChild.php b/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetChild.php new file mode 100644 index 00000000000..d50e257190d --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetChild.php @@ -0,0 +1,44 @@ + + * + * 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\PropertyFilter; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; + +#[ApiResource( + operations: [ + new Get( + uriTemplate: '/sparse_fieldset_children/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +final class SparseFieldsetChild +{ + public function __construct( + #[ApiProperty(identifier: true)] + public int $id, + public string $name, + public ?string $description = null, + ) { + } + + public static function provide(Operation $operation, array $uriVariables = []): self + { + return new self((int) $uriVariables['id'], 'Child #'.$uriVariables['id'], 'A description'); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParent.php b/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParent.php new file mode 100644 index 00000000000..4eb2f7eae91 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParent.php @@ -0,0 +1,57 @@ + + * + * 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\PropertyFilter; + +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Serializer\Filter\PropertyFilter; + +#[ApiResource( + operations: [ + new Get( + uriTemplate: '/sparse_fieldset_parents/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), + ], +)] +#[ApiFilter(PropertyFilter::class)] +final class SparseFieldsetParent +{ + public function __construct( + #[ApiProperty(identifier: true)] + public int $id, + public string $name, + public string $alias, + public string $nameConverted, + public ?SparseFieldsetChild $child = null, + ) { + } + + public static function provide(Operation $operation, array $uriVariables = []): self + { + $id = (int) $uriVariables['id']; + + return new self( + $id, + 'Parent #'.$id, + 'Alias #'.$id, + 'Converted '.$id, + new SparseFieldsetChild($id, 'Child #'.$id, 'A description'), + ); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParentWithQueryParameter.php b/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParentWithQueryParameter.php new file mode 100644 index 00000000000..405291bda02 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParentWithQueryParameter.php @@ -0,0 +1,54 @@ + + * + * 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\PropertyFilter; + +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Serializer\Filter\PropertyFilter; + +#[Get( + uriTemplate: '/sparse_fieldset_parents_qp/{id}', + uriVariables: ['id'], + parameters: [ + 'properties' => new QueryParameter(filter: new PropertyFilter()), + ], + provider: [self::class, 'provide'], +)] +final class SparseFieldsetParentWithQueryParameter +{ + public function __construct( + #[ApiProperty(identifier: true)] + public int $id, + public string $name, + public string $alias, + public string $nameConverted, + public ?SparseFieldsetChild $child = null, + ) { + } + + public static function provide(Operation $operation, array $uriVariables = []): self + { + $id = (int) $uriVariables['id']; + + return new self( + $id, + 'Parent #'.$id, + 'Alias #'.$id, + 'Converted '.$id, + new SparseFieldsetChild($id, 'Child #'.$id, 'A description'), + ); + } +} diff --git a/tests/Functional/Filter/FilterValidationTest.php b/tests/Functional/Filter/FilterValidationTest.php new file mode 100644 index 00000000000..af74725a240 --- /dev/null +++ b/tests/Functional/Filter/FilterValidationTest.php @@ -0,0 +1,147 @@ + + * + * 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\Filter; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ArrayFilterValidator; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilterValidator; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * Validation built from legacy filter descriptions registered through the + * `filters` attribute on the resource. The QueryParameter equivalent is + * covered by {@see \ApiPlatform\Tests\Functional\Parameters\ValidationTest}. + */ +final class FilterValidationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [FilterValidator::class, ArrayFilterValidator::class]; + } + + protected function setUp(): void + { + $this->recreateSchema($this->getResources()); + } + + public function testRequiredFilterValid(): void + { + self::createClient()->request('GET', '/filter_validators?required=foo&required-allow-empty=&arrayRequired[foo]=', [ + 'headers' => ['Accept' => 'application/json'], + ]); + $this->assertResponseStatusCodeSame(200); + } + + public function testRequiredFilterBlank(): void + { + self::createClient()->request('GET', '/filter_validators?required=&required-allow-empty=&arrayRequired[foo]=', [ + 'headers' => ['Accept' => 'application/json'], + ]); + $this->assertResponseStatusCodeSame(422); + $this->assertJsonContains(['detail' => 'required: This value should not be blank.']); + } + + public function testRequiredFilterMissing(): void + { + self::createClient()->request('GET', '/filter_validators', [ + 'headers' => ['Accept' => 'application/json'], + ]); + $this->assertResponseStatusCodeSame(422); + $this->assertJsonContains([ + 'detail' => "required: This value should not be blank.\nrequired-allow-empty: This value should not be null.", + ]); + } + + public function testArrayRequiredValid(): void + { + self::createClient()->request('GET', '/array_filter_validators?arrayRequired[]=foo&indexedArrayRequired[foo]=foo', [ + 'headers' => ['Accept' => 'application/json'], + ]); + $this->assertResponseStatusCodeSame(200); + } + + public function testArrayRequiredMissing(): void + { + self::createClient()->request('GET', '/array_filter_validators', [ + 'headers' => ['Accept' => 'application/json'], + ]); + $this->assertResponseStatusCodeSame(422); + $this->assertJsonContains([ + 'detail' => "arrayRequired[]: This value should not be blank.\nindexedArrayRequired[foo]: This value should not be blank.", + ]); + } + + public function testArrayRequiredOnlyOneKeyProvided(): void + { + self::createClient()->request('GET', '/array_filter_validators?arrayRequired[foo]=foo', [ + 'headers' => ['Accept' => 'application/json'], + ]); + $this->assertResponseStatusCodeSame(422); + $this->assertJsonContains([ + 'detail' => 'indexedArrayRequired[foo]: This value should not be blank.', + ]); + + self::createClient()->request('GET', '/array_filter_validators?arrayRequired[]=foo', [ + 'headers' => ['Accept' => 'application/json'], + ]); + $this->assertResponseStatusCodeSame(422); + $this->assertJsonContains([ + 'detail' => 'indexedArrayRequired[foo]: This value should not be blank.', + ]); + } + + public static function bounds(): iterable + { + yield 'maximum valid' => ['maximum=10', 200, null]; + yield 'maximum invalid' => ['maximum=11', 422, 'maximum: This value should be less than or equal to 10.']; + yield 'exclusiveMaximum valid' => ['exclusiveMaximum=9', 200, null]; + yield 'exclusiveMaximum invalid' => ['exclusiveMaximum=10', 422, 'exclusiveMaximum: This value should be less than 10.']; + yield 'minimum valid' => ['minimum=5', 200, null]; + yield 'minimum invalid' => ['minimum=0', 422, 'minimum: This value should be greater than or equal to 5.']; + yield 'exclusiveMinimum valid' => ['exclusiveMinimum=6', 200, null]; + yield 'exclusiveMinimum invalid' => ['exclusiveMinimum=5', 422, 'exclusiveMinimum: This value should be greater than 5.']; + yield 'max length valid' => ['max-length-3=123', 200, null]; + yield 'max length invalid' => ['max-length-3=1234', 422, 'max-length-3: This value is too long. It should have 3 characters or less.']; + yield 'min length valid' => ['min-length-3=123', 200, null]; + yield 'min length invalid' => ['min-length-3=12', 422, 'min-length-3: This value is too short. It should have 3 characters or more.']; + yield 'pattern valid' => ['pattern=nrettap', 200, null]; + yield 'pattern invalid' => ['pattern=not-pattern', 422, 'pattern: This value is not valid.']; + yield 'enum valid' => ['enum=in-enum', 200, null]; + yield 'enum invalid' => ['enum=not-in-enum', 422, 'enum: The value you selected is not a valid choice.']; + yield 'multipleOf valid' => ['multiple-of=4', 200, null]; + yield 'multipleOf invalid' => ['multiple-of=3', 422, 'multiple-of: This value should be a multiple of 2.']; + } + + #[DataProvider('bounds')] + public function testFilterBounds(string $extraQuery, int $expectedStatus, ?string $expectedDetail): void + { + $url = '/filter_validators?required=foo&required-allow-empty&'.$extraQuery; + + self::createClient()->request('GET', $url, [ + 'headers' => ['Accept' => 'application/json'], + ]); + + $this->assertResponseStatusCodeSame($expectedStatus); + if (null !== $expectedDetail) { + $this->assertJsonContains(['detail' => $expectedDetail]); + } + } +} diff --git a/tests/Functional/Filter/PropertyFilterTest.php b/tests/Functional/Filter/PropertyFilterTest.php new file mode 100644 index 00000000000..10764f9629f --- /dev/null +++ b/tests/Functional/Filter/PropertyFilterTest.php @@ -0,0 +1,100 @@ + + * + * 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\Filter; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\PropertyFilter\SparseFieldsetChild; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\PropertyFilter\SparseFieldsetParent; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\PropertyFilter\SparseFieldsetParentWithQueryParameter; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Covers PropertyFilter sparse fieldset selection on resource relations. + * Non-resource selection is covered by {@see \ApiPlatform\Tests\Functional\JsonLd\NonResourceTest::testSparseFieldsetOnNonResourceObject}. + */ +final class PropertyFilterTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + SparseFieldsetParent::class, + SparseFieldsetParentWithQueryParameter::class, + SparseFieldsetChild::class, + ]; + } + + public function testApiFilterSelectsScalarProperties(): void + { + $response = self::createClient()->request( + 'GET', + '/sparse_fieldset_parents/1?properties[]=name&properties[]=alias&properties[]=nameConverted', + ['headers' => ['Accept' => 'application/ld+json']], + ); + + $body = $response->toArray(); + $this->assertSame('Parent #1', $body['name']); + $this->assertSame('Alias #1', $body['alias']); + // The name converter snake_cases this property at serialization time. + $this->assertSame('Converted 1', $body['name_converted']); + $this->assertArrayNotHasKey('child', $body); + } + + public function testApiFilterSelectsNestedRelationProperty(): void + { + $response = self::createClient()->request( + 'GET', + '/sparse_fieldset_parents/1?properties[]=name&properties[child][]=name', + ['headers' => ['Accept' => 'application/ld+json']], + ); + + $body = $response->toArray(); + $this->assertSame('Parent #1', $body['name']); + $this->assertSame('Child #1', $body['child']['name']); + $this->assertArrayNotHasKey('description', $body['child']); + $this->assertArrayNotHasKey('alias', $body); + } + + public function testQueryParameterSelectsScalarProperties(): void + { + $response = self::createClient()->request( + 'GET', + '/sparse_fieldset_parents_qp/1?properties[]=name&properties[]=alias', + ['headers' => ['Accept' => 'application/ld+json']], + ); + + $body = $response->toArray(); + $this->assertSame('Parent #1', $body['name']); + $this->assertSame('Alias #1', $body['alias']); + $this->assertArrayNotHasKey('child', $body); + $this->assertArrayNotHasKey('nameConverted', $body); + } + + public function testQueryParameterSelectsNestedRelationProperty(): void + { + $response = self::createClient()->request( + 'GET', + '/sparse_fieldset_parents_qp/1?properties[]=name&properties[child][]=name', + ['headers' => ['Accept' => 'application/ld+json']], + ); + + $body = $response->toArray(); + $this->assertSame('Parent #1', $body['name']); + $this->assertSame('Child #1', $body['child']['name']); + $this->assertArrayNotHasKey('description', $body['child']); + } +} diff --git a/tests/Functional/HttpCache/CacheTagsTest.php b/tests/Functional/HttpCache/CacheTagsTest.php new file mode 100644 index 00000000000..f7761c1b76e --- /dev/null +++ b/tests/Functional/HttpCache/CacheTagsTest.php @@ -0,0 +1,201 @@ + + * + * 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\HttpCache; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\NullPurger; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Relation1; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Relation2; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Relation3; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CacheTagsTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + RelationEmbedder::class, + RelatedDummy::class, + ThirdLevel::class, + Relation1::class, + Relation2::class, + Relation3::class, + ]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('HTTP Cache tags only enabled on SQLite test suite'); + } + + $this->recreateSchema($this->getResources()); + $this->purger()->clear(); + } + + public function testFullCacheTagsLifecycle(): void + { + $client = self::createClient(); + + // Create an embedded relation; collection IRIs should be purged. + $client->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'anotherRelated' => ['name' => 'Related', 'thirdLevel' => new \stdClass()], + ], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertResponseNotHasHeader('Cache-Tags'); + $this->assertSamePurgedIris([ + '/relation_embedders', + '/related_dummies', + '/third_levels', + ]); + + // Item GET exposes Cache-Tags. + $client->request('GET', '/relation_embedders/1'); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Cache-Tags', '/third_levels/1,/related_dummies/1,/relation_embedders/1'); + + // Create a second embedded relation. + $client->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['anotherRelated' => ['name' => 'Another Related', 'thirdLevel' => new \stdClass()]], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertResponseNotHasHeader('Cache-Tags'); + + // Collection GET aggregates per-item tags. + $client->request('GET', '/relation_embedders'); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame( + 'Cache-Tags', + '/third_levels/1,/related_dummies/1,/relation_embedders/1,/third_levels/2,/related_dummies/2,/relation_embedders/2,/relation_embedders', + ); + + // PUT purges item and related dummy. + $this->purger()->clear(); + $client->request('PUT', '/relation_embedders/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['paris' => 'France'], + ]); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseNotHasHeader('Cache-Tags'); + $this->assertSamePurgedIris(['/relation_embedders', '/relation_embedders/1', '/related_dummies/1']); + + // DELETE purges item and related dummy. + $this->purger()->clear(); + $client->request('DELETE', '/relation_embedders/1'); + $this->assertResponseStatusCodeSame(204); + $this->assertResponseNotHasHeader('Cache-Tags'); + $this->assertSamePurgedIris(['/relation_embedders', '/relation_embedders/1', '/related_dummies/1']); + } + + public function testManyToManyCacheTags(): void + { + $client = self::createClient(); + + // Two Relation2 instances. + $client->request('POST', '/relation2s', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => new \stdClass(), + ]); + $client->request('POST', '/relation2s', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => new \stdClass(), + ]); + $this->assertResponseStatusCodeSame(201); + + // Item GET on a Relation2 lists embedded collection tag. + $client->request('GET', '/relation2s/1'); + $this->assertResponseHeaderSame('Cache-Tags', '/relation2s/1'); + + // Many-to-one purges Relation2 sibling. + $this->purger()->clear(); + $client->request('POST', '/relation1s', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['relation2' => '/relation2s/1'], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertSamePurgedIris(['/relation1s', '/relation2s/1']); + + // Replacing the relation purges old + new sides. + $this->purger()->clear(); + $client->request('PUT', '/relation1s/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['relation2' => '/relation2s/2'], + ]); + $this->assertResponseStatusCodeSame(200); + $this->assertSamePurgedIris(['/relation1s', '/relation1s/1', '/relation2s/2', '/relation2s/1']); + + // Many-to-many POST purges all referenced Relation2. + $this->purger()->clear(); + $client->request('POST', '/relation3s', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['relation2s' => ['/relation2s/1', '/relation2s/2']], + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertSamePurgedIris(['/relation3s', '/relation2s/1', '/relation2s/2']); + + // Collection GET aggregates tags including the collection IRI. + $client->request('GET', '/relation3s'); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Cache-Tags', '/relation2s/1,/relation2s/2,/relation3s/1,/relation3s'); + + // Updating a many-to-many removes a sibling and purges the old & new ones. + $this->purger()->clear(); + $client->request('PUT', '/relation3s/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['relation2s' => ['/relation2s/2']], + ]); + $this->assertResponseStatusCodeSame(200); + $this->assertSamePurgedIris(['/relation3s', '/relation3s/1', '/relation2s/2', '/relation2s', '/relation2s/1']); + + // Deleting the m2m owner purges the remaining sibling. + $this->purger()->clear(); + $client->request('DELETE', '/relation3s/1'); + $this->assertResponseStatusCodeSame(204); + $this->assertSamePurgedIris(['/relation3s', '/relation3s/1', '/relation2s/2']); + } + + private function assertSamePurgedIris(array $expected): void + { + $purged = $this->purger()->getIris(); + sort($expected); + sort($purged); + $this->assertSame($expected, $purged); + } + + private function purger(): NullPurger + { + $purger = static::getContainer()->get('test.api_platform.http_cache.purger'); + \assert($purger instanceof NullPurger); + + return $purger; + } + + private function isMongoDB(): bool + { + return 'mongodb' === static::getContainer()->getParameter('kernel.environment'); + } +} diff --git a/tests/Functional/HttpCache/HeadersTest.php b/tests/Functional/HttpCache/HeadersTest.php new file mode 100644 index 00000000000..4d0c810f75d --- /dev/null +++ b/tests/Functional/HttpCache/HeadersTest.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\Tests\Functional\HttpCache; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class HeadersTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [RelationEmbedder::class]; + } + + public function testDefaultCacheHeaders(): void + { + $this->recreateSchema([RelationEmbedder::class]); + + $response = self::createClient()->request('GET', '/relation_embedders'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Etag', '"032297ac74d75a50"'); + $this->assertResponseHeaderSame('Cache-Control', 'max-age=60, public, s-maxage=3600'); + // Vary headers may come on multiple lines depending on the framework version. + $this->assertSame( + ['accept', 'cookie', 'accept-language'], + array_map('strtolower', $response->getHeaders()['vary'] ?? []), + ); + } +} diff --git a/tests/Functional/HttpCache/PushRelationsTest.php b/tests/Functional/HttpCache/PushRelationsTest.php new file mode 100644 index 00000000000..7d6899e557a --- /dev/null +++ b/tests/Functional/HttpCache/PushRelationsTest.php @@ -0,0 +1,88 @@ + + * + * 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\HttpCache; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class PushRelationsTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [Dummy::class, RelatedDummy::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('HTTP/2 push only enabled on SQLite test suite'); + } + + $this->recreateSchema([Dummy::class, RelatedDummy::class]); + $this->loadDummies(2); + } + + public function testCollectionPushesRelatedIris(): void + { + self::createClient()->request('GET', '/dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseHeaderSame( + 'Link', + '; rel="preload"; as="fetch",; rel="preload"; as="fetch",; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"', + ); + } + + public function testItemPushesRelatedIri(): void + { + self::createClient()->request('GET', '/dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseHeaderSame( + 'Link', + '; rel="preload"; as="fetch",; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"', + ); + } + + private function loadDummies(int $count): void + { + $manager = static::getContainer()->get('doctrine')->getManager(); + + for ($i = 1; $i <= $count; ++$i) { + $related = new RelatedDummy(); + $related->setName('RelatedDummy #'.$i); + + $dummy = new Dummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->nameConverted = "Converted $i"; + $dummy->setRelatedDummy($related); + + $manager->persist($related); + $manager->persist($dummy); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/HttpCache/TagCollectorTest.php b/tests/Functional/HttpCache/TagCollectorTest.php new file mode 100644 index 00000000000..3a3c280b0c9 --- /dev/null +++ b/tests/Functional/HttpCache/TagCollectorTest.php @@ -0,0 +1,227 @@ + + * + * 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\HttpCache; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ExtraPropertiesOnProperty; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Relation2; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Relation3; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\HttpCache\TagCollectorCustom; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class TagCollectorTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + RelationEmbedder::class, + RelatedDummy::class, + ThirdLevel::class, + ExtraPropertiesOnProperty::class, + Relation2::class, + Relation3::class, + ]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Custom tag collector is only enabled on SQLite test suite'); + } + + // Force a fresh kernel so the custom collector replacement is in effect + // before any service that depends on it is instantiated. + static::ensureKernelShutdown(); + self::bootKernel(); + $container = static::getContainer(); + $container->set( + 'api_platform.http_cache.tag_collector', + new TagCollectorCustom($container->get('api_platform.iri_converter')), + ); + + $this->recreateSchema($this->getResources()); + } + + /** + * Returns a client that keeps the kernel alive between HTTP requests so the + * tag_collector override registered in setUp survives across calls. + */ + private function disableRebootClient(): \ApiPlatform\Symfony\Bundle\Test\Client + { + $client = self::createClient(); + $client->getKernelBrowser()->disableReboot(); + + return $client; + } + + public function testCustomTagsOnEmptyResource(): void + { + $this->disableRebootClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => new \stdClass(), + ]); + $this->assertResponseStatusCodeSame(201); + $this->assertResponseNotHasHeader('Cache-Tags'); + + $this->disableRebootClient()->request('GET', '/relation_embedders/1'); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Cache-Tags', '/RE/1#anotherRelated,/RE/1#related,/RE/1'); + } + + public function testCustomTagsForEmbeddedRelationJsonLd(): void + { + $this->disableRebootClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['anotherRelated' => ['name' => 'Related']], + ]); + $this->assertResponseStatusCodeSame(201); + + $this->disableRebootClient()->request('GET', '/relation_embedders/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame( + 'Cache-Tags', + '/related_dummies/1#thirdLevel,/related_dummies/1,/RE/1#anotherRelated,/RE/1#related,/RE/1', + ); + $this->assertJsonContains([ + '@context' => '/contexts/RelationEmbedder', + '@id' => '/relation_embedders/1', + '@type' => 'RelationEmbedder', + 'krondstadt' => 'Krondstadt', + 'anotherRelated' => [ + '@id' => '/related_dummies/1', + '@type' => 'https://schema.org/Product', + 'symfony' => 'symfony', + 'thirdLevel' => null, + ], + 'related' => null, + ]); + } + + public function testCustomTagsForEmbeddedRelationHal(): void + { + $this->disableRebootClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['anotherRelated' => ['name' => 'Related']], + ]); + + $this->disableRebootClient()->request('GET', '/relation_embedders/1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame( + 'Cache-Tags', + '/RE/1,/related_dummies/1,/related_dummies/1#thirdLevel,/RE/1#anotherRelated,/RE/1#related', + ); + $this->assertJsonContains([ + '_embedded' => [ + 'anotherRelated' => [ + '_links' => ['self' => ['href' => '/related_dummies/1']], + ], + ], + ]); + } + + public function testCustomTagsForEmbeddedRelationJsonApi(): void + { + $this->disableRebootClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['anotherRelated' => ['name' => 'Related']], + ]); + + $this->disableRebootClient()->request('GET', '/relation_embedders/1', [ + 'headers' => ['Accept' => 'application/vnd.api+json'], + ]); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame( + 'Cache-Tags', + '/RE/1,/RE/1#anotherRelated,/RE/1#related', + ); + $this->assertJsonContains([ + 'data' => [ + 'relationships' => [ + 'anotherRelated' => [ + 'data' => ['type' => 'RelatedDummy', 'id' => '/related_dummies/1'], + ], + ], + ], + ]); + } + + public function testCustomTagsFromApiPropertyExtraProperties(): void + { + $this->disableRebootClient()->request('POST', '/extra_properties_on_properties', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => new \stdClass(), + ]); + $this->assertResponseStatusCodeSame(201); + + $this->disableRebootClient()->request('GET', '/extra_properties_on_properties/1'); + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame( + 'Cache-Tags', + '/extra_properties_on_properties/1#overrideRelationTag,/extra_properties_on_properties/1', + ); + } + + /** + * Replaces the three "Get a Relation3 (test collection of links; ...)" behat + * scenarios. Each format asserts the same Cache-Tags set because the + * resource collection only contains link-only Relation2 references. + */ + public function testCustomTagsForManyToManyCollections(): void + { + $client = $this->disableRebootClient(); + $client->request('POST', '/relation2s', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => new \stdClass(), + ]); + $client->request('POST', '/relation2s', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => new \stdClass(), + ]); + $client->request('POST', '/relation3s', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['relation2s' => ['/relation2s/1', '/relation2s/2']], + ]); + $this->assertResponseStatusCodeSame(201); + + // Each format produces a different ordering of tags but the set must match. + $expected = ['/relation3s/1#relation2s', '/relation3s/1', '/relation3s']; + sort($expected); + + foreach (['application/ld+json', 'application/hal+json', 'application/vnd.api+json'] as $accept) { + $response = $client->request('GET', '/relation3s', ['headers' => ['Accept' => $accept]]); + $this->assertResponseStatusCodeSame(200); + $actual = explode(',', $response->getHeaders()['cache-tags'][0] ?? ''); + sort($actual); + $this->assertSame($expected, $actual, \sprintf('Cache-Tags mismatch for %s', $accept)); + } + } + + private function isMongoDB(): bool + { + return 'mongodb' === static::getContainer()->getParameter('kernel.environment'); + } +} diff --git a/tests/Functional/Issue5926Test.php b/tests/Functional/Issue5926Test.php new file mode 100644 index 00000000000..2f3aa2e11c8 --- /dev/null +++ b/tests/Functional/Issue5926Test.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5926\TestIssue5926; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; + +/** + * @see https://github.com/api-platform/core/issues/5926 + */ +final class Issue5926Test extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [TestIssue5926::class]; + } + + public static function formats(): iterable + { + yield ['application/json', 'application/json; charset=utf-8']; + yield ['application/vnd.api+json', 'application/vnd.api+json; charset=utf-8']; + yield ['application/ld+json', 'application/ld+json; charset=utf-8']; + yield ['application/hal+json', 'application/hal+json; charset=utf-8']; + } + + #[DataProvider('formats')] + public function testGetWriteResourceWithEmbeddedNonResourceCollection(string $accept, string $expectedContentType): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + self::createClient()->request('GET', '/test_issue5926s/1', [ + 'headers' => ['Accept' => $accept], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', $expectedContentType); + } + + private function isMongoDB(): bool + { + return 'mongodb' === static::getContainer()->getParameter('kernel.environment'); + } +} diff --git a/tests/Functional/Json/InputOutputTest.php b/tests/Functional/Json/InputOutputTest.php new file mode 100644 index 00000000000..61846f32f71 --- /dev/null +++ b/tests/Functional/Json/InputOutputTest.php @@ -0,0 +1,67 @@ + + * + * 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\Json; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\User; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class InputOutputTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [User::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([User::class]); + } + + public function testPasswordResetRequest(): void + { + self::createClient()->request('POST', '/users_reset/password_reset_request', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => ['email' => 'user@example.com'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/json; charset=utf-8'); + $this->assertJsonEquals(['emailSentAt' => '2019-07-05T15:44:00+00:00']); + } + + public function testPasswordResetRequestForUnknownUser(): void + { + self::createClient()->request('POST', '/users_reset/password_reset_request', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => ['email' => 'does-not-exist@example.com'], + ]); + + $this->assertResponseStatusCodeSame(404); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains(['detail' => 'User does not exist.']); + } +} diff --git a/tests/Functional/Json/RelationTest.php b/tests/Functional/Json/RelationTest.php index cbe39825f7f..d56ea46c187 100644 --- a/tests/Functional/Json/RelationTest.php +++ b/tests/Functional/Json/RelationTest.php @@ -14,21 +14,32 @@ namespace ApiPlatform\Tests\Functional\Json; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; use ApiPlatform\Tests\RecreateSchemaTrait; use ApiPlatform\Tests\SetupClassResourcesTrait; +/** + * Validates that JSON requests on resources accepting application/ld+json + * responses cover embedded creation, IRI relations and plain identifiers. + */ final class RelationTest extends ApiTestCase { use RecreateSchemaTrait; use SetupClassResourcesTrait; - protected static ?bool $alwaysBootKernel = false; + protected static ?bool $alwaysBootKernel = true; public static function getResources(): array { - return [ThirdLevel::class, RelatedDummy::class]; + return [ + ThirdLevel::class, + RelationEmbedder::class, + RelatedDummy::class, + Dummy::class, + ]; } protected function setUp(): void @@ -39,7 +50,130 @@ protected function setUp(): void $this->markTestSkipped('Not tested with MongoDB.'); } - $this->recreateSchema([ThirdLevel::class, RelatedDummy::class]); + $this->recreateSchema($this->getResources()); + } + + public function testCreateThirdLevelReturnsLdJson(): void + { + self::createClient()->request('POST', '/third_levels', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['level' => 3], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/ThirdLevel', + '@id' => '/third_levels/1', + '@type' => 'ThirdLevel', + 'fourthLevel' => null, + 'badFourthLevel' => null, + 'id' => 1, + 'level' => 3, + 'test' => true, + 'relatedDummies' => [], + ]); + } + + public function testCreateEmbeddedRelation(): void + { + self::createClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['anotherRelated' => ['symfony' => 'laravel']], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/RelationEmbedder', + '@id' => '/relation_embedders/1', + '@type' => 'RelationEmbedder', + 'krondstadt' => 'Krondstadt', + 'anotherRelated' => [ + '@id' => '/related_dummies/1', + '@type' => 'https://schema.org/Product', + 'symfony' => 'laravel', + 'thirdLevel' => null, + ], + 'related' => null, + ]); + } + + public function testReplaceEmbeddedRelationCreatesNewRelated(): void + { + // Bootstrap a RelationEmbedder with a related dummy. + self::createClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['anotherRelated' => ['symfony' => 'laravel']], + ]); + + self::createClient()->request('PUT', '/relation_embedders/1', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['anotherRelated' => ['symfony' => 'laravel2']], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonContains([ + '@id' => '/relation_embedders/1', + 'anotherRelated' => [ + '@id' => '/related_dummies/2', + '@type' => 'https://schema.org/Product', + 'symfony' => 'laravel2', + 'thirdLevel' => null, + ], + 'related' => null, + ]); + } + + public function testUpdateEmbeddedRelationUsingIri(): void + { + self::createClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['anotherRelated' => ['symfony' => 'laravel']], + ]); + + self::createClient()->request('PUT', '/relation_embedders/1', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['anotherRelated' => ['id' => '/related_dummies/1', 'symfony' => 'API Platform']], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains([ + '@id' => '/relation_embedders/1', + 'anotherRelated' => [ + '@id' => '/related_dummies/1', + '@type' => 'https://schema.org/Product', + 'symfony' => 'API Platform', + 'thirdLevel' => null, + ], + 'related' => null, + ]); + } + + public function testUpdateEmbeddedRelationUsingPlainIdentifier(): void + { + self::createClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['anotherRelated' => ['symfony' => 'laravel']], + ]); + + self::createClient()->request('PUT', '/relation_embedders/1', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['anotherRelated' => ['id' => 1, 'symfony' => 'API Platform 2']], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains([ + '@id' => '/relation_embedders/1', + 'anotherRelated' => [ + '@id' => '/related_dummies/1', + '@type' => 'https://schema.org/Product', + 'symfony' => 'API Platform 2', + 'thirdLevel' => null, + ], + 'related' => null, + ]); } public function testCreateRelatedDummyWithPlainIdentifierForRelation(): void @@ -50,20 +184,52 @@ public function testCreateRelatedDummyWithPlainIdentifierForRelation(): void 'headers' => ['Content-Type' => 'application/json'], 'json' => ['level' => 3], ]); - $this->assertResponseStatusCodeSame(201); // RelatedDummyPlainIdentifierDenormalizer calls getIriFromResource(ThirdLevel::class, new Get(), …). // Without the fix the '_c' slot collision returns the GetCollection op, producing // "/third_levels?id=1" instead of "/third_levels/1". - $response = self::createClient()->request('POST', '/related_dummies', [ + self::createClient()->request('POST', '/related_dummies', [ 'headers' => ['Content-Type' => 'application/json'], 'json' => ['thirdLevel' => '1'], ]); + $this->assertResponseStatusCodeSame(201); + $this->assertJsonContains([ + '@context' => '/contexts/RelatedDummy', + '@id' => '/related_dummies/1', + '@type' => 'https://schema.org/Product', + 'thirdLevel' => [ + '@id' => '/third_levels/1', + '@type' => 'ThirdLevel', + 'fourthLevel' => null, + ], + ]); + } - $data = $response->toArray(false); - $this->assertArrayHasKey('thirdLevel', $data); - $this->assertIsArray($data['thirdLevel']); - $this->assertSame('/third_levels/1', $data['thirdLevel']['@id']); + public function testCreateDummyWithPlainIdentifiersForRelations(): void + { + self::createClient()->request('POST', '/related_dummies', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => new \stdClass(), + ]); + + self::createClient()->request('POST', '/dummies', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => [ + 'relatedDummy' => '1', + 'relatedDummies' => ['1'], + 'name' => 'Dummy with plain relations', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonContains([ + '@context' => '/contexts/Dummy', + '@id' => '/dummies/1', + '@type' => 'Dummy', + 'relatedDummy' => '/related_dummies/1', + 'relatedDummies' => ['/related_dummies/1'], + 'name' => 'Dummy with plain relations', + ]); } } diff --git a/tests/Functional/Mercure/MercureTest.php b/tests/Functional/Mercure/MercureTest.php new file mode 100644 index 00000000000..4304e7b5ba5 --- /dev/null +++ b/tests/Functional/Mercure/MercureTest.php @@ -0,0 +1,149 @@ + + * + * 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\Mercure; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyMercure; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5074\MercureWithTopics; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MercureWithTopicsAndGetOperation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Mercure\TestHub; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\Mercure\Update; + +final class MercureTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + DummyMercure::class, + RelatedDummy::class, + MercureWithTopics::class, + MercureWithTopicsAndGetOperation::class, + ]; + } + + public function testDiscoveryLinkOnMercureResource(): void + { + $this->recreateSchema([DummyMercure::class, RelatedDummy::class]); + + $response = self::createClient()->request('GET', '/dummy_mercures', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertContains( + '; rel="mercure"', + $response->getHeaders()['link'], + ); + } + + public function testNoDiscoveryLinkOnNonMercureEndpoint(): void + { + $response = self::createClient()->request('GET', '/'); + + $this->assertNotContains( + '; rel="mercure"', + $response->getHeaders()['link'] ?? [], + ); + } + + public function testPublishUpdateOnPostWithIriTopic(): void + { + $this->recreateSchema([MercureWithTopics::class]); + $hub = $this->resetTestHub(); + + self::createClient()->request('POST', '/issue5074/mercure_with_topics', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => [ + 'name' => 'Hello World!', + 'description' => 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + + $updates = $hub->getUpdates(); + $this->assertCount(1, $updates); + /** @var Update $update */ + $update = $updates[0]; + $this->assertSame(['http://localhost/issue5074/mercure_with_topics/1'], array_values($update->getTopics())); + $this->assertJsonStringEqualsJsonString( + json_encode([ + '@context' => '/contexts/MercureWithTopics', + '@id' => '/issue5074/mercure_with_topics/1', + '@type' => 'MercureWithTopics', + 'id' => 1, + 'name' => 'Hello World!', + ], \JSON_THROW_ON_ERROR), + $update->getData(), + ); + } + + public function testPublishUpdateWithExpressionLanguageTopics(): void + { + $this->recreateSchema([MercureWithTopicsAndGetOperation::class]); + $hub = $this->resetTestHub(); + + self::createClient()->request('POST', '/mercure_with_topics_and_get_operations', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'json' => ['name' => 'Hello World!'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + + $updates = $hub->getUpdates(); + $this->assertCount(1, $updates); + /** @var Update $update */ + $update = $updates[0]; + $this->assertSame([ + 'http://localhost/mercure_with_topics_and_get_operations/1', + 'http://localhost/custom_resource/mercure_with_topics_and_get_operations/1', + ], array_values($update->getTopics())); + $this->assertJsonStringEqualsJsonString( + json_encode([ + '@context' => '/contexts/MercureWithTopicsAndGetOperation', + '@id' => '/mercure_with_topics_and_get_operations/1', + '@type' => 'MercureWithTopicsAndGetOperation', + 'id' => 1, + 'name' => 'Hello World!', + ], \JSON_THROW_ON_ERROR), + $update->getData(), + ); + } + + private function resetTestHub(): TestHub + { + $hub = static::getContainer()->get('mercure.hub.default.test_hub'); + \assert($hub instanceof TestHub); + + $reflection = new \ReflectionProperty(TestHub::class, 'updates'); + $reflection->setValue($hub, []); + + return $hub; + } +} diff --git a/tests/Functional/SubResource/MultipleRelationTest.php b/tests/Functional/SubResource/MultipleRelationTest.php new file mode 100644 index 00000000000..f81b23074aa --- /dev/null +++ b/tests/Functional/SubResource/MultipleRelationTest.php @@ -0,0 +1,94 @@ + + * + * 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\SubResource; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationMultiple; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class MultipleRelationTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [RelationMultiple::class, Dummy::class]; + } + + public function testGetMultipleRelationItem(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + self::createClient()->request('GET', '/dummy/1/relations/2', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/RelationMultiple', + '@id' => '/dummy/1/relations/2', + '@type' => 'RelationMultiple', + 'id' => 1, + 'first' => '/dummies/1', + 'second' => '/dummies/2', + ]); + } + + public function testGetMultipleRelationCollection(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + self::createClient()->request('GET', '/dummy/1/relations', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/RelationMultiple', + '@id' => '/dummy/1/relations', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + [ + '@id' => '/dummy/1/relations/2', + '@type' => 'RelationMultiple', + 'id' => 1, + 'first' => '/dummies/1', + 'second' => '/dummies/2', + ], + [ + '@id' => '/dummy/1/relations/3', + '@type' => 'RelationMultiple', + 'id' => 2, + 'first' => '/dummies/1', + 'second' => '/dummies/3', + ], + ], + 'hydra:totalItems' => 2, + ]); + } + + private function isMongoDB(): bool + { + return 'mongodb' === static::getContainer()->getParameter('kernel.environment'); + } +} diff --git a/tests/Functional/Xml/DeserializationTest.php b/tests/Functional/Xml/DeserializationTest.php new file mode 100644 index 00000000000..3ef98f016cf --- /dev/null +++ b/tests/Functional/Xml/DeserializationTest.php @@ -0,0 +1,173 @@ + + * + * 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\Xml; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProperty; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ResourceWithBoolean; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ResourceWithFloat; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ResourceWithInteger; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ResourceWithString; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; + +final class DeserializationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [ + ResourceWithString::class, + ResourceWithBoolean::class, + ResourceWithInteger::class, + ResourceWithFloat::class, + DummyProperty::class, + ]; + } + + private const XML_HEADERS = [ + 'Accept' => 'application/xml', + 'Content-Type' => 'application/xml', + ]; + + public function testPostStringResource(): void + { + $this->recreateSchema([ResourceWithString::class]); + + self::createClient()->request('POST', '/resource_with_strings', [ + 'headers' => self::XML_HEADERS, + 'body' => <<<'XML' + + + string + + XML, + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + } + + public static function booleanValues(): iterable + { + yield ['true']; + yield ['false']; + yield ['1']; + yield ['0']; + } + + #[DataProvider('booleanValues')] + public function testPostBooleanResource(string $value): void + { + $this->recreateSchema([ResourceWithBoolean::class]); + + self::createClient()->request('POST', '/resource_with_booleans', [ + 'headers' => self::XML_HEADERS, + 'body' => \sprintf(<<<'XML' + + + %s + + XML, $value), + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + } + + public static function integerValues(): iterable + { + yield ['42']; + yield ['-6']; + yield ['1']; + yield ['0']; + } + + #[DataProvider('integerValues')] + public function testPostIntegerResource(string $value): void + { + $this->recreateSchema([ResourceWithInteger::class]); + + self::createClient()->request('POST', '/resource_with_integers', [ + 'headers' => self::XML_HEADERS, + 'body' => \sprintf(<<<'XML' + + + %s + + XML, $value), + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + } + + public static function floatValues(): iterable + { + yield ['3.14']; + yield ['NaN']; + yield ['INF']; + yield ['-INF']; + } + + #[DataProvider('floatValues')] + public function testPostFloatResource(string $value): void + { + if ($this->isMysql()) { + $this->markTestSkipped('MySQL does not support NaN/Inf floats'); + } + + $this->recreateSchema([ResourceWithFloat::class]); + + self::createClient()->request('POST', '/resource_with_floats', [ + 'headers' => self::XML_HEADERS, + 'body' => \sprintf(<<<'XML' + + + %s + + XML, $value), + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + } + + public function testPostSingleElementCollection(): void + { + $this->recreateSchema([DummyProperty::class]); + + self::createClient()->request('POST', '/dummy_properties', [ + 'headers' => self::XML_HEADERS, + 'body' => <<<'XML' + + + + + bar + + + + XML, + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + } +} diff --git a/tests/RecreateSchemaTrait.php b/tests/RecreateSchemaTrait.php index 7ab756e4604..a5f53cb9326 100644 --- a/tests/RecreateSchemaTrait.php +++ b/tests/RecreateSchemaTrait.php @@ -29,11 +29,18 @@ private function recreateSchema(array $classes = []): void if ($manager instanceof DocumentManager) { $schemaManager = $manager->getSchemaManager(); + $firstDocumentClass = null; foreach ($classes as $c) { $class = str_contains($c, 'Entity') ? str_replace('Entity', 'Document', $c) : $c; + $firstDocumentClass ??= $class; $schemaManager->dropDocumentCollection($class); } + // Reset INCREMENT id counters; otherwise IDs persist across test methods. + if (null !== $firstDocumentClass) { + $manager->getDocumentDatabase($firstDocumentClass)->dropCollection('doctrine_increment_ids'); + } + return; } From e6922f1d7f6850bf201d19c736bb3e24823bae46 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 26 May 2026 08:06:05 +0200 Subject: [PATCH 13/84] test: eliminate behat-migration skips and tighten ported asserts (#8198) --- .github/workflows/ci.yml | 4 +- features/main/attribute_resource.feature | 120 --- features/main/circular_reference.feature | 89 -- features/main/composite.feature | 139 --- features/main/configurable.feature | 62 -- features/main/content_negotiation.feature | 171 ---- features/main/crud.feature | 782 ----------------- features/main/crud_abstract.feature | 167 ---- features/main/crud_uri_variables.feature | 206 ----- features/main/custom_controller.feature | 130 --- features/main/custom_identifier.feature | 121 --- ...custom_identifier_with_subresource.feature | 95 --- features/main/custom_normalized.feature | 213 ----- features/main/custom_put.feature | 29 - .../main/custom_writable_identifier.feature | 112 --- features/main/default_order.feature | 267 ------ features/main/exception_to_status.feature | 47 -- features/main/exposed_state.feature | 48 -- features/main/headers.feature | 14 - features/main/input_output.feature | 15 - features/main/not_exposed.feature | 204 ----- features/main/operation.feature | 97 --- features/main/operation_resource.feature | 66 -- features/main/overridden_operation.feature | 156 ---- features/main/patch.feature | 99 --- features/main/put_collection.feature | 32 - features/main/relation.feature | 546 ------------ .../serializable_item_data_provider.feature | 18 - features/main/standard_put.feature | 148 ---- features/main/sub_resource.feature | 633 -------------- features/main/table_inheritance.feature | 798 ------------------ features/main/union_intersect_types.feature | 121 --- features/main/url_encoded_id.feature | 26 - features/main/uuid.feature | 205 ----- features/main/validation.feature | 120 --- phpunit.baseline.xml | 3 + tests/Fixtures/TestBundle/Entity/Answer.php | 21 - .../TestBundle/Entity/FourthLevel.php | 8 +- .../Entity/OneToOneSubresourceAnswer.php | 70 ++ .../Entity/OneToOneSubresourceQuestion.php | 63 ++ tests/Fixtures/TestBundle/Entity/Question.php | 4 +- tests/Functional/AttributeResourceTest.php | 151 ++++ tests/Functional/CircularReferenceTest.php | 111 +++ tests/Functional/CompositeIdentifierTest.php | 170 ++++ tests/Functional/ConfigurableTest.php | 113 +++ tests/Functional/ContentNegotiationTest.php | 244 ++++++ tests/Functional/CrudAbstractTest.php | 171 ++++ tests/Functional/CrudTest.php | 180 ++++ tests/Functional/CrudUriVariablesTest.php | 206 +++++ tests/Functional/CustomControllerTest.php | 217 +++++ tests/Functional/CustomIdentifierTest.php | 172 ++++ .../CustomIdentifierWithSubresourceTest.php | 137 +++ tests/Functional/CustomNormalizedTest.php | 204 +++++ tests/Functional/CustomPutTest.php | 59 ++ .../CustomWritableIdentifierTest.php | 153 ++++ tests/Functional/DefaultOrderTest.php | 143 ++++ tests/Functional/ExceptionToStatusTest.php | 97 +++ tests/Functional/ExposedStateTest.php | 88 ++ tests/Functional/HeadersAdditionTest.php | 55 ++ .../Json/OutputAndEntityClassTest.php | 54 ++ .../SerializableItemDataProviderTest.php | 48 ++ tests/Functional/NotExposedTest.php | 165 ++++ tests/Functional/OperationResourceTest.php | 103 +++ tests/Functional/OperationTest.php | 155 ++++ tests/Functional/OverriddenOperationTest.php | 206 +++++ tests/Functional/PatchTest.php | 165 ++++ .../ProviderProcessorEntityTest.php | 132 +++ tests/Functional/PutCollectionTest.php | 71 ++ tests/Functional/RelationTest.php | 480 +++++++++++ tests/Functional/StandardPutTest.php | 189 +++++ .../SubResource/SubResourceTest.php | 596 +++++++++++++ tests/Functional/TableInheritanceTest.php | 301 +++++++ tests/Functional/UnionIntersectTypesTest.php | 103 +++ tests/Functional/UrlEncodedIdTest.php | 73 ++ tests/Functional/Uuid/UuidIdentifierTest.php | 302 +++++++ tests/Functional/ValidationGroupsTest.php | 131 +++ 76 files changed, 6091 insertions(+), 6123 deletions(-) delete mode 100644 features/main/attribute_resource.feature delete mode 100644 features/main/circular_reference.feature delete mode 100644 features/main/composite.feature delete mode 100644 features/main/configurable.feature delete mode 100644 features/main/content_negotiation.feature delete mode 100644 features/main/crud.feature delete mode 100644 features/main/crud_abstract.feature delete mode 100644 features/main/crud_uri_variables.feature delete mode 100644 features/main/custom_controller.feature delete mode 100644 features/main/custom_identifier.feature delete mode 100644 features/main/custom_identifier_with_subresource.feature delete mode 100644 features/main/custom_normalized.feature delete mode 100644 features/main/custom_put.feature delete mode 100644 features/main/custom_writable_identifier.feature delete mode 100644 features/main/default_order.feature delete mode 100644 features/main/exception_to_status.feature delete mode 100644 features/main/exposed_state.feature delete mode 100644 features/main/headers.feature delete mode 100644 features/main/input_output.feature delete mode 100644 features/main/not_exposed.feature delete mode 100644 features/main/operation.feature delete mode 100644 features/main/operation_resource.feature delete mode 100644 features/main/overridden_operation.feature delete mode 100644 features/main/patch.feature delete mode 100644 features/main/put_collection.feature delete mode 100644 features/main/relation.feature delete mode 100644 features/main/serializable_item_data_provider.feature delete mode 100644 features/main/standard_put.feature delete mode 100644 features/main/sub_resource.feature delete mode 100644 features/main/table_inheritance.feature delete mode 100644 features/main/union_intersect_types.feature delete mode 100644 features/main/url_encoded_id.feature delete mode 100644 features/main/uuid.feature delete mode 100644 features/main/validation.feature create mode 100644 tests/Fixtures/TestBundle/Entity/OneToOneSubresourceAnswer.php create mode 100644 tests/Fixtures/TestBundle/Entity/OneToOneSubresourceQuestion.php create mode 100644 tests/Functional/AttributeResourceTest.php create mode 100644 tests/Functional/CircularReferenceTest.php create mode 100644 tests/Functional/CompositeIdentifierTest.php create mode 100644 tests/Functional/ConfigurableTest.php create mode 100644 tests/Functional/ContentNegotiationTest.php create mode 100644 tests/Functional/CrudAbstractTest.php create mode 100644 tests/Functional/CrudTest.php create mode 100644 tests/Functional/CrudUriVariablesTest.php create mode 100644 tests/Functional/CustomControllerTest.php create mode 100644 tests/Functional/CustomIdentifierTest.php create mode 100644 tests/Functional/CustomIdentifierWithSubresourceTest.php create mode 100644 tests/Functional/CustomNormalizedTest.php create mode 100644 tests/Functional/CustomPutTest.php create mode 100644 tests/Functional/CustomWritableIdentifierTest.php create mode 100644 tests/Functional/DefaultOrderTest.php create mode 100644 tests/Functional/ExceptionToStatusTest.php create mode 100644 tests/Functional/ExposedStateTest.php create mode 100644 tests/Functional/HeadersAdditionTest.php create mode 100644 tests/Functional/Json/OutputAndEntityClassTest.php create mode 100644 tests/Functional/JsonLd/SerializableItemDataProviderTest.php create mode 100644 tests/Functional/NotExposedTest.php create mode 100644 tests/Functional/OperationResourceTest.php create mode 100644 tests/Functional/OperationTest.php create mode 100644 tests/Functional/OverriddenOperationTest.php create mode 100644 tests/Functional/PatchTest.php create mode 100644 tests/Functional/ProviderProcessorEntityTest.php create mode 100644 tests/Functional/PutCollectionTest.php create mode 100644 tests/Functional/RelationTest.php create mode 100644 tests/Functional/StandardPutTest.php create mode 100644 tests/Functional/SubResource/SubResourceTest.php create mode 100644 tests/Functional/TableInheritanceTest.php create mode 100644 tests/Functional/UnionIntersectTypesTest.php create mode 100644 tests/Functional/UrlEncodedIdTest.php create mode 100644 tests/Functional/Uuid/UuidIdentifierTest.php create mode 100644 tests/Functional/ValidationGroupsTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fedf149ef3d..fca06a3f403 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -455,12 +455,11 @@ jobs: matrix: php: ${{ fromJSON(github.event_name == 'pull_request' && '["8.2","8.5"]' || '["8.2","8.3","8.4","8.5"]') }} shard: - - main - graphql-doctrine - misc include: - php: '8.5' - shard: main + shard: graphql-doctrine coverage: true fail-fast: false steps: @@ -494,7 +493,6 @@ jobs: id: shard run: | case "${{ matrix.shard }}" in - main) paths="features/main" ;; graphql-doctrine) paths="features/graphql features/doctrine" ;; misc) paths="features/filter features/issues features/security features/serializer features/http_cache features/sub_resources features/json features/xml features/push_relations features/mercure" ;; esac diff --git a/features/main/attribute_resource.feature b/features/main/attribute_resource.feature deleted file mode 100644 index da92073e98f..00000000000 --- a/features/main/attribute_resource.feature +++ /dev/null @@ -1,120 +0,0 @@ -@php8 -@v3 -@!mysql -@!mongodb -Feature: Resource attributes - In order to use the Resource attribute - As a developer - I should be able to fetch data from a state provider - - Scenario: Retrieve a Resource collection - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/attribute_resources" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/AttributeResources", - "@id": "/attribute_resources", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/attribute_resources/1", - "@type": "AttributeResource", - "identifier": 1, - "name": "Foo" - }, - { - "@id": "/attribute_resources/2", - "@type": "AttributeResource", - "identifier": 2, - "name": "Bar" - } - ] - } - """ - - Scenario: Retrieve the first resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/attribute_resources/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/AttributeResource", - "@id": "/attribute_resources/1", - "@type": "AttributeResource", - "identifier": 1, - "name": "Foo" - } - """ - - Scenario: Retrieve the aliased resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/dummy/1/attribute_resources/2" - Then the response status code should be 301 - And the header "Location" should be equal to "/attribute_resources/2" - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/AttributeResource", - "@id": "/attribute_resources/2", - "@type": "AttributeResource", - "identifier": 2, - "dummy": "/dummies/1", - "name": "Foo" - } - """ - - Scenario: Patch the aliased resource - When I add "Content-Type" header equal to "application/merge-patch+json" - And I send a "PATCH" request to "/dummy/1/attribute_resources/2" with body: - """ - {"name": "Patched"} - """ - Then the response status code should be 301 - And the header "Location" should be equal to "/attribute_resources/2" - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/AttributeResource", - "@id": "/attribute_resources/2", - "@type": "AttributeResource", - "identifier": 2, - "dummy": "/dummies/1", - "name": "Patched" - } - """ - - Scenario: Uri variables should be configured properly - When I send a "GET" request to "/photos/1/resize/300/100" - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' - And the JSON node "detail" should be equal to 'Unable to generate an IRI for the item of type "ApiPlatform\Tests\Fixtures\TestBundle\Entity\IncompleteUriVariableConfigured"' - - Scenario: Uri variables with Post operation - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/post_with_uri_variables_and_no_provider/{id}" with body: - """ - {} - """ - Then the response status code should be 201 - - Scenario: Throw validation exception in a provider - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/post_with_uri_variables/{id}" with body: - """ - {} - """ - Then the response status code should be 422 - diff --git a/features/main/circular_reference.feature b/features/main/circular_reference.feature deleted file mode 100644 index f53d44d9164..00000000000 --- a/features/main/circular_reference.feature +++ /dev/null @@ -1,89 +0,0 @@ -Feature: Circular references handling - In order to handle circular references - As a developer - I should be able to catch circular references. - - @createSchema - Scenario: Create a circular reference - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/circular_references" with body: - """ - {} - """ - And I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/circular_references/1" with body: - """ - { - "parent": "/circular_references/1" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CircularReference", - "@id": "/circular_references/1", - "@type": "CircularReference", - "parent": "/circular_references/1", - "children": [ - "/circular_references/1" - ] - } - """ - - Scenario: Fetch circular reference - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/circular_references" with body: - """ - {} - """ - And I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/circular_references/2" with body: - """ - { - "parent": "/circular_references/1" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CircularReference", - "@id": "/circular_references/2", - "@type": "CircularReference", - "parent": { - "@id": "/circular_references/1", - "@type": "CircularReference", - "parent": "/circular_references/1", - "children": [ - "/circular_references/1", - "/circular_references/2" - ] - }, - "children": [] - } - """ - And I send a "GET" request to "/circular_references/1" - Then the response status code should be 200 - And the JSON should be equal to: - """ - { - "@context": "/contexts/CircularReference", - "@id": "/circular_references/1", - "@type": "CircularReference", - "parent": "/circular_references/1", - "children": [ - "/circular_references/1", - { - "@id": "/circular_references/2", - "@type": "CircularReference", - "parent": "/circular_references/1", - "children": [] - } - ] - } - """ diff --git a/features/main/composite.feature b/features/main/composite.feature deleted file mode 100644 index ab99527bd7f..00000000000 --- a/features/main/composite.feature +++ /dev/null @@ -1,139 +0,0 @@ -@!mongodb -Feature: Retrieve data with Composite identifiers - In order to retrieve relations with composite identifiers - As a client software developer - I need to retrieve all collections - - @createSchema - Scenario: Get a collection with composite identifiers - Given there are Composite identifier objects - When I send a "GET" request to "/composite_items" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CompositeItem", - "@id": "/composite_items", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/composite_items/1", - "@type": "CompositeItem", - "id": 1, - "field1": "foobar", - "compositeValues": [ - "/composite_relations/compositeItem=1;compositeLabel=1", - "/composite_relations/compositeItem=1;compositeLabel=2", - "/composite_relations/compositeItem=1;compositeLabel=3", - "/composite_relations/compositeItem=1;compositeLabel=4" - ] - } - ], - "hydra:totalItems": 1 - } - """ - - @createSchema - Scenario: Get collection with composite identifiers - Given there are Composite identifier objects - When I send a "GET" request to "/composite_relations" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CompositeRelation", - "@id": "/composite_relations", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/composite_relations/compositeItem=1;compositeLabel=1", - "@type": "CompositeRelation", - "value": "somefoobardummy", - "compositeItem": "/composite_items/1", - "compositeLabel": "/composite_labels/1" - }, - { - "@id": "/composite_relations/compositeItem=1;compositeLabel=2", - "@type": "CompositeRelation", - "value": "somefoobardummy", - "compositeItem": "/composite_items/1", - "compositeLabel": "/composite_labels/2" - }, - { - "@id": "/composite_relations/compositeItem=1;compositeLabel=3", - "@type": "CompositeRelation", - "value": "somefoobardummy", - "compositeItem": "/composite_items/1", - "compositeLabel": "/composite_labels/3" - } - ], - "hydra:totalItems": 4, - "hydra:view": { - "@id": "/composite_relations?page=1", - "@type": "hydra:PartialCollectionView", - "hydra:first": "/composite_relations?page=1", - "hydra:last": "/composite_relations?page=2", - "hydra:next": "/composite_relations?page=2" - } - } - """ - - @createSchema - Scenario: Get the first composite relation - Given there are Composite identifier objects - When I send a "GET" request to "/composite_relations/compositeItem=1;compositeLabel=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CompositeRelation", - "@id": "/composite_relations/compositeItem=1;compositeLabel=1", - "@type": "CompositeRelation", - "value": "somefoobardummy", - "compositeItem": "/composite_items/1", - "compositeLabel": "/composite_labels/1" - } - """ - - @createSchema - Scenario: Get the first composite relation with a reverse identifiers order - Given there are Composite identifier objects - When I send a "GET" request to "/composite_relations/compositeLabel=1;compositeItem=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CompositeRelation", - "@id": "/composite_relations/compositeItem=1;compositeLabel=1", - "@type": "CompositeRelation", - "value": "somefoobardummy", - "compositeItem": "/composite_items/1", - "compositeLabel": "/composite_labels/1" - } - """ - - @createSchema - Scenario: Get the first composite relation with a missing identifier - Given there are Composite identifier objects - When I send a "GET" request to "/composite_relations/compositeLabel=1;" - Then the response status code should be 404 - - Scenario: Get first composite item - Given there are Composite identifier objects - When I send a "GET" request to "/composite_items/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - Scenario: Get identifiers with different types - Given there are Composite identifier objects - When I send a "GET" request to "/composite_key_with_different_types/id=82133;verificationKey=7d75af772e637e45c36d041696e1128d" - Then the response status code should be 200 diff --git a/features/main/configurable.feature b/features/main/configurable.feature deleted file mode 100644 index c0e73d1ba2b..00000000000 --- a/features/main/configurable.feature +++ /dev/null @@ -1,62 +0,0 @@ -Feature: Configurable resource CRUD - As a client software developer - I need to be able to configure api resources through YAML - - @createSchema - Scenario: Retrieve the ConfigDummy resource - Given there is a FileConfigDummy object - When I send a "GET" request to "/fileconfigdummies" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/fileconfigdummy", - "@id": "/fileconfigdummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/fileconfigdummies/1", - "@type": "fileconfigdummy", - "id": 1, - "name": "ConfigDummy", - "foo": "Foo" - } - ], - "hydra:totalItems": 1 - } - """ - - Scenario: Get a single file configured resource - When I send a "GET" request to "/single_file_configs" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/single_file_config", - "@id": "/single_file_configs", - "@type": "hydra:Collection", - "hydra:member": [], - "hydra:totalItems": 0 - } - """ - - Scenario: Retrieve the ConfigDummy resource - When I send a "GET" request to "/fileconfigdummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/fileconfigdummy", - "@id": "/fileconfigdummies/1", - "@type": "fileconfigdummy", - "id": 1, - "name": "ConfigDummy", - "foo": "Foo" - } - """ diff --git a/features/main/content_negotiation.feature b/features/main/content_negotiation.feature deleted file mode 100644 index 7f22db3b396..00000000000 --- a/features/main/content_negotiation.feature +++ /dev/null @@ -1,171 +0,0 @@ -Feature: Content Negotiation support - In order to make the API supporting several input and output formats - As an API developer - I need to be able to specify the format I want to use - - @createSchema - Scenario: Post an XML body - When I add "Accept" header equal to "application/xml" - And I add "Content-Type" header equal to "application/xml" - And I send a "POST" request to "/dummies" with body: - """ - - XML! - - """ - Then the response status code should be 201 - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - And the response should be in XML - And the XML should be equal to: - """ - - 1XML! - """ - - Scenario: Retrieve a collection in XML - When I add "Accept" header equal to "text/xml" - And I send a "GET" request to "/dummies" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - And the response should be in XML - And the XML should be equal to: - """ - - 1XML! - """ - - Scenario: Retrieve a collection in XML using the .xml URL - When I send a "GET" request to "/dummies.xml" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - And the response should be in XML - And the XML should be equal to: - """ - - 1XML! - """ - - Scenario: Retrieve a collection in JSON - When I add "Accept" header equal to "application/json" - And I send a "GET" request to "/dummies" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/json; charset=utf-8" - And the response should be in JSON - And the JSON should be equal to: - """ - [ - { - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "relatedDummy": null, - "relatedDummies": [], - "jsonData": [], - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": null, - "relatedOwningDummy": null, - "id": 1, - "name": "XML!", - "alias": null, - "foo": null - } - ] - """ - - Scenario: Post a JSON document and retrieve an XML body - When I add "Accept" header equal to "application/xml" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/dummies" with body: - """ - {"name": "Sent in JSON"} - """ - Then the response status code should be 201 - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - And the response should be in XML - And the XML should be equal to: - """ - - 2Sent in JSON - """ - - Scenario: Requesting the same format in the Accept header and in the URL should work - When I add "Accept" header equal to "text/xml" - And I send a "GET" request to "/dummies/1.xml" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - - Scenario: Requesting any format in the Accept header should default to the first configured format - When I add "Accept" header equal to "*/*" - And I send a "GET" request to "/dummies/1" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - Scenario: Requesting any format in the Accept header should default to the format passed in the URL - When I add "Accept" header equal to "text/plain; charset=utf-8, */*" - And I send a "GET" request to "/dummies/1.xml" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - - Scenario: Requesting an unknown format should throw an error - When I add "Accept" header equal to "text/plain" - And I send a "GET" request to "/dummies/1" - Then the response status code should be 406 - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - Scenario: If the request format is HTML, the error should be in HTML - When I add "Accept" header equal to "text/html" - And I send a "GET" request to "/dummies/666" - Then the response status code should be 404 - And the header "Content-Type" should be equal to "text/html; charset=utf-8" - - Scenario: Retrieve a collection in JSON should not be possible if the format has been removed at resource level - When I add "Accept" header equal to "application/json" - And I send a "GET" request to "/dummy_custom_formats" - Then the response status code should be 406 - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - Scenario: Post CSV body allowed on a single resource - When I add "Accept" header equal to "application/xml" - And I add "Content-Type" header equal to "text/csv" - And I send a "POST" request to "/dummy_custom_formats" with body: - """ - name - Kevin - """ - Then the response status code should be 201 - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - And the response should be in XML - And the XML should be equal to: - """ - - 1Kevin - """ - - Scenario: Retrieve a collection in CSV should be possible if the format is at resource level - When I add "Accept" header equal to "text/csv" - And I send a "GET" request to "/dummy_custom_formats" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "text/csv; charset=utf-8" - And the response should be equal to - """ - id,name - 1,Kevin - """ - - Scenario: Get a security response in JSON - Given there are 1 SecuredDummy objects - And I add "Accept" header equal to "application/json" - When I send a "GET" request to "/secured_dummies" - Then the response status code should be 401 - And the header "Content-Type" should be equal to "application/json" - And the response should be in JSON - And the JSON should be equal to: - """ - { - "message": "Authentication Required" - } - """ diff --git a/features/main/crud.feature b/features/main/crud.feature deleted file mode 100644 index 5933812bccc..00000000000 --- a/features/main/crud.feature +++ /dev/null @@ -1,782 +0,0 @@ -Feature: Create-Retrieve-Update-Delete - In order to use an hypermedia API - As a client software developer - I need to be able to retrieve, create, update and delete JSON-LD encoded resources. - - @createSchema - Scenario: Create a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "My Dummy", - "dummyDate": "2015-03-01T10:00:00+00:00", - "jsonData": { - "key": [ - "value1", - "value2" - ] - } - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should be equal to "/dummies/1.jsonld" - And the header "Location" should be equal to "/dummies/1" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Dummy", - "@id": "/dummies/1", - "@type": "Dummy", - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": "2015-03-01T10:00:00+00:00", - "dummyFloat": null, - "dummyPrice": null, - "relatedDummy": null, - "relatedDummies": [], - "jsonData": { - "key": [ - "value1", - "value2" - ] - }, - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": null, - "relatedOwningDummy": null, - "id": 1, - "name": "My Dummy", - "alias": null, - "foo": null - } - """ - - Scenario: Get a resource - When I send a "GET" request to "/dummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/Dummy", - "@id": "/dummies/1", - "@type": "Dummy", - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": "2015-03-01T10:00:00+00:00", - "dummyFloat": null, - "dummyPrice": null, - "relatedDummy": null, - "relatedDummies": [], - "jsonData": { - "key": [ - "value1", - "value2" - ] - }, - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": null, - "relatedOwningDummy": null, - "id": 1, - "name": "My Dummy", - "alias": null, - "foo": null - } - """ - - Scenario: Create a resource with empty body - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" - Then the response status code should be 400 - And the JSON node "detail" should be equal to "Syntax error" - - Scenario: Get a not found exception - When I send a "GET" request to "/dummies/42" - Then the response status code should be 404 - And the header "Content-Location" should not exist - - Scenario: Get a collection - When I send a "GET" request to "/dummies" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/Dummy", - "@id": "/dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/dummies/1", - "@type": "Dummy", - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": "2015-03-01T10:00:00+00:00", - "dummyFloat": null, - "dummyPrice": null, - "relatedDummy": null, - "relatedDummies": [], - "jsonData": { - "key": [ - "value1", - "value2" - ] - }, - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": null, - "relatedOwningDummy": null, - "id": 1, - "name": "My Dummy", - "alias": null, - "foo": null - } - ], - "hydra:totalItems": 1, - "hydra:search": { - "@type": "hydra:IriTemplate", - "hydra:template": "/dummies{?dummyBoolean,relatedDummy.embeddedDummy.dummyBoolean,dummyDate[before],dummyDate[strictly_before],dummyDate[after],dummyDate[strictly_after],relatedDummy.dummyDate[before],relatedDummy.dummyDate[strictly_before],relatedDummy.dummyDate[after],relatedDummy.dummyDate[strictly_after],exists[alias],exists[description],exists[relatedDummy.name],exists[dummyBoolean],exists[relatedDummy],exists[relatedDummies],dummyFloat,dummyFloat[],dummyPrice,dummyPrice[],order[id],order[name],order[description],order[relatedDummy.name],order[relatedDummy.symfony],order[dummyDate],dummyFloat[between],dummyFloat[gt],dummyFloat[gte],dummyFloat[lt],dummyFloat[lte],dummyPrice[between],dummyPrice[gt],dummyPrice[gte],dummyPrice[lt],dummyPrice[lte],id,id[],name,alias,description,relatedDummy.name,relatedDummy.name[],relatedDummies,relatedDummies[],dummy,relatedDummies.name,relatedDummy.thirdLevel.level,relatedDummy.thirdLevel.level[],relatedDummy.thirdLevel.fourthLevel.level,relatedDummy.thirdLevel.fourthLevel.level[],relatedDummy.thirdLevel.badFourthLevel.level,relatedDummy.thirdLevel.badFourthLevel.level[],relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level,relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level[],name_converted,properties[]}", - "hydra:variableRepresentation": "BasicRepresentation", - "hydra:mapping": [ - { - "@type": "IriTemplateMapping", - "variable": "dummyBoolean", - "property": "dummyBoolean", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.embeddedDummy.dummyBoolean", - "property": "relatedDummy.embeddedDummy.dummyBoolean", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[before]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[strictly_before]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[after]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[strictly_after]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.dummyDate[before]", - "property": "relatedDummy.dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.dummyDate[strictly_before]", - "property": "relatedDummy.dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.dummyDate[after]", - "property": "relatedDummy.dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.dummyDate[strictly_after]", - "property": "relatedDummy.dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "exists[alias]", - "property": "alias", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "exists[description]", - "property": "description", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "exists[relatedDummy.name]", - "property": "relatedDummy.name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "exists[dummyBoolean]", - "property": "dummyBoolean", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "exists[relatedDummy]", - "property": "relatedDummy", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "exists[relatedDummies]", - "property": "relatedDummies", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyFloat", - "property": "dummyFloat", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyFloat[]", - "property": "dummyFloat", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyPrice", - "property": "dummyPrice", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyPrice[]", - "property": "dummyPrice", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "order[id]", - "property": "id", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "order[name]", - "property": "name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "order[description]", - "property": "description", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "order[relatedDummy.name]", - "property": "relatedDummy.name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "order[relatedDummy.symfony]", - "property": "relatedDummy.symfony", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "order[dummyDate]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyFloat[between]", - "property": "dummyFloat", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyFloat[gt]", - "property": "dummyFloat", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyFloat[gte]", - "property": "dummyFloat", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyFloat[lt]", - "property": "dummyFloat", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyFloat[lte]", - "property": "dummyFloat", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyPrice[between]", - "property": "dummyPrice", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyPrice[gt]", - "property": "dummyPrice", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyPrice[gte]", - "property": "dummyPrice", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyPrice[lt]", - "property": "dummyPrice", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyPrice[lte]", - "property": "dummyPrice", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "id", - "property": "id", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "id[]", - "property": "id", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "name", - "property": "name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "alias", - "property": "alias", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "description", - "property": "description", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.name", - "property": "relatedDummy.name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.name[]", - "property": "relatedDummy.name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummies", - "property": "relatedDummies", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummies[]", - "property": "relatedDummies", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummy", - "property": "dummy", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummies.name", - "property": "relatedDummies.name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.thirdLevel.level", - "property": "relatedDummy.thirdLevel.level", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.thirdLevel.level[]", - "property": "relatedDummy.thirdLevel.level", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.thirdLevel.fourthLevel.level", - "property": "relatedDummy.thirdLevel.fourthLevel.level", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.thirdLevel.fourthLevel.level[]", - "property": "relatedDummy.thirdLevel.fourthLevel.level", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.thirdLevel.badFourthLevel.level", - "property": "relatedDummy.thirdLevel.badFourthLevel.level", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.thirdLevel.badFourthLevel.level[]", - "property": "relatedDummy.thirdLevel.badFourthLevel.level", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level", - "property": "relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level[]", - "property": "relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "name_converted", - "property": "name_converted", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "properties[]", - "property": null, - "required": false - } - ] - } - } - """ - - Scenario: Update a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/dummies/1" with body: - """ - { - "@id": "/dummies/1", - "name": "A nice dummy", - "dummyDate": "2018-12-01 13:12", - "jsonData": [{ - "key": "value1" - }, - { - "key": "value2" - } - ] - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should be equal to "/dummies/1.jsonld" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Dummy", - "@id": "/dummies/1", - "@type": "Dummy", - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": "2018-12-01T13:12:00+00:00", - "dummyFloat": null, - "dummyPrice": null, - "relatedDummy": null, - "relatedDummies": [], - "jsonData": [ - { - "key": "value1" - }, - { - "key": "value2" - } - ], - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": null, - "relatedOwningDummy": null, - "id": 1, - "name": "A nice dummy", - "alias": null, - "foo": null - } - """ - - Scenario: Update a resource with empty body - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/dummies/1" - Then the response status code should be 400 - And the JSON node "detail" should be equal to "Syntax error" - - Scenario: Delete a resource - When I send a "DELETE" request to "/dummies/1" - Then the response status code should be 204 - And the response should be empty - - @php8 - @createSchema - Scenario: Create a resource ProcessorEntity - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/processor_entities" with body: - """ - { - "foo": "bar" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should be equal to "/processor_entities/1.jsonld" - And the header "Location" should be equal to "/processor_entities/1" - And the JSON should be equal to: - """ - { - "@context": "/contexts/ProcessorEntity", - "@id": "/processor_entities/1", - "@type": "ProcessorEntity", - "id": 1, - "foo": "bar" - } - """ - - @php8 - Scenario: Create a resource ProviderEntity - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/provider_entities" with body: - """ - { - "foo": "bar" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should be equal to "/provider_entities/1.jsonld" - And the header "Location" should be equal to "/provider_entities/1" - And the JSON should be equal to: - """ - { - "@context": "/contexts/ProviderEntity", - "@id": "/provider_entities/1", - "@type": "ProviderEntity", - "id": 1, - "foo": "bar" - } - """ - - @php8 - Scenario: Get a collection of Provider Entities - When I send a "GET" request to "/provider_entities" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/ProviderEntity", - "@id": "/provider_entities", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/provider_entities/1", - "@type": "ProviderEntity", - "id": 1, - "foo": "bar" - } - ], - "hydra:totalItems": 1 - } - """ - - @php8 - Scenario: Get a resource ProviderEntity - When I send a "GET" request to "/provider_entities/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/ProviderEntity", - "@id": "/provider_entities/1", - "@type": "ProviderEntity", - "id": 1, - "foo": "bar" - } - """ - - Scenario: Get a resource in v3 configured in YAML - Given there is a Program - When I send a "GET" request to "/programs/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/Program", - "@id": "/programs/1", - "@type": "Program", - "id": 1, - "name": "Lorem ipsum 1", - "date": "2015-03-01T10:00:00+00:00", - "author": "/users/1" - } - """ - - Scenario: Get a collection resource in v3 configured in YAML - Given there are 3 Programs - When I send a "GET" request to "/users/1/programs" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/Program", - "@id": "/users/1/programs", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/programs/1", - "@type": "Program", - "id": 1, - "name": "Lorem ipsum 1", - "date": "2015-03-01T10:00:00+00:00", - "author": "/users/1" - }, - { - "@id": "/programs/2", - "@type": "Program", - "id": 2, - "name": "Lorem ipsum 2", - "date": "2015-03-02T10:00:00+00:00", - "author": "/users/1" - }, - { - "@id": "/programs/3", - "@type": "Program", - "id": 3, - "name": "Lorem ipsum 3", - "date": "2015-03-03T10:00:00+00:00", - "author": "/users/1" - } - ], - "hydra:totalItems": 3 - } - """ - - Scenario: Get a resource in v3 configured in XML - Given there is a Comment - When I send a "GET" request to "/comments/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/Comment", - "@id": "/comments/1", - "@type": "Comment", - "id": 1, - "comment": "Lorem ipsum dolor sit amet 1", - "date": "2015-03-01T10:00:00+00:00", - "author": "/users/1" - } - """ - - Scenario: Get a collection resource in v3 configured in XML - Given there are 3 Comments - When I send a "GET" request to "/users/1/comments" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/Comment", - "@id": "/users/1/comments", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/comments/1", - "@type": "Comment", - "id": 1, - "comment": "Lorem ipsum dolor sit amet 1", - "date": "2015-03-01T10:00:00+00:00", - "author": "/users/1" - }, - { - "@id": "/comments/2", - "@type": "Comment", - "id": 2, - "comment": "Lorem ipsum dolor sit amet 2", - "date": "2015-03-02T10:00:00+00:00", - "author": "/users/1" - }, - { - "@id": "/comments/3", - "@type": "Comment", - "id": 3, - "comment": "Lorem ipsum dolor sit amet 3", - "date": "2015-03-03T10:00:00+00:00", - "author": "/users/1" - } - ], - "hydra:totalItems": 3 - } - """ diff --git a/features/main/crud_abstract.feature b/features/main/crud_abstract.feature deleted file mode 100644 index fc8bd66c69a..00000000000 --- a/features/main/crud_abstract.feature +++ /dev/null @@ -1,167 +0,0 @@ -Feature: Create-Retrieve-Update-Delete on abstract resource - In order to use an hypermedia API - As a client software developer - I need to be able to retrieve, create, update and delete JSON-LD encoded resources even if they are abstract. - - @createSchema - Scenario: Create a concrete resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/concrete_dummies" with body: - """ - { - "instance": "Concrete", - "name": "My Dummy" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should be equal to "/concrete_dummies/1.jsonld" - And the header "Location" should be equal to "/concrete_dummies/1" - And the JSON should be equal to: - """ - { - "@context": "/contexts/ConcreteDummy", - "@id": "/concrete_dummies/1", - "@type": "ConcreteDummy", - "instance": "Concrete", - "id": 1, - "name": "My Dummy" - } - """ - - Scenario: Get a resource - When I send a "GET" request to "/abstract_dummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/ConcreteDummy", - "@id": "/concrete_dummies/1", - "@type": "ConcreteDummy", - "instance": "Concrete", - "id": 1, - "name": "My Dummy" - } - """ - - Scenario: Get a collection - When I send a "GET" request to "/abstract_dummies" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should not exist - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^ConcreteDummy$" - }, - "instance": { - "type": "string", - "required": "true" - } - } - }, - "minItems": 1 - } - }, - "required": ["hydra:member"] - } - """ - - Scenario: Update a concrete resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/concrete_dummies/1" with body: - """ - { - "@id": "/concrete_dummies/1", - "instance": "Become real", - "name": "A nice dummy" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should be equal to "/concrete_dummies/1.jsonld" - And the JSON should be equal to: - """ - { - "@context": "/contexts/ConcreteDummy", - "@id": "/concrete_dummies/1", - "@type": "ConcreteDummy", - "instance": "Become real", - "id": 1, - "name": "A nice dummy" - } - """ - - Scenario: Update a concrete resource using abstract resource uri - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/abstract_dummies/1" with body: - """ - { - "@id": "/concrete_dummies/1", - "instance": "Become surreal", - "name": "A nicer dummy" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should be equal to "/concrete_dummies/1.jsonld" - And the JSON should be equal to: - """ - { - "@context": "/contexts/ConcreteDummy", - "@id": "/concrete_dummies/1", - "@type": "ConcreteDummy", - "instance": "Become surreal", - "id": 1, - "name": "A nicer dummy" - } - """ - - Scenario: Delete a resource - When I send a "DELETE" request to "/abstract_dummies/1" - Then the response status code should be 204 - And the response should be empty - - @createSchema - Scenario: Create a concrete resource with discriminator - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/abstract_dummies" with body: - """ - { - "discr": "concrete", - "instance": "Concrete", - "name": "My Dummy" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should be equal to "/concrete_dummies/1.jsonld" - And the header "Location" should be equal to "/concrete_dummies/1" - And the JSON should be equal to: - """ - { - "@context": "/contexts/ConcreteDummy", - "@id": "/concrete_dummies/1", - "@type": "ConcreteDummy", - "instance": "Concrete", - "id": 1, - "name": "My Dummy" - } - """ diff --git a/features/main/crud_uri_variables.feature b/features/main/crud_uri_variables.feature deleted file mode 100644 index 37787dc56d2..00000000000 --- a/features/main/crud_uri_variables.feature +++ /dev/null @@ -1,206 +0,0 @@ -Feature: Uri Variables - - @createSchema - @php8 - Scenario: Create a resource Company - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/companies" with body: - """ - { - "name": "Foo Company 1" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should be equal to "/companies/1.jsonld" - And the header "Location" should be equal to "/companies/1" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Company", - "@id": "/companies/1", - "@type": "Company", - "id": 1, - "name": "Foo Company 1", - "employees": null - } - """ - - @php8 - Scenario: Create a second resource Company - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/companies" with body: - """ - { - "name": "Foo Company 2" - } - """ - Then the response status code should be 201 - - @php8 - Scenario: Create first Employee - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/employees" with body: - """ - { - "name": "foo", - "company": "/companies/1" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should be equal to "/companies/1/employees/1.jsonld" - And the header "Location" should be equal to "/companies/1/employees/1" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Employee", - "@id": "/companies/1/employees/1", - "@type": "Employee", - "id": 1, - "name": "foo", - "company": "/companies/1" - } - """ - - @php8 - Scenario: Create second Employee - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/employees" with body: - """ - { - "name": "foo2", - "company": "/companies/2" - } - """ - Then the response status code should be 201 - - @php8 - Scenario: Create third Employee - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/employees" with body: - """ - { - "name": "foo3", - "company": "/companies/2" - } - """ - Then the response status code should be 201 - - @php8 - Scenario: Retrieve the collection of employees - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/companies/2/employees" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/Employee", - "@id": "/companies/2/employees", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/companies/2/employees/2", - "@type": "Employee", - "name": "foo2", - "company": { - "@id": "/companies/2", - "@type": "Company", - "name": "Foo Company 2" - } - }, - { - "@id": "/companies/2/employees/3", - "@type": "Employee", - "name": "foo3", - "company": { - "@id": "/companies/2", - "@type": "Company", - "name": "Foo Company 2" - } - } - ], - "hydra:totalItems": 2 - } - """ - When I send the following GraphQL request: - """ - { - companies { - edges { - node { - name - employees { - edges { - node { - name - } - } - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.companies.edges[0].node.name" should be equal to "Foo Company 1" - And the JSON node "data.companies.edges[0].node.employees.edges" should have 1 element - And the JSON node "data.companies.edges[0].node.employees.edges[0].node.name" should be equal to "foo" - And the JSON node "data.companies.edges[1].node.name" should be equal to "Foo Company 2" - And the JSON node "data.companies.edges[1].node.employees.edges" should have 2 elements - And the JSON node "data.companies.edges[1].node.employees.edges[0].node.name" should be equal to "foo2" - And the JSON node "data.companies.edges[1].node.employees.edges[1].node.name" should be equal to "foo3" - - @php8 - Scenario: Retrieve the company of an employee - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/employees/1/company" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/Company", - "@id": "/employees/1/company", - "@type": "Company", - "id": 1, - "name": "Foo Company 1", - "employees": null - } - """ - - @php8 - Scenario: Retrieve an employee - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/companies/1/employees/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should not exist - And the JSON should be equal to: - """ - { - "@context": "/contexts/Employee", - "@id": "/companies/1/employees/1", - "@type": "Employee", - "id": 1, - "name": "foo", - "company": "/companies/1" - } - """ - - @php8 - Scenario: Trying to get an employee of wrong company - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/companies/1/employees/2" - Then the response status code should be 404 - And the header "Content-Location" should not exist diff --git a/features/main/custom_controller.feature b/features/main/custom_controller.feature deleted file mode 100644 index 16516099e53..00000000000 --- a/features/main/custom_controller.feature +++ /dev/null @@ -1,130 +0,0 @@ -@controller -Feature: Custom operation - As a client software developer - I need to be able to create custom operations - - Background: - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - - @createSchema - Scenario: Custom normalization operation - When I send a "POST" request to "/custom/denormalization" - Then the JSON should be equal to: - """ - { - "@context": "/contexts/CustomActionDummy", - "@id": "/custom_action_dummies/1", - "@type": "CustomActionDummy", - "id": 1, - "foo": "custom!" - } - """ - - Scenario: Custom normalization operation - When I send a "GET" request to "/custom/1/normalization" - Then the JSON should be equal to: - """ - { - "id": 1, - "foo": "foo" - } - """ - - Scenario: Custom normalization operation with shorthand configuration - When I send a "POST" request to "/short_custom/denormalization" - Then the JSON should be equal to: - """ - { - "@context": "/contexts/CustomActionDummy", - "@id": "/custom_action_dummies/2", - "@type": "CustomActionDummy", - "id": 2, - "foo": "short declaration" - } - """ - - Scenario: Custom normalization operation with shorthand configuration - When I send a "GET" request to "/short_custom/2/normalization" - Then the JSON should be equal to: - """ - { - "id": 2, - "foo": "short" - } - """ - - Scenario: Custom collection name without specific route - When I send a "GET" request to "/custom_action_collection_dummies" - Then the response status code should be 200 - Then the JSON node "hydra:member" should have 2 elements - - Scenario: Custom operation name without specific route - When I send a "GET" request to "/custom_action_collection_dummies/1" - Then the JSON should be equal to: - """ - { - "@context": "/contexts/CustomActionDummy", - "@id": "/custom_action_collection_dummies/1", - "@type": "CustomActionDummy", - "id": 1, - "foo": "custom!" - } - """ - - @createSchema - Scenario: Create a payment - When I send a "POST" request to "/payments" with body: - """ - { - "amount": "123.45" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Payment", - "@id": "/payments/1", - "@type": "Payment", - "id": 1, - "amount": "123.45", - "voidPayment": null - } - """ - - @createSchema - Scenario: Void a payment - Given there is a payment - When I send a "POST" request to "/payments/1/void" - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/VoidPayment", - "@id": "/void_payments/1", - "@type": "VoidPayment", - "id": 1, - "payment": "/payments/1" - } - """ - - Scenario: Get a void payment - When I send a "GET" request to "/void_payments/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/VoidPayment", - "@id": "/void_payments/1", - "@type": "VoidPayment", - "id": 1, - "payment": "/payments/1" - } - """ diff --git a/features/main/custom_identifier.feature b/features/main/custom_identifier.feature deleted file mode 100644 index 4af01787309..00000000000 --- a/features/main/custom_identifier.feature +++ /dev/null @@ -1,121 +0,0 @@ -Feature: Using custom identifier on resource - In order to use an hypermedia API - As a client software developer - I need to be able to user other identifier than id in resources - - @createSchema - Scenario: Create a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/custom_identifier_dummies" with body: - """ - { - "name": "My Dummy" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomIdentifierDummy", - "@id": "/custom_identifier_dummies/1", - "@type": "CustomIdentifierDummy", - "customId": 1, - "name": "My Dummy" - } - """ - - Scenario: Get a resource - When I send a "GET" request to "/custom_identifier_dummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomIdentifierDummy", - "@id": "/custom_identifier_dummies/1", - "@type": "CustomIdentifierDummy", - "customId": 1, - "name": "My Dummy" - } - """ - - Scenario: Get a collection - When I send a "GET" request to "/custom_identifier_dummies" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomIdentifierDummy", - "@id": "/custom_identifier_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/custom_identifier_dummies/1", - "@type": "CustomIdentifierDummy", - "customId": 1, - "name": "My Dummy" - } - ], - "hydra:totalItems": 1 - } - """ - - Scenario: Update a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/custom_identifier_dummies/1" with body: - """ - { - "name": "My Dummy modified" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomIdentifierDummy", - "@id": "/custom_identifier_dummies/1", - "@type": "CustomIdentifierDummy", - "customId": 1, - "name": "My Dummy modified" - } - """ - - Scenario: API doc is correctly generated - When I send a "GET" request to "/docs.jsonld" - Then the response status code should be 200 - And the response should be in JSON - And the Hydra class "CustomIdentifierDummy" exists - And 4 operations are available for Hydra class "CustomIdentifierDummy" - And 1 properties are available for Hydra class "CustomIdentifierDummy" - And "name" property is readable for Hydra class "CustomIdentifierDummy" - And "name" property is writable for Hydra class "CustomIdentifierDummy" - - Scenario: Delete a resource - When I send a "DELETE" request to "/custom_identifier_dummies/1" - Then the response status code should be 204 - And the response should be empty - - @createSchema - Scenario: Get a resource - Given there is a custom multiple identifier dummy - When I send a "GET" request to "/custom_multiple_identifier_dummies/1/2" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomMultipleIdentifierDummy", - "@id": "/custom_multiple_identifier_dummies/1/2", - "@type": "CustomMultipleIdentifierDummy", - "firstId": 1, - "secondId": 2, - "name": "Orwell" - } - """ diff --git a/features/main/custom_identifier_with_subresource.feature b/features/main/custom_identifier_with_subresource.feature deleted file mode 100644 index 74d9c65a4cd..00000000000 --- a/features/main/custom_identifier_with_subresource.feature +++ /dev/null @@ -1,95 +0,0 @@ -Feature: Using custom parent identifier for resources - In order to use an hypermedia API - As a client software developer - I need to be able to use custom identifiers and query resources - - @createSchema - Scenario: Create a parent dummy - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/slug_parent_dummies" with body: - """ - { - "slug": "parent-dummy" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/SlugParentDummy", - "@id": "/slug_parent_dummies/parent-dummy", - "@type": "SlugParentDummy", - "id": 1, - "slug": "parent-dummy", - "childDummies": [] - } - """ - - Scenario: Create a child dummy - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/slug_child_dummies" with body: - """ - { - "slug": "child-dummy", - "parentDummy": "/slug_parent_dummies/parent-dummy" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/SlugChildDummy", - "@id": "/slug_child_dummies/child-dummy", - "@type": "SlugChildDummy", - "id": 1, - "slug": "child-dummy", - "parentDummy": "/slug_parent_dummies/parent-dummy" - } - """ - - Scenario: Get child dummies of parent dummy - When I send a "GET" request to "/slug_parent_dummies/parent-dummy/child_dummies" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/SlugChildDummy", - "@id": "/slug_parent_dummies/parent-dummy/child_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/slug_child_dummies/child-dummy", - "@type": "SlugChildDummy", - "id": 1, - "slug": "child-dummy", - "parentDummy": "/slug_parent_dummies/parent-dummy" - } - ], - "hydra:totalItems": 1 - } - """ - - Scenario: Get parent dummy of child dummy - When I send a "GET" request to "/slug_child_dummies/child-dummy/parent_dummy" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/SlugParentDummy", - "@id": "/slug_child_dummies/child-dummy/parent_dummy", - "@type": "SlugParentDummy", - "id": 1, - "slug": "parent-dummy", - "childDummies": [ - "/slug_child_dummies/child-dummy" - ] - } - """ diff --git a/features/main/custom_normalized.feature b/features/main/custom_normalized.feature deleted file mode 100644 index 7d1a49f3fed..00000000000 --- a/features/main/custom_normalized.feature +++ /dev/null @@ -1,213 +0,0 @@ -Feature: Using custom normalized entity - In order to use an hypermedia API - As a client software developer - I need to be able to filter correctly attribute of my entities - - @createSchema - Scenario: Create a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/custom_normalized_dummies" with body: - """ - { - "name": "My Dummy", - "alias": "My alias" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should be equal to "/custom_normalized_dummies/1.jsonld" - And the header "Location" should be equal to "/custom_normalized_dummies/1" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomNormalizedDummy", - "@id": "/custom_normalized_dummies/1", - "@type": "CustomNormalizedDummy", - "id": 1, - "name": "My Dummy", - "alias": "My alias" - } - """ - - @createSchema - Scenario: Create a resource with a custom normalized dummy - When I add "Content-Type" header equal to "application/json" - When I add "Accept" header equal to "application/json" - And I send a "POST" request to "/related_normalized_dummies" with body: - """ - { - "name": "My Dummy" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json; charset=utf-8" - And the header "Content-Location" should be equal to "/related_normalized_dummies/1.json" - And the header "Location" should be equal to "/related_normalized_dummies/1" - And the JSON should be equal to: - """ - { - "id": 1, - "name": "My Dummy", - "customNormalizedDummy": [] - } - """ - - @createSchema - Scenario: Create a resource with a custom normalized dummy and an id - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/custom_normalized_dummies" with body: - """ - { - "name": "My Dummy", - "alias": "My alias" - } - """ - Then the response status code should be 201 - When I add "Content-Type" header equal to "application/json" - When I add "Accept" header equal to "application/json" - And I send a "POST" request to "/related_normalized_dummies" with body: - """ - { - "name": "My Dummy" - } - """ - Then the response status code should be 201 - When I add "Content-Type" header equal to "application/json" - When I add "Accept" header equal to "application/json" - And I send a "PUT" request to "/related_normalized_dummies/1" with body: - """ - { - "name": "My Dummy", - "customNormalizedDummy":[{ - "@context": "/contexts/CustomNormalizedDummy", - "@id": "/custom_normalized_dummies/1", - "@type": "CustomNormalizedDummy", - "id": 1, - "name": "My Dummy" - }] - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json; charset=utf-8" - And the header "Content-Location" should be equal to "/related_normalized_dummies/1.json" - And the JSON should be equal to: - """ - { - "id": 1, - "name": "My Dummy", - "customNormalizedDummy":[{ - "id": 1, - "name": "My Dummy", - "alias": "My alias" - }] - } - """ - - Scenario: Get a custom normalized dummy resource - When I send a "GET" request to "/custom_normalized_dummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomNormalizedDummy", - "@id": "/custom_normalized_dummies/1", - "@type": "CustomNormalizedDummy", - "id": 1, - "name": "My Dummy", - "alias": "My alias" - } - """ - - Scenario: Get a collection - When I send a "GET" request to "/custom_normalized_dummies" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomNormalizedDummy", - "@id": "/custom_normalized_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/custom_normalized_dummies/1", - "@type": "CustomNormalizedDummy", - "id": 1, - "name": "My Dummy", - "alias": "My alias" - } - ], - "hydra:totalItems": 1 - } - """ - - Scenario: Update a resource (legacy non-standard PUT) - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/custom_normalized_dummies/1" with body: - """ - { - "name": "My Dummy modified" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should be equal to "/custom_normalized_dummies/1.jsonld" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomNormalizedDummy", - "@id": "/custom_normalized_dummies/1", - "@type": "CustomNormalizedDummy", - "id": 1, - "name": "My Dummy modified", - "alias": "My alias" - } - """ - - Scenario: Update a resource - When I add "Content-Type" header equal to "application/merge-patch+json" - And I send a "PATCH" request to "/custom_normalized_dummies/1" with body: - """ - { - "name": "My Dummy modified" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should be equal to "/custom_normalized_dummies/1.jsonld" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomNormalizedDummy", - "@id": "/custom_normalized_dummies/1", - "@type": "CustomNormalizedDummy", - "id": 1, - "name": "My Dummy modified", - "alias": "My alias" - } - """ - - Scenario: API doc is correctly generated - When I send a "GET" request to "/docs.jsonld" - Then the response status code should be 200 - And the response should be in JSON - And the Hydra class "CustomNormalizedDummy" exists - And 4 operations are available for Hydra class "CustomNormalizedDummy" - And 2 properties are available for Hydra class "CustomNormalizedDummy" - And "name" property is readable for Hydra class "CustomNormalizedDummy" - And "name" property is writable for Hydra class "CustomNormalizedDummy" - And "alias" property is readable for Hydra class "CustomNormalizedDummy" - And "alias" property is writable for Hydra class "CustomNormalizedDummy" - - Scenario: Delete a resource - When I send a "DELETE" request to "/custom_normalized_dummies/1" - Then the response status code should be 204 - And the response should be empty diff --git a/features/main/custom_put.feature b/features/main/custom_put.feature deleted file mode 100644 index 9b286b57cb4..00000000000 --- a/features/main/custom_put.feature +++ /dev/null @@ -1,29 +0,0 @@ -Feature: Spec-compliant PUT support - As a client software developer - I need to be able to create or replace resources using the PUT HTTP method - - @createSchema - @!mongodb - Scenario: Get a correct status code when updating a resource that is not allowed to read nor to create - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/custom_puts/1" with body: - """ - { - "foo": "a", - "bar": "b" - } - """ - Then the response status code should be 200 - And the response status code should not be 201 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomPut", - "@id": "/custom_puts/1", - "@type": "CustomPut", - "id": 1, - "foo": "a", - "bar": "b" - } - """ diff --git a/features/main/custom_writable_identifier.feature b/features/main/custom_writable_identifier.feature deleted file mode 100644 index 097d253b9ad..00000000000 --- a/features/main/custom_writable_identifier.feature +++ /dev/null @@ -1,112 +0,0 @@ -Feature: Using custom writable identifier on resource - In order to use an hypermedia API - As a client software developer - I need to be able to user other identifier than id in resource and set it via API call on POST / PUT. - - @createSchema - Scenario: Create a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/custom_writable_identifier_dummies" with body: - """ - { - "name": "My Dummy", - "slug": "my_slug" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should be equal to "/custom_writable_identifier_dummies/my_slug.jsonld" - And the header "Location" should be equal to "/custom_writable_identifier_dummies/my_slug" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomWritableIdentifierDummy", - "@id": "/custom_writable_identifier_dummies/my_slug", - "@type": "CustomWritableIdentifierDummy", - "slug": "my_slug", - "name": "My Dummy" - } - """ - - Scenario: Get a resource - When I send a "GET" request to "/custom_writable_identifier_dummies/my_slug" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomWritableIdentifierDummy", - "@id": "/custom_writable_identifier_dummies/my_slug", - "@type": "CustomWritableIdentifierDummy", - "slug": "my_slug", - "name": "My Dummy" - } - """ - - Scenario: Get a collection - When I send a "GET" request to "/custom_writable_identifier_dummies" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomWritableIdentifierDummy", - "@id": "/custom_writable_identifier_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/custom_writable_identifier_dummies/my_slug", - "@type": "CustomWritableIdentifierDummy", - "slug": "my_slug", - "name": "My Dummy" - } - ], - "hydra:totalItems": 1 - } - """ - - @!mongodb - Scenario: Update a resource (legacy non-standard PUT) - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/custom_writable_identifier_dummies/my_slug" with body: - """ - { - "name": "My Dummy modified", - "slug": "slug_modified" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should be equal to "/custom_writable_identifier_dummies/slug_modified.jsonld" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomWritableIdentifierDummy", - "@id": "/custom_writable_identifier_dummies/slug_modified", - "@type": "CustomWritableIdentifierDummy", - "slug": "slug_modified", - "name": "My Dummy modified" - } - """ - - Scenario: API docs are correctly generated - When I send a "GET" request to "/docs.jsonld" - Then the response status code should be 200 - And the response should be in JSON - And the Hydra class "CustomWritableIdentifierDummy" exists - And 4 operations are available for Hydra class "CustomWritableIdentifierDummy" - And 2 properties are available for Hydra class "CustomWritableIdentifierDummy" - And "name" property is readable for Hydra class "CustomWritableIdentifierDummy" - And "name" property is writable for Hydra class "CustomWritableIdentifierDummy" - And "slug" property is readable for Hydra class "CustomWritableIdentifierDummy" - And "slug" property is writable for Hydra class "CustomWritableIdentifierDummy" - - @!mongodb - Scenario: Delete a resource - When I send a "DELETE" request to "/custom_writable_identifier_dummies/slug_modified" - Then the response status code should be 204 - And the response should be empty diff --git a/features/main/default_order.feature b/features/main/default_order.feature deleted file mode 100644 index ad05e08a835..00000000000 --- a/features/main/default_order.feature +++ /dev/null @@ -1,267 +0,0 @@ -Feature: Default order - In order to get a list in a specific order, - As a client software developer, - I need to be able to specify default order. - - @createSchema - Scenario: Override custom order - Given there are 5 foo objects with fake names - When I send a "GET" request to "/foos?itemsPerPage=10" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Foo", - "@id": "/foos", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/foos/5", - "@type": "Foo", - "id": 5, - "name": "Balbo", - "bar": "Amet" - }, - { - "@id": "/foos/3", - "@type": "Foo", - "id": 3, - "name": "Ephesian", - "bar": "Dolor" - }, - { - "@id": "/foos/2", - "@type": "Foo", - "id": 2, - "name": "Sthenelus", - "bar": "Ipsum" - }, - { - "@id": "/foos/1", - "@type": "Foo", - "id": 1, - "name": "Hawsepipe", - "bar": "Lorem" - }, - { - "@id": "/foos/4", - "@type": "Foo", - "id": 4, - "name": "Separativeness", - "bar": "Sit" - } - ], - "hydra:totalItems": 5, - "hydra:view": { - "@id": "/foos?itemsPerPage=10", - "@type": "hydra:PartialCollectionView" - } - } - """ - - Scenario: Override custom order by association - Given there are 5 fooDummy objects with fake names - When I send a "GET" request to "/foo_dummies?itemsPerPage=10" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/FooDummy", - "@id": "/foo_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/foo_dummies/5", - "@type": "FooDummy", - "id": 5, - "name": "Balbo", - "nonWritableProp": "readonly", - "embeddedFoo": null, - "dummy": "/dummies/5", - "soManies": [ - "/so_manies/13", - "/so_manies/14", - "/so_manies/15" - ] - - }, - { - "@id": "/foo_dummies/3", - "@type": "FooDummy", - "id": 3, - "name": "Sthenelus", - "nonWritableProp": "readonly", - "embeddedFoo": null, - "dummy": "/dummies/3", - "soManies": [ - "/so_manies/7", - "/so_manies/8", - "/so_manies/9" - ] - }, - { - "@id": "/foo_dummies/2", - "@type": "FooDummy", - "id": 2, - "name": "Ephesian", - "nonWritableProp": "readonly", - "embeddedFoo": null, - "dummy": "/dummies/2", - "soManies": [ - "/so_manies/4", - "/so_manies/5", - "/so_manies/6" - ] - }, - { - "@id": "/foo_dummies/1", - "@type": "FooDummy", - "id": 1, - "name": "Hawsepipe", - "nonWritableProp": "readonly", - "embeddedFoo": null, - "dummy": "/dummies/1", - "soManies": [ - "/so_manies/1", - "/so_manies/2", - "/so_manies/3" - ] - }, - { - "@id": "/foo_dummies/4", - "@type": "FooDummy", - "id": 4, - "name": "Separativeness", - "nonWritableProp": "readonly", - "embeddedFoo": null, - "dummy": "/dummies/4", - "soManies": [ - "/so_manies/10", - "/so_manies/11", - "/so_manies/12" - ] - } - ], - "hydra:totalItems": 5, - "hydra:view": { - "@id": "/foo_dummies?itemsPerPage=10", - "@type": "hydra:PartialCollectionView" - } - } - """ - - Scenario: Override custom order asc - When I send a "GET" request to "/custom_collection_asc_foos?itemsPerPage=10" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Foo", - "@id": "/custom_collection_asc_foos", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/foos/5", - "@type": "Foo", - "id": 5, - "name": "Balbo", - "bar": "Amet" - }, - { - "@id": "/foos/3", - "@type": "Foo", - "id": 3, - "name": "Ephesian", - "bar": "Dolor" - }, - { - "@id": "/foos/1", - "@type": "Foo", - "id": 1, - "name": "Hawsepipe", - "bar": "Lorem" - }, - { - "@id": "/foos/4", - "@type": "Foo", - "id": 4, - "name": "Separativeness", - "bar": "Sit" - }, - { - "@id": "/foos/2", - "@type": "Foo", - "id": 2, - "name": "Sthenelus", - "bar": "Ipsum" - } - ], - "hydra:totalItems": 5, - "hydra:view": { - "@id": "/custom_collection_asc_foos?itemsPerPage=10", - "@type": "hydra:PartialCollectionView" - } - } - """ - - Scenario: Override custom order desc - When I send a "GET" request to "/custom_collection_desc_foos?itemsPerPage=10" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Foo", - "@id": "/custom_collection_desc_foos", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/foos/2", - "@type": "Foo", - "id": 2, - "name": "Sthenelus", - "bar": "Ipsum" - }, - { - "@id": "/foos/4", - "@type": "Foo", - "id": 4, - "name": "Separativeness", - "bar": "Sit" - }, - { - "@id": "/foos/1", - "@type": "Foo", - "id": 1, - "name": "Hawsepipe", - "bar": "Lorem" - }, - { - "@id": "/foos/3", - "@type": "Foo", - "id": 3, - "name": "Ephesian", - "bar": "Dolor" - }, - { - "@id": "/foos/5", - "@type": "Foo", - "id": 5, - "name": "Balbo", - "bar": "Amet" - } - ], - "hydra:totalItems": 5, - "hydra:view": { - "@id": "/custom_collection_desc_foos?itemsPerPage=10", - "@type": "hydra:PartialCollectionView" - } - } - """ diff --git a/features/main/exception_to_status.feature b/features/main/exception_to_status.feature deleted file mode 100644 index a182ea848a8..00000000000 --- a/features/main/exception_to_status.feature +++ /dev/null @@ -1,47 +0,0 @@ -Feature: Using exception_to_status config - As an API developer - I can customize the status code returned if the application throws an exception - - @createSchema - @!mongodb - Scenario: Configure status code via the operation exceptionToStatus to map custom NotFound error to 404 - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/dummy_exception_to_statuses/123" - Then the response status code should be 404 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - @!mongodb - Scenario: Configure status code via the resource exceptionToStatus to map custom NotFound error to 400 - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/dummy_exception_to_statuses/123" with body: - """ - { - "name": "black" - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - @!mongodb - Scenario: Configure status code via the config file to map FilterValidationException to 400 - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/dummy_exception_to_statuses" - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - @!mongodb - Scenario: Override validation exception status code from delete operation - When I add "Content-Type" header equal to "application/ld+json" - And I send a "DELETE" request to "/error_with_overriden_status/1" - Then the response status code should be 403 - And the JSON node "status" should be equal to 403 - - @!mongodb - Scenario: Get HTTP Exception headers - When I add "Accept" header equal to "application/ld+json" - And I send a "GET" request to "/issue5924" - Then the response status code should be 429 - Then the header "retry-after" should be equal to 32 diff --git a/features/main/exposed_state.feature b/features/main/exposed_state.feature deleted file mode 100644 index 1915732f380..00000000000 --- a/features/main/exposed_state.feature +++ /dev/null @@ -1,48 +0,0 @@ -@postgres -Feature: Expose persisted object state - In order to use an hypermedia API - As a client software developer - I need to be able to retrieve the exact state of resources after persistence. - - @!mongodb - @createSchema - Scenario: Create a resource with truncable value should return the correct object state - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/truncated_dummies" with body: - """ - { - "value": "20.3325" - } - """ - Then the response status code should be 201 - And the JSON should be equal to: - """ - { - "@context": "/contexts/TruncatedDummy", - "@id": "/truncated_dummies/1", - "@type": "TruncatedDummy", - "value": "20.3", - "id": 1 - } - """ - - @!mongodb - Scenario: Update a resource with truncable value value should return the correct object state - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/truncated_dummies/1" with body: - """ - { - "value": "42.42" - } - """ - Then the response status code should be 200 - And the JSON should be equal to: - """ - { - "@context": "/contexts/TruncatedDummy", - "@id": "/truncated_dummies/1", - "@type": "TruncatedDummy", - "value": "42.4", - "id": 1 - } - """ diff --git a/features/main/headers.feature b/features/main/headers.feature deleted file mode 100644 index d61e6769574..00000000000 --- a/features/main/headers.feature +++ /dev/null @@ -1,14 +0,0 @@ -Feature: Headers addition - - @createSchema - Scenario: Test Sunset header addition - Given there is a DummyCar entity with related colors - When I send a "GET" request to "/dummy_cars" - Then the response status code should be 200 - And the header "Sunset" should be equal to "Sat, 01 Jan 2050 00:00:00 +0000" - - Scenario: Declare headers from resource - When I send a "GET" request to "/redirect_to_foobar" - Then the response status code should be 301 - And the header "Location" should be equal to "/foobar" - And the header "Hello" should be equal to "World" diff --git a/features/main/input_output.feature b/features/main/input_output.feature deleted file mode 100644 index 0c6f49926d9..00000000000 --- a/features/main/input_output.feature +++ /dev/null @@ -1,15 +0,0 @@ -Feature: DTO input and output - In order to use a hypermedia API - As a client software developer - I need to be able to use DTOs on my resources as Input or Output objects. - - Background: - Given I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - - @!mongodb - Scenario: Fetch a collection of outputs with an entityClass as state option - When I send a "GET" request to "/output_and_entity_classes" - And the JSON node "hydra:member[0].@type" should be equal to "OutputAndEntityClassEntity" - - diff --git a/features/main/not_exposed.feature b/features/main/not_exposed.feature deleted file mode 100644 index 809b95dd8e5..00000000000 --- a/features/main/not_exposed.feature +++ /dev/null @@ -1,204 +0,0 @@ -@php8 -@v3 -Feature: Expose only a collection of objects - - Background: - Given I add "Accept" header equal to "application/ld+json" - - # A NotExposed operation with "routeName: api_genid" is automatically added to this resource. - Scenario: Get a collection of objects without identifiers from a single resource with a single collection - When I send a "GET" request to "/chairs" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], - "properties": { - "@context": {"pattern": "^/contexts/Chair$"}, - "@id": {"pattern": "^/chairs$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "id", "owner"], - "properties": { - "@id": {"pattern": "^/.well-known/genid/.+$"}, - "@type": {"pattern": "^Chair$"}, - "id": {"type": "string"}, - "owner": {"type": "string"} - } - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} - } - } - """ - - # A NotExposed operation with a valid path (e.g.: "/tables/{id}") is automatically added to this resource. - Scenario: Get a collection of objects with identifiers from a single resource with a single collection - When I send a "GET" request to "/tables" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], - "properties": { - "@context": {"pattern": "^/contexts/Table$"}, - "@id": {"pattern": "^/tables$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "id", "owner"], - "properties": { - "@id": {"pattern": "^/tables/.+$"}, - "@type": {"pattern": "^Table$"}, - "id": {"type": "string"}, - "owner": {"type": "string"} - } - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} - } - } - """ - - # A NotExposed operation with a valid path (e.g.: "/forks/{id}") is automatically added to the last resource. - # This operation does not inherit from the resource uriTemplate as it's not intended to. - Scenario Outline: Get a collection of objects with identifiers from a multiple resources class with multiple collections - When I send a "GET" request to "" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], - "properties": { - "@context": {"pattern": "^/contexts/Fork$"}, - "@id": {"pattern": "^"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "id", "owner"], - "properties": { - "@id": {"pattern": "^/forks/.+$"}, - "@type": {"pattern": "^Fork$"}, - "id": {"type": "string"}, - "owner": {"type": "string"} - } - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} - } - } - """ - Examples: - | uri | - | /forks | - | /fourchettes | - - - # A NotExposed operation is not automatically added. - Scenario Outline: Get a collection of objects with identifiers from a multiple resources class with multiple collections and an item operation - When I send a "GET" request to "" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems"], - "properties": { - "@context": {"pattern": "^/contexts/Spoon$"}, - "@id": {"pattern": "^"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "additionalProperties": false, - "required": ["@id", "@type", "id", "owner"], - "properties": { - "@id": {"pattern": "^/cuillers/.+$"}, - "@type": {"pattern": "^Spoon$"}, - "id": {"type": "string"}, - "owner": {"type": "string"} - } - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2} - } - } - """ - Examples: - | uri | - | /spoons | - | /cuillers | - - Scenario Outline: Get a not exposed route returns a 404 with an explanation - When I send a "GET" request to "" - Then the response status code should be 404 - And the response should be in JSON - And the JSON node "detail" should be equal to "" - Examples: - | uri | description | - | /tables/12345 | This route does not aim to be called. | - | /forks/12345 | This route does not aim to be called. | - - Scenario Outline: Get a not exposed route returns a 404 with an explanation - When I send a "GET" request to "" - Then the response status code should be 404 - And the response should be in JSON - And the JSON node "detail" should be equal to "" - Examples: - | uri | description | - | /.well-known/genid/12345 | This route is not exposed on purpose. It generates an IRI for a collection resource without identifier nor item operation. | - - - Scenario: Get a single item still works - When I send a "GET" request to "/cuillers/12345" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Spoon", - "@id": "/cuillers/12345", - "@type": "Spoon", - "id": "12345", - "owner": "Vincent" - } - """ diff --git a/features/main/operation.feature b/features/main/operation.feature deleted file mode 100644 index 24a82890cfd..00000000000 --- a/features/main/operation.feature +++ /dev/null @@ -1,97 +0,0 @@ -Feature: Operation support - In order to make the API fitting custom need - As an API developer - I need to be able to add custom operations and remove built-in ones - - @createSchema - Scenario: Can not write readonly property - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/readable_only_properties" with body: - """ - { - "name": "My Dummy" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/ReadableOnlyProperty", - "@id": "/readable_only_properties/1", - "@type": "ReadableOnlyProperty", - "id": 1, - "name": "Read only" - } - """ - - Scenario: Access custom operations - When I send a "GET" request to "/relation_embedders/42/custom" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - "This is a custom action for 42." - """ - - @createSchema - Scenario: Select a resource and it's embedded data - Given there are 1 embedded dummy objects - When I send a "GET" request to "/embedded_dummies_groups/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "@context": "/contexts/EmbeddedDummy", - "@id": "/embedded_dummies_groups/1", - "@type": "EmbeddedDummy", - "name": "Dummy #1", - "embeddedDummy": { - "@type": "EmbeddableDummy", - "dummyName": "Dummy #1" - } - } - """ - - Scenario: Get the collection of a resource that have disabled item operation - When I send a "GET" request to "/disable_item_operations" - Then the response status code should be 200 - - Scenario: Get a 404 response for the disabled item operation - When I send a "GET" request to "/disable_item_operations/1" - Then the response status code should be 404 - - @createSchema - Scenario: Get a book by its ISBN - Given there is a book - When I send a "GET" request to "books/by_isbn/9780451524935" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Book", - "@id": "/books/by_isbn/9780451524935", - "@type": "Book", - "name": "1984", - "isbn": "9780451524935", - "id": 1 - } - """ - - Scenario: Call a non API Platform route - When I send a "GET" request to "/common/custom/object" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "id": 1, - "text": "Lorem ipsum dolor sit amet" - } - """ diff --git a/features/main/operation_resource.feature b/features/main/operation_resource.feature deleted file mode 100644 index b4bd729fcaf..00000000000 --- a/features/main/operation_resource.feature +++ /dev/null @@ -1,66 +0,0 @@ -Feature: Resource operations - In order to use the Resource Operation - As a developer - I should be able to persist data from a processor - - @php8 - @createSchema - @!mongodb - Scenario: Create an operation resource - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/operation_resources" with body: - """ - { - "identifier": 1, - "dummy": null, - "name": "string" - } - """ - Then the response status code should be 201 - - @php8 - @!mongodb - Scenario: Patch an operation resource - When I add "Content-Type" header equal to "application/merge-patch+json" - And I send a "PATCH" request to "/operation_resources/1" with body: - """ - {"name": "Patched"} - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/OperationResource", - "@id": "/operation_resources/1", - "@type": "OperationResource", - "identifier": 1, - "name": "Patched" - } - """ - - @php8 - @!mongodb - Scenario: Update an operation resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/operation_resources/1" with body: - """ - { - "name": "Modified" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should be equal to "/operation_resources/1.jsonld" - And the JSON should be equal to: - """ - { - "@context": "/contexts/OperationResource", - "@id": "/operation_resources/1", - "@type": "OperationResource", - "identifier": 1, - "name": "Modified" - } - """ diff --git a/features/main/overridden_operation.feature b/features/main/overridden_operation.feature deleted file mode 100644 index d07181de8bd..00000000000 --- a/features/main/overridden_operation.feature +++ /dev/null @@ -1,156 +0,0 @@ -Feature: Create-Retrieve-Update-Delete with a Overridden Operation context - In order to use an hypermedia API - As a client software developer - I need to be able to retrieve, create, update and delete JSON-LD encoded resources. - - @createSchema - Scenario: Create a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/overridden_operation_dummies" with body: - """ - { - "name": "My Overridden Operation Dummy", - "description" : "Gerard", - "alias": "notWritable" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/OverriddenOperationDummy", - "@id": "/overridden_operation_dummies/1", - "@type": "OverriddenOperationDummy", - "name": "My Overridden Operation Dummy", - "alias": null, - "description": "Gerard" - } - """ - - Scenario: Get a resource - When I send a "GET" request to "/overridden_operation_dummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/OverriddenOperationDummy", - "@id": "/overridden_operation_dummies/1", - "@type": "OverriddenOperationDummy", - "name": "My Overridden Operation Dummy", - "alias": null, - "description": "Gerard" - } - """ - - Scenario: Get a resource in XML - When I add "Accept" header equal to "application/xml" - And I send a "GET" request to "/overridden_operation_dummies/1" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/xml; charset=utf-8" - And the response should be equal to - """ - - My Overridden Operation DummyGerard - """ - - Scenario: Get a not found exception - When I send a "GET" request to "/overridden_operation_dummies/42" - Then the response status code should be 404 - - Scenario: Get a collection - When I send a "GET" request to "/overridden_operation_dummies" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/OverriddenOperationDummy", - "@id": "/overridden_operation_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/overridden_operation_dummies/1", - "@type": "OverriddenOperationDummy", - "name": "My Overridden Operation Dummy", - "alias": null, - "description": "Gerard" - } - ], - "hydra:totalItems": 1 - } - """ - - Scenario: Update a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/overridden_operation_dummies/1" with body: - """ - { - "@id": "/overridden_operation_dummies/1", - "name": "A nice dummy", - "alias": "Dummy" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/OverriddenOperationDummy", - "@id": "/overridden_operation_dummies/1", - "@type": "OverriddenOperationDummy", - "alias": "Dummy", - "description": "Gerard" - } - """ - - Scenario: Get the final resource - When I send a "GET" request to "/overridden_operation_dummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/OverriddenOperationDummy", - "@id": "/overridden_operation_dummies/1", - "@type": "OverriddenOperationDummy", - "name": "My Overridden Operation Dummy", - "alias": "Dummy", - "description": "Gerard" - } - """ - - Scenario: Delete a resource - When I send a "DELETE" request to "/overridden_operation_dummies/1" - Then the response status code should be 204 - And the response should be empty - - @createSchema - Scenario: Use a POST operation to do a Remote Procedure Call without identifiers - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/rpc" - """ - { - "value": "Hello world" - } - """ - Then the response status code should be 202 - - @createSchema - Scenario: Use a POST operation to do a Remote Procedure Call without identifiers and with an output DTO - When I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/rpc_output" - """ - { - "value": "Hello world" - } - """ - Then the response status code should be 200 - And the JSON node "success" should be equal to "YES" - And the JSON node "@type" should be equal to "RPCOutput" diff --git a/features/main/patch.feature b/features/main/patch.feature deleted file mode 100644 index d6be7ec5437..00000000000 --- a/features/main/patch.feature +++ /dev/null @@ -1,99 +0,0 @@ -Feature: Sending PATCH requets - As a client software developer - I need to be able to send partial updates - - @createSchema - Scenario: Detect accepted patch formats - Given I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/patch_dummies" with body: - """ - {"name": "Hello"} - """ - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/patch_dummies/1" - Then the header "Accept-Patch" should be equal to "application/merge-patch+json, application/vnd.api+json" - - Scenario: Patch an item - When I add "Content-Type" header equal to "application/merge-patch+json" - And I send a "PATCH" request to "/patch_dummies/1" with body: - """ - {"name": "Patched"} - """ - Then the JSON node "name" should contain "Patched" - - Scenario: Remove a property according to RFC 7386 - When I add "Content-Type" header equal to "application/merge-patch+json" - And I send a "PATCH" request to "/patch_dummies/1" with body: - """ - {"name": null} - """ - Then the JSON node "name" should not exist - - @createSchema - Scenario: Patch the relation - Given there is a PatchDummyRelation - When I add "Content-Type" header equal to "application/merge-patch+json" - And I send a "PATCH" request to "/patch_dummy_relations/1" with body: - """ - { - "related": { - "symfony": "A new name" - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/PatchDummyRelation", - "@id": "/patch_dummy_relations/1", - "@type": "PatchDummyRelation", - "related": { - "@id": "/related_dummies/1", - "@type": "https://schema.org/Product", - "id": 1, - "symfony": "A new name" - } - } - """ - - Scenario: Patch a relation with uri variables that are not `id` - When I add "Content-Type" header equal to "application/merge-patch+json" - And I send a "PATCH" request to "/betas/1" with body: - """ - { - "alpha": "/alphas/2" - } - """ - Then the response should be in JSON - And the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Beta", - "@id": "/betas/1", - "@type": "Beta", - "betaId": 1, - "alpha": "/alphas/2" - } - """ - - @use_listener - @controller - # Previously to 3.3 it was not possible to disable a read, this test is ignored on the - # legacy test suite (EVENT_LISTENERS_BACKWARD_COMPATIBILITY_LAYER=1) - Scenario: Patch a non-readable resource - When I add "Content-Type" header equal to "application/merge-patch+json" - And I send a "PATCH" request to "/order_products/1/count" with body: - """ - { - "id": 1, - "count": 10 - } - - """ - Then the response status code should be 200 - And the JSON node "id" should contain "1" diff --git a/features/main/put_collection.feature b/features/main/put_collection.feature deleted file mode 100644 index 2423043b886..00000000000 --- a/features/main/put_collection.feature +++ /dev/null @@ -1,32 +0,0 @@ -Feature: Update an embed collection with PUT - As a client software developer - I need to be able to update an embed collection - - Background: - Given I add "Content-Type" header equal to "application/ld+json" - - @createSchema - @!mongodb - Scenario: Update embed collection - And I send a "POST" request to "/issue5584_employees" with body: - """ - {"name": "One"} - """ - Then I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/issue5584_employees" with body: - """ - {"name": "Two"} - """ - Then print last JSON response - Then I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/issue5584_businesses" with body: - """ - {"name": "Business"} - """ - Then I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/issue5584_businesses/1" with body: - """ - {"name": "Business", "businessEmployees": [{"@id": "/issue5584_employees/1", "id": 1}, {"@id": "/issue5584_employees/2", "id": 2}]} - """ - And the JSON node "businessEmployees[0].name" should contain 'One' - And the JSON node "businessEmployees[1].name" should contain 'Two' diff --git a/features/main/relation.feature b/features/main/relation.feature deleted file mode 100644 index 5eba540f96e..00000000000 --- a/features/main/relation.feature +++ /dev/null @@ -1,546 +0,0 @@ -Feature: Relations support - In order to use a hypermedia API - As a client software developer - I need to be able to update relations between resources - - @createSchema - Scenario: Create a third level - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/third_levels" with body: - """ - {"level": 3} - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/ThirdLevel", - "@id": "/third_levels/1", - "@type": "ThirdLevel", - "fourthLevel": null, - "badFourthLevel": null, - "id": 1, - "level": 3, - "test": true, - "relatedDummies": [] - } - """ - - Scenario: Create a dummy friend - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_friends" with body: - """ - {"name": "Zoidberg"} - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/DummyFriend", - "@id": "/dummy_friends/1", - "@type": "DummyFriend", - "id": 1, - "name": "Zoidberg" - } - """ - - Scenario: Create a related dummy - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/related_dummies" with body: - """ - {"thirdLevel": "/third_levels/1"} - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelatedDummy", - "@id": "/related_dummies/1", - "@type": "https://schema.org/Product", - "id": 1, - "name": null, - "symfony": "symfony", - "dummyDate": null, - "thirdLevel": { - "@id": "/third_levels/1", - "@type": "ThirdLevel", - "fourthLevel": null - }, - "relatedToDummyFriend": [], - "dummyBoolean": null, - "embeddedDummy": [], - "age": null - } - """ - - @!mongodb - Scenario: Create a friend relationship - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/related_to_dummy_friends" with body: - """ - { - "name": "Friends relation", - "dummyFriend": "/dummy_friends/1", - "relatedDummy": "/related_dummies/1" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelatedToDummyFriend", - "@id": "/related_to_dummy_friends/dummyFriend=1;relatedDummy=1", - "@type": "RelatedToDummyFriend", - "name": "Friends relation", - "description": null, - "dummyFriend": { - "@id": "/dummy_friends/1", - "@type": "DummyFriend", - "name": "Zoidberg" - } - } - """ - - @!mongodb - Scenario: Get the relationship - When I send a "GET" request to "/related_to_dummy_friends/dummyFriend=1;relatedDummy=1" - And the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelatedToDummyFriend", - "@id": "/related_to_dummy_friends/dummyFriend=1;relatedDummy=1", - "@type": "RelatedToDummyFriend", - "name": "Friends relation", - "description": null, - "dummyFriend": { - "@id": "/dummy_friends/1", - "@type": "DummyFriend", - "name": "Zoidberg" - } - } - """ - - Scenario: Create a dummy with relations - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Dummy with relations", - "relatedDummy": "http://example.com/related_dummies/1", - "relatedDummies": [ - "/related_dummies/1" - ], - "name_converted": null - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Dummy", - "@id": "/dummies/1", - "@type": "Dummy", - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "relatedDummy": "/related_dummies/1", - "relatedDummies": [ - "/related_dummies/1" - ], - "jsonData": [], - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": null, - "relatedOwningDummy": null, - "id": 1, - "name": "Dummy with relations", - "alias": null, - "foo": null - } - """ - - Scenario: Filter on a relation - When I send a "GET" request to "/dummies?relatedDummy=%2Frelated_dummies%2F1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 1}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/1$"} - } - }, - "maxItems": 1 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummy=%2Frelated_dummies%2F1$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter on a to-many relation - When I send a "GET" request to "/dummies?relatedDummies[]=%2Frelated_dummies%2F1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 1}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/1$"} - } - }, - "maxItems": 1 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummies%5B%5D=%2Frelated_dummies%2F1$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Embed a relation in the parent object - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "related": "/related_dummies/1" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelationEmbedder", - "@id": "/relation_embedders/1", - "@type": "RelationEmbedder", - "krondstadt": "Krondstadt", - "anotherRelated": null, - "related": { - "@id": "/related_dummies/1", - "@type": "https://schema.org/Product", - "symfony": "symfony", - "thirdLevel": { - "@id": "/third_levels/1", - "@type": "ThirdLevel", - "level": 3, - "fourthLevel": null - } - } - } - """ - - Scenario: Create an existing relation - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "anotherRelated": { - "symfony": "laravel" - } - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelationEmbedder", - "@id": "/relation_embedders/2", - "@type": "RelationEmbedder", - "krondstadt": "Krondstadt", - "anotherRelated": { - "@id": "/related_dummies/2", - "@type": "https://schema.org/Product", - "symfony": "laravel", - "thirdLevel": null - }, - "related": null - } - """ - - Scenario: Update the relation with a new one - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/relation_embedders/2" with body: - """ - { - "anotherRelated": { - "symfony": "laravel2" - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelationEmbedder", - "@id": "/relation_embedders/2", - "@type": "RelationEmbedder", - "krondstadt": "Krondstadt", - "anotherRelated": { - "@id": "/related_dummies/3", - "@type": "https://schema.org/Product", - "symfony": "laravel2", - "thirdLevel": null - }, - "related": null - } - """ - - Scenario: Post a wrong relation - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "anotherRelated": { - "@id": "/related_dummies/123", - "@type": "https://schema.org/Product", - "symfony": "phalcon" - } - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - Scenario: Post a relation with a not existing IRI - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "related": "/related_dummies/123" - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - Scenario: Update an embedded relation - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/relation_embedders/2" with body: - """ - { - "anotherRelated": { - "@id": "/related_dummies/2", - "symfony": "API Platform" - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelationEmbedder", - "@id": "/relation_embedders/2", - "@type": "RelationEmbedder", - "krondstadt": "Krondstadt", - "anotherRelated": { - "@id": "/related_dummies/2", - "@type": "https://schema.org/Product", - "symfony": "API Platform", - "thirdLevel": null - }, - "related": null - } - """ - - @createSchema - Scenario: Eager load relations should not be duplicated - Given there is an order with same customer and recipient - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/orders" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/Order", - "@id": "/orders", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/orders/1", - "@type": "Order", - "id": 1, - "customer": { - "@id": "/customers/1", - "@type": "Customer", - "id": 1, - "name": "customer_name", - "addresses": [ - { - "@id": "/addresses/1", - "@type": "Address", - "id": 1, - "name": "foo" - }, - { - "@id": "/addresses/2", - "@type": "Address", - "id": 2, - "name": "bar" - } - ] - }, - "recipient": { - "@id": "/customers/1", - "@type": "Customer", - "id": 1, - "name": "customer_name", - "addresses": [ - { - "@id": "/addresses/1", - "@type": "Address", - "id": 1, - "name": "foo" - }, - { - "@id": "/addresses/2", - "@type": "Address", - "id": 2, - "name": "bar" - } - ] - } - } - ], - "hydra:totalItems": 1 - } - """ - - Scenario: Passing an invalid IRI to a relation - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "related": "certainly not an IRI" - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "detail" should contain 'Invalid IRI "certainly not an IRI".' - - Scenario: Passing an invalid type to a relation - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/relation_embedders" with body: - """ - { - "related": 8 - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^hydra:Error$" - }, - "hydra:title": { - "type": "string", - "pattern": "^An error occurred$" - }, - "detail": { - "pattern": "^The type of the \"ApiPlatform\\\\Tests\\\\Fixtures\\\\TestBundle\\\\(Document|Entity)\\\\RelatedDummy\" resource must be \"array\" \\(nested document\\) or \"string\" \\(IRI\\), \"integer\" given.$" - } - }, - "required": [ - "@type", - "hydra:title", - "detail" - ] - } - """ - - @createSchema - Scenario: Issue #1222 - Given there are people having pets - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/people" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be a superset of: - """ - { - "@context": "/contexts/Person", - "@id": "/people", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/people/1", - "@type": "Person", - "name": "foo", - "pets": [ - { - "@type": "PersonToPet", - "pet": { - "@id": "/pets/1", - "@type": "Pet", - "name": "bar" - } - } - ] - } - ], - "hydra:totalItems": 1 - } - """ diff --git a/features/main/serializable_item_data_provider.feature b/features/main/serializable_item_data_provider.feature deleted file mode 100644 index 11a111cf101..00000000000 --- a/features/main/serializable_item_data_provider.feature +++ /dev/null @@ -1,18 +0,0 @@ -Feature: Serializable item data provider - In order to call any external API - As a developer - I should be able to serialize the response directly from the ItemDataProvider. - - Scenario: Get a resource containing a raw object - When I send a "GET" request to "/serializable_resources/1" - Then the JSON should be equal to: - """ - { - "@context": "/contexts/SerializableResource", - "@id": "/serializable_resources/1", - "@type": "SerializableResource", - "id": 1, - "foo": "Lorem", - "bar": "Ipsum" - } - """ diff --git a/features/main/standard_put.feature b/features/main/standard_put.feature deleted file mode 100644 index 670ab6d0d0b..00000000000 --- a/features/main/standard_put.feature +++ /dev/null @@ -1,148 +0,0 @@ -Feature: Spec-compliant PUT support - As a client software developer - I need to be able to create or replace resources using the PUT HTTP method - - @createSchema - Scenario: Create a new resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/standard_puts/5" with body: - """ - { - "foo": "a", - "bar": "b" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/StandardPut", - "@id": "/standard_puts/5", - "@type": "StandardPut", - "id": 5, - "foo": "a", - "bar": "b" - } - """ - - Scenario: Create a new resource with JSON-LD attributes - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/standard_puts/6" with body: - """ - { - "@id": "/standard_puts/6", - "@context": "/contexts/StandardPut", - "@type": "StandardPut", - "foo": "a", - "bar": "b" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/StandardPut", - "@id": "/standard_puts/6", - "@type": "StandardPut", - "id": 6, - "foo": "a", - "bar": "b" - } - """ - - Scenario: Fails to create a new resource with the wrong JSON-LD @id - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/standard_puts/7" with body: - """ - { - "@id": "/dummies/6", - "@context": "/contexts/StandardPut", - "@type": "StandardPut", - "foo": "a", - "bar": "b" - } - """ - Then the response status code should be 400 - - Scenario: Fails to create a new resource when the JSON-LD @id doesn't match the URI - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/standard_puts/7" with body: - """ - { - "@id": "/standard_puts/6", - "@context": "/contexts/StandardPut", - "@type": "StandardPut", - "foo": "a", - "bar": "b" - } - """ - Then the response status code should be 400 - - Scenario: Replace an existing resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/standard_puts/5" with body: - """ - { - "foo": "c" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/StandardPut", - "@id": "/standard_puts/5", - "@type": "StandardPut", - "id": 5, - "foo": "c", - "bar": "" - } - """ - - @createSchema - @!mongodb - Scenario: Create a new resource identified by an uid - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/uid_identifieds/fbcf5910-d915-4f7d-ba39-6b2957c57335" with body: - """ - { - "name": "test" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/UidIdentified", - "@id": "/uid_identifieds/fbcf5910-d915-4f7d-ba39-6b2957c57335", - "@type": "UidIdentified", - "id": "fbcf5910-d915-4f7d-ba39-6b2957c57335", - "name": "test" - } - """ - - @!mongodb - Scenario: Replace an existing resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/uid_identifieds/fbcf5910-d915-4f7d-ba39-6b2957c57335" with body: - """ - { - "name": "bar" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/UidIdentified", - "@id": "/uid_identifieds/fbcf5910-d915-4f7d-ba39-6b2957c57335", - "@type": "UidIdentified", - "id": "fbcf5910-d915-4f7d-ba39-6b2957c57335", - "name": "bar" - } - """ diff --git a/features/main/sub_resource.feature b/features/main/sub_resource.feature deleted file mode 100644 index 1a8b9e14ad1..00000000000 --- a/features/main/sub_resource.feature +++ /dev/null @@ -1,633 +0,0 @@ -Feature: Sub-resource support - In order to use a hypermedia API - As a client software developer - I need to be able to retrieve embedded resources only as resources - - @createSchema - Scenario: Get sub-resource one to one relation - Given there is an answer "42" to the question "What's the answer to the Ultimate Question of Life, the Universe and Everything?" - When I send a "GET" request to "/questions/1/answer" - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/Answer", - "@id": "/questions/1/answer", - "@type": "Answer", - "id": 1, - "content": "42", - "question": "/questions/1", - "relatedQuestions": [ - "/questions/1" - ] - } - """ - - @createSchema - Scenario: Get a non existent sub-resource - Given there is an answer "42" to the question "What's the answer to the Ultimate Question of Life, the Universe and Everything?" - When I send a "GET" request to "/questions/999999/answer" - Then the response status code should be 404 - And the response should be in JSON - - @createSchema - Scenario: Get recursive sub-resource one to many relation - Given there is an answer "42" to the question "What's the answer to the Ultimate Question of Life, the Universe and Everything?" - When I send a "GET" request to "/questions/1/answer/related_questions" - 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 - } - """ - - @createSchema - Scenario: Get the sub-resource relation collection - Given there is a dummy object with a fourth level relation - When I send a "GET" request to "/dummies/1/related_dummies" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelatedDummy", - "@id": "/dummies/1/related_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/related_dummies/1", - "@type": "https://schema.org/Product", - "id": 1, - "name": "Hello", - "symfony": "symfony", - "dummyDate": null, - "thirdLevel": { - "@id": "/third_levels/1", - "@type": "ThirdLevel", - "fourthLevel": "/fourth_levels/1" - }, - "relatedToDummyFriend": [], - "dummyBoolean": null, - "embeddedDummy": [], - "age": null - }, - { - "@id": "/related_dummies/2", - "@type": "https://schema.org/Product", - "id": 2, - "name": null, - "symfony": "symfony", - "dummyDate": null, - "thirdLevel": { - "@id": "/third_levels/1", - "@type": "ThirdLevel", - "fourthLevel": "/fourth_levels/1" - }, - "relatedToDummyFriend": [], - "dummyBoolean": null, - "embeddedDummy": [], - "age": null - } - ], - "hydra:totalItems": 2, - "hydra:search": { - "@type": "hydra:IriTemplate", - "hydra:template": "/dummies/1/related_dummies{?relatedToDummyFriend.dummyFriend,relatedToDummyFriend.dummyFriend[],name,age,age[],id,id[],symfony,symfony[],dummyDate[before],dummyDate[strictly_before],dummyDate[after],dummyDate[strictly_after]}", - "hydra:variableRepresentation": "BasicRepresentation", - "hydra:mapping": [ - { - "@type": "IriTemplateMapping", - "variable": "relatedToDummyFriend.dummyFriend", - "property": "relatedToDummyFriend.dummyFriend", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedToDummyFriend.dummyFriend[]", - "property": "relatedToDummyFriend.dummyFriend", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "name", - "property": "name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "age", - "property": "age", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "age[]", - "property": "age", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "id", - "property": "id", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "id[]", - "property": "id", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "symfony", - "property": "symfony", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "symfony[]", - "property": "symfony", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[before]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[strictly_before]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[after]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[strictly_after]", - "property": "dummyDate", - "required": false - } - ] - } - } - """ - - @createSchema - Scenario: Get filtered embedded relation sub-resource collection - Given there is a dummy object with a fourth level relation - When I send a "GET" request to "/dummies/1/related_dummies?name=Hello" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelatedDummy", - "@id": "/dummies/1/related_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/related_dummies/1", - "@type": "https://schema.org/Product", - "id": 1, - "name": "Hello", - "symfony": "symfony", - "dummyDate": null, - "thirdLevel": { - "@id": "/third_levels/1", - "@type": "ThirdLevel", - "fourthLevel": "/fourth_levels/1" - }, - "relatedToDummyFriend": [], - "dummyBoolean": null, - "embeddedDummy": [], - "age": null - } - ], - "hydra:totalItems": 1, - "hydra:view": { - "@id": "/dummies/1/related_dummies?name=Hello", - "@type": "hydra:PartialCollectionView" - }, - "hydra:search": { - "@type": "hydra:IriTemplate", - "hydra:template": "/dummies/1/related_dummies{?relatedToDummyFriend.dummyFriend,relatedToDummyFriend.dummyFriend[],name,age,age[],id,id[],symfony,symfony[],dummyDate[before],dummyDate[strictly_before],dummyDate[after],dummyDate[strictly_after]}", - "hydra:variableRepresentation": "BasicRepresentation", - "hydra:mapping": [ - { - "@type": "IriTemplateMapping", - "variable": "relatedToDummyFriend.dummyFriend", - "property": "relatedToDummyFriend.dummyFriend", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "relatedToDummyFriend.dummyFriend[]", - "property": "relatedToDummyFriend.dummyFriend", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "name", - "property": "name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "age", - "property": "age", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "age[]", - "property": "age", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "id", - "property": "id", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "id[]", - "property": "id", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "symfony", - "property": "symfony", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "symfony[]", - "property": "symfony", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[before]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[strictly_before]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[after]", - "property": "dummyDate", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "dummyDate[strictly_after]", - "property": "dummyDate", - "required": false - } - ] - } - } - """ - - @createSchema - Scenario: Get the sub-resource relation item - Given there is a dummy object with a fourth level relation - When I send a "GET" request to "/dummies/1/related_dummies/2" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/RelatedDummy", - "@id": "/dummies/1/related_dummies/2", - "@type": "https://schema.org/Product", - "id": 2, - "name": null, - "symfony": "symfony", - "dummyDate": null, - "thirdLevel": { - "@id": "/third_levels/1", - "@type": "ThirdLevel", - "fourthLevel": "/fourth_levels/1" - }, - "relatedToDummyFriend": [], - "dummyBoolean": null, - "embeddedDummy": [], - "age": null - } - """ - - Scenario: Create a dummy with a relation that is a sub-resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Dummy with relations", - "relatedDummy": "/dummies/1/related_dummies/2" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - Scenario: Get the embedded relation sub-resource item at the third level - When I send a "GET" request to "/dummies/1/related_dummies/1/third_level" - And the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/ThirdLevel", - "@id": "/dummies/1/related_dummies/1/third_level", - "@type": "ThirdLevel", - "fourthLevel": "/fourth_levels/1", - "badFourthLevel": null, - "id": 1, - "level": 3, - "test": true, - "relatedDummies": [ - "/related_dummies/1", - "/related_dummies/2" - ] - } - """ - - Scenario: Get the embedded relation sub-resource item at the fourth level - When I send a "GET" request to "/dummies/1/related_dummies/1/third_level/fourth_level" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/FourthLevel", - "@id": "/dummies/1/related_dummies/1/third_level/fourth_level", - "@type": "FourthLevel", - "badThirdLevel": [], - "id": 1, - "level": 4 - } - """ - - @createSchema - Scenario: Get offers sub-resource from aggregate offers sub-resource - Given I have a product with offers - When I send a "GET" request to "/dummy_products/2/offers/1/offers" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/DummyOffer", - "@id": "/dummy_products/2/offers/1/offers", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/dummy_offers/1", - "@type": "DummyOffer", - "id": 1, - "value": 2, - "aggregate": "/dummy_aggregate_offers/1" - } - ], - "hydra:totalItems": 1 - } - """ - - Scenario: Get offers sub-resource from aggregate offers sub-resource - When I send a "GET" request to "/dummy_aggregate_offers/1/offers" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/DummyOffer", - "@id": "/dummy_aggregate_offers/1/offers", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/dummy_offers/1", - "@type": "DummyOffer", - "id": 1, - "value": 2, - "aggregate": "/dummy_aggregate_offers/1" - } - ], - "hydra:totalItems": 1 - } - """ - - Scenario: The recipient of the person's greetings should be empty - Given there is a person named "Alice" greeting with a "hello" message - When I send a "GET" request to "/people/1/sent_greetings" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Greeting", - "@id": "/people/1/sent_greetings", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/greetings/1", - "@type": "Greeting", - "message": "hello", - "sender": "/people/1", - "recipient": null, - "id": 1 - } - ], - "hydra:totalItems": 1 - } - """ - - Scenario: Recursive resource - When I send a "GET" request to "/dummy_products/2" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/DummyProduct", - "@id": "/dummy_products/2", - "@type": "DummyProduct", - "offers": [ - "/dummy_aggregate_offers/1" - ], - "id": 2, - "name": "Dummy product", - "relatedProducts": [ - "/dummy_products/1" - ], - "parent": null - } - """ - - @createSchema - Scenario: The OneToOne sub-resource should be accessible from owned side - Given there is a RelatedOwnedDummy object with OneToOne relation - When I send a "GET" request to "/related_owned_dummies/1/owning_dummy" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Dummy", - "@id": "/related_owned_dummies/1/owning_dummy", - "@type": "Dummy", - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "relatedDummy": null, - "relatedDummies": [], - "jsonData": [], - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": "/related_owned_dummies/1", - "relatedOwningDummy": null, - "id": 1, - "name": "plop", - "alias": null, - "foo": null - } - """ - - @createSchema - Scenario: The OneToOne sub-resource should be accessible from owning side - Given there is a RelatedOwningDummy object with OneToOne relation - When I send a "GET" request to "/related_owning_dummies/1/owned_dummy" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Dummy", - "@id": "/related_owning_dummies/1/owned_dummy", - "@type": "Dummy", - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "relatedDummy": null, - "relatedDummies": [], - "jsonData": [], - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": null, - "relatedOwningDummy": "/related_owning_dummies/1", - "id": 1, - "name": "plop", - "alias": null, - "foo": null - } - """ - - @!mongodb - @createSchema - Scenario Outline: The generated crud should allow us to interact with the subresources - Given I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/subresource_organizations" with body: - """ - { - "name": "Les Tilleuls" - } - """ - Then the response status code should be 201 - Given I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "" with body: - """ - { - "name": "soyuka" - } - """ - Then the response status code should be 404 - Given I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "" with body: - """ - { - "name": "soyuka" - } - """ - Then the response status code should be 201 - And I send a "GET" request to "" - Then the response status code should be 200 - And I send a "GET" request to "" - Then the response status code should be 200 - Given I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "" with body: - """ - { - "name": "ok" - } - """ - Then the response status code should be 200 - Given I send a "DELETE" request to "" - Then the response status code should be 204 - Examples: - | invalid_uri | collection_uri | item_uri | - | /subresource_organizations/invalid/subresource_employees | /subresource_organizations/1/subresource_employees | /subresource_organizations/1/subresource_employees/1 | - | /subresource_organizations/invalid/subresource_factories | /subresource_organizations/1/subresource_factories | /subresource_organizations/1/subresource_factories/1 | - - @!mongodb - @createSchema - Scenario: I can POST on a subresource using CreateProvider with parent_uri_template - Given I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/subresource_categories/1/subresource_bikes" with body: - """ - { - "name": "Hello World!" - } - """ - Then the response status code should be 404 - Given I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/subresource_categories_with_create_provider/1/subresource_bikes" with body: - """ - { - "name": "Hello World!" - } - """ - Then the response status code should be 201 diff --git a/features/main/table_inheritance.feature b/features/main/table_inheritance.feature deleted file mode 100644 index 1c3617f5f92..00000000000 --- a/features/main/table_inheritance.feature +++ /dev/null @@ -1,798 +0,0 @@ -Feature: Table inheritance - In order to use the api with Doctrine table inheritance - As a client software developer - I need to be able to create resources and fetch them on the upper entity - - @createSchema - Scenario: Create a table inherited resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_table_inheritance_children" with body: - """ - {"name": "foo", "nickname": "bar"} - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritanceChild$" - }, - "@context": { - "type": "string", - "pattern": "^/contexts/DummyTableInheritanceChild$" - }, - "@id": { - "type": "string", - "pattern": "^/dummy_table_inheritance_children/1$" - }, - "name": { - "type": "string", - "pattern": "^foo$" - }, - "nickname": { - "type": "string", - "pattern": "^bar$" - } - }, - "required": [ - "@type", - "@context", - "@id", - "name", - "nickname" - ] - } - """ - - Scenario: Get the parent entity collection - When I send a "GET" request to "/dummy_table_inheritances" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritanceChild$" - }, - "@id": { - "type": "string", - "pattern": "^/dummy_table_inheritance_children/1$" - }, - "name": { - "type": "string" - }, - "nickname": { - "type": "string" - } - }, - "required": [ - "@type", - "@id", - "name", - "nickname" - ] - } - ], - "additionalItems": false - } - }, - "required": [ - "hydra:member" - ] - } - """ - - Scenario: Some children not api resources are created in the app - When some dummy table inheritance data but not api resource child are created - And I send a "GET" request to "/dummy_table_inheritances" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritanceChild$" - }, - "@id": { - "type": "string", - "pattern": "^/dummy_table_inheritance_children/1$" - }, - "name": { - "type": "string" - } - }, - "required": [ - "@type", - "@id", - "name" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritance$" - }, - "@id": { - "type": "string", - "pattern": "^/dummy_table_inheritances/2$" - }, - "name": { - "type": "string" - } - }, - "required": [ - "@type", - "@id", - "name" - ] - } - ], - "additionalItems": false - }, - "hydra:totalItems": { - "type": "integer", - "minimum": 2, - "maximum": 2 - } - }, - "required": [ - "hydra:member", - "hydra:totalItems" - ] - } - """ - - Scenario: Create a table inherited resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_table_inheritance_children" with body: - """ - {"name": "foo", "nickname": "bar"} - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritanceChild$" - }, - "@context": { - "type": "string", - "pattern": "^/contexts/DummyTableInheritanceChild$" - }, - "@id": { - "type": "string", - "pattern": "^/dummy_table_inheritance_children/3$" - }, - "name": { - "type": "string", - "pattern": "^foo$" - }, - "nickname": { - "type": "string", - "pattern": "^bar$" - } - }, - "required": [ - "@type", - "@context", - "@id", - "name", - "nickname" - ] - } - """ - - Scenario: Create a different table inherited resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_table_inheritance_different_children" with body: - """ - {"name": "foo", "email": "bar@localhost"} - """ - Then the response status code should be 201 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritanceDifferentChild$" - }, - "@context": { - "type": "string", - "pattern": "^/contexts/DummyTableInheritanceDifferentChild$" - }, - "@id": { - "type": "string", - "pattern": "^/dummy_table_inheritance_different_children/4$" - }, - "name": { - "type": "string", - "pattern": "^foo$" - }, - "email": { - "type": "string", - "pattern": "^bar\\@localhost$" - } - }, - "required": [ - "@type", - "@context", - "@id", - "name", - "email" - ] - } - """ - - Scenario: Get related entity with multiple inherited children types - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_table_inheritance_relateds" with body: - """ - { - "children": [ - "/dummy_table_inheritance_children/1", - "/dummy_table_inheritance_different_children/4" - ] - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritanceRelated$" - }, - "@context": { - "type": "string", - "pattern": "^/contexts/DummyTableInheritanceRelated$" - }, - "@id": { - "type": "string", - "pattern": "^/dummy_table_inheritance_relateds/1$" - }, - "children": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritanceChild$" - }, - "name": { - "type": "string" - }, - "nickname": { - "type": "string" - } - }, - "required": [ - "@type", - "name", - "nickname" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritanceDifferentChild$" - }, - "name": { - "type": "string" - }, - "email": { - "type": "string" - } - }, - "required": [ - "@type", - "name", - "email" - ] - } - ], - "additionalItems": false - } - }, - "required": [ - "@type", - "@context", - "@id", - "children" - ] - } - """ - - Scenario: Get the parent entity collection which contains multiple inherited children type - When I send a "GET" request to "/dummy_table_inheritances" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritanceChild$" - }, - "@id": { - "type": "string", - "pattern": "^/dummy_table_inheritance_children/1$" - }, - "name": { - "type": "string" - }, - "nickname": { - "type": "string" - } - }, - "required": [ - "@type", - "@id", - "name", - "nickname" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritance$" - }, - "@id": { - "type": "string", - "pattern": "^/dummy_table_inheritances/2$" - }, - "name": { - "type": "string" - } - }, - "required": [ - "@type", - "@id", - "name" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^DummyTableInheritanceChild$" - }, - "@id": { - "type": "string", - "pattern": "^/dummy_table_inheritance_children/3$" - }, - "name": { - "type": "string" - }, - "nickname": { - "type": "string" - } - }, - "required": [ - "@type", - "@id", - "name", - "nickname" - ] - } - ], - "additionalItems": false - }, - "hydra:totalItems": { - "type": "integer", - "minimum": 4, - "maximum": 4 - } - }, - "required": [ - "hydra:member", - "hydra:totalItems" - ] - } - """ - - Scenario: Get the parent interface collection - When I send a "GET" request to "/resource_interfaces" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^ResourceInterface$" - }, - "@id": { - "type": "string", - "pattern": "^/resource_interfaces/item1" - }, - "foo": { - "type": "string", - "pattern": "^item1$" - }, - "fooz": { - "type": "string", - "pattern": "^fooz$" - } - }, - "required": [ - "@type", - "@id", - "foo", - "fooz" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^ResourceInterface$" - }, - "@id": { - "type": "string", - "pattern": "^/resource_interfaces/item2" - }, - "foo": { - "type": "string", - "pattern": "^item2$" - }, - "fooz": { - "type": "string", - "pattern": "^fooz$" - } - }, - "required": [ - "@type", - "@id", - "foo", - "fooz" - ] - } - ], - "additionalItems": false - } - }, - "required": [ - "hydra:member" - ] - } - """ - - Scenario: Get an interface resource item - When I send a "GET" request to "/resource_interfaces/some-id" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": { - "type": "string", - "pattern": "^/contexts/ResourceInterface$" - }, - "@id": { - "type": "string", - "pattern": "^/resource_interfaces/single%20item$" - }, - "@type": { - "type": "string", - "pattern": "^ResourceInterface$" - }, - "foo": { - "type": "string", - "pattern": "^single item$" - }, - "fooz": { - "type": "string", - "pattern": "fooz" - } - }, - "required": [ - "@context", - "@id", - "@type", - "foo", - "fooz" - ], - "additionalProperties": false - } - """ - - @!mongodb - Scenario: Generate iri from parent resource - Given there are 3 sites with internal owner - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/sites" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^Site$" - }, - "@id": { - "type": "string", - "pattern": "^/sites/1$" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "owner": { - "type": "string", - "pattern": "^/custom_users/1$" - } - }, - "required": [ - "@type", - "@id", - "title", - "description", - "owner" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^Site$" - }, - "@id": { - "type": "string", - "pattern": "^/sites/2$" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "owner": { - "type": "string", - "pattern": "^/custom_users/2$" - } - }, - "required": [ - "@type", - "@id", - "title", - "description", - "owner" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^Site$" - }, - "@id": { - "type": "string", - "pattern": "^/sites/3$" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "owner": { - "type": "string", - "pattern": "^/custom_users/3$" - } - }, - "required": [ - "@type", - "@id", - "title", - "description", - "owner" - ] - } - ], - "additionalItems": false - } - }, - "required": [ - "hydra:member" - ] - } - """ - - @!mongodb - @createSchema - Scenario: Generate iri from current resource even if parent class is a resource - Given there are 3 sites with external owner - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/sites" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^Site$" - }, - "@id": { - "type": "string", - "pattern": "^/sites/1$" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "owner": { - "type": "string", - "pattern": "^/external_users/1$" - } - }, - "required": [ - "@type", - "@id", - "title", - "description", - "owner" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^Site$" - }, - "@id": { - "type": "string", - "pattern": "^/sites/2$" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "owner": { - "type": "string", - "pattern": "^/external_users/2$" - } - }, - "required": [ - "@type", - "@id", - "title", - "description", - "owner" - ] - }, - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^Site$" - }, - "@id": { - "type": "string", - "pattern": "^/sites/3$" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "owner": { - "type": "string", - "pattern": "^/external_users/3$" - } - }, - "required": [ - "@type", - "@id", - "title", - "description", - "owner" - ] - } - ], - "additionalItems": false - } - }, - "required": [ - "hydra:member" - ] - } - """ diff --git a/features/main/union_intersect_types.feature b/features/main/union_intersect_types.feature deleted file mode 100644 index 73195804d38..00000000000 --- a/features/main/union_intersect_types.feature +++ /dev/null @@ -1,121 +0,0 @@ -Feature: Union/Intersect types - - Scenario Outline: Create a resource with union type - When I add "Content-Type" header equal to "application/ld+json" - And I add "Accept" header equal to "application/ld+json" - And I send a "POST" request to "/issue-5452/books" with body: - """ - { - "number": , - "isbn": "978-3-16-148410-0" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^Book$" - }, - "@context": { - "type": "string", - "pattern": "^/contexts/Book$" - }, - "@id": { - "type": "string", - "pattern": "^/.well-known/genid/.+$" - }, - "number": { - "type": "" - }, - "isbn": { - "type": "string", - "pattern": "^978-3-16-148410-0$" - } - }, - "required": [ - "@type", - "@context", - "@id", - "number", - "isbn" - ] - } - """ - Examples: - | number | type | - | "1" | string | - | 1 | integer | - - Scenario: Create a resource with valid intersect type - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/issue-5452/books" with body: - """ - { - "number": 1, - "isbn": "978-3-16-148410-0", - "author": "/issue-5452/authors/1" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^Book$" - }, - "@context": { - "type": "string", - "pattern": "^/contexts/Book$" - }, - "@id": { - "type": "string", - "pattern": "^/.well-known/genid/.+$" - }, - "number": { - "type": "integer" - }, - "isbn": { - "type": "string", - "pattern": "^978-3-16-148410-0$" - }, - "author": { - "type": "string", - "pattern": "^/issue-5452/authors/1$" - } - }, - "required": [ - "@type", - "@context", - "@id", - "number", - "isbn", - "author" - ] - } - """ - - Scenario: Create a resource with invalid intersect type - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/issue-5452/books" with body: - """ - { - "number": 1, - "isbn": "978-3-16-148410-0", - "library": "/issue-5452/libraries/1" - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "detail" should be equal to 'Could not denormalize object of type "ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\ActivableInterface", no supporting normalizer found.' diff --git a/features/main/url_encoded_id.feature b/features/main/url_encoded_id.feature deleted file mode 100644 index 6e5828bbab3..00000000000 --- a/features/main/url_encoded_id.feature +++ /dev/null @@ -1,26 +0,0 @@ -Feature: Allowing resource identifiers with characters that should be URL encoded - In order to have a resource with an id with special characters - As a client software developer - I need to be able to set and retrieve these resources with the URL encoded ID - - @createSchema - Scenario Outline: Get a resource whether or not the id is URL encoded - Given there is a UrlEncodedId resource - And I add "Content-Type" header equal to "application/ld+json" - When I send a "GET" request to "" - Then the response status code should be 200 - And the JSON should be equal to: - """ - { - "@context": "/contexts/UrlEncodedId", - "@id": "/url_encoded_ids/%25encode:id", - "@type": "UrlEncodedId", - "id": "%encode:id" - } - """ - Examples: - | url | - | /url_encoded_ids/%encode:id | - | /url_encoded_ids/%25encode%3Aid | - | /url_encoded_ids/%25encode:id | - | /url_encoded_ids/%encode%3Aid | diff --git a/features/main/uuid.feature b/features/main/uuid.feature deleted file mode 100644 index a7506a15bec..00000000000 --- a/features/main/uuid.feature +++ /dev/null @@ -1,205 +0,0 @@ -Feature: Using uuid identifier on resource - In order to use an hypermedia API - As a client software developer - I need to be able to user other identifier than id in resource and set it via API call on POST / PUT. - - @createSchema - Scenario: Create a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/uuid_identifier_dummies" with body: - """ - { - "name": "My Dummy", - "uuid": "41b29566-144b-11e6-a148-3e1d05defe78" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should be equal to "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78.jsonld" - And the header "Location" should be equal to "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78" - - Scenario: Get a resource - When I send a "GET" request to "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/UuidIdentifierDummy", - "@id": "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78", - "@type": "UuidIdentifierDummy", - "uuid": "41b29566-144b-11e6-a148-3e1d05defe78", - "name": "My Dummy" - } - """ - - Scenario: Get a collection - When I send a "GET" request to "/uuid_identifier_dummies" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/UuidIdentifierDummy", - "@id": "/uuid_identifier_dummies", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78", - "@type": "UuidIdentifierDummy", - "uuid": "41b29566-144b-11e6-a148-3e1d05defe78", - "name": "My Dummy" - } - ], - "hydra:totalItems": 1 - } - """ - - Scenario: Update a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78" with body: - """ - { - "name": "My Dummy modified" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should be equal to "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78.jsonld" - And the JSON should be equal to: - """ - { - "@context": "/contexts/UuidIdentifierDummy", - "@id": "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78", - "@type": "UuidIdentifierDummy", - "uuid": "41b29566-144b-11e6-a148-3e1d05defe78", - "name": "My Dummy modified" - } - """ - - Scenario: Create a resource with custom id generator - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/custom_generated_identifiers" with body: - """ - {} - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "Content-Location" should be equal to "/custom_generated_identifiers/foo.jsonld" - And the header "Location" should be equal to "/custom_generated_identifiers/foo" - And the JSON should be equal to: - """ - { - "@context": "/contexts/CustomGeneratedIdentifier", - "@id": "/custom_generated_identifiers/foo", - "@type": "CustomGeneratedIdentifier", - "id": "foo" - } - """ - - Scenario: Delete a resource - When I send a "DELETE" request to "/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78" - Then the response status code should be 204 - And the response should be empty - - @!mongodb - @createSchema - Scenario: Retrieve a resource identified by Ramsey\Uuid\Uuid - Given there is a ramsey identified resource with uuid "41B29566-144B-11E6-A148-3E1D05DEFE78" - When I send a "GET" request to "/ramsey_uuid_dummies/41B29566-144B-11E6-A148-3E1D05DEFE78" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - @!mongodb - Scenario: Delete a resource identified by a Ramsey\Uuid\Uuid - When I send a "DELETE" request to "/ramsey_uuid_dummies/41B29566-144B-11E6-A148-3E1D05DEFE78" - Then the response status code should be 204 - And the response should be empty - - @!mongodb - Scenario: Retrieve a resource identified by a bad Ramsey\Uuid\Uuid - When I send a "GET" request to "/ramsey_uuid_dummies/41B29566-144B-E1D05DEFE78" - Then the response status code should be 404 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - @!mongodb - @createSchema - Scenario: Create a resource identified by Ramsey\Uuid\Uuid - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/ramsey_uuid_dummies" with body: - """ - { - "id": "41b29566-144b-11e6-a148-3e1d05defe78" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - @!mongodb - Scenario: Create a resource with a Ramsey\Uuid\Uuid non-id field - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/ramsey_uuid_dummies" with body: - """ - { - "other": "51b29566-144b-11e6-a148-3e1d05defe78" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - @!mongodb - Scenario: Update a resource with a Ramsey\Uuid\Uuid non-id field - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/ramsey_uuid_dummies/41b29566-144b-11e6-a148-3e1d05defe78" with body: - """ - { - "other": "61b29566-144b-11e6-a148-3e1d05defe78" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - @!mongodb - Scenario: Create a resource identified by a bad Ramsey\Uuid\Uuid - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/ramsey_uuid_dummies" with body: - """ - { - "id": "41b29566-144b-e1d05defe78" - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - @!mongodb - Scenario: Update a resource with a bad Ramsey\Uuid\Uuid non-id field - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/ramsey_uuid_dummies/41b29566-144b-11e6-a148-3e1d05defe78" with body: - """ - { - "other": "61b29566-144b-e1d05defe78" - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - @!mongodb - @createSchema - Scenario: Retrieve a resource identified by Symfony\Component\Uid\Uuid - Given there is a Symfony dummy identified resource with uuid "cdf8f706-ebe3-4fb6-b0bd-ae7b48028f24" - When I send a "GET" request to "/symfony_uuid_dummies/cdf8f706-ebe3-4fb6-b0bd-ae7b48028f24" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" diff --git a/features/main/validation.feature b/features/main/validation.feature deleted file mode 100644 index 40e22bdfb3a..00000000000 --- a/features/main/validation.feature +++ /dev/null @@ -1,120 +0,0 @@ -Feature: Using validations groups - As a client software developer - I need to be able to use validation groups - - @createSchema - Scenario: Create a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_validation" with body: - """ - { - "code": "My Dummy" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - @createSchema - Scenario: Create a resource with validation - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_validation/validation_groups" with body: - """ - { - "code": "My Dummy" - } - """ - Then the response status code should be 422 - And the response should be in JSON - And the JSON should be a superset of: - """ - { - "@context": "/contexts/ConstraintViolation", - "@type": "ConstraintViolation", - "detail": "name: This value should not be null.", - "violations": [ - { - "propertyPath": "name", - "message": "This value should not be null.", - "code": "ad32d13f-c3d4-423b-909a-857b961eb720" - } - ] - } - """ - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - @createSchema - Scenario: Create a resource with validation group sequence - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_validation/validation_sequence" with body: - """ - { - "code": "My Dummy" - } - """ - Then the response status code should be 422 - And the response should be in JSON - And the JSON should be a superset of: - """ - { - "@context": "/contexts/ConstraintViolation", - "@type": "ConstraintViolation", - "detail": "title: This value should not be null.", - "violations": [ - { - "propertyPath": "title", - "message": "This value should not be null.", - "code": "ad32d13f-c3d4-423b-909a-857b961eb720" - } - ] - } - """ - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - @createSchema - Scenario: Create a resource with serializedName property - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "dummy_validation_serialized_name" with body: - """ - { - "code": "My Dummy" - } - """ - Then the response status code should be 422 - And the response should be in JSON - And the JSON node "violations[0].message" should be equal to "This value should not be null." - And the JSON node "violations[0].propertyPath" should be equal to "test" - And the JSON node "detail" should be equal to "test: This value should not be null." - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - @createSchema - @!mongodb - Scenario: Get violations constraints - When I add "Accept" header equal to "application/json" - And I add "Content-Type" header equal to "application/json" - And I send a "POST" request to "/issue5912s" with body: - """ - { - "title": "" - } - """ - Then the response status code should be 422 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "status": 422, - "violations": [ - { - "propertyPath": "title", - "message": "This value should not be blank.", - "code": "c1051bb4-d103-4f74-8988-acbcafc7fdc3" - } - ], - "detail": "title: This value should not be blank.", - "type": "/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3", - "title": "An error occurred" - } - """ - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - diff --git a/phpunit.baseline.xml b/phpunit.baseline.xml index 342ad126c6b..ebb80a94bd9 100644 --- a/phpunit.baseline.xml +++ b/phpunit.baseline.xml @@ -43,6 +43,9 @@ + + + diff --git a/tests/Fixtures/TestBundle/Entity/Answer.php b/tests/Fixtures/TestBundle/Entity/Answer.php index 0eda5f4063a..250ca65a6be 100644 --- a/tests/Fixtures/TestBundle/Entity/Answer.php +++ b/tests/Fixtures/TestBundle/Entity/Answer.php @@ -42,9 +42,6 @@ class Answer #[ORM\Column(nullable: false)] #[Serializer\Groups(['foobar'])] private ?string $content = null; - #[ORM\OneToOne(targetEntity: Question::class, mappedBy: 'answer')] - #[Serializer\Groups(['foobar'])] - private ?Question $question = null; /** * @var Collection */ @@ -85,24 +82,6 @@ public function getContent(): ?string return $this->content; } - /** - * Set question. - */ - public function setQuestion(?Question $question = null): self - { - $this->question = $question; - - return $this; - } - - /** - * Get question. - */ - public function getQuestion(): ?Question - { - return $this->question; - } - /** * Get related question. */ diff --git a/tests/Fixtures/TestBundle/Entity/FourthLevel.php b/tests/Fixtures/TestBundle/Entity/FourthLevel.php index cb0313b5dec..c85935e8601 100644 --- a/tests/Fixtures/TestBundle/Entity/FourthLevel.php +++ b/tests/Fixtures/TestBundle/Entity/FourthLevel.php @@ -16,6 +16,7 @@ use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Link; +use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; @@ -46,7 +47,12 @@ class FourthLevel #[Groups(['barcelona', 'chicago'])] private int $level = 4; #[ORM\OneToMany(targetEntity: ThirdLevel::class, cascade: ['persist'], mappedBy: 'badFourthLevel')] - public Collection|iterable|null $badThirdLevel = null; + public Collection|iterable $badThirdLevel; + + public function __construct() + { + $this->badThirdLevel = new ArrayCollection(); + } public function getId(): ?int { diff --git a/tests/Fixtures/TestBundle/Entity/OneToOneSubresourceAnswer.php b/tests/Fixtures/TestBundle/Entity/OneToOneSubresourceAnswer.php new file mode 100644 index 00000000000..32f2f511bc1 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/OneToOneSubresourceAnswer.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\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[ApiResource( + uriTemplate: '/one_to_one_subresource_questions/{id}/answer{._format}', + uriVariables: ['id' => new Link(fromClass: OneToOneSubresourceQuestion::class, identifiers: ['id'], fromProperty: 'answer')], + status: 200, + operations: [new Get()] +)] +#[ORM\Entity] +class OneToOneSubresourceAnswer +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column(nullable: false)] + private ?string $content = null; + + #[ORM\OneToOne(targetEntity: OneToOneSubresourceQuestion::class, mappedBy: 'answer')] + private ?OneToOneSubresourceQuestion $question = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function setContent(?string $content): self + { + $this->content = $content; + + return $this; + } + + public function getQuestion(): ?OneToOneSubresourceQuestion + { + return $this->question; + } + + public function setQuestion(?OneToOneSubresourceQuestion $question): self + { + $this->question = $question; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/OneToOneSubresourceQuestion.php b/tests/Fixtures/TestBundle/Entity/OneToOneSubresourceQuestion.php new file mode 100644 index 00000000000..306027a6756 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/OneToOneSubresourceQuestion.php @@ -0,0 +1,63 @@ + + * + * 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\Entity; + +use ApiPlatform\Metadata\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +#[ApiResource] +#[ORM\Entity] +class OneToOneSubresourceQuestion +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column(nullable: true)] + private ?string $content = null; + + #[ORM\OneToOne(targetEntity: OneToOneSubresourceAnswer::class, inversedBy: 'question', cascade: ['persist'])] + #[ORM\JoinColumn(name: 'answer_id', referencedColumnName: 'id')] + private ?OneToOneSubresourceAnswer $answer = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getContent(): ?string + { + return $this->content; + } + + public function setContent(?string $content): self + { + $this->content = $content; + + return $this; + } + + public function getAnswer(): ?OneToOneSubresourceAnswer + { + return $this->answer; + } + + public function setAnswer(?OneToOneSubresourceAnswer $answer): self + { + $this->answer = $answer; + + return $this; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Question.php b/tests/Fixtures/TestBundle/Entity/Question.php index aa1b82cd74e..de56210af04 100644 --- a/tests/Fixtures/TestBundle/Entity/Question.php +++ b/tests/Fixtures/TestBundle/Entity/Question.php @@ -30,8 +30,8 @@ class Question private ?int $id = null; #[ORM\Column(nullable: true)] private ?string $content = null; - #[ORM\OneToOne(targetEntity: Answer::class, inversedBy: 'question')] - #[ORM\JoinColumn(name: 'answer_id', referencedColumnName: 'id', unique: true)] + #[ORM\ManyToOne(targetEntity: Answer::class, inversedBy: 'relatedQuestions')] + #[ORM\JoinColumn(name: 'answer_id', referencedColumnName: 'id')] private ?Answer $answer = null; /** diff --git a/tests/Functional/AttributeResourceTest.php b/tests/Functional/AttributeResourceTest.php new file mode 100644 index 00000000000..9c959ff4044 --- /dev/null +++ b/tests/Functional/AttributeResourceTest.php @@ -0,0 +1,151 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\PostWithUriVariables; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AttributeResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AttributeResources; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\IncompleteUriVariableConfigured; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class AttributeResourceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [AttributeResource::class, AttributeResources::class, IncompleteUriVariableConfigured::class, PostWithUriVariables::class, Dummy::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB() || $this->isMysql()) { + $this->markTestSkipped(); + } + } + + public function testGetAttributeResourcesCollection(): void + { + self::createClient()->request('GET', '/attribute_resources', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/AttributeResources', + '@id' => '/attribute_resources', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + ['@id' => '/attribute_resources/1', '@type' => 'AttributeResource', 'identifier' => 1, 'name' => 'Foo'], + ['@id' => '/attribute_resources/2', '@type' => 'AttributeResource', 'identifier' => 2, 'name' => 'Bar'], + ], + ]); + } + + public function testGetAttributeResourceItem(): void + { + self::createClient()->request('GET', '/attribute_resources/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/AttributeResource', + '@id' => '/attribute_resources/1', + '@type' => 'AttributeResource', + 'identifier' => 1, + 'name' => 'Foo', + ]); + } + + public function testAliasedResourceRedirectsAndShowsTarget(): void + { + self::createClient()->request('GET', '/dummy/1/attribute_resources/2', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(301); + $this->assertResponseHeaderSame('Location', '/attribute_resources/2'); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/AttributeResource', + '@id' => '/attribute_resources/2', + '@type' => 'AttributeResource', + 'identifier' => 2, + 'dummy' => '/dummies/1', + 'name' => 'Foo', + ]); + } + + public function testPatchAliasedResource(): void + { + self::createClient()->request('PATCH', '/dummy/1/attribute_resources/2', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['name' => 'Patched'], + ]); + + $this->assertResponseStatusCodeSame(301); + $this->assertResponseHeaderSame('Location', '/attribute_resources/2'); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/AttributeResource', + '@id' => '/attribute_resources/2', + '@type' => 'AttributeResource', + 'identifier' => 2, + 'dummy' => '/dummies/1', + 'name' => 'Patched', + ]); + } + + public function testIncompleteUriVariableConfigurationProducesProblem(): void + { + $response = self::createClient()->request('GET', '/photos/1/resize/300/100'); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $linkHeader = $response->getHeaders(false)['link'][0] ?? ''; + $this->assertStringContainsString('; rel="http://www.w3.org/ns/json-ld#error"', $linkHeader); + $this->assertJsonContains(['detail' => 'Unable to generate an IRI for the item of type "ApiPlatform\\Tests\\Fixtures\\TestBundle\\Entity\\IncompleteUriVariableConfigured"']); + } + + public function testPostWithUriVariablesAndNoProvider(): void + { + self::createClient()->request('POST', '/post_with_uri_variables_and_no_provider/{id}', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => new \stdClass(), + ]); + + $this->assertResponseStatusCodeSame(201); + } + + public function testProviderThrowsValidationException(): void + { + self::createClient()->request('POST', '/post_with_uri_variables/{id}', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => new \stdClass(), + ]); + + $this->assertResponseStatusCodeSame(422); + } +} diff --git a/tests/Functional/CircularReferenceTest.php b/tests/Functional/CircularReferenceTest.php new file mode 100644 index 00000000000..5dfc1e33557 --- /dev/null +++ b/tests/Functional/CircularReferenceTest.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\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CircularReference; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CircularReferenceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [CircularReference::class]; + } + + protected function setUp(): void + { + $this->recreateSchema($this->getResources()); + } + + public function testSelfReferencingCircularReference(): void + { + $client = self::createClient(); + $headers = ['Content-Type' => 'application/ld+json']; + + $client->request('POST', '/circular_references', ['headers' => $headers, 'json' => new \stdClass()]); + $client->request('PUT', '/circular_references/1', [ + 'headers' => $headers, + 'json' => ['parent' => '/circular_references/1'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/CircularReference', + '@id' => '/circular_references/1', + '@type' => 'CircularReference', + 'parent' => '/circular_references/1', + 'children' => ['/circular_references/1'], + ]); + } + + public function testFetchCircularReferenceWithParentSibling(): void + { + $client = self::createClient(); + $headers = ['Content-Type' => 'application/ld+json']; + + $client->request('POST', '/circular_references', ['headers' => $headers, 'json' => new \stdClass()]); + $client->request('POST', '/circular_references', ['headers' => $headers, 'json' => new \stdClass()]); + $client->request('PUT', '/circular_references/1', [ + 'headers' => $headers, + 'json' => ['parent' => '/circular_references/1'], + ]); + $client->request('PUT', '/circular_references/2', [ + 'headers' => $headers, + 'json' => ['parent' => '/circular_references/1'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/CircularReference', + '@id' => '/circular_references/2', + '@type' => 'CircularReference', + 'parent' => [ + '@id' => '/circular_references/1', + '@type' => 'CircularReference', + 'parent' => '/circular_references/1', + 'children' => ['/circular_references/1', '/circular_references/2'], + ], + 'children' => [], + ]); + + $client->request('GET', '/circular_references/1'); + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/CircularReference', + '@id' => '/circular_references/1', + '@type' => 'CircularReference', + 'parent' => '/circular_references/1', + 'children' => [ + '/circular_references/1', + [ + '@id' => '/circular_references/2', + '@type' => 'CircularReference', + 'parent' => '/circular_references/1', + 'children' => [], + ], + ], + ]); + } +} diff --git a/tests/Functional/CompositeIdentifierTest.php b/tests/Functional/CompositeIdentifierTest.php new file mode 100644 index 00000000000..1e7617ecdf4 --- /dev/null +++ b/tests/Functional/CompositeIdentifierTest.php @@ -0,0 +1,170 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5396\CompositeKeyWithDifferentType; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeItem; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeLabel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeRelation; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CompositeIdentifierTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [CompositeItem::class, CompositeLabel::class, CompositeRelation::class, CompositeKeyWithDifferentType::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([CompositeItem::class, CompositeLabel::class, CompositeRelation::class]); + $this->seedComposite(); + } + + private function seedComposite(): void + { + $manager = $this->getManager(); + $item = new CompositeItem(); + $item->setField1('foobar'); + $manager->persist($item); + $manager->flush(); + + for ($i = 0; $i < 4; ++$i) { + $label = new CompositeLabel(); + $label->setValue('foo-'.$i); + $manager->persist($label); + $manager->flush(); + + $rel = new CompositeRelation(); + $rel->setCompositeLabel($label); + $rel->setCompositeItem($item); + $rel->setValue('somefoobardummy'); + $manager->persist($rel); + } + $manager->flush(); + $manager->clear(); + } + + public function testCollectionWithCompositeIdentifiers(): void + { + self::createClient()->request('GET', '/composite_items'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/CompositeItem', + '@id' => '/composite_items', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + [ + '@id' => '/composite_items/1', + '@type' => 'CompositeItem', + 'id' => 1, + 'field1' => 'foobar', + 'compositeValues' => [ + '/composite_relations/compositeItem=1;compositeLabel=1', + '/composite_relations/compositeItem=1;compositeLabel=2', + '/composite_relations/compositeItem=1;compositeLabel=3', + '/composite_relations/compositeItem=1;compositeLabel=4', + ], + ], + ], + 'hydra:totalItems' => 1, + ]); + } + + public function testCollectionOfCompositeRelations(): void + { + self::createClient()->request('GET', '/composite_relations'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/CompositeRelation', + '@id' => '/composite_relations', + '@type' => 'hydra:Collection', + 'hydra:totalItems' => 4, + 'hydra:view' => [ + '@id' => '/composite_relations?page=1', + '@type' => 'hydra:PartialCollectionView', + 'hydra:first' => '/composite_relations?page=1', + 'hydra:last' => '/composite_relations?page=2', + 'hydra:next' => '/composite_relations?page=2', + ], + ]); + } + + public function testGetCompositeRelationByCanonicalOrder(): void + { + self::createClient()->request('GET', '/composite_relations/compositeItem=1;compositeLabel=1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/CompositeRelation', + '@id' => '/composite_relations/compositeItem=1;compositeLabel=1', + '@type' => 'CompositeRelation', + 'value' => 'somefoobardummy', + 'compositeItem' => '/composite_items/1', + 'compositeLabel' => '/composite_labels/1', + ]); + } + + public function testGetCompositeRelationByReverseOrder(): void + { + self::createClient()->request('GET', '/composite_relations/compositeLabel=1;compositeItem=1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains([ + '@id' => '/composite_relations/compositeItem=1;compositeLabel=1', + '@type' => 'CompositeRelation', + ]); + } + + public function testMissingCompositeIdentifierReturns404(): void + { + self::createClient()->request('GET', '/composite_relations/compositeLabel=1;'); + + $this->assertResponseStatusCodeSame(404); + } + + public function testGetCompositeItem(): void + { + self::createClient()->request('GET', '/composite_items/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testCompositeIdentifierWithDifferentTypes(): void + { + self::createClient()->request('GET', '/composite_key_with_different_types/id=82133;verificationKey=7d75af772e637e45c36d041696e1128d'); + + $this->assertResponseStatusCodeSame(200); + } +} diff --git a/tests/Functional/ConfigurableTest.php b/tests/Functional/ConfigurableTest.php new file mode 100644 index 00000000000..674beae78a4 --- /dev/null +++ b/tests/Functional/ConfigurableTest.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FileConfigDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SingleFileConfigDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ConfigurableTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FileConfigDummy::class, SingleFileConfigDummy::class]; + } + + private function seedFileConfigDummy(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('FileConfigDummy fixtures are ORM-only.'); + } + + $this->recreateSchema($this->getResources()); + + $manager = $this->getManager(); + $entity = new FileConfigDummy(); + $entity->setName('ConfigDummy'); + $entity->setFoo('Foo'); + $manager->persist($entity); + $manager->flush(); + $manager->clear(); + } + + public function testCollectionOfFileConfigDummies(): void + { + $this->seedFileConfigDummy(); + + self::createClient()->request('GET', '/fileconfigdummies'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/fileconfigdummy', + '@id' => '/fileconfigdummies', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + [ + '@id' => '/fileconfigdummies/1', + '@type' => 'fileconfigdummy', + 'id' => 1, + 'name' => 'ConfigDummy', + 'foo' => 'Foo', + ], + ], + 'hydra:totalItems' => 1, + ]); + } + + public function testCollectionOfSingleFileConfig(): void + { + $this->recreateSchema($this->getResources()); + + self::createClient()->request('GET', '/single_file_configs'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/single_file_config', + '@id' => '/single_file_configs', + '@type' => 'hydra:Collection', + 'hydra:member' => [], + 'hydra:totalItems' => 0, + ]); + } + + public function testFileConfigDummyItem(): void + { + $this->seedFileConfigDummy(); + + self::createClient()->request('GET', '/fileconfigdummies/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/fileconfigdummy', + '@id' => '/fileconfigdummies/1', + '@type' => 'fileconfigdummy', + 'id' => 1, + 'name' => 'ConfigDummy', + 'foo' => 'Foo', + ]); + } +} diff --git a/tests/Functional/ContentNegotiationTest.php b/tests/Functional/ContentNegotiationTest.php new file mode 100644 index 00000000000..d159aa24129 --- /dev/null +++ b/tests/Functional/ContentNegotiationTest.php @@ -0,0 +1,244 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCustomFormat; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ContentNegotiationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Dummy::class, DummyCustomFormat::class, SecuredDummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([Dummy::class, DummyCustomFormat::class, SecuredDummy::class]); + } + + private function createDummyViaXml(): void + { + self::createClient()->request('POST', '/dummies', [ + 'headers' => ['Accept' => 'application/xml', 'Content-Type' => 'application/xml'], + 'body' => "\n XML!\n", + ]); + } + + public function testPostXmlBody(): void + { + $response = self::createClient()->request('POST', '/dummies', [ + 'headers' => ['Accept' => 'application/xml', 'Content-Type' => 'application/xml'], + 'body' => "\n XML!\n", + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + $this->assertStringContainsString('XML!', $response->getContent()); + $this->assertStringContainsString('1', $response->getContent()); + } + + public function testRetrieveCollectionInXml(): void + { + $this->createDummyViaXml(); + + $response = self::createClient()->request('GET', '/dummies', [ + 'headers' => ['Accept' => 'text/xml'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + $this->assertStringContainsString('', $response->getContent()); + $this->assertStringContainsString('XML!', $response->getContent()); + } + + public function testRetrieveCollectionInXmlViaUrlSuffix(): void + { + $this->createDummyViaXml(); + + $response = self::createClient()->request('GET', '/dummies.xml', [ + 'headers' => ['Accept' => '*/*'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + $this->assertStringContainsString('XML!', $response->getContent()); + } + + public function testRetrieveCollectionInJson(): void + { + $this->createDummyViaXml(); + + $response = self::createClient()->request('GET', '/dummies', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/json; charset=utf-8'); + $data = $response->toArray(); + $this->assertIsArray($data); + $this->assertCount(1, $data); + $this->assertSame('XML!', $data[0]['name']); + $this->assertSame(1, $data[0]['id']); + } + + public function testPostJsonAcceptXml(): void + { + $this->createDummyViaXml(); + + $response = self::createClient()->request('POST', '/dummies', [ + 'headers' => ['Accept' => 'application/xml', 'Content-Type' => 'application/json'], + 'json' => ['name' => 'Sent in JSON'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + $this->assertStringContainsString('Sent in JSON', $response->getContent()); + $this->assertStringContainsString('2', $response->getContent()); + } + + public function testFormatNegotiatedViaUrlMatchesAccept(): void + { + $this->createDummyViaXml(); + + self::createClient()->request('GET', '/dummies/1.xml', [ + 'headers' => ['Accept' => 'text/xml'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + } + + public function testWildcardAcceptDefaultsToFirstFormat(): void + { + $this->createDummyViaXml(); + + self::createClient()->request('GET', '/dummies/1', [ + 'headers' => ['Accept' => '*/*'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testWildcardAcceptDefaultsToUrlFormat(): void + { + $this->createDummyViaXml(); + + self::createClient()->request('GET', '/dummies/1.xml', [ + 'headers' => ['Accept' => 'text/plain; charset=utf-8, */*'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + } + + public function testUnknownFormatReturns406(): void + { + $this->createDummyViaXml(); + + self::createClient()->request('GET', '/dummies/1', [ + 'headers' => ['Accept' => 'text/plain'], + ]); + + $this->assertResponseStatusCodeSame(406); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + } + + public function testHtmlAcceptReturnsHtmlError(): void + { + $response = self::createClient()->request('GET', '/dummies/666', [ + 'headers' => ['Accept' => 'text/html'], + ]); + + $this->assertResponseStatusCodeSame(404); + $contentType = $response->getHeaders(false)['content-type'][0] ?? ''; + $this->assertStringStartsWith('text/html', $contentType); + } + + public function testRemovedFormatReturns406(): void + { + self::createClient()->request('GET', '/dummy_custom_formats', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + $this->assertResponseStatusCodeSame(406); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + } + + public function testPostCsvBodyOnCustomFormatResource(): void + { + $response = self::createClient()->request('POST', '/dummy_custom_formats', [ + 'headers' => ['Accept' => 'application/xml', 'Content-Type' => 'text/csv'], + 'body' => "name\nKevin\n", + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + $this->assertStringContainsString('Kevin', $response->getContent()); + $this->assertStringContainsString('1', $response->getContent()); + } + + public function testRetrieveCollectionInCsv(): void + { + self::createClient()->request('POST', '/dummy_custom_formats', [ + 'headers' => ['Accept' => 'application/xml', 'Content-Type' => 'text/csv'], + 'body' => "name\nKevin\n", + ]); + + $response = self::createClient()->request('GET', '/dummy_custom_formats', [ + 'headers' => ['Accept' => 'text/csv'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'text/csv; charset=utf-8'); + $this->assertStringContainsString('id,name', $response->getContent()); + $this->assertStringContainsString('1,Kevin', $response->getContent()); + } + + public function testSecurityErrorInJson(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('SecuredDummy seed uses ORM Entity class.'); + } + + $manager = $this->getManager(); + $securedDummy = new SecuredDummy(); + $securedDummy->setTitle('#1'); + $securedDummy->setDescription('Hello #1'); + $securedDummy->setOwner('notexist'); + $manager->persist($securedDummy); + $manager->flush(); + + self::createClient()->request('GET', '/secured_dummies', [ + 'headers' => ['Accept' => 'application/json'], + ]); + + $this->assertResponseStatusCodeSame(401); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + $this->assertJsonEquals(['message' => 'Authentication Required']); + } +} diff --git a/tests/Functional/CrudAbstractTest.php b/tests/Functional/CrudAbstractTest.php new file mode 100644 index 00000000000..414ce6d2343 --- /dev/null +++ b/tests/Functional/CrudAbstractTest.php @@ -0,0 +1,171 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AbstractDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConcreteDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CrudAbstractTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [AbstractDummy::class, ConcreteDummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([AbstractDummy::class, ConcreteDummy::class]); + } + + private function createConcrete(): void + { + self::createClient()->request('POST', '/concrete_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['instance' => 'Concrete', 'name' => 'My Dummy'], + ]); + } + + public function testCreateConcrete(): void + { + $this->createConcrete(); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertResponseHeaderSame('Content-Location', '/concrete_dummies/1.jsonld'); + $this->assertResponseHeaderSame('Location', '/concrete_dummies/1'); + $this->assertJsonEquals([ + '@context' => '/contexts/ConcreteDummy', + '@id' => '/concrete_dummies/1', + '@type' => 'ConcreteDummy', + 'instance' => 'Concrete', + 'id' => 1, + 'name' => 'My Dummy', + ]); + } + + public function testGetItemViaAbstractUri(): void + { + $this->createConcrete(); + + $response = self::createClient()->request('GET', '/abstract_dummies/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertArrayNotHasKey('content-location', array_change_key_case($response->getHeaders())); + $this->assertJsonEquals([ + '@context' => '/contexts/ConcreteDummy', + '@id' => '/concrete_dummies/1', + '@type' => 'ConcreteDummy', + 'instance' => 'Concrete', + 'id' => 1, + 'name' => 'My Dummy', + ]); + } + + public function testGetCollectionViaAbstractUri(): void + { + $this->createConcrete(); + + $response = self::createClient()->request('GET', '/abstract_dummies'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertGreaterThanOrEqual(1, \count($data['hydra:member'])); + $this->assertSame('ConcreteDummy', $data['hydra:member'][0]['@type']); + $this->assertNotEmpty($data['hydra:member'][0]['instance']); + } + + public function testUpdateConcreteUri(): void + { + $this->createConcrete(); + + self::createClient()->request('PUT', '/concrete_dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['@id' => '/concrete_dummies/1', 'instance' => 'Become real', 'name' => 'A nice dummy'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Location', '/concrete_dummies/1.jsonld'); + $this->assertJsonEquals([ + '@context' => '/contexts/ConcreteDummy', + '@id' => '/concrete_dummies/1', + '@type' => 'ConcreteDummy', + 'instance' => 'Become real', + 'id' => 1, + 'name' => 'A nice dummy', + ]); + } + + public function testUpdateConcreteViaAbstractUri(): void + { + $this->createConcrete(); + + self::createClient()->request('PUT', '/abstract_dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['@id' => '/concrete_dummies/1', 'instance' => 'Become surreal', 'name' => 'A nicer dummy'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Location', '/concrete_dummies/1.jsonld'); + $this->assertJsonEquals([ + '@context' => '/contexts/ConcreteDummy', + '@id' => '/concrete_dummies/1', + '@type' => 'ConcreteDummy', + 'instance' => 'Become surreal', + 'id' => 1, + 'name' => 'A nicer dummy', + ]); + } + + public function testDeleteViaAbstractUri(): void + { + $this->createConcrete(); + + self::createClient()->request('DELETE', '/abstract_dummies/1'); + + $this->assertResponseStatusCodeSame(204); + } + + public function testCreateConcreteViaDiscriminatorOnAbstract(): void + { + self::createClient()->request('POST', '/abstract_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['discr' => 'concrete', 'instance' => 'Concrete', 'name' => 'My Dummy'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Location', '/concrete_dummies/1.jsonld'); + $this->assertResponseHeaderSame('Location', '/concrete_dummies/1'); + $this->assertJsonEquals([ + '@context' => '/contexts/ConcreteDummy', + '@id' => '/concrete_dummies/1', + '@type' => 'ConcreteDummy', + 'instance' => 'Concrete', + 'id' => 1, + 'name' => 'My Dummy', + ]); + } +} diff --git a/tests/Functional/CrudTest.php b/tests/Functional/CrudTest.php new file mode 100644 index 00000000000..99c015ffda9 --- /dev/null +++ b/tests/Functional/CrudTest.php @@ -0,0 +1,180 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CrudTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + private const DUMMY = [ + '@context' => '/contexts/Dummy', + '@id' => '/dummies/1', + '@type' => 'Dummy', + 'description' => null, + 'dummy' => null, + 'dummyBoolean' => null, + 'dummyDate' => '2015-03-01T10:00:00+00:00', + 'dummyFloat' => null, + 'dummyPrice' => null, + 'relatedDummy' => null, + 'relatedDummies' => [], + 'jsonData' => ['key' => ['value1', 'value2']], + 'arrayData' => [], + 'name_converted' => null, + 'relatedOwnedDummy' => null, + 'relatedOwningDummy' => null, + 'id' => 1, + 'name' => 'My Dummy', + 'alias' => null, + 'foo' => null, + ]; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Dummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([Dummy::class]); + } + + private function createDummy(): void + { + self::createClient()->request('POST', '/dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name' => 'My Dummy', + 'dummyDate' => '2015-03-01T10:00:00+00:00', + 'jsonData' => ['key' => ['value1', 'value2']], + ], + ]); + } + + public function testCreateDummy(): void + { + $this->createDummy(); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertResponseHeaderSame('Content-Location', '/dummies/1.jsonld'); + $this->assertResponseHeaderSame('Location', '/dummies/1'); + $this->assertJsonContains(self::DUMMY); + } + + public function testGetItem(): void + { + $this->createDummy(); + + $response = self::createClient()->request('GET', '/dummies/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertArrayNotHasKey('content-location', array_change_key_case($response->getHeaders())); + $this->assertJsonContains(self::DUMMY); + } + + public function testCreateEmptyBodyReturns400(): void + { + self::createClient()->request('POST', '/dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertJsonContains(['detail' => 'Syntax error']); + } + + public function testNotFoundReturns404(): void + { + $response = self::createClient()->request('GET', '/dummies/42'); + + $this->assertResponseStatusCodeSame(404); + $this->assertArrayNotHasKey('content-location', array_change_key_case($response->getHeaders(false))); + } + + public function testGetCollection(): void + { + $this->createDummy(); + + $response = self::createClient()->request('GET', '/dummies'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('/contexts/Dummy', $data['@context']); + $this->assertSame('/dummies', $data['@id']); + $this->assertSame('hydra:Collection', $data['@type']); + $this->assertSame(1, $data['hydra:totalItems']); + $this->assertSame('/dummies/1', $data['hydra:member'][0]['@id']); + $this->assertSame('My Dummy', $data['hydra:member'][0]['name']); + } + + public function testUpdateDummy(): void + { + $this->createDummy(); + + self::createClient()->request('PUT', '/dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + '@id' => '/dummies/1', + 'name' => 'A nice dummy', + 'dummyDate' => '2018-12-01 13:12', + 'jsonData' => [['key' => 'value1'], ['key' => 'value2']], + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Location', '/dummies/1.jsonld'); + $this->assertJsonContains([ + '@id' => '/dummies/1', + '@type' => 'Dummy', + 'name' => 'A nice dummy', + 'dummyDate' => '2018-12-01T13:12:00+00:00', + 'jsonData' => [['key' => 'value1'], ['key' => 'value2']], + 'id' => 1, + ]); + } + + public function testUpdateEmptyBodyReturns400(): void + { + $this->createDummy(); + + self::createClient()->request('PUT', '/dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertJsonContains(['detail' => 'Syntax error']); + } + + public function testDeleteDummy(): void + { + $this->createDummy(); + + self::createClient()->request('DELETE', '/dummies/1'); + + $this->assertResponseStatusCodeSame(204); + } +} diff --git a/tests/Functional/CrudUriVariablesTest.php b/tests/Functional/CrudUriVariablesTest.php new file mode 100644 index 00000000000..695d3b6938c --- /dev/null +++ b/tests/Functional/CrudUriVariablesTest.php @@ -0,0 +1,206 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Company; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Employee; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CrudUriVariablesTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Company::class, Employee::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([Company::class, Employee::class]); + } + + private function seed(): void + { + $client = self::createClient(); + $headers = ['Content-Type' => 'application/ld+json']; + $client->request('POST', '/companies', ['headers' => $headers, 'json' => ['name' => 'Foo Company 1']]); + $client->request('POST', '/companies', ['headers' => $headers, 'json' => ['name' => 'Foo Company 2']]); + $client->request('POST', '/employees', ['headers' => $headers, 'json' => ['name' => 'foo', 'company' => '/companies/1']]); + $client->request('POST', '/employees', ['headers' => $headers, 'json' => ['name' => 'foo2', 'company' => '/companies/2']]); + $client->request('POST', '/employees', ['headers' => $headers, 'json' => ['name' => 'foo3', 'company' => '/companies/2']]); + } + + public function testCreateCompany(): void + { + self::createClient()->request('POST', '/companies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Foo Company 1'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Location', '/companies/1.jsonld'); + $this->assertResponseHeaderSame('Location', '/companies/1'); + $this->assertJsonEquals([ + '@context' => '/contexts/Company', + '@id' => '/companies/1', + '@type' => 'Company', + 'id' => 1, + 'name' => 'Foo Company 1', + 'employees' => [], + ]); + } + + public function testCreateSecondCompany(): void + { + $client = self::createClient(); + $client->request('POST', '/companies', ['headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['name' => 'Foo Company 1']]); + $client->request('POST', '/companies', ['headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['name' => 'Foo Company 2']]); + + $this->assertResponseStatusCodeSame(201); + } + + public function testCreateEmployeeReturnsScopedUri(): void + { + $client = self::createClient(); + $client->request('POST', '/companies', ['headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['name' => 'Foo Company 1']]); + $client->request('POST', '/employees', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'foo', 'company' => '/companies/1'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Location', '/companies/1/employees/1.jsonld'); + $this->assertResponseHeaderSame('Location', '/companies/1/employees/1'); + $this->assertJsonEquals([ + '@context' => '/contexts/Employee', + '@id' => '/companies/1/employees/1', + '@type' => 'Employee', + 'id' => 1, + 'name' => 'foo', + 'company' => '/companies/1', + ]); + } + + public function testGetEmployeesCollectionByCompany(): void + { + $this->seed(); + + self::createClient()->request('GET', '/companies/2/employees', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/Employee', + '@id' => '/companies/2/employees', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + [ + '@id' => '/companies/2/employees/2', + '@type' => 'Employee', + 'name' => 'foo2', + 'company' => ['@id' => '/companies/2', '@type' => 'Company', 'name' => 'Foo Company 2'], + ], + [ + '@id' => '/companies/2/employees/3', + '@type' => 'Employee', + 'name' => 'foo3', + 'company' => ['@id' => '/companies/2', '@type' => 'Company', 'name' => 'Foo Company 2'], + ], + ], + 'hydra:totalItems' => 2, + ]); + } + + public function testGetCompanyOfEmployee(): void + { + $this->seed(); + + self::createClient()->request('GET', '/employees/1/company', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/Company', + '@id' => '/employees/1/company', + '@type' => 'Company', + 'id' => 1, + 'name' => 'Foo Company 1', + 'employees' => [], + ]); + } + + public function testGetEmployeeWithCompanyUriVariable(): void + { + $this->seed(); + + self::createClient()->request('GET', '/companies/1/employees/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/Employee', + '@id' => '/companies/1/employees/1', + '@type' => 'Employee', + 'id' => 1, + 'name' => 'foo', + 'company' => '/companies/1', + ]); + } + + public function testWrongCompanyContextReturns404(): void + { + $this->seed(); + + self::createClient()->request('GET', '/companies/1/employees/2', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testGraphQLCompaniesAndEmployees(): void + { + $this->seed(); + + $response = self::createClient()->request('POST', '/graphql', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['query' => '{ companies { edges { node { name employees { edges { node { name } } } } } } }'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/json'); + $data = $response->toArray(); + $companies = $data['data']['companies']['edges']; + $this->assertSame('Foo Company 1', $companies[0]['node']['name']); + $this->assertCount(1, $companies[0]['node']['employees']['edges']); + $this->assertSame('foo', $companies[0]['node']['employees']['edges'][0]['node']['name']); + $this->assertSame('Foo Company 2', $companies[1]['node']['name']); + $this->assertCount(2, $companies[1]['node']['employees']['edges']); + $this->assertSame('foo2', $companies[1]['node']['employees']['edges'][0]['node']['name']); + $this->assertSame('foo3', $companies[1]['node']['employees']['edges'][1]['node']['name']); + } +} diff --git a/tests/Functional/CustomControllerTest.php b/tests/Functional/CustomControllerTest.php new file mode 100644 index 00000000000..450ddbbf27b --- /dev/null +++ b/tests/Functional/CustomControllerTest.php @@ -0,0 +1,217 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CustomActionDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Payment; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VoidPayment; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * Ports the @controller-tagged features/main/custom_controller.feature scenarios. + * Controllers return raw entities or JsonResponse and rely on SerializeListener to + * wrap them, so they require USE_SYMFONY_LISTENERS=1 (CI: phpunit_listeners job). + */ +final class CustomControllerTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [CustomActionDummy::class, Payment::class, VoidPayment::class]; + } + + protected function setUp(): void + { + if (!($_SERVER['USE_SYMFONY_LISTENERS'] ?? false)) { + $this->markTestSkipped('Requires USE_SYMFONY_LISTENERS=1.'); + } + + $this->recreateSchema([CustomActionDummy::class, Payment::class, VoidPayment::class]); + } + + public function testCustomDenormalizationRoute(): void + { + self::createClient()->request('POST', '/custom/denormalization', [ + 'headers' => ['Accept' => 'application/ld+json', 'Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomActionDummy', + '@id' => '/custom_action_dummies/1', + '@type' => 'CustomActionDummy', + 'id' => 1, + 'foo' => 'custom!', + ]); + } + + public function testCustomNormalizationRoute(): void + { + $this->seedCustomDummy('custom!'); + + $response = self::createClient()->request('GET', '/custom/1/normalization', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertSame(['id' => 1, 'foo' => 'foo'], $response->toArray()); + } + + public function testShortCustomDenormalizationRoute(): void + { + self::createClient()->request('POST', '/short_custom/denormalization', [ + 'headers' => ['Accept' => 'application/ld+json', 'Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomActionDummy', + '@id' => '/custom_action_dummies/1', + '@type' => 'CustomActionDummy', + 'id' => 1, + 'foo' => 'short declaration', + ]); + } + + public function testShortCustomNormalizationRoute(): void + { + $this->seedCustomDummy('custom!'); + + $response = self::createClient()->request('GET', '/short_custom/1/normalization', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertSame(['id' => 1, 'foo' => 'short'], $response->toArray()); + } + + public function testCustomCollectionWithoutSpecificRoute(): void + { + $this->seedCustomDummy('first'); + $this->seedCustomDummy('second'); + + $response = self::createClient()->request('GET', '/custom_action_collection_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertCount(2, $response->toArray()['hydra:member']); + } + + public function testCustomItemOperationWithoutSpecificRoute(): void + { + $this->seedCustomDummy('custom!'); + + self::createClient()->request('GET', '/custom_action_collection_dummies/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomActionDummy', + '@id' => '/custom_action_collection_dummies/1', + '@type' => 'CustomActionDummy', + 'id' => 1, + 'foo' => 'custom!', + ]); + } + + public function testCreatePayment(): void + { + self::createClient()->request('POST', '/payments', [ + 'headers' => ['Accept' => 'application/ld+json', 'Content-Type' => 'application/ld+json'], + 'json' => ['amount' => '123.45'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/Payment', + '@id' => '/payments/1', + '@type' => 'Payment', + 'id' => 1, + 'amount' => '123.45', + 'voidPayment' => null, + ]); + } + + public function testVoidPayment(): void + { + $this->seedPayment('123.45'); + + self::createClient()->request('POST', '/payments/1/void', [ + 'headers' => ['Accept' => 'application/ld+json', 'Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/VoidPayment', + '@id' => '/void_payments/1', + '@type' => 'VoidPayment', + 'id' => 1, + 'payment' => '/payments/1', + ]); + } + + public function testGetVoidPayment(): void + { + $this->seedPayment('123.45'); + self::createClient()->request('POST', '/payments/1/void', [ + 'headers' => ['Accept' => 'application/ld+json', 'Content-Type' => 'application/ld+json'], + ]); + + self::createClient()->request('GET', '/void_payments/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/VoidPayment', + '@id' => '/void_payments/1', + '@type' => 'VoidPayment', + 'id' => 1, + 'payment' => '/payments/1', + ]); + } + + private function seedCustomDummy(string $foo): void + { + $manager = $this->getManager(); + $dummy = new CustomActionDummy(); + $dummy->setFoo($foo); + $manager->persist($dummy); + $manager->flush(); + } + + private function seedPayment(string $amount): Payment + { + $manager = $this->getManager(); + $payment = new Payment($amount); + $manager->persist($payment); + $manager->flush(); + + return $payment; + } +} diff --git a/tests/Functional/CustomIdentifierTest.php b/tests/Functional/CustomIdentifierTest.php new file mode 100644 index 00000000000..a68627a7800 --- /dev/null +++ b/tests/Functional/CustomIdentifierTest.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\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CustomIdentifierDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CustomMultipleIdentifierDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CustomIdentifierTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [CustomIdentifierDummy::class, CustomMultipleIdentifierDummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([CustomIdentifierDummy::class, CustomMultipleIdentifierDummy::class]); + } + + private function createDummy(): void + { + self::createClient()->request('POST', '/custom_identifier_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'My Dummy'], + ]); + } + + public function testCreate(): void + { + $this->createDummy(); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomIdentifierDummy', + '@id' => '/custom_identifier_dummies/1', + '@type' => 'CustomIdentifierDummy', + 'customId' => 1, + 'name' => 'My Dummy', + ]); + } + + public function testGetItem(): void + { + $this->createDummy(); + + self::createClient()->request('GET', '/custom_identifier_dummies/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomIdentifierDummy', + '@id' => '/custom_identifier_dummies/1', + '@type' => 'CustomIdentifierDummy', + 'customId' => 1, + 'name' => 'My Dummy', + ]); + } + + public function testGetCollection(): void + { + $this->createDummy(); + + self::createClient()->request('GET', '/custom_identifier_dummies'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomIdentifierDummy', + '@id' => '/custom_identifier_dummies', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/custom_identifier_dummies/1', + '@type' => 'CustomIdentifierDummy', + 'customId' => 1, + 'name' => 'My Dummy', + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testUpdate(): void + { + $this->createDummy(); + + self::createClient()->request('PUT', '/custom_identifier_dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'My Dummy modified'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomIdentifierDummy', + '@id' => '/custom_identifier_dummies/1', + '@type' => 'CustomIdentifierDummy', + 'customId' => 1, + 'name' => 'My Dummy modified', + ]); + } + + public function testApiDocReportsCustomIdentifierClass(): void + { + $response = self::createClient()->request('GET', '/docs.jsonld'); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $classes = array_filter($data['hydra:supportedClass'], static fn ($c) => 'CustomIdentifierDummy' === $c['hydra:title']); + $this->assertCount(1, $classes, 'CustomIdentifierDummy is missing from /docs.jsonld'); + $class = reset($classes); + $properties = array_column($class['hydra:supportedProperty'] ?? [], 'hydra:title'); + $this->assertContains('name', $properties); + } + + public function testDelete(): void + { + $this->createDummy(); + + self::createClient()->request('DELETE', '/custom_identifier_dummies/1'); + + $this->assertResponseStatusCodeSame(204); + } + + public function testGetCustomMultipleIdentifierDummy(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('CustomMultipleIdentifierDummy fixture is ORM-only.'); + } + + $manager = $this->getManager(); + $dummy = new CustomMultipleIdentifierDummy(); + $dummy->setName('Orwell'); + $dummy->setFirstId(1); + $dummy->setSecondId(2); + $manager->persist($dummy); + $manager->flush(); + + self::createClient()->request('GET', '/custom_multiple_identifier_dummies/1/2'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomMultipleIdentifierDummy', + '@id' => '/custom_multiple_identifier_dummies/1/2', + '@type' => 'CustomMultipleIdentifierDummy', + 'firstId' => 1, + 'secondId' => 2, + 'name' => 'Orwell', + ]); + } +} diff --git a/tests/Functional/CustomIdentifierWithSubresourceTest.php b/tests/Functional/CustomIdentifierWithSubresourceTest.php new file mode 100644 index 00000000000..fd91785325b --- /dev/null +++ b/tests/Functional/CustomIdentifierWithSubresourceTest.php @@ -0,0 +1,137 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SlugChildDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SlugParentDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CustomIdentifierWithSubresourceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [SlugParentDummy::class, SlugChildDummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema($this->getResources()); + } + + private function seed(): void + { + $client = self::createClient(); + $headers = ['Content-Type' => 'application/ld+json']; + $client->request('POST', '/slug_parent_dummies', ['headers' => $headers, 'json' => ['slug' => 'parent-dummy']]); + $client->request('POST', '/slug_child_dummies', [ + 'headers' => $headers, + 'json' => ['slug' => 'child-dummy', 'parentDummy' => '/slug_parent_dummies/parent-dummy'], + ]); + } + + public function testCreateParentWithSlug(): void + { + self::createClient()->request('POST', '/slug_parent_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['slug' => 'parent-dummy'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/SlugParentDummy', + '@id' => '/slug_parent_dummies/parent-dummy', + '@type' => 'SlugParentDummy', + 'id' => 1, + 'slug' => 'parent-dummy', + 'childDummies' => [], + ]); + } + + public function testCreateChildReferencingParentBySlug(): void + { + self::createClient()->request('POST', '/slug_parent_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['slug' => 'parent-dummy'], + ]); + self::createClient()->request('POST', '/slug_child_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['slug' => 'child-dummy', 'parentDummy' => '/slug_parent_dummies/parent-dummy'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/SlugChildDummy', + '@id' => '/slug_child_dummies/child-dummy', + '@type' => 'SlugChildDummy', + 'id' => 1, + 'slug' => 'child-dummy', + 'parentDummy' => '/slug_parent_dummies/parent-dummy', + ]); + } + + public function testGetChildDummiesOfParentBySlug(): void + { + $this->seed(); + + self::createClient()->request('GET', '/slug_parent_dummies/parent-dummy/child_dummies'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/SlugChildDummy', + '@id' => '/slug_parent_dummies/parent-dummy/child_dummies', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + [ + '@id' => '/slug_child_dummies/child-dummy', + '@type' => 'SlugChildDummy', + 'id' => 1, + 'slug' => 'child-dummy', + 'parentDummy' => '/slug_parent_dummies/parent-dummy', + ], + ], + 'hydra:totalItems' => 1, + ]); + } + + public function testGetParentOfChildBySlug(): void + { + $this->seed(); + + self::createClient()->request('GET', '/slug_child_dummies/child-dummy/parent_dummy'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/SlugParentDummy', + '@id' => '/slug_child_dummies/child-dummy/parent_dummy', + '@type' => 'SlugParentDummy', + 'id' => 1, + 'slug' => 'parent-dummy', + 'childDummies' => ['/slug_child_dummies/child-dummy'], + ]); + } +} diff --git a/tests/Functional/CustomNormalizedTest.php b/tests/Functional/CustomNormalizedTest.php new file mode 100644 index 00000000000..7fca11e774a --- /dev/null +++ b/tests/Functional/CustomNormalizedTest.php @@ -0,0 +1,204 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CustomNormalizedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedNormalizedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CustomNormalizedTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [CustomNormalizedDummy::class, RelatedNormalizedDummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([CustomNormalizedDummy::class, RelatedNormalizedDummy::class]); + } + + private function createCustom(): void + { + self::createClient()->request('POST', '/custom_normalized_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'My Dummy', 'alias' => 'My alias'], + ]); + } + + public function testCreateCustomNormalized(): void + { + $this->createCustom(); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertResponseHeaderSame('Content-Location', '/custom_normalized_dummies/1.jsonld'); + $this->assertResponseHeaderSame('Location', '/custom_normalized_dummies/1'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomNormalizedDummy', + '@id' => '/custom_normalized_dummies/1', + '@type' => 'CustomNormalizedDummy', + 'id' => 1, + 'name' => 'My Dummy', + 'alias' => 'My alias', + ]); + } + + public function testCreateRelatedNormalizedReturnsJson(): void + { + self::createClient()->request('POST', '/related_normalized_dummies', [ + 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + 'json' => ['name' => 'My Dummy'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/json; charset=utf-8'); + $this->assertResponseHeaderSame('Content-Location', '/related_normalized_dummies/1.json'); + $this->assertResponseHeaderSame('Location', '/related_normalized_dummies/1'); + $this->assertJsonEquals(['id' => 1, 'name' => 'My Dummy', 'customNormalizedDummy' => []]); + } + + public function testPutRelatedNormalizedReplacesEmbeddedDummies(): void + { + $this->createCustom(); + self::createClient()->request('POST', '/related_normalized_dummies', [ + 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + 'json' => ['name' => 'My Dummy'], + ]); + + self::createClient()->request('PUT', '/related_normalized_dummies/1', [ + 'headers' => ['Content-Type' => 'application/json', 'Accept' => 'application/json'], + 'json' => [ + 'name' => 'My Dummy', + 'customNormalizedDummy' => [[ + '@context' => '/contexts/CustomNormalizedDummy', + '@id' => '/custom_normalized_dummies/1', + '@type' => 'CustomNormalizedDummy', + 'id' => 1, + 'name' => 'My Dummy', + ]], + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/json; charset=utf-8'); + $this->assertResponseHeaderSame('Content-Location', '/related_normalized_dummies/1.json'); + $this->assertJsonEquals([ + 'id' => 1, + 'name' => 'My Dummy', + 'customNormalizedDummy' => [['id' => 1, 'name' => 'My Dummy', 'alias' => 'My alias']], + ]); + } + + public function testGetCustomNormalizedItem(): void + { + $this->createCustom(); + + self::createClient()->request('GET', '/custom_normalized_dummies/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomNormalizedDummy', + '@id' => '/custom_normalized_dummies/1', + '@type' => 'CustomNormalizedDummy', + 'id' => 1, + 'name' => 'My Dummy', + 'alias' => 'My alias', + ]); + } + + public function testGetCustomNormalizedCollection(): void + { + $this->createCustom(); + + self::createClient()->request('GET', '/custom_normalized_dummies'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomNormalizedDummy', + '@id' => '/custom_normalized_dummies', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/custom_normalized_dummies/1', + '@type' => 'CustomNormalizedDummy', + 'id' => 1, + 'name' => 'My Dummy', + 'alias' => 'My alias', + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testPutCustomNormalizedRetainsExistingAlias(): void + { + $this->createCustom(); + + self::createClient()->request('PUT', '/custom_normalized_dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'My Dummy modified'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Location', '/custom_normalized_dummies/1.jsonld'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomNormalizedDummy', + '@id' => '/custom_normalized_dummies/1', + '@type' => 'CustomNormalizedDummy', + 'id' => 1, + 'name' => 'My Dummy modified', + 'alias' => 'My alias', + ]); + } + + public function testPatchCustomNormalized(): void + { + $this->createCustom(); + + self::createClient()->request('PATCH', '/custom_normalized_dummies/1', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['name' => 'My Dummy modified'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Location', '/custom_normalized_dummies/1.jsonld'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomNormalizedDummy', + '@id' => '/custom_normalized_dummies/1', + '@type' => 'CustomNormalizedDummy', + 'id' => 1, + 'name' => 'My Dummy modified', + 'alias' => 'My alias', + ]); + } + + public function testDeleteCustomNormalized(): void + { + $this->createCustom(); + + self::createClient()->request('DELETE', '/custom_normalized_dummies/1'); + + $this->assertResponseStatusCodeSame(204); + } +} diff --git a/tests/Functional/CustomPutTest.php b/tests/Functional/CustomPutTest.php new file mode 100644 index 00000000000..d901a8034bd --- /dev/null +++ b/tests/Functional/CustomPutTest.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\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CustomPut; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CustomPutTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [CustomPut::class]; + } + + public function testPutWithoutReadOrAllowCreateReturns200(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([CustomPut::class]); + + self::createClient()->request('PUT', '/custom_puts/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'a', 'bar' => 'b'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomPut', + '@id' => '/custom_puts/1', + '@type' => 'CustomPut', + 'id' => 1, + 'foo' => 'a', + 'bar' => 'b', + ]); + } +} diff --git a/tests/Functional/CustomWritableIdentifierTest.php b/tests/Functional/CustomWritableIdentifierTest.php new file mode 100644 index 00000000000..48affd6dd5a --- /dev/null +++ b/tests/Functional/CustomWritableIdentifierTest.php @@ -0,0 +1,153 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CustomWritableIdentifierDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CustomWritableIdentifierTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [CustomWritableIdentifierDummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([CustomWritableIdentifierDummy::class]); + } + + private function createWithSlug(string $name, string $slug): void + { + self::createClient()->request('POST', '/custom_writable_identifier_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => $name, 'slug' => $slug], + ]); + } + + public function testCreateWithWritableSlug(): void + { + $this->createWithSlug('My Dummy', 'my_slug'); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertResponseHeaderSame('Content-Location', '/custom_writable_identifier_dummies/my_slug.jsonld'); + $this->assertResponseHeaderSame('Location', '/custom_writable_identifier_dummies/my_slug'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomWritableIdentifierDummy', + '@id' => '/custom_writable_identifier_dummies/my_slug', + '@type' => 'CustomWritableIdentifierDummy', + 'slug' => 'my_slug', + 'name' => 'My Dummy', + ]); + } + + public function testGetItemBySlug(): void + { + $this->createWithSlug('My Dummy', 'my_slug'); + + self::createClient()->request('GET', '/custom_writable_identifier_dummies/my_slug'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomWritableIdentifierDummy', + '@id' => '/custom_writable_identifier_dummies/my_slug', + '@type' => 'CustomWritableIdentifierDummy', + 'slug' => 'my_slug', + 'name' => 'My Dummy', + ]); + } + + public function testGetCollection(): void + { + $this->createWithSlug('My Dummy', 'my_slug'); + + self::createClient()->request('GET', '/custom_writable_identifier_dummies'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomWritableIdentifierDummy', + '@id' => '/custom_writable_identifier_dummies', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/custom_writable_identifier_dummies/my_slug', + '@type' => 'CustomWritableIdentifierDummy', + 'slug' => 'my_slug', + 'name' => 'My Dummy', + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testPutChangesIdentifier(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->createWithSlug('My Dummy', 'my_slug'); + + self::createClient()->request('PUT', '/custom_writable_identifier_dummies/my_slug', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'My Dummy modified', 'slug' => 'slug_modified'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Location', '/custom_writable_identifier_dummies/slug_modified.jsonld'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomWritableIdentifierDummy', + '@id' => '/custom_writable_identifier_dummies/slug_modified', + '@type' => 'CustomWritableIdentifierDummy', + 'slug' => 'slug_modified', + 'name' => 'My Dummy modified', + ]); + } + + public function testApiDocReportsClass(): void + { + $response = self::createClient()->request('GET', '/docs.jsonld'); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $classes = array_filter($data['hydra:supportedClass'], static fn ($c) => 'CustomWritableIdentifierDummy' === $c['hydra:title']); + $this->assertCount(1, $classes); + $class = reset($classes); + $properties = array_column($class['hydra:supportedProperty'] ?? [], 'hydra:title'); + $this->assertContains('name', $properties); + $this->assertContains('slug', $properties); + } + + public function testDelete(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->createWithSlug('My Dummy', 'my_slug'); + + self::createClient()->request('DELETE', '/custom_writable_identifier_dummies/my_slug'); + + $this->assertResponseStatusCodeSame(204); + } +} diff --git a/tests/Functional/DefaultOrderTest.php b/tests/Functional/DefaultOrderTest.php new file mode 100644 index 00000000000..2d48386c8d1 --- /dev/null +++ b/tests/Functional/DefaultOrderTest.php @@ -0,0 +1,143 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FooDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SoMany; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class DefaultOrderTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Foo::class, FooDummy::class, Dummy::class, SoMany::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema($this->getResources()); + } + + private function seedFoos(): void + { + $manager = $this->getManager(); + $names = ['Hawsepipe', 'Sthenelus', 'Ephesian', 'Separativeness', 'Balbo']; + $bars = ['Lorem', 'Ipsum', 'Dolor', 'Sit', 'Amet']; + for ($i = 0; $i < 5; ++$i) { + $foo = new Foo(); + $foo->setName($names[$i]); + $foo->setBar($bars[$i]); + $manager->persist($foo); + } + $manager->flush(); + $manager->clear(); + } + + public function testDefaultOrderOnFooCollection(): void + { + $this->seedFoos(); + + self::createClient()->request('GET', '/foos?itemsPerPage=10'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/Foo', + '@id' => '/foos', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + ['@id' => '/foos/5', '@type' => 'Foo', 'id' => 5, 'name' => 'Balbo', 'bar' => 'Amet'], + ['@id' => '/foos/3', '@type' => 'Foo', 'id' => 3, 'name' => 'Ephesian', 'bar' => 'Dolor'], + ['@id' => '/foos/2', '@type' => 'Foo', 'id' => 2, 'name' => 'Sthenelus', 'bar' => 'Ipsum'], + ['@id' => '/foos/1', '@type' => 'Foo', 'id' => 1, 'name' => 'Hawsepipe', 'bar' => 'Lorem'], + ['@id' => '/foos/4', '@type' => 'Foo', 'id' => 4, 'name' => 'Separativeness', 'bar' => 'Sit'], + ], + 'hydra:totalItems' => 5, + 'hydra:view' => [ + '@id' => '/foos?itemsPerPage=10', + '@type' => 'hydra:PartialCollectionView', + ], + ]); + } + + public function testDefaultOrderByAssociationOnFooDummy(): void + { + $manager = $this->getManager(); + $names = ['Hawsepipe', 'Ephesian', 'Sthenelus', 'Separativeness', 'Balbo']; + $dummies = ['Lorem', 'Ipsum', 'Dolor', 'Sit', 'Amet']; + for ($i = 0; $i < 5; ++$i) { + $dummy = new Dummy(); + $dummy->setName($dummies[$i]); + $foo = new FooDummy(); + $foo->setName($names[$i]); + $foo->setDummy($dummy); + for ($j = 0; $j < 3; ++$j) { + $soMany = new SoMany(); + $soMany->content = "So many $j"; + $soMany->fooDummy = $foo; + $foo->soManies->add($soMany); + } + $manager->persist($foo); + } + $manager->flush(); + $manager->clear(); + + $response = self::createClient()->request('GET', '/foo_dummies?itemsPerPage=10'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $names = array_column($data['hydra:member'], 'name'); + $this->assertSame(['Balbo', 'Sthenelus', 'Ephesian', 'Hawsepipe', 'Separativeness'], $names); + $this->assertSame(5, $data['hydra:totalItems']); + } + + public function testCustomCollectionOrderAsc(): void + { + $this->seedFoos(); + + $response = self::createClient()->request('GET', '/custom_collection_asc_foos?itemsPerPage=10'); + + $this->assertResponseStatusCodeSame(200); + $names = array_column($response->toArray()['hydra:member'], 'name'); + $this->assertSame(['Balbo', 'Ephesian', 'Hawsepipe', 'Separativeness', 'Sthenelus'], $names); + } + + public function testCustomCollectionOrderDesc(): void + { + $this->seedFoos(); + + $response = self::createClient()->request('GET', '/custom_collection_desc_foos?itemsPerPage=10'); + + $this->assertResponseStatusCodeSame(200); + $names = array_column($response->toArray()['hydra:member'], 'name'); + $this->assertSame(['Sthenelus', 'Separativeness', 'Hawsepipe', 'Ephesian', 'Balbo'], $names); + } +} diff --git a/tests/Functional/ExceptionToStatusTest.php b/tests/Functional/ExceptionToStatusTest.php new file mode 100644 index 00000000000..66563a6d022 --- /dev/null +++ b/tests/Functional/ExceptionToStatusTest.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ErrorWithOverridenStatus; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5924\TooManyRequests; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyExceptionToStatus; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ExceptionToStatusTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyExceptionToStatus::class, ErrorWithOverridenStatus::class, TooManyRequests::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([DummyExceptionToStatus::class]); + } + + public function testOperationExceptionToStatusMaps404(): void + { + self::createClient()->request('GET', '/dummy_exception_to_statuses/123', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(404); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + } + + public function testResourceExceptionToStatusMaps400(): void + { + self::createClient()->request('PUT', '/dummy_exception_to_statuses/123', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'black'], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + } + + public function testFilterValidationExceptionMaps400(): void + { + self::createClient()->request('GET', '/dummy_exception_to_statuses', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + } + + public function testOverrideValidationExceptionStatusOnDelete(): void + { + self::createClient()->request('DELETE', '/error_with_overriden_status/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(403); + $this->assertJsonContains(['status' => 403]); + } + + public function testHttpExceptionHeadersAreRetained(): void + { + self::createClient()->request('GET', '/issue5924', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(429); + $this->assertResponseHeaderSame('retry-after', '32'); + } +} diff --git a/tests/Functional/ExposedStateTest.php b/tests/Functional/ExposedStateTest.php new file mode 100644 index 00000000000..1e2922bbaa5 --- /dev/null +++ b/tests/Functional/ExposedStateTest.php @@ -0,0 +1,88 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\TruncatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ExposedStateTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [TruncatedDummy::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + if (!$this->isPostgres()) { + $this->markTestSkipped('Decimal truncation is enforced by Postgres only.'); + } + + $this->recreateSchema($this->getResources()); + } + + public function testCreateReturnsTruncatedValue(): void + { + self::createClient()->request('POST', '/truncated_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['value' => '20.3325'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonEquals([ + '@context' => '/contexts/TruncatedDummy', + '@id' => '/truncated_dummies/1', + '@type' => 'TruncatedDummy', + 'value' => '20.3', + 'id' => 1, + ]); + } + + public function testUpdateReturnsTruncatedValue(): void + { + $client = self::createClient(); + $client->request('POST', '/truncated_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['value' => '20.3325'], + ]); + + $client->request('PUT', '/truncated_dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['value' => '42.42'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/TruncatedDummy', + '@id' => '/truncated_dummies/1', + '@type' => 'TruncatedDummy', + 'value' => '42.4', + 'id' => 1, + ]); + } +} diff --git a/tests/Functional/HeadersAdditionTest.php b/tests/Functional/HeadersAdditionTest.php new file mode 100644 index 00000000000..15b6acbcad3 --- /dev/null +++ b/tests/Functional/HeadersAdditionTest.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Headers; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class HeadersAdditionTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyCar::class, Headers::class]; + } + + public function testSunsetHeaderOnResourceCollection(): void + { + $this->recreateSchema([DummyCar::class]); + + self::createClient()->request('GET', '/dummy_cars'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Sunset', 'Sat, 01 Jan 2050 00:00:00 +0000'); + } + + public function testDeclareHeadersFromResource(): void + { + self::createClient()->request('GET', '/redirect_to_foobar'); + + $this->assertResponseStatusCodeSame(301); + $this->assertResponseHeaderSame('Location', '/foobar'); + $this->assertResponseHeaderSame('Hello', 'World'); + } +} diff --git a/tests/Functional/Json/OutputAndEntityClassTest.php b/tests/Functional/Json/OutputAndEntityClassTest.php new file mode 100644 index 00000000000..766de7c2fa6 --- /dev/null +++ b/tests/Functional/Json/OutputAndEntityClassTest.php @@ -0,0 +1,54 @@ + + * + * 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\Json; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6358\OutputAndEntityClass; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class OutputAndEntityClassTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [OutputAndEntityClass::class]; + } + + public function testCollectionUsesEntityClassFromStateOptionsForType(): void + { + if ('mongodb' === static::getContainer()->getParameter('kernel.environment')) { + $this->markTestSkipped(); + } + + self::createClient()->request('GET', '/output_and_entity_classes', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains([ + 'hydra:member' => [ + ['@type' => 'OutputAndEntityClassEntity'], + ], + ]); + } +} diff --git a/tests/Functional/JsonLd/SerializableItemDataProviderTest.php b/tests/Functional/JsonLd/SerializableItemDataProviderTest.php new file mode 100644 index 00000000000..d20032d311c --- /dev/null +++ b/tests/Functional/JsonLd/SerializableItemDataProviderTest.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\Tests\Functional\JsonLd; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Model\SerializableResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class SerializableItemDataProviderTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [SerializableResource::class]; + } + + public function testGetSerializableResource(): void + { + self::createClient()->request('GET', '/serializable_resources/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/SerializableResource', + '@id' => '/serializable_resources/1', + '@type' => 'SerializableResource', + 'id' => 1, + 'foo' => 'Lorem', + 'bar' => 'Ipsum', + ]); + } +} diff --git a/tests/Functional/NotExposedTest.php b/tests/Functional/NotExposedTest.php new file mode 100644 index 00000000000..58ab2f5d9ff --- /dev/null +++ b/tests/Functional/NotExposedTest.php @@ -0,0 +1,165 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Model\Chair; +use ApiPlatform\Tests\Fixtures\TestBundle\Model\Fork; +use ApiPlatform\Tests\Fixtures\TestBundle\Model\Spoon; +use ApiPlatform\Tests\Fixtures\TestBundle\Model\Table; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; + +final class NotExposedTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Chair::class, Table::class, Fork::class, Spoon::class]; + } + + public function testChairsCollectionIsExposedWithGenIdIris(): void + { + $response = self::createClient()->request('GET', '/chairs', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('/contexts/Chair', $data['@context']); + $this->assertSame('/chairs', $data['@id']); + $this->assertSame('hydra:Collection', $data['@type']); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertCount(2, $data['hydra:member']); + foreach ($data['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('#^/.well-known/genid/.+$#', $member['@id']); + $this->assertSame('Chair', $member['@type']); + } + } + + public function testTablesCollectionExposesItemIris(): void + { + $response = self::createClient()->request('GET', '/tables', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertSame('/contexts/Table', $data['@context']); + $this->assertSame(2, $data['hydra:totalItems']); + foreach ($data['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('#^/tables/.+$#', $member['@id']); + $this->assertSame('Table', $member['@type']); + } + } + + public static function forkUris(): iterable + { + yield ['/forks']; + yield ['/fourchettes']; + } + + #[DataProvider('forkUris')] + public function testForkMultipleCollectionsExposed(string $uri): void + { + $response = self::createClient()->request('GET', $uri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertSame('/contexts/Fork', $data['@context']); + $this->assertSame(2, $data['hydra:totalItems']); + foreach ($data['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('#^/forks/.+$#', $member['@id']); + $this->assertSame('Fork', $member['@type']); + } + } + + public static function spoonUris(): iterable + { + yield ['/spoons']; + yield ['/cuillers']; + } + + #[DataProvider('spoonUris')] + public function testSpoonCollectionExposesCuillersAsItemIris(string $uri): void + { + $response = self::createClient()->request('GET', $uri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertSame('/contexts/Spoon', $data['@context']); + $this->assertSame(2, $data['hydra:totalItems']); + foreach ($data['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('#^/cuillers/.+$#', $member['@id']); + $this->assertSame('Spoon', $member['@type']); + } + } + + public static function notExposedItemUris(): iterable + { + yield ['/tables/12345']; + yield ['/forks/12345']; + } + + #[DataProvider('notExposedItemUris')] + public function testNotExposedItemReturns404(string $uri): void + { + self::createClient()->request('GET', $uri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(404); + $this->assertJsonContains(['detail' => 'This route does not aim to be called.']); + } + + public function testGenidNotExposedReturns404WithExplanation(): void + { + self::createClient()->request('GET', '/.well-known/genid/12345', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(404); + $this->assertJsonContains([ + 'detail' => 'This route is not exposed on purpose. It generates an IRI for a collection resource without identifier nor item operation.', + ]); + } + + public function testSpoonItemViaCuillersIsExposed(): void + { + self::createClient()->request('GET', '/cuillers/12345', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/Spoon', + '@id' => '/cuillers/12345', + '@type' => 'Spoon', + 'id' => '12345', + 'owner' => 'Vincent', + ]); + } +} diff --git a/tests/Functional/OperationResourceTest.php b/tests/Functional/OperationResourceTest.php new file mode 100644 index 00000000000..826c4042bc6 --- /dev/null +++ b/tests/Functional/OperationResourceTest.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\OperationResource; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class OperationResourceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [OperationResource::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema($this->getResources()); + } + + private function seedOne(): void + { + self::createClient()->request('POST', '/operation_resources', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['identifier' => 1, 'dummy' => null, 'name' => 'string'], + ]); + } + + public function testCreateOperationResource(): void + { + self::createClient()->request('POST', '/operation_resources', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['identifier' => 1, 'dummy' => null, 'name' => 'string'], + ]); + + $this->assertResponseStatusCodeSame(201); + } + + public function testPatchOperationResource(): void + { + $this->seedOne(); + + self::createClient()->request('PATCH', '/operation_resources/1', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['name' => 'Patched'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/OperationResource', + '@id' => '/operation_resources/1', + '@type' => 'OperationResource', + 'identifier' => 1, + 'name' => 'Patched', + ]); + } + + public function testPutOperationResource(): void + { + $this->seedOne(); + + self::createClient()->request('PUT', '/operation_resources/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Modified'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertResponseHeaderSame('Content-Location', '/operation_resources/1.jsonld'); + $this->assertJsonEquals([ + '@context' => '/contexts/OperationResource', + '@id' => '/operation_resources/1', + '@type' => 'OperationResource', + 'identifier' => 1, + 'name' => 'Modified', + ]); + } +} diff --git a/tests/Functional/OperationTest.php b/tests/Functional/OperationTest.php new file mode 100644 index 00000000000..a8e175d561d --- /dev/null +++ b/tests/Functional/OperationTest.php @@ -0,0 +1,155 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Book; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DisableItemOperation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ReadableOnlyProperty; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class OperationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ReadableOnlyProperty::class, RelationEmbedder::class, EmbeddedDummy::class, DisableItemOperation::class, Book::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([ReadableOnlyProperty::class, RelationEmbedder::class, EmbeddedDummy::class, DisableItemOperation::class, Book::class]); + } + + public function testReadOnlyPropertyIgnoresInput(): void + { + self::createClient()->request('POST', '/readable_only_properties', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'My Dummy'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/ReadableOnlyProperty', + '@id' => '/readable_only_properties/1', + '@type' => 'ReadableOnlyProperty', + 'id' => 1, + 'name' => 'Read only', + ]); + } + + public function testCustomOperationOnRelationEmbedder(): void + { + $response = self::createClient()->request('GET', '/relation_embedders/42/custom'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertSame('"This is a custom action for 42."', $response->getContent()); + } + + public function testEmbeddedDummyWithGroups(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('EmbeddedDummy fixture uses ORM Embeddable.'); + } + + $manager = $this->getManager(); + $dummy = new EmbeddedDummy(); + $dummy->setName('Dummy #1'); + $embeddable = new EmbeddableDummy(); + $embeddable->setDummyName('Dummy #1'); + $dummy->setEmbeddedDummy($embeddable); + $manager->persist($dummy); + $manager->flush(); + + self::createClient()->request('GET', '/embedded_dummies_groups/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/EmbeddedDummy', + '@id' => '/embedded_dummies_groups/1', + '@type' => 'EmbeddedDummy', + 'name' => 'Dummy #1', + 'embeddedDummy' => [ + '@type' => 'EmbeddableDummy', + 'dummyName' => 'Dummy #1', + ], + ]); + } + + public function testCollectionOnResourceWithDisabledItemOperation(): void + { + self::createClient()->request('GET', '/disable_item_operations'); + + $this->assertResponseStatusCodeSame(200); + } + + public function testDisabledItemOperationReturns404(): void + { + self::createClient()->request('GET', '/disable_item_operations/1'); + + $this->assertResponseStatusCodeSame(404); + } + + public function testGetBookByCustomUriTemplate(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Book fixture is ORM-only.'); + } + + $manager = $this->getManager(); + $book = new Book(); + $book->name = '1984'; + $book->isbn = '9780451524935'; + $manager->persist($book); + $manager->flush(); + + self::createClient()->request('GET', '/books/by_isbn/9780451524935'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/Book', + '@id' => '/books/by_isbn/9780451524935', + '@type' => 'Book', + 'name' => '1984', + 'isbn' => '9780451524935', + 'id' => 1, + ]); + } + + public function testNonApiPlatformRouteIsReachable(): void + { + self::createClient()->request('GET', '/common/custom/object'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + 'id' => 1, + 'text' => 'Lorem ipsum dolor sit amet', + ]); + } +} diff --git a/tests/Functional/OverriddenOperationTest.php b/tests/Functional/OverriddenOperationTest.php new file mode 100644 index 00000000000..b75adf724ec --- /dev/null +++ b/tests/Functional/OverriddenOperationTest.php @@ -0,0 +1,206 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\OverriddenOperationDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RPC; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class OverriddenOperationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [OverriddenOperationDummy::class, RPC::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([OverriddenOperationDummy::class]); + } + + private function createDummy(): void + { + self::createClient()->request('POST', '/overridden_operation_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name' => 'My Overridden Operation Dummy', + 'description' => 'Gerard', + 'alias' => 'notWritable', + ], + ]); + } + + public function testCreateRespectsNotWritable(): void + { + $this->createDummy(); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/OverriddenOperationDummy', + '@id' => '/overridden_operation_dummies/1', + '@type' => 'OverriddenOperationDummy', + 'name' => 'My Overridden Operation Dummy', + 'alias' => null, + 'description' => 'Gerard', + ]); + } + + public function testGetItem(): void + { + $this->createDummy(); + + self::createClient()->request('GET', '/overridden_operation_dummies/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/OverriddenOperationDummy', + '@id' => '/overridden_operation_dummies/1', + '@type' => 'OverriddenOperationDummy', + 'name' => 'My Overridden Operation Dummy', + 'alias' => null, + 'description' => 'Gerard', + ]); + } + + public function testGetItemInXml(): void + { + $this->createDummy(); + + $response = self::createClient()->request('GET', '/overridden_operation_dummies/1', [ + 'headers' => ['Accept' => 'application/xml'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/xml; charset=utf-8'); + $this->assertSame( + ''."\n".'My Overridden Operation DummyGerard'."\n", + $response->getContent() + ); + } + + public function testNotFound(): void + { + self::createClient()->request('GET', '/overridden_operation_dummies/42'); + + $this->assertResponseStatusCodeSame(404); + } + + public function testGetCollection(): void + { + $this->createDummy(); + + self::createClient()->request('GET', '/overridden_operation_dummies'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/OverriddenOperationDummy', + '@id' => '/overridden_operation_dummies', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/overridden_operation_dummies/1', + '@type' => 'OverriddenOperationDummy', + 'name' => 'My Overridden Operation Dummy', + 'alias' => null, + 'description' => 'Gerard', + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testPutHidesName(): void + { + $this->createDummy(); + + self::createClient()->request('PUT', '/overridden_operation_dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + '@id' => '/overridden_operation_dummies/1', + 'name' => 'A nice dummy', + 'alias' => 'Dummy', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/OverriddenOperationDummy', + '@id' => '/overridden_operation_dummies/1', + '@type' => 'OverriddenOperationDummy', + 'alias' => 'Dummy', + 'description' => 'Gerard', + ]); + } + + public function testGetItemAfterPutShowsName(): void + { + $this->createDummy(); + self::createClient()->request('PUT', '/overridden_operation_dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['@id' => '/overridden_operation_dummies/1', 'name' => 'A nice dummy', 'alias' => 'Dummy'], + ]); + + self::createClient()->request('GET', '/overridden_operation_dummies/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/OverriddenOperationDummy', + '@id' => '/overridden_operation_dummies/1', + '@type' => 'OverriddenOperationDummy', + 'name' => 'My Overridden Operation Dummy', + 'alias' => 'Dummy', + 'description' => 'Gerard', + ]); + } + + public function testDelete(): void + { + $this->createDummy(); + + self::createClient()->request('DELETE', '/overridden_operation_dummies/1'); + + $this->assertResponseStatusCodeSame(204); + } + + public function testRpcMessengerOperationReturns202(): void + { + self::createClient()->request('POST', '/rpc', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['value' => 'Hello world'], + ]); + + $this->assertResponseStatusCodeSame(202); + } + + public function testRpcOperationWithOutputDtoReturns200(): void + { + self::createClient()->request('POST', '/rpc_output', [ + 'headers' => ['Content-Type' => 'application/json'], + 'json' => ['value' => 'Hello world'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains(['success' => 'YES', '@type' => 'RPCOutput']); + } +} diff --git a/tests/Functional/PatchTest.php b/tests/Functional/PatchTest.php new file mode 100644 index 00000000000..c78fc9720aa --- /dev/null +++ b/tests/Functional/PatchTest.php @@ -0,0 +1,165 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5736\Alpha; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5736\Beta; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6355\OrderProductCount; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PatchDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PatchDummyRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class PatchTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [PatchDummy::class, PatchDummyRelation::class, RelatedDummy::class, Beta::class, Alpha::class, OrderProductCount::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([PatchDummy::class, PatchDummyRelation::class, RelatedDummy::class]); + } + + public function testAcceptPatchHeader(): void + { + $client = self::createClient(); + $client->request('POST', '/patch_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Hello'], + ]); + $response = $client->request('GET', '/patch_dummies/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseHeaderSame('Accept-Patch', 'application/merge-patch+json, application/vnd.api+json'); + } + + public function testPatchItem(): void + { + $client = self::createClient(); + $client->request('POST', '/patch_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Hello'], + ]); + + $client->request('PATCH', '/patch_dummies/1', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['name' => 'Patched'], + ]); + + $this->assertJsonContains(['name' => 'Patched']); + } + + public function testPatchRemovesPropertyWithNull(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('PatchDummy fixture is ORM-only.'); + } + + $client = self::createClient(); + $client->request('POST', '/patch_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Hello'], + ]); + + $response = $client->request('PATCH', '/patch_dummies/1', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['name' => null], + ]); + + $data = $response->toArray(); + $this->assertArrayNotHasKey('name', $data); + } + + public function testPatchRelation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('PatchDummyRelation/RelatedDummy fixtures are ORM-only.'); + } + + $manager = $this->getManager(); + $related = new RelatedDummy(); + $manager->persist($related); + $manager->flush(); + $dummy = new PatchDummyRelation(); + $dummy->setRelated($related); + $manager->persist($dummy); + $manager->flush(); + $manager->clear(); + + self::createClient()->request('PATCH', '/patch_dummy_relations/1', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['related' => ['symfony' => 'A new name']], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/PatchDummyRelation', + '@id' => '/patch_dummy_relations/1', + '@type' => 'PatchDummyRelation', + 'related' => [ + '@id' => '/related_dummies/1', + '@type' => 'https://schema.org/Product', + 'id' => 1, + 'symfony' => 'A new name', + ], + ]); + } + + public function testPatchRelationWithNonIdUriVariable(): void + { + self::createClient()->request('PATCH', '/betas/1', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['alpha' => '/alphas/2'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/Beta', + '@id' => '/betas/1', + '@type' => 'Beta', + 'betaId' => 1, + 'alpha' => '/alphas/2', + ]); + } + + public function testPatchNonReadableResource(): void + { + if (!($_SERVER['USE_SYMFONY_LISTENERS'] ?? false)) { + $this->markTestSkipped('Requires USE_SYMFONY_LISTENERS=1.'); + } + + $response = self::createClient()->request('PATCH', '/order_products/1/count', [ + 'headers' => ['Content-Type' => 'application/merge-patch+json'], + 'json' => ['id' => 1, 'count' => 10], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertSame(1, $response->toArray()['id']); + } +} diff --git a/tests/Functional/ProviderProcessorEntityTest.php b/tests/Functional/ProviderProcessorEntityTest.php new file mode 100644 index 00000000000..ec0697814f5 --- /dev/null +++ b/tests/Functional/ProviderProcessorEntityTest.php @@ -0,0 +1,132 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ProcessorEntity; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ProviderEntity; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ProviderProcessorEntityTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ProcessorEntity::class, ProviderEntity::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Processor/Provider entity fixtures are ORM-only.'); + } + + $this->recreateSchema([ProcessorEntity::class, ProviderEntity::class]); + } + + public function testCreateProcessorEntity(): void + { + self::createClient()->request('POST', '/processor_entities', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'bar'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertResponseHeaderSame('Content-Location', '/processor_entities/1.jsonld'); + $this->assertResponseHeaderSame('Location', '/processor_entities/1'); + $this->assertJsonEquals([ + '@context' => '/contexts/ProcessorEntity', + '@id' => '/processor_entities/1', + '@type' => 'ProcessorEntity', + 'id' => 1, + 'foo' => 'bar', + ]); + } + + public function testCreateProviderEntity(): void + { + self::createClient()->request('POST', '/provider_entities', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'bar'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertResponseHeaderSame('Content-Location', '/provider_entities/1.jsonld'); + $this->assertResponseHeaderSame('Location', '/provider_entities/1'); + $this->assertJsonEquals([ + '@context' => '/contexts/ProviderEntity', + '@id' => '/provider_entities/1', + '@type' => 'ProviderEntity', + 'id' => 1, + 'foo' => 'bar', + ]); + } + + public function testGetProviderEntityCollection(): void + { + self::createClient()->request('POST', '/provider_entities', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'bar'], + ]); + + $response = self::createClient()->request('GET', '/provider_entities'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertArrayNotHasKey('content-location', array_change_key_case($response->getHeaders())); + $this->assertJsonEquals([ + '@context' => '/contexts/ProviderEntity', + '@id' => '/provider_entities', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/provider_entities/1', + '@type' => 'ProviderEntity', + 'id' => 1, + 'foo' => 'bar', + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testGetProviderEntityItem(): void + { + self::createClient()->request('POST', '/provider_entities', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'bar'], + ]); + + $response = self::createClient()->request('GET', '/provider_entities/1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertArrayNotHasKey('content-location', array_change_key_case($response->getHeaders())); + $this->assertJsonEquals([ + '@context' => '/contexts/ProviderEntity', + '@id' => '/provider_entities/1', + '@type' => 'ProviderEntity', + 'id' => 1, + 'foo' => 'bar', + ]); + } +} diff --git a/tests/Functional/PutCollectionTest.php b/tests/Functional/PutCollectionTest.php new file mode 100644 index 00000000000..f5f76ddd419 --- /dev/null +++ b/tests/Functional/PutCollectionTest.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5587\Business; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5587\Employee; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class PutCollectionTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Business::class, Employee::class]; + } + + public function testPutReplacesEmbeddedCollection(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema($this->getResources()); + + $client = self::createClient(); + $headers = ['Content-Type' => 'application/ld+json']; + + $client->request('POST', '/issue5584_employees', ['headers' => $headers, 'json' => ['name' => 'One']]); + $client->request('POST', '/issue5584_employees', ['headers' => $headers, 'json' => ['name' => 'Two']]); + $client->request('POST', '/issue5584_businesses', ['headers' => $headers, 'json' => ['name' => 'Business']]); + + $client->request('PUT', '/issue5584_businesses/1', [ + 'headers' => $headers, + 'json' => [ + 'name' => 'Business', + 'businessEmployees' => [ + ['@id' => '/issue5584_employees/1', 'id' => 1], + ['@id' => '/issue5584_employees/2', 'id' => 2], + ], + ], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + 'businessEmployees' => [ + ['name' => 'One'], + ['name' => 'Two'], + ], + ]); + } +} diff --git a/tests/Functional/RelationTest.php b/tests/Functional/RelationTest.php new file mode 100644 index 00000000000..8fc88a1648b --- /dev/null +++ b/tests/Functional/RelationTest.php @@ -0,0 +1,480 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Address; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Customer; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Order; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Person; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PersonToPet; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Pet; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class RelationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + ThirdLevel::class, + DummyFriend::class, + RelatedDummy::class, + RelatedToDummyFriend::class, + RelationEmbedder::class, + Dummy::class, + Order::class, + Customer::class, + Address::class, + Person::class, + Pet::class, + ]; + } + + private function seedBasics(): void + { + $client = self::createClient(); + $headers = ['Content-Type' => 'application/ld+json']; + $client->request('POST', '/third_levels', ['headers' => $headers, 'json' => ['level' => 3]]); + $client->request('POST', '/dummy_friends', ['headers' => $headers, 'json' => ['name' => 'Zoidberg']]); + $client->request('POST', '/related_dummies', ['headers' => $headers, 'json' => ['thirdLevel' => '/third_levels/1']]); + } + + public function testCreateThirdLevel(): void + { + $this->recreateSchema([ThirdLevel::class]); + + self::createClient()->request('POST', '/third_levels', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['level' => 3], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonEquals([ + '@context' => '/contexts/ThirdLevel', + '@id' => '/third_levels/1', + '@type' => 'ThirdLevel', + 'fourthLevel' => null, + 'badFourthLevel' => null, + 'id' => 1, + 'level' => 3, + 'test' => true, + 'relatedDummies' => [], + ]); + } + + public function testCreateDummyFriend(): void + { + $this->recreateSchema([DummyFriend::class]); + + self::createClient()->request('POST', '/dummy_friends', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Zoidberg'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonEquals([ + '@context' => '/contexts/DummyFriend', + '@id' => '/dummy_friends/1', + '@type' => 'DummyFriend', + 'id' => 1, + 'name' => 'Zoidberg', + ]); + } + + public function testCreateRelatedDummyWithThirdLevel(): void + { + $this->recreateSchema([ThirdLevel::class, RelatedDummy::class]); + self::createClient()->request('POST', '/third_levels', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['level' => 3], + ]); + + self::createClient()->request('POST', '/related_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['thirdLevel' => '/third_levels/1'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonContains([ + '@context' => '/contexts/RelatedDummy', + '@id' => '/related_dummies/1', + '@type' => 'https://schema.org/Product', + 'id' => 1, + 'symfony' => 'symfony', + 'thirdLevel' => [ + '@id' => '/third_levels/1', + '@type' => 'ThirdLevel', + 'fourthLevel' => null, + ], + ]); + } + + public function testCreateFriendRelationship(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([ThirdLevel::class, DummyFriend::class, RelatedDummy::class, RelatedToDummyFriend::class]); + $this->seedBasics(); + + self::createClient()->request('POST', '/related_to_dummy_friends', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name' => 'Friends relation', + 'dummyFriend' => '/dummy_friends/1', + 'relatedDummy' => '/related_dummies/1', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonEquals([ + '@context' => '/contexts/RelatedToDummyFriend', + '@id' => '/related_to_dummy_friends/dummyFriend=1;relatedDummy=1', + '@type' => 'RelatedToDummyFriend', + 'name' => 'Friends relation', + 'description' => null, + 'dummyFriend' => [ + '@id' => '/dummy_friends/1', + '@type' => 'DummyFriend', + 'name' => 'Zoidberg', + ], + ]); + } + + public function testGetFriendRelationship(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([ThirdLevel::class, DummyFriend::class, RelatedDummy::class, RelatedToDummyFriend::class]); + $this->seedBasics(); + self::createClient()->request('POST', '/related_to_dummy_friends', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name' => 'Friends relation', + 'dummyFriend' => '/dummy_friends/1', + 'relatedDummy' => '/related_dummies/1', + ], + ]); + + self::createClient()->request('GET', '/related_to_dummy_friends/dummyFriend=1;relatedDummy=1'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/RelatedToDummyFriend', + '@id' => '/related_to_dummy_friends/dummyFriend=1;relatedDummy=1', + '@type' => 'RelatedToDummyFriend', + 'name' => 'Friends relation', + 'description' => null, + 'dummyFriend' => [ + '@id' => '/dummy_friends/1', + '@type' => 'DummyFriend', + 'name' => 'Zoidberg', + ], + ]); + } + + public function testCreateDummyWithRelations(): void + { + $this->recreateSchema([ThirdLevel::class, RelatedDummy::class, Dummy::class]); + self::createClient()->request('POST', '/third_levels', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['level' => 3], + ]); + self::createClient()->request('POST', '/related_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['thirdLevel' => '/third_levels/1'], + ]); + + self::createClient()->request('POST', '/dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name' => 'Dummy with relations', + 'relatedDummy' => 'http://example.com/related_dummies/1', + 'relatedDummies' => ['/related_dummies/1'], + 'name_converted' => null, + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonContains([ + '@id' => '/dummies/1', + '@type' => 'Dummy', + 'name' => 'Dummy with relations', + 'relatedDummy' => '/related_dummies/1', + 'relatedDummies' => ['/related_dummies/1'], + ]); + } + + public function testFilterOnRelation(): void + { + $this->testCreateDummyWithRelations(); + + $response = self::createClient()->request('GET', '/dummies?relatedDummy=%2Frelated_dummies%2F1'); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertSame('hydra:Collection', $data['@type']); + $this->assertSame(1, $data['hydra:totalItems']); + $this->assertSame('/dummies/1', $data['hydra:member'][0]['@id']); + } + + public function testFilterOnToManyRelation(): void + { + $this->testCreateDummyWithRelations(); + + $response = self::createClient()->request('GET', '/dummies?relatedDummies[]=%2Frelated_dummies%2F1'); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertSame(1, $data['hydra:totalItems']); + $this->assertSame('/dummies/1', $data['hydra:member'][0]['@id']); + } + + public function testEmbedRelationInParent(): void + { + $this->recreateSchema([ThirdLevel::class, RelatedDummy::class, RelationEmbedder::class]); + self::createClient()->request('POST', '/third_levels', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['level' => 3], + ]); + self::createClient()->request('POST', '/related_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['thirdLevel' => '/third_levels/1'], + ]); + + self::createClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['related' => '/related_dummies/1'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonContains([ + '@context' => '/contexts/RelationEmbedder', + '@id' => '/relation_embedders/1', + '@type' => 'RelationEmbedder', + 'krondstadt' => 'Krondstadt', + 'anotherRelated' => null, + 'related' => [ + '@id' => '/related_dummies/1', + '@type' => 'https://schema.org/Product', + 'symfony' => 'symfony', + 'thirdLevel' => [ + '@id' => '/third_levels/1', + '@type' => 'ThirdLevel', + 'level' => 3, + 'fourthLevel' => null, + ], + ], + ]); + } + + public function testPostWrongRelationReturns400(): void + { + $this->recreateSchema([ThirdLevel::class, RelatedDummy::class, RelationEmbedder::class]); + + self::createClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'anotherRelated' => [ + '@id' => '/related_dummies/123', + '@type' => 'https://schema.org/Product', + 'symfony' => 'phalcon', + ], + ], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + } + + public function testPostRelationWithNotExistingIriReturns400(): void + { + $this->recreateSchema([ThirdLevel::class, RelatedDummy::class, RelationEmbedder::class]); + + self::createClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['related' => '/related_dummies/123'], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + } + + public function testInvalidIriReturns400(): void + { + $this->recreateSchema([ThirdLevel::class, RelatedDummy::class, RelationEmbedder::class]); + + self::createClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['related' => 'certainly not an IRI'], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains(['detail' => 'Invalid IRI "certainly not an IRI".']); + } + + public function testInvalidTypeReturns400(): void + { + $this->recreateSchema([ThirdLevel::class, RelatedDummy::class, RelationEmbedder::class]); + + $response = self::createClient()->request('POST', '/relation_embedders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['related' => 8], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $linkHeader = $response->getHeaders(false)['link'][0] ?? ''; + $this->assertStringContainsString('; rel="http://www.w3.org/ns/json-ld#error"', $linkHeader); + $data = $response->toArray(false); + $this->assertMatchesRegularExpression( + '/The type of the "ApiPlatform\\\\Tests\\\\Fixtures\\\\TestBundle\\\\(Document|Entity)\\\\RelatedDummy" resource must be "array" \(nested document\) or "string" \(IRI\), "integer" given\./', + $data['detail'] + ); + } + + public function testEagerLoadOrdersAreNotDuplicated(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Order/Customer/Address fixtures use ORM-specific relations.'); + } + + $this->recreateSchema([Order::class, Customer::class, Address::class]); + + $manager = $this->getManager(); + $customer = new Customer(); + $customer->name = 'customer_name'; + $a1 = new Address(); + $a1->name = 'foo'; + $a2 = new Address(); + $a2->name = 'bar'; + $customer->addresses->add($a1); + $customer->addresses->add($a2); + $manager->persist($a1); + $manager->persist($a2); + $manager->persist($customer); + $manager->flush(); + + $order = new Order(); + $order->customer = $customer; + $order->recipient = $customer; + $manager->persist($order); + $manager->flush(); + + self::createClient()->request('GET', '/orders', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/Order', + '@id' => '/orders', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/orders/1', + '@type' => 'Order', + 'id' => 1, + 'customer' => [ + '@id' => '/customers/1', + '@type' => 'Customer', + 'id' => 1, + 'name' => 'customer_name', + 'addresses' => [ + ['@id' => '/addresses/1', '@type' => 'Address', 'id' => 1, 'name' => 'foo'], + ['@id' => '/addresses/2', '@type' => 'Address', 'id' => 2, 'name' => 'bar'], + ], + ], + 'recipient' => [ + '@id' => '/customers/1', + '@type' => 'Customer', + 'id' => 1, + 'name' => 'customer_name', + 'addresses' => [ + ['@id' => '/addresses/1', '@type' => 'Address', 'id' => 1, 'name' => 'foo'], + ['@id' => '/addresses/2', '@type' => 'Address', 'id' => 2, 'name' => 'bar'], + ], + ], + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testIssue1222PeopleWithPets(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Person/Pet/PersonToPet fixtures use ORM-specific relations.'); + } + + $this->recreateSchema([Person::class, Pet::class, PersonToPet::class]); + + $manager = $this->getManager(); + $person = new Person(); + $person->name = 'foo'; + $manager->persist($person); + $pet = new Pet(); + $pet->name = 'bar'; + $manager->persist($pet); + $manager->flush(); + $personToPet = new PersonToPet(); + $personToPet->person = $person; + $personToPet->pet = $pet; + $manager->persist($personToPet); + $manager->flush(); + $manager->clear(); + + self::createClient()->request('GET', '/people', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains([ + '@context' => '/contexts/Person', + '@id' => '/people', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/people/1', + '@type' => 'Person', + 'name' => 'foo', + 'pets' => [[ + '@type' => 'PersonToPet', + 'pet' => [ + '@id' => '/pets/1', + '@type' => 'Pet', + 'name' => 'bar', + ], + ]], + ]], + 'hydra:totalItems' => 1, + ]); + } +} diff --git a/tests/Functional/StandardPutTest.php b/tests/Functional/StandardPutTest.php new file mode 100644 index 00000000000..e3910a89c94 --- /dev/null +++ b/tests/Functional/StandardPutTest.php @@ -0,0 +1,189 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\StandardPut; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UidIdentified; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class StandardPutTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [StandardPut::class, UidIdentified::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('UidIdentified fixture has no MongoDB document twin.'); + } + + $this->recreateSchema([StandardPut::class, UidIdentified::class]); + } + + public function testCreateWithPut(): void + { + self::createClient()->request('PUT', '/standard_puts/5', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'a', 'bar' => 'b'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonEquals([ + '@context' => '/contexts/StandardPut', + '@id' => '/standard_puts/5', + '@type' => 'StandardPut', + 'id' => 5, + 'foo' => 'a', + 'bar' => 'b', + ]); + } + + public function testCreateWithPutAndJsonLdAttributes(): void + { + self::createClient()->request('PUT', '/standard_puts/6', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + '@id' => '/standard_puts/6', + '@context' => '/contexts/StandardPut', + '@type' => 'StandardPut', + 'foo' => 'a', + 'bar' => 'b', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonEquals([ + '@context' => '/contexts/StandardPut', + '@id' => '/standard_puts/6', + '@type' => 'StandardPut', + 'id' => 6, + 'foo' => 'a', + 'bar' => 'b', + ]); + } + + public function testFailsWhenJsonLdIdRefersToWrongResource(): void + { + self::createClient()->request('PUT', '/standard_puts/7', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + '@id' => '/dummies/6', + '@context' => '/contexts/StandardPut', + '@type' => 'StandardPut', + 'foo' => 'a', + 'bar' => 'b', + ], + ]); + + $this->assertResponseStatusCodeSame(400); + } + + public function testFailsWhenJsonLdIdDoesNotMatchUri(): void + { + self::createClient()->request('PUT', '/standard_puts/7', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + '@id' => '/standard_puts/6', + '@context' => '/contexts/StandardPut', + '@type' => 'StandardPut', + 'foo' => 'a', + 'bar' => 'b', + ], + ]); + + $this->assertResponseStatusCodeSame(400); + } + + public function testReplaceExistingWithPut(): void + { + self::createClient()->request('PUT', '/standard_puts/5', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'a', 'bar' => 'b'], + ]); + + self::createClient()->request('PUT', '/standard_puts/5', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'c'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/StandardPut', + '@id' => '/standard_puts/5', + '@type' => 'StandardPut', + 'id' => 5, + 'foo' => 'c', + 'bar' => '', + ]); + } + + public function testCreateWithPutAndUidIdentifier(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + self::createClient()->request('PUT', '/uid_identifieds/fbcf5910-d915-4f7d-ba39-6b2957c57335', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'test'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertJsonEquals([ + '@context' => '/contexts/UidIdentified', + '@id' => '/uid_identifieds/fbcf5910-d915-4f7d-ba39-6b2957c57335', + '@type' => 'UidIdentified', + 'id' => 'fbcf5910-d915-4f7d-ba39-6b2957c57335', + 'name' => 'test', + ]); + } + + public function testReplaceExistingUidIdentifier(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + self::createClient()->request('PUT', '/uid_identifieds/fbcf5910-d915-4f7d-ba39-6b2957c57335', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'test'], + ]); + + self::createClient()->request('PUT', '/uid_identifieds/fbcf5910-d915-4f7d-ba39-6b2957c57335', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'bar'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/UidIdentified', + '@id' => '/uid_identifieds/fbcf5910-d915-4f7d-ba39-6b2957c57335', + '@type' => 'UidIdentified', + 'id' => 'fbcf5910-d915-4f7d-ba39-6b2957c57335', + 'name' => 'bar', + ]); + } +} diff --git a/tests/Functional/SubResource/SubResourceTest.php b/tests/Functional/SubResource/SubResourceTest.php new file mode 100644 index 00000000000..45d79665fae --- /dev/null +++ b/tests/Functional/SubResource/SubResourceTest.php @@ -0,0 +1,596 @@ + + * + * 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\SubResource; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\SubresourceBike; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\SubresourceCategory; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Answer; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyAggregateOffer; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyOffer; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProduct; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FourthLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Greeting; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\OneToOneSubresourceAnswer; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\OneToOneSubresourceQuestion; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Person; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Question; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedOwnedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedOwningDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SubresourceEmployee; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SubresourceFactory; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SubresourceOrganization; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; + +final class SubResourceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Question::class, + Answer::class, + OneToOneSubresourceQuestion::class, + OneToOneSubresourceAnswer::class, + FourthLevel::class, + ThirdLevel::class, + RelatedDummy::class, + Dummy::class, + RelatedOwnedDummy::class, + RelatedOwningDummy::class, + DummyProduct::class, + DummyAggregateOffer::class, + DummyOffer::class, + Person::class, + Greeting::class, + SubresourceOrganization::class, + SubresourceEmployee::class, + SubresourceFactory::class, + SubresourceCategory::class, + SubresourceBike::class, + ]; + } + + private function seedAnswerToQuestion(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Subresource Question/Answer fixtures use ORM-specific relations.'); + } + + $this->recreateSchema([Question::class, Answer::class]); + + $manager = $this->getManager(); + $answer = new Answer(); + $answer->setContent('42'); + + $question = new Question(); + $question->setContent("What's the answer to the Ultimate Question of Life, the Universe and Everything?"); + $question->setAnswer($answer); + $answer->addRelatedQuestion($question); + + $manager->persist($answer); + $manager->persist($question); + $manager->flush(); + $manager->clear(); + } + + private function seedOneToOneSubresource(): void + { + $this->recreateSchema([OneToOneSubresourceQuestion::class, OneToOneSubresourceAnswer::class]); + + $manager = $this->getManager(); + $answer = new OneToOneSubresourceAnswer(); + $answer->setContent('42'); + + $question = new OneToOneSubresourceQuestion(); + $question->setContent("What's the answer to the Ultimate Question of Life, the Universe and Everything?"); + $question->setAnswer($answer); + $answer->setQuestion($question); + + $manager->persist($answer); + $manager->persist($question); + $manager->flush(); + $manager->clear(); + } + + private function seedDummyWithFourthLevel(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Nested subresource fixtures use ORM-specific relations.'); + } + + $this->recreateSchema([Dummy::class, RelatedDummy::class, ThirdLevel::class, FourthLevel::class]); + + $manager = $this->getManager(); + $fourthLevel = new FourthLevel(); + $fourthLevel->setLevel(4); + $manager->persist($fourthLevel); + + $thirdLevel = new ThirdLevel(); + $thirdLevel->setLevel(3); + $thirdLevel->setFourthLevel($fourthLevel); + $manager->persist($thirdLevel); + + $named = new RelatedDummy(); + $named->setName('Hello'); + $named->setThirdLevel($thirdLevel); + $manager->persist($named); + + $other = new RelatedDummy(); + $other->setThirdLevel($thirdLevel); + $manager->persist($other); + + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + $dummy->setRelatedDummy($named); + $dummy->addRelatedDummy($named); + $dummy->addRelatedDummy($other); + $manager->persist($dummy); + + $manager->flush(); + } + + public function testGetOneToOneSubResource(): void + { + $this->seedAnswerToQuestion(); + + $response = self::createClient()->request('GET', '/questions/1/answer', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/Answer', + '@id' => '/questions/1/answer', + '@type' => 'Answer', + 'id' => 1, + 'content' => '42', + 'relatedQuestions' => ['/questions/1'], + ]); + } + + public function testOneToOneSubresourceExposesInverseSideBackIri(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('OneToOneSubresource fixtures are ORM-only.'); + } + + $this->seedOneToOneSubresource(); + + self::createClient()->request('GET', '/one_to_one_subresource_questions/1/answer', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/OneToOneSubresourceAnswer', + '@id' => '/one_to_one_subresource_questions/1/answer', + '@type' => 'OneToOneSubresourceAnswer', + 'id' => 1, + 'content' => '42', + 'question' => '/one_to_one_subresource_questions/1', + ]); + } + + public function testGetNonExistentSubResourceReturns404(): void + { + $this->seedAnswerToQuestion(); + + self::createClient()->request('GET', '/questions/999999/answer'); + + $this->assertResponseStatusCodeSame(404); + } + + public function testGetRecursiveSubResource(): void + { + $this->seedAnswerToQuestion(); + + self::createClient()->request('GET', '/questions/1/answer/related_questions', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@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, + ]); + } + + public function testGetSubResourceCollection(): void + { + $this->seedDummyWithFourthLevel(); + + $response = self::createClient()->request('GET', '/dummies/1/related_dummies'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('/dummies/1/related_dummies', $data['@id']); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertSame('/related_dummies/1', $data['hydra:member'][0]['@id']); + $this->assertSame('Hello', $data['hydra:member'][0]['name']); + $this->assertSame('/related_dummies/2', $data['hydra:member'][1]['@id']); + $this->assertNull($data['hydra:member'][1]['name']); + } + + public function testGetFilteredSubResourceCollection(): void + { + $this->seedDummyWithFourthLevel(); + + $response = self::createClient()->request('GET', '/dummies/1/related_dummies?name=Hello'); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertSame(1, $data['hydra:totalItems']); + $this->assertSame('Hello', $data['hydra:member'][0]['name']); + } + + public function testGetSubResourceItem(): void + { + $this->seedDummyWithFourthLevel(); + + self::createClient()->request('GET', '/dummies/1/related_dummies/2'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/RelatedDummy', + '@id' => '/dummies/1/related_dummies/2', + '@type' => 'https://schema.org/Product', + 'id' => 2, + 'name' => null, + ]); + } + + public function testCreateDummyWithSubResourceRelation(): void + { + $this->seedDummyWithFourthLevel(); + + self::createClient()->request('POST', '/dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Dummy with relations', 'relatedDummy' => '/dummies/1/related_dummies/2'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testGetEmbeddedRelationAtThirdLevel(): void + { + $this->seedDummyWithFourthLevel(); + + $response = self::createClient()->request('GET', '/dummies/1/related_dummies/1/third_level'); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertSame('/contexts/ThirdLevel', $data['@context']); + $this->assertSame('/dummies/1/related_dummies/1/third_level', $data['@id']); + $this->assertSame('ThirdLevel', $data['@type']); + $this->assertSame('/fourth_levels/1', $data['fourthLevel']); + $this->assertSame(1, $data['id']); + $this->assertSame(3, $data['level']); + $this->assertTrue($data['test']); + } + + public function testGetEmbeddedRelationAtFourthLevel(): void + { + $this->seedDummyWithFourthLevel(); + + $response = self::createClient()->request('GET', '/dummies/1/related_dummies/1/third_level/fourth_level', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/FourthLevel', + '@id' => '/dummies/1/related_dummies/1/third_level/fourth_level', + '@type' => 'FourthLevel', + 'badThirdLevel' => [], + 'id' => 1, + 'level' => 4, + ]); + } + + private function seedProductWithOffers(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Product/Offer fixtures use ORM-specific relations.'); + } + + $this->recreateSchema([DummyProduct::class, DummyAggregateOffer::class, DummyOffer::class]); + + $manager = $this->getManager(); + $offer = new DummyOffer(); + $offer->setId(1); + $offer->setValue(2); + + $aggregate = new DummyAggregateOffer(); + $aggregate->setValue(1); + $aggregate->addOffer($offer); + + $product = new DummyProduct(); + $product->setId(2); + $product->setName('Dummy product'); + $product->addOffer($aggregate); + + $relatedProduct = new DummyProduct(); + $relatedProduct->setName('Dummy related product'); + $relatedProduct->setId(1); + $relatedProduct->setParent($product); + $product->addRelatedProduct($relatedProduct); + + $manager->persist($offer); + $manager->persist($aggregate); + $manager->persist($product); + $manager->persist($relatedProduct); + $manager->flush(); + } + + public function testGetOffersFromAggregateOffers(): void + { + $this->seedProductWithOffers(); + + self::createClient()->request('GET', '/dummy_products/2/offers/1/offers'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/DummyOffer', + '@id' => '/dummy_products/2/offers/1/offers', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/dummy_offers/1', + '@type' => 'DummyOffer', + 'id' => 1, + 'value' => 2, + 'aggregate' => '/dummy_aggregate_offers/1', + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testGetOffersFromAggregateOffersDirect(): void + { + $this->seedProductWithOffers(); + + self::createClient()->request('GET', '/dummy_aggregate_offers/1/offers'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/DummyOffer', + '@id' => '/dummy_aggregate_offers/1/offers', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/dummy_offers/1', + '@type' => 'DummyOffer', + 'id' => 1, + 'value' => 2, + 'aggregate' => '/dummy_aggregate_offers/1', + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testRecursiveResource(): void + { + $this->seedProductWithOffers(); + + self::createClient()->request('GET', '/dummy_products/2'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/DummyProduct', + '@id' => '/dummy_products/2', + '@type' => 'DummyProduct', + 'offers' => ['/dummy_aggregate_offers/1'], + 'id' => 2, + 'name' => 'Dummy product', + 'relatedProducts' => ['/dummy_products/1'], + 'parent' => null, + ]); + } + + public function testPersonSentGreetings(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Person/Greeting fixtures use ORM-specific relations.'); + } + + $this->recreateSchema([Person::class, Greeting::class]); + + $manager = $this->getManager(); + $person = new Person(); + $person->name = 'Alice'; + + $greeting = new Greeting(); + $greeting->message = 'hello'; + $greeting->sender = $person; + $manager->persist($person); + $manager->persist($greeting); + $manager->flush(); + + self::createClient()->request('GET', '/people/1/sent_greetings'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/Greeting', + '@id' => '/people/1/sent_greetings', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/greetings/1', + '@type' => 'Greeting', + 'message' => 'hello', + 'sender' => '/people/1', + 'recipient' => null, + 'id' => 1, + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testOneToOneFromOwnedSide(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('RelatedOwnedDummy fixtures use ORM-specific relations.'); + } + + $this->recreateSchema([Dummy::class, RelatedOwnedDummy::class]); + + $manager = $this->getManager(); + $relatedOwned = new RelatedOwnedDummy(); + $manager->persist($relatedOwned); + + $dummy = new Dummy(); + $dummy->setName('plop'); + $dummy->setRelatedOwnedDummy($relatedOwned); + $manager->persist($dummy); + $manager->flush(); + + self::createClient()->request('GET', '/related_owned_dummies/1/owning_dummy'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains([ + '@context' => '/contexts/Dummy', + '@id' => '/related_owned_dummies/1/owning_dummy', + '@type' => 'Dummy', + 'name' => 'plop', + 'relatedOwnedDummy' => '/related_owned_dummies/1', + 'relatedOwningDummy' => null, + 'id' => 1, + ]); + } + + public function testOneToOneFromOwningSide(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('RelatedOwningDummy fixtures use ORM-specific relations.'); + } + + $this->recreateSchema([Dummy::class, RelatedOwningDummy::class]); + + $manager = $this->getManager(); + $dummy = new Dummy(); + $dummy->setName('plop'); + $manager->persist($dummy); + + $relatedOwning = new RelatedOwningDummy(); + $relatedOwning->setOwnedDummy($dummy); + $manager->persist($relatedOwning); + $manager->flush(); + + self::createClient()->request('GET', '/related_owning_dummies/1/owned_dummy'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonContains([ + '@context' => '/contexts/Dummy', + '@id' => '/related_owning_dummies/1/owned_dummy', + '@type' => 'Dummy', + 'name' => 'plop', + 'relatedOwningDummy' => '/related_owning_dummies/1', + 'relatedOwnedDummy' => null, + 'id' => 1, + ]); + } + + public static function subresourceCrudUris(): iterable + { + yield 'employees' => [ + '/subresource_organizations/invalid/subresource_employees', + '/subresource_organizations/1/subresource_employees', + '/subresource_organizations/1/subresource_employees/1', + ]; + yield 'factories' => [ + '/subresource_organizations/invalid/subresource_factories', + '/subresource_organizations/1/subresource_factories', + '/subresource_organizations/1/subresource_factories/1', + ]; + } + + #[DataProvider('subresourceCrudUris')] + public function testGeneratedSubresourceCrud(string $invalidUri, string $collectionUri, string $itemUri): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema([SubresourceOrganization::class, SubresourceEmployee::class, SubresourceFactory::class]); + + $client = self::createClient(); + $headers = ['Content-Type' => 'application/ld+json']; + + $client->request('POST', '/subresource_organizations', ['headers' => $headers, 'json' => ['name' => 'Les Tilleuls']]); + $this->assertResponseStatusCodeSame(201); + + $client->request('POST', $invalidUri, ['headers' => $headers, 'json' => ['name' => 'soyuka']]); + $this->assertResponseStatusCodeSame(404); + + $client->request('POST', $collectionUri, ['headers' => $headers, 'json' => ['name' => 'soyuka']]); + $this->assertResponseStatusCodeSame(201); + + $client->request('GET', $itemUri); + $this->assertResponseStatusCodeSame(200); + + $client->request('GET', $collectionUri); + $this->assertResponseStatusCodeSame(200); + + $client->request('PUT', $itemUri, ['headers' => $headers, 'json' => ['name' => 'ok']]); + $this->assertResponseStatusCodeSame(200); + + $client->request('DELETE', $itemUri); + $this->assertResponseStatusCodeSame(204); + } + + public function testCreateProviderSubresource(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + self::createClient()->request('POST', '/subresource_categories/1/subresource_bikes', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Hello World!'], + ]); + $this->assertResponseStatusCodeSame(404); + + self::createClient()->request('POST', '/subresource_categories_with_create_provider/1/subresource_bikes', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Hello World!'], + ]); + $this->assertResponseStatusCodeSame(201); + } +} diff --git a/tests/Functional/TableInheritanceTest.php b/tests/Functional/TableInheritanceTest.php new file mode 100644 index 00000000000..20e0189f116 --- /dev/null +++ b/tests/Functional/TableInheritanceTest.php @@ -0,0 +1,301 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AbstractUser; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritance; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceChild; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceDifferentChild; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceNotApiResourceChild; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceRelated; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ExternalUser; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\InternalUser; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Site; +use ApiPlatform\Tests\Fixtures\TestBundle\Model\ResourceInterface; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class TableInheritanceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + DummyTableInheritance::class, + DummyTableInheritanceChild::class, + DummyTableInheritanceDifferentChild::class, + DummyTableInheritanceRelated::class, + ResourceInterface::class, + Site::class, + AbstractUser::class, + ExternalUser::class, + ]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Table inheritance fixtures are ORM-only.'); + } + + $this->recreateSchema([ + DummyTableInheritance::class, + DummyTableInheritanceChild::class, + DummyTableInheritanceDifferentChild::class, + DummyTableInheritanceNotApiResourceChild::class, + DummyTableInheritanceRelated::class, + Site::class, + InternalUser::class, + ExternalUser::class, + ]); + } + + private function createChild(string $name = 'foo', string $nickname = 'bar'): array + { + $response = self::createClient()->request('POST', '/dummy_table_inheritance_children', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => $name, 'nickname' => $nickname], + ]); + + return $response->toArray(); + } + + public function testCreateChildResource(): void + { + $data = $this->createChild(); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertSame('DummyTableInheritanceChild', $data['@type']); + $this->assertSame('/contexts/DummyTableInheritanceChild', $data['@context']); + $this->assertSame('/dummy_table_inheritance_children/1', $data['@id']); + $this->assertSame('foo', $data['name']); + $this->assertSame('bar', $data['nickname']); + } + + public function testParentCollectionExposesChildren(): void + { + $this->createChild(); + + $response = self::createClient()->request('GET', '/dummy_table_inheritances'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertCount(1, $data['hydra:member']); + $this->assertSame('DummyTableInheritanceChild', $data['hydra:member'][0]['@type']); + $this->assertSame('/dummy_table_inheritance_children/1', $data['hydra:member'][0]['@id']); + } + + public function testNonApiResourceChildAppearsAsParent(): void + { + $this->createChild(); + $manager = $this->getManager(); + $notApi = new DummyTableInheritanceNotApiResourceChild(); + $notApi->setName('Foobarbaz inheritance'); + $manager->persist($notApi); + $manager->flush(); + + $response = self::createClient()->request('GET', '/dummy_table_inheritances'); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertCount(2, $data['hydra:member']); + $this->assertSame('DummyTableInheritanceChild', $data['hydra:member'][0]['@type']); + $this->assertSame('DummyTableInheritance', $data['hydra:member'][1]['@type']); + $this->assertSame('/dummy_table_inheritances/2', $data['hydra:member'][1]['@id']); + $this->assertSame(2, $data['hydra:totalItems']); + } + + public function testCreateDifferentChildResource(): void + { + $response = self::createClient()->request('POST', '/dummy_table_inheritance_different_children', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'foo', 'email' => 'bar@localhost'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('DummyTableInheritanceDifferentChild', $data['@type']); + $this->assertSame('/contexts/DummyTableInheritanceDifferentChild', $data['@context']); + $this->assertSame('foo', $data['name']); + $this->assertSame('bar@localhost', $data['email']); + } + + public function testRelatedEntityWithMixedInheritedChildren(): void + { + $child = $this->createChild(); + $different = self::createClient()->request('POST', '/dummy_table_inheritance_different_children', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'foo', 'email' => 'bar@localhost'], + ])->toArray(); + + $response = self::createClient()->request('POST', '/dummy_table_inheritance_relateds', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['children' => [$child['@id'], $different['@id']]], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('DummyTableInheritanceRelated', $data['@type']); + $this->assertSame('/dummy_table_inheritance_relateds/1', $data['@id']); + $this->assertCount(2, $data['children']); + $this->assertSame('DummyTableInheritanceChild', $data['children'][0]['@type']); + $this->assertSame('DummyTableInheritanceDifferentChild', $data['children'][1]['@type']); + } + + public function testParentCollectionMixesChildrenTypes(): void + { + $this->createChild('foo', 'bar'); + $manager = $this->getManager(); + $notApi = new DummyTableInheritanceNotApiResourceChild(); + $notApi->setName('Foobarbaz inheritance'); + $manager->persist($notApi); + $manager->flush(); + $this->createChild('foo2', 'bar2'); + self::createClient()->request('POST', '/dummy_table_inheritance_different_children', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'foo', 'email' => 'bar@localhost'], + ]); + + $response = self::createClient()->request('GET', '/dummy_table_inheritances?pagination=false'); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + $this->assertSame(4, $data['hydra:totalItems']); + $types = array_column($data['hydra:member'], '@type'); + $this->assertContains('DummyTableInheritanceChild', $types); + $this->assertContains('DummyTableInheritance', $types); + $this->assertContains('DummyTableInheritanceDifferentChild', $types); + } + + public function testInterfaceCollection(): void + { + $response = self::createClient()->request('GET', '/resource_interfaces', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $members = $data['hydra:member']; + $this->assertCount(2, $members); + $this->assertSame('ResourceInterface', $members[0]['@type']); + $this->assertSame('/resource_interfaces/item1', $members[0]['@id']); + $this->assertSame('item1', $members[0]['foo']); + $this->assertSame('fooz', $members[0]['fooz']); + $this->assertSame('ResourceInterface', $members[1]['@type']); + $this->assertSame('/resource_interfaces/item2', $members[1]['@id']); + $this->assertSame('item2', $members[1]['foo']); + $this->assertSame('fooz', $members[1]['fooz']); + } + + public function testInterfaceItem(): void + { + $response = self::createClient()->request('GET', '/resource_interfaces/some-id', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('/contexts/ResourceInterface', $data['@context']); + $this->assertSame('/resource_interfaces/single%20item', $data['@id']); + $this->assertSame('ResourceInterface', $data['@type']); + $this->assertSame('single item', $data['foo']); + $this->assertSame('fooz', $data['fooz']); + } + + public function testSitesWithInternalOwnerUseParentIri(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $manager = $this->getManager(); + for ($i = 1; $i <= 3; ++$i) { + $user = new InternalUser(); + $user->setFirstname('Internal'); + $user->setLastname('User'); + $user->setEmail('john.doe@example.com'); + $user->setInternalId('INT'); + $site = new Site(); + $site->setTitle('title'); + $site->setDescription('description'); + $site->setOwner($user); + $manager->persist($site); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/sites', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertCount(3, $data['hydra:member']); + foreach ($data['hydra:member'] as $i => $member) { + $this->assertSame('Site', $member['@type']); + $this->assertSame('/sites/'.($i + 1), $member['@id']); + $this->assertSame('title', $member['title']); + $this->assertSame('description', $member['description']); + $ownerIri = \is_string($member['owner']) ? $member['owner'] : $member['owner']['@id']; + $this->assertSame('/custom_users/'.($i + 1), $ownerIri); + } + } + + public function testSitesWithExternalOwnerUseCurrentResourceIri(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $manager = $this->getManager(); + for ($i = 1; $i <= 3; ++$i) { + $user = new ExternalUser(); + $user->setFirstname('External'); + $user->setLastname('User'); + $user->setEmail('john.doe@example.com'); + $user->setExternalId('EXT'); + $site = new Site(); + $site->setTitle('title'); + $site->setDescription('description'); + $site->setOwner($user); + $manager->persist($site); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/sites', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $data = $response->toArray(); + foreach ($data['hydra:member'] as $i => $member) { + $ownerIri = \is_string($member['owner']) ? $member['owner'] : $member['owner']['@id']; + $this->assertSame('/external_users/'.($i + 1), $ownerIri); + } + } +} diff --git a/tests/Functional/UnionIntersectTypesTest.php b/tests/Functional/UnionIntersectTypesTest.php new file mode 100644 index 00000000000..3d04fe4ad47 --- /dev/null +++ b/tests/Functional/UnionIntersectTypesTest.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Author; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Book; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5452\Library; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class UnionIntersectTypesTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Book::class, Author::class, Library::class]; + } + + public function testCreateBookWithUnionTypeNumberAsString(): void + { + $response = self::createClient()->request('POST', '/issue-5452/books', [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'json' => ['number' => '1', 'isbn' => '978-3-16-148410-0'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('Book', $data['@type']); + $this->assertSame('/contexts/Book', $data['@context']); + $this->assertMatchesRegularExpression('#^/.well-known/genid/.+$#', $data['@id']); + $this->assertSame('1', $data['number']); + $this->assertSame('978-3-16-148410-0', $data['isbn']); + } + + public function testCreateBookWithUnionTypeNumberAsInteger(): void + { + $response = self::createClient()->request('POST', '/issue-5452/books', [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'json' => ['number' => 1, 'isbn' => '978-3-16-148410-0'], + ]); + + $this->assertResponseStatusCodeSame(201); + $data = $response->toArray(); + $this->assertSame(1, $data['number']); + $this->assertSame('978-3-16-148410-0', $data['isbn']); + } + + public function testCreateBookWithValidIntersectType(): void + { + $response = self::createClient()->request('POST', '/issue-5452/books', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'number' => 1, + 'isbn' => '978-3-16-148410-0', + 'author' => '/issue-5452/authors/1', + ], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('Book', $data['@type']); + $this->assertSame(1, $data['number']); + $this->assertSame('978-3-16-148410-0', $data['isbn']); + $this->assertSame('/issue-5452/authors/1', $data['author']); + } + + public function testCreateBookWithInvalidIntersectTypeReturns400(): void + { + self::createClient()->request('POST', '/issue-5452/books', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'number' => 1, + 'isbn' => '978-3-16-148410-0', + 'library' => '/issue-5452/libraries/1', + ], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + 'detail' => 'Could not denormalize object of type "ApiPlatform\\Tests\\Fixtures\\TestBundle\\ApiResource\\Issue5452\\ActivableInterface", no supporting normalizer found.', + ]); + } +} diff --git a/tests/Functional/UrlEncodedIdTest.php b/tests/Functional/UrlEncodedIdTest.php new file mode 100644 index 00000000000..aba8e157671 --- /dev/null +++ b/tests/Functional/UrlEncodedIdTest.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\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UrlEncodedId; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; + +final class UrlEncodedIdTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [UrlEncodedId::class]; + } + + public static function urlVariants(): iterable + { + yield 'raw colon and percent' => ['/url_encoded_ids/%encode:id']; + yield 'fully encoded' => ['/url_encoded_ids/%25encode%3Aid']; + yield 'encoded percent only' => ['/url_encoded_ids/%25encode:id']; + yield 'encoded colon only' => ['/url_encoded_ids/%encode%3Aid']; + } + + #[DataProvider('urlVariants')] + public function testGetEncodedIdWhetherOrNotEncoded(string $url): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('UrlEncodedId fixture is ORM-only.'); + } + + $this->recreateSchema([UrlEncodedId::class]); + + $client = self::createClient(); + $manager = $this->getManager(); + $entity = new UrlEncodedId(); + $manager->persist($entity); + $manager->flush(); + $manager->clear(); + + $client->request('GET', $url, [ + 'headers' => ['Content-Type' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/UrlEncodedId', + '@id' => '/url_encoded_ids/%25encode:id', + '@type' => 'UrlEncodedId', + 'id' => '%encode:id', + ]); + } +} diff --git a/tests/Functional/Uuid/UuidIdentifierTest.php b/tests/Functional/Uuid/UuidIdentifierTest.php new file mode 100644 index 00000000000..943131ccf3c --- /dev/null +++ b/tests/Functional/Uuid/UuidIdentifierTest.php @@ -0,0 +1,302 @@ + + * + * 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\Uuid; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CustomGeneratedIdentifier; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RamseyUuidDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SymfonyUuidDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UuidIdentifierDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Ramsey\Uuid\Uuid as RamseyUuid; +use Symfony\Component\Uid\Uuid as SymfonyUuid; + +final class UuidIdentifierTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [UuidIdentifierDummy::class, RamseyUuidDummy::class, CustomGeneratedIdentifier::class, SymfonyUuidDummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([UuidIdentifierDummy::class, CustomGeneratedIdentifier::class]); + } + + private function createUuidDummy(): void + { + self::createClient()->request('POST', '/uuid_identifier_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'My Dummy', 'uuid' => '41b29566-144b-11e6-a148-3e1d05defe78'], + ]); + } + + public function testCreateUuidIdentifier(): void + { + $this->createUuidDummy(); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertResponseHeaderSame('Content-Location', '/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78.jsonld'); + $this->assertResponseHeaderSame('Location', '/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78'); + } + + public function testGetUuidItem(): void + { + $this->createUuidDummy(); + + self::createClient()->request('GET', '/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/UuidIdentifierDummy', + '@id' => '/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78', + '@type' => 'UuidIdentifierDummy', + 'uuid' => '41b29566-144b-11e6-a148-3e1d05defe78', + 'name' => 'My Dummy', + ]); + } + + public function testGetUuidCollection(): void + { + $this->createUuidDummy(); + + self::createClient()->request('GET', '/uuid_identifier_dummies'); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals([ + '@context' => '/contexts/UuidIdentifierDummy', + '@id' => '/uuid_identifier_dummies', + '@type' => 'hydra:Collection', + 'hydra:member' => [[ + '@id' => '/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78', + '@type' => 'UuidIdentifierDummy', + 'uuid' => '41b29566-144b-11e6-a148-3e1d05defe78', + 'name' => 'My Dummy', + ]], + 'hydra:totalItems' => 1, + ]); + } + + public function testPutUuidIdentifier(): void + { + $this->createUuidDummy(); + + self::createClient()->request('PUT', '/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'My Dummy modified'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Location', '/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78.jsonld'); + $this->assertJsonEquals([ + '@context' => '/contexts/UuidIdentifierDummy', + '@id' => '/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78', + '@type' => 'UuidIdentifierDummy', + 'uuid' => '41b29566-144b-11e6-a148-3e1d05defe78', + 'name' => 'My Dummy modified', + ]); + } + + public function testCustomGeneratedIdentifier(): void + { + self::createClient()->request('POST', '/custom_generated_identifiers', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => new \stdClass(), + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Location', '/custom_generated_identifiers/foo.jsonld'); + $this->assertResponseHeaderSame('Location', '/custom_generated_identifiers/foo'); + $this->assertJsonEquals([ + '@context' => '/contexts/CustomGeneratedIdentifier', + '@id' => '/custom_generated_identifiers/foo', + '@type' => 'CustomGeneratedIdentifier', + 'id' => 'foo', + ]); + } + + public function testDeleteUuid(): void + { + $this->createUuidDummy(); + + self::createClient()->request('DELETE', '/uuid_identifier_dummies/41b29566-144b-11e6-a148-3e1d05defe78'); + + $this->assertResponseStatusCodeSame(204); + } + + public function testGetRamseyUuidDummy(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + $this->recreateSchema([RamseyUuidDummy::class]); + + $manager = $this->getManager(); + $dummy = new RamseyUuidDummy(RamseyUuid::fromString('41B29566-144B-11E6-A148-3E1D05DEFE78')); + $manager->persist($dummy); + $manager->flush(); + + self::createClient()->request('GET', '/ramsey_uuid_dummies/41B29566-144B-11E6-A148-3E1D05DEFE78'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testDeleteRamseyUuidDummy(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + $this->recreateSchema([RamseyUuidDummy::class]); + + $manager = $this->getManager(); + $dummy = new RamseyUuidDummy(RamseyUuid::fromString('41B29566-144B-11E6-A148-3E1D05DEFE78')); + $manager->persist($dummy); + $manager->flush(); + + self::createClient()->request('DELETE', '/ramsey_uuid_dummies/41B29566-144B-11E6-A148-3E1D05DEFE78'); + + $this->assertResponseStatusCodeSame(204); + } + + public function testRetrieveBadRamseyUuidReturns404(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + self::createClient()->request('GET', '/ramsey_uuid_dummies/41B29566-144B-E1D05DEFE78'); + + $this->assertResponseStatusCodeSame(404); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + } + + public function testCreateRamseyUuidDummy(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + $this->recreateSchema([RamseyUuidDummy::class]); + + self::createClient()->request('POST', '/ramsey_uuid_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['id' => '41b29566-144b-11e6-a148-3e1d05defe78'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testCreateRamseyUuidDummyWithNonIdField(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + $this->recreateSchema([RamseyUuidDummy::class]); + + self::createClient()->request('POST', '/ramsey_uuid_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['other' => '51b29566-144b-11e6-a148-3e1d05defe78'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testUpdateRamseyUuidNonIdField(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + $this->recreateSchema([RamseyUuidDummy::class]); + + $manager = $this->getManager(); + $dummy = new RamseyUuidDummy(RamseyUuid::fromString('41b29566-144b-11e6-a148-3e1d05defe78')); + $manager->persist($dummy); + $manager->flush(); + + self::createClient()->request('PUT', '/ramsey_uuid_dummies/41b29566-144b-11e6-a148-3e1d05defe78', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['other' => '61b29566-144b-11e6-a148-3e1d05defe78'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testCreateBadRamseyUuidReturns400(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + $this->recreateSchema([RamseyUuidDummy::class]); + + self::createClient()->request('POST', '/ramsey_uuid_dummies', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['id' => '41b29566-144b-e1d05defe78'], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + } + + public function testUpdateBadRamseyUuidReturns400(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + $this->recreateSchema([RamseyUuidDummy::class]); + + $manager = $this->getManager(); + $dummy = new RamseyUuidDummy(RamseyUuid::fromString('41b29566-144b-11e6-a148-3e1d05defe78')); + $manager->persist($dummy); + $manager->flush(); + + self::createClient()->request('PUT', '/ramsey_uuid_dummies/41b29566-144b-11e6-a148-3e1d05defe78', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['other' => '61b29566-144b-e1d05defe78'], + ]); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + } + + public function testGetSymfonyUuidDummy(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + $this->recreateSchema([SymfonyUuidDummy::class]); + + $manager = $this->getManager(); + $dummy = new SymfonyUuidDummy(SymfonyUuid::fromString('cdf8f706-ebe3-4fb6-b0bd-ae7b48028f24')); + $manager->persist($dummy); + $manager->flush(); + + self::createClient()->request('GET', '/symfony_uuid_dummies/cdf8f706-ebe3-4fb6-b0bd-ae7b48028f24'); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } +} diff --git a/tests/Functional/ValidationGroupsTest.php b/tests/Functional/ValidationGroupsTest.php new file mode 100644 index 00000000000..ee2bc3cb5a5 --- /dev/null +++ b/tests/Functional/ValidationGroupsTest.php @@ -0,0 +1,131 @@ + + * + * 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\Tests\Fixtures\TestBundle\Entity\DummyValidation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyValidationSerializedName; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5912\Dummy as Issue5912Dummy; + +final class ValidationGroupsTest extends \ApiPlatform\Symfony\Bundle\Test\ApiTestCase +{ + use \ApiPlatform\Tests\RecreateSchemaTrait; + use \ApiPlatform\Tests\SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyValidation::class, DummyValidationSerializedName::class, Issue5912Dummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([DummyValidation::class, DummyValidationSerializedName::class]); + } + + public function testCreateMinimalResourceWithoutGroups(): void + { + self::createClient()->request('POST', '/dummy_validation', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['code' => 'My Dummy'], + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testValidationGroupsTriggerFailure(): void + { + self::createClient()->request('POST', '/dummy_validation/validation_groups', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['code' => 'My Dummy'], + ]); + + $this->assertResponseStatusCodeSame(422); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/ConstraintViolation', + '@type' => 'ConstraintViolation', + 'detail' => 'name: This value should not be null.', + 'violations' => [[ + 'propertyPath' => 'name', + 'message' => 'This value should not be null.', + 'code' => 'ad32d13f-c3d4-423b-909a-857b961eb720', + ]], + ]); + } + + public function testValidationGroupSequence(): void + { + self::createClient()->request('POST', '/dummy_validation/validation_sequence', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['code' => 'My Dummy'], + ]); + + $this->assertResponseStatusCodeSame(422); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + 'detail' => 'title: This value should not be null.', + 'violations' => [[ + 'propertyPath' => 'title', + 'message' => 'This value should not be null.', + 'code' => 'ad32d13f-c3d4-423b-909a-857b961eb720', + ]], + ]); + } + + public function testValidationUsesSerializedNameForPropertyPath(): void + { + $response = self::createClient()->request('POST', '/dummy_validation_serialized_name', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['code' => 'My Dummy'], + ]); + + $this->assertResponseStatusCodeSame(422); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $data = $response->toArray(false); + $this->assertSame('test: This value should not be null.', $data['detail']); + $this->assertSame('test', $data['violations'][0]['propertyPath']); + $this->assertSame('This value should not be null.', $data['violations'][0]['message']); + } + + public function testGetViolationConstraints(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + self::createClient()->request('POST', '/issue5912s', [ + 'headers' => ['Accept' => 'application/json', 'Content-Type' => 'application/json'], + 'json' => ['title' => ''], + ]); + + $this->assertResponseStatusCodeSame(422); + $this->assertResponseHeaderSame('Content-Type', 'application/problem+json; charset=utf-8'); + $this->assertJsonEquals([ + 'status' => 422, + 'violations' => [[ + 'propertyPath' => 'title', + 'message' => 'This value should not be blank.', + 'code' => 'c1051bb4-d103-4f74-8988-acbcafc7fdc3', + ]], + 'detail' => 'title: This value should not be blank.', + 'type' => '/validation_errors/c1051bb4-d103-4f74-8988-acbcafc7fdc3', + 'title' => 'An error occurred', + ]); + } +} From d44867d0b0eeb59f7e6d5cbb2b6398f83326c67a Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 28 May 2026 14:58:28 +0200 Subject: [PATCH 14/84] test: migrate elasticsearch/security/serializer/mongodb behat suites to ApiTestCase (#8202) --- .github/workflows/ci.yml | 14 +- behat.yml.dist | 43 - composer.json | 4 +- ...embed_many_without_target_document.feature | 60 -- features/mongodb/filters.feature | 27 - features/security/README.md | 108 --- .../security/send_security_headers.feature | 32 - features/security/strong_typing.feature | 162 ---- features/security/unknown_attributes.feature | 43 - .../validate_incoming_content-types.feature | 17 - .../security/validate_response_types.feature | 38 - ...erialize_objects_using_constructor.feature | 41 - features/serializer/dynamic_groups.feature | 11 - .../serializer/empty_array_as_object.feature | 33 - features/serializer/groups_related.feature | 17 - features/serializer/vo_relations.feature | 209 ----- tests/Behat/ElasticsearchContext.php | 143 ---- .../app/config/config_elasticsearch.yml | 7 - .../Fixtures/app/config/config_opensearch.yml | 7 - .../Elasticsearch/ElasticsearchSetupTrait.php | 112 +++ .../Elasticsearch/MatchFilterTest.php | 239 ++++-- .../Elasticsearch/OrderFilterTest.php | 304 ++++--- .../Functional/Elasticsearch/ReadTest.php | 244 ++++-- .../Elasticsearch/TermFilterTest.php | 352 ++++---- .../EmbedManyWithoutTargetDocumentTest.php | 70 ++ .../NestedReferenceFilterErrorTest.php | 99 +++ .../Security/ContentNegotiationErrorsTest.php | 110 +++ .../Security/SecurityHeadersTest.php | 79 ++ .../Functional/Security/StrongTypingTest.php | 224 ++++++ .../ConstructorDeserializationTest.php | 65 ++ .../Serializer/DynamicGroupsTest.php | 49 ++ .../Serializer/EmptyArrayAsObjectTest.php | 52 ++ .../Functional/Serializer/GroupFilterTest.php | 754 ++++++++++++------ .../Serializer/GroupsRelatedTest.php | 67 ++ .../Serializer/PropertyFilterTest.php | 367 ++++++--- .../Serializer/ValueObjectRelationsTest.php | 270 +++++++ tools/feature_to_phpunit.php | 219 +++++ 37 files changed, 2886 insertions(+), 1806 deletions(-) delete mode 100644 features/mongodb/deserialize_embed_many_without_target_document.feature delete mode 100644 features/mongodb/filters.feature delete mode 100644 features/security/README.md delete mode 100644 features/security/send_security_headers.feature delete mode 100644 features/security/strong_typing.feature delete mode 100644 features/security/unknown_attributes.feature delete mode 100644 features/security/validate_incoming_content-types.feature delete mode 100644 features/security/validate_response_types.feature delete mode 100644 features/serializer/deserialize_objects_using_constructor.feature delete mode 100644 features/serializer/dynamic_groups.feature delete mode 100644 features/serializer/empty_array_as_object.feature delete mode 100644 features/serializer/groups_related.feature delete mode 100644 features/serializer/vo_relations.feature delete mode 100644 tests/Behat/ElasticsearchContext.php create mode 100644 tests/Functional/Elasticsearch/ElasticsearchSetupTrait.php rename features/elasticsearch/match_filter.feature => tests/Functional/Elasticsearch/MatchFilterTest.php (65%) rename features/elasticsearch/order_filter.feature => tests/Functional/Elasticsearch/OrderFilterTest.php (68%) rename features/elasticsearch/read.feature => tests/Functional/Elasticsearch/ReadTest.php (81%) rename features/elasticsearch/term_filter.feature => tests/Functional/Elasticsearch/TermFilterTest.php (57%) create mode 100644 tests/Functional/MongoDb/EmbedManyWithoutTargetDocumentTest.php create mode 100644 tests/Functional/MongoDb/NestedReferenceFilterErrorTest.php create mode 100644 tests/Functional/Security/ContentNegotiationErrorsTest.php create mode 100644 tests/Functional/Security/SecurityHeadersTest.php create mode 100644 tests/Functional/Security/StrongTypingTest.php create mode 100644 tests/Functional/Serializer/ConstructorDeserializationTest.php create mode 100644 tests/Functional/Serializer/DynamicGroupsTest.php create mode 100644 tests/Functional/Serializer/EmptyArrayAsObjectTest.php rename features/serializer/group_filter.feature => tests/Functional/Serializer/GroupFilterTest.php (52%) create mode 100644 tests/Functional/Serializer/GroupsRelatedTest.php rename features/serializer/property_filter.feature => tests/Functional/Serializer/PropertyFilterTest.php (50%) create mode 100644 tests/Functional/Serializer/ValueObjectRelationsTest.php create mode 100644 tools/feature_to_phpunit.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fca06a3f403..27b2dec6098 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -456,7 +456,6 @@ jobs: php: ${{ fromJSON(github.event_name == 'pull_request' && '["8.2","8.5"]' || '["8.2","8.3","8.4","8.5"]') }} shard: - graphql-doctrine - - misc include: - php: '8.5' shard: graphql-doctrine @@ -494,7 +493,6 @@ jobs: run: | case "${{ matrix.shard }}" in graphql-doctrine) paths="features/graphql features/doctrine" ;; - misc) paths="features/filter features/issues features/security features/serializer features/http_cache features/sub_resources features/json features/xml features/push_relations features/mercure" ;; esac echo "paths=$paths" >> $GITHUB_OUTPUT - name: Run Behat tests (PHP ${{ matrix.php }} ${{ matrix.shard }}) @@ -824,7 +822,7 @@ jobs: continue-on-error: true elasticsearch: - name: Behat (PHP ${{ matrix.php }}) (Elasticsearch ${{ matrix.elasticsearch-version }}) + name: PHPUnit (PHP ${{ matrix.php }}) (Elasticsearch ${{ matrix.elasticsearch-version }}) runs-on: ubuntu-22.04 timeout-minutes: 20 strategy: @@ -888,11 +886,11 @@ jobs: fi - name: Clear test app cache run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - run: vendor/bin/behat --out=std --format=progress --profile=elasticsearch --no-interaction + - name: Run PHPUnit tests + run: vendor/bin/phpunit tests/Functional/Elasticsearch/ opensearch: - name: Behat (PHP ${{ matrix.php }}) (OpenSearch ${{ matrix.opensearch-version }}) + name: PHPUnit (PHP ${{ matrix.php }}) (OpenSearch ${{ matrix.opensearch-version }}) runs-on: ubuntu-22.04 timeout-minutes: 20 strategy: @@ -946,8 +944,8 @@ jobs: composer require --dev opensearch-project/opensearch-php "^2.5" -W - name: Clear test app cache run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - run: vendor/bin/behat --out=std --format=progress --profile=opensearch --no-interaction + - name: Run PHPUnit tests + run: vendor/bin/phpunit tests/Functional/Elasticsearch/ phpunit-no-deprecations: name: PHPUnit (PHP ${{ matrix.php }}) (no deprecations) diff --git a/behat.yml.dist b/behat.yml.dist index c96ba8d3a43..a771434c534 100644 --- a/behat.yml.dist +++ b/behat.yml.dist @@ -92,36 +92,6 @@ mercure: filters: tags: '@mercure' -elasticsearch: - suites: - default: false - elasticsearch: &elasticsearch-suite - paths: - - '%paths.base%/features/elasticsearch' - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\ElasticsearchContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '@elasticsearch&&~@mercure&&~@query_parameter_validator' - -opensearch: - suites: - default: false - opensearch: - paths: - - '%paths.base%/features/elasticsearch' - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\ElasticsearchContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '@elasticsearch&&~@mercure&&~@query_parameter_validator' - default-coverage: suites: default: &default-coverage-suite @@ -180,19 +150,6 @@ mercure-coverage: - 'Behat\MinkExtension\Context\MinkContext' - 'behatch:context:rest' -elasticsearch-coverage: - suites: - default: false - elasticsearch: &elasticsearch-coverage-suite - <<: *elasticsearch-suite - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\ElasticsearchContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\CoverageContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - legacy: suites: default: diff --git a/composer.json b/composer.json index 3d30deea4e3..6ae2bbb50fb 100644 --- a/composer.json +++ b/composer.json @@ -130,6 +130,8 @@ "doctrine/common": "^3.2.2", "doctrine/dbal": "^4.0", "doctrine/doctrine-bundle": "^2.11 || ^3.1", + "doctrine/mongodb-odm": "^2.16", + "doctrine/mongodb-odm-bundle": "^5.6", "doctrine/orm": "^2.17 || ^3.0", "elasticsearch/elasticsearch": "^7.17 || ^8.4 || ^9.0", "friends-of-behat/mink-browserkit-driver": "^1.3.1", @@ -146,8 +148,8 @@ "illuminate/support": "^11.0 || ^12.0 || ^13.0", "jangregor/phpstan-prophecy": "^2.1.11", "justinrainbow/json-schema": "^6.5.2", - "mcp/sdk": ">=0.4 <1.0", "laravel/framework": "^11.0 || ^12.0 || ^13.0", + "mcp/sdk": ">=0.4 <1.0", "orchestra/testbench": "^10.9 || ^11.0", "phpspec/prophecy-phpunit": "^2.2", "phpstan/extension-installer": "^1.1", diff --git a/features/mongodb/deserialize_embed_many_without_target_document.feature b/features/mongodb/deserialize_embed_many_without_target_document.feature deleted file mode 100644 index 5a984a96f3f..00000000000 --- a/features/mongodb/deserialize_embed_many_without_target_document.feature +++ /dev/null @@ -1,60 +0,0 @@ -@mongodb -Feature: Embed many without target document deserializable - In order to create and update resources - As a developer - I need to be able to deserialize data into objects with embed many that omit target document directive - - @createSchema - Scenario: Post a resource with embedded data - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_with_embed_many_omitting_target_documents" with body: - """ - { - "embeddedDummies": [ - { - "dummyName": "foo", - "dummyBoolean": true, - "dummyDate": "2020-01-01", - "dummyFloat": 0.1, - "dummyPrice": 10 - }, - { - "dummyName": "bar", - "dummyBoolean": false, - "dummyDate": "2021-01-01", - "dummyFloat": 0.2, - "dummyPrice": 20 - } - ] - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "@context": "/contexts/DummyWithEmbedManyOmittingTargetDocument", - "@id": "/dummy_with_embed_many_omitting_target_documents/1", - "@type": "DummyWithEmbedManyOmittingTargetDocument", - "id": 1, - "embeddedDummies": [ - { - "@type": "EmbeddableDummy", - "dummyName": "foo", - "dummyBoolean": true, - "dummyDate": "2020-01-01T00:00:00+00:00", - "dummyFloat": 0.1, - "dummyPrice": 10 - }, - { - "@type": "EmbeddableDummy", - "dummyName": "bar", - "dummyBoolean": false, - "dummyDate": "2021-01-01T00:00:00+00:00", - "dummyFloat": 0.2, - "dummyPrice": 20 - } - ] - } - """ diff --git a/features/mongodb/filters.feature b/features/mongodb/filters.feature deleted file mode 100644 index 5bb7c3b07e8..00000000000 --- a/features/mongodb/filters.feature +++ /dev/null @@ -1,27 +0,0 @@ -@mongodb -Feature: Filters on collections - In order to retrieve large collections of resources - As a client software developer - I need to retrieve collections with filters - - @createSchema - Scenario: Error when getting collection with nested properties if references are not correctly stored (owning side) - Given there is a dummy object with a fourth level relation - When I send a "GET" request to "/dummies?relatedDummy.thirdLevel.badFourthLevel.level=4" - Then the response status code should be 500 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "@context" should be equal to "/contexts/Error" - And the JSON node "@type" should be equal to "hydra:Error" - And the JSON node "detail" should be equal to "Cannot use reference 'badFourthLevel' in class 'ThirdLevel' for lookup or graphLookup: dbRef references are not supported." - And the JSON node "trace" should exist - - Scenario: Error when getting collection with nested properties if references are not correctly stored (not owning side) - When I send a "GET" request to "/dummies?relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level=3" - Then the response status code should be 500 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "@context" should be equal to "/contexts/Error" - And the JSON node "@type" should be equal to "hydra:Error" - And the JSON node "detail" should be equal to "Cannot use reference 'badThirdLevel' in class 'FourthLevel' for lookup or graphLookup: dbRef references are not supported." - And the JSON node "trace" should exist diff --git a/features/security/README.md b/features/security/README.md deleted file mode 100644 index b74ad06b3a5..00000000000 --- a/features/security/README.md +++ /dev/null @@ -1,108 +0,0 @@ -# Security tests - -This directory contains a list of tests proving that API Platform -enforces [OWASP's recommendations for REST APIs](https://www.owasp.org/index.php/REST_Security_Cheat_Sheet). -If you find a vulnerability in API Platform, please report it according to the procedure detailed in -the [CONTRIBUTING.md](../../CONTRIBUTING.md) -file. - -## Authentication and session management - -Authentication and session management is delegated to -the [Symfony Security component](http://symfony.com/doc/current/components/security.html). -This component has its own test suite. - -## Authorization - -Authorization is delegated to the [Symfony Security component](http://symfony.com/doc/current/components/security.html). -This component has its own test suite. - -## Input validation - -### Input validation 101 - -Input validation is delegated to -the [Symfony Validator component](http://symfony.com/doc/current/components/validator.html) -(an implementation of the [JSR-303 Bean Validation specification](https://jcp.org/en/jsr/detail?id=303). -This component has its own test suite. - -### Secure parsing - -Parsing is delegated to the [Symfony Serializer component](http://symfony.com/doc/current/components/serializer.html). -This component has its own test suite. - -### Strong typing - -Strong typing is ensured by [our "strong typing" functional test suite](strong_typing.feature) -and [the unit tests of the `AbstractItemNormalizer` -class](../../tests/Serializer/AbstractItemNormalizerTest.php). - -You might also be interested to see [how extra attributes are ignored](unknown_attributes.feature). - -### Validate incoming content-types - -Incoming content-types validation is ensured -by [our "validate incoming content-types" functional test suite](validate_incoming_content-types.feature) -and [the unit tests of the `DeserializeListener` -class](../../tests/EventListener/DeserializeListenerTest.php). - -### Validate response types - -Response type validation is ensured -by [our "validate response types" functional test suite](validate_response_types.feature) -and [the unit tests of the `AddFormatListener` class](../../tests/EventListener/AddFormatListenerTest.php). - -### XML input validation - -XML parsing is delegated to -the [Symfony Serializer component](http://symfony.com/doc/current/components/serializer.html). -This component has its own test suite. - -### Framework-Provided validation - -API Platform is shipped with the [Symfony Validator component](http://symfony.com/doc/current/components/validator.html) -, -one of the most popular framework validation in the world. - -## Output encoding - -### Send security headers - -The sending of security headers is ensured -by [our "send security headers" functional test suite](send_security_headers.feature) -and the unit tests of the [`RespondListener`](../../tests/EventListener/RespondListenerTest.php) -, [`ExceptionAction`](../../tests/Action/ExceptionActionTest.php) -and [`ValidationExceptionListener`](../../tests/Bridge/Symfony/Validator/EventListener/ValidationExceptionListenerTest.php) -. - -### JSON encoding - -API Platform relies on the [Symfony Serializer component](http://symfony.com/doc/current/components/serializer.html), to -encode JSON. -This component has its own test suite. - -### XML encoding - -API Platform relies on the [Symfony Serializer component](http://symfony.com/doc/current/components/serializer.html), to -encode XML. -This component has its own test suite. - -## Cryptography - -Cryptography for transit and storage should be enabled and properly configured on your servers depending of the nature -of -you application. -API Platform natively supports both HTTPS (always recommended) and HTTP (for read-only public data only). - -## Message Integrity - -API Platform relies on the [LexikJWTAuthenticationBundle](https://github.com/lexik/LexikJWTAuthenticationBundle), -for JWT support. -This bundle and the underlying [JSON Object Signing and Encryption library for PHP](https://github.com/namshi/jose) -library have their own test suites. - -## HTTP Return Code - -Setting proper HTTP return codes is delegated to -the [Symfony Security component](http://symfony.com/doc/current/components/security.html). -This component has its own test suite. diff --git a/features/security/send_security_headers.feature b/features/security/send_security_headers.feature deleted file mode 100644 index bca2f6fb9c1..00000000000 --- a/features/security/send_security_headers.feature +++ /dev/null @@ -1,32 +0,0 @@ -Feature: Send security header - In order to have secure API - As a client software developer - The API must send correct HTTP headers - - @createSchema - Scenario: API responses must always contain security headers - When I send a "GET" request to "/dummies" - Then the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the header "X-Content-Type-Options" should be equal to "nosniff" - And the header "X-Frame-Options" should be equal to "deny" - - Scenario: Exceptions responses must always contain security headers - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - {"name": 1} - """ - Then the response status code should be 400 - And the header "X-Content-Type-Options" should be equal to "nosniff" - And the header "X-Frame-Options" should be equal to "deny" - - Scenario: Error validation responses must always contain security headers - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - {"name": ""} - """ - Then the response status code should be 422 - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the header "X-Content-Type-Options" should be equal to "nosniff" - And the header "X-Frame-Options" should be equal to "deny" diff --git a/features/security/strong_typing.feature b/features/security/strong_typing.feature deleted file mode 100644 index 27e669816dd..00000000000 --- a/features/security/strong_typing.feature +++ /dev/null @@ -1,162 +0,0 @@ -Feature: Handle properly invalid data submitted to the API - In order to have robust API - As a client software developer - The API must enforce strong typing - - @createSchema - Scenario: Create a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Not existing", - "unsupported": true - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Dummy", - "@id": "/dummies/1", - "@type": "Dummy", - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "relatedDummy": null, - "relatedDummies": [], - "jsonData": [], - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": null, - "relatedOwningDummy": null, - "id": 1, - "name": "Not existing", - "alias": null, - "foo": null - } - """ - - Scenario: Create a resource without a required property with a strongly-typed setter - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": null - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "@context" should be equal to "/contexts/Error" - And the JSON node "@type" should be equal to "hydra:Error" - And the JSON node "detail" should be equal to 'The type of the "name" attribute must be "string", "NULL" given.' - - Scenario: Create a resource with wrong value type for relation - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Foo", - "relatedDummy": "1" - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "@context" should be equal to "/contexts/Error" - And the JSON node "@type" should be equal to "hydra:Error" - And the JSON node "detail" should be equal to 'Invalid IRI "1".' - And the JSON node "trace" should exist - - Scenario: Ignore invalid dates - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Invalid date", - "dummyDate": "Invalid" - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - Scenario: Ignore date with wrong format - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Invalid date format", - "dummyDateWithFormat": "2020-01-01T00:00:00+00:00" - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - - Scenario: Send non-array data when an array is expected - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Invalid", - "relatedDummies": "hello" - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "@context" should be equal to "/contexts/Error" - And the JSON node "@type" should be equal to "hydra:Error" - And the JSON node "detail" should be equal to 'The type of the "relatedDummies" attribute must be "array", "string" given.' - And the JSON node "trace" should exist - - Scenario: Send an object where an array is expected - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Invalid", - "relatedDummies": {"a": {}, "b": {}} - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "@context" should be equal to "/contexts/Error" - And the JSON node "@type" should be equal to "hydra:Error" - And the JSON node "detail" should be equal to 'The type of the key "a" must be "int", "string" given.' - - Scenario: Send a scalar having the bad type - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": 42 - } - """ - Then the response status code should be 400 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "@context" should be equal to "/contexts/Error" - And the JSON node "@type" should be equal to "hydra:Error" - And the JSON node "detail" should be equal to 'The type of the "name" attribute must be "string", "integer" given.' - - Scenario: According to the JSON spec, allow numbers without explicit floating point for JSON formats - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "foo", - "dummyFloat": 42 - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" diff --git a/features/security/unknown_attributes.feature b/features/security/unknown_attributes.feature deleted file mode 100644 index efe7b954c0a..00000000000 --- a/features/security/unknown_attributes.feature +++ /dev/null @@ -1,43 +0,0 @@ -Feature: Ignore unknown attributes - In order to be robust - As a client software developer - I can send unsupported attributes that will be ignored - - @createSchema - Scenario: Create a resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - { - "name": "Not existing", - "unsupported": true - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/Dummy", - "@id": "/dummies/1", - "@type": "Dummy", - "description": null, - "dummy": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "relatedDummy": null, - "relatedDummies": [], - "jsonData": [], - "arrayData": [], - "name_converted": null, - "relatedOwnedDummy": null, - "relatedOwningDummy": null, - "id": 1, - "name": "Not existing", - "alias": null, - "foo": null - } - """ diff --git a/features/security/validate_incoming_content-types.feature b/features/security/validate_incoming_content-types.feature deleted file mode 100644 index 80c6fd3b65a..00000000000 --- a/features/security/validate_incoming_content-types.feature +++ /dev/null @@ -1,17 +0,0 @@ -Feature: Validate incoming content type - In order to have robust API - As a client software developer - The API must check incoming the content-type - - # It's not possible to omit the Content-Type with Behat. A unit test enforce that a 406 error code is returned in such case. - - Scenario: Send a document with a not supported content-type - When I add "Content-Type" header equal to "text/plain" - And I add "Accept" header equal to "application/ld+json" - And I send a "POST" request to "/dummies" with body: - """ - something - """ - Then the response status code should be 415 - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "detail" should be equal to 'The content-type "text/plain" is not supported. Supported MIME types are "application/ld+json", "application/hal+json", "application/vnd.api+json", "application/xml", "text/xml", "application/json", "text/html", "application/graphql", "multipart/form-data".' diff --git a/features/security/validate_response_types.feature b/features/security/validate_response_types.feature deleted file mode 100644 index 2b0884dbb92..00000000000 --- a/features/security/validate_response_types.feature +++ /dev/null @@ -1,38 +0,0 @@ -Feature: Validate response types - In order to have robust API - As a client software developer - The API must check the requested response type - - Scenario: Send a document without content-type - When I add "Accept" header equal to "text/plain" - And I send a "GET" request to "/dummies" - Then the response status code should be 406 - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "detail" should be equal to 'Requested format "text/plain" is not supported. Supported MIME types are "application/ld+json", "application/hal+json", "application/vnd.api+json", "application/xml", "text/xml", "application/json", "text/html", "application/graphql", "multipart/form-data".' - - Scenario: Requesting a different format in the Accept header and in the URL should error - When I add "Accept" header equal to "text/xml" - And I send a "GET" request to "/dummies/1.json" - Then the response status code should be 406 - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "detail" should be equal to 'Requested format "text/xml" is not supported. Supported MIME types are "application/json".' - - Scenario: Sending an invalid Accept header should error - When I add "Accept" header equal to "invalid" - And I send a "GET" request to "/dummies/1" - Then the response status code should be 406 - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "detail" should be equal to 'Requested format "invalid" is not supported. Supported MIME types are "application/ld+json", "application/hal+json", "application/vnd.api+json", "application/xml", "text/xml", "application/json", "text/html", "application/graphql", "multipart/form-data".' - - Scenario: Requesting an invalid format in the URL should throw an error - And I send a "GET" request to "/dummies/1.invalid" - Then the response status code should be 404 - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "detail" should be equal to 'Format "invalid" is not supported' - - Scenario: Requesting an invalid format in the Accept header and in the URL should throw an error - When I add "Accept" header equal to "text/invalid" - And I send a "GET" request to "/dummies/1.invalid" - Then the response status code should be 404 - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the JSON node "detail" should be equal to 'Format "invalid" is not supported' diff --git a/features/serializer/deserialize_objects_using_constructor.feature b/features/serializer/deserialize_objects_using_constructor.feature deleted file mode 100644 index 24e5f20af96..00000000000 --- a/features/serializer/deserialize_objects_using_constructor.feature +++ /dev/null @@ -1,41 +0,0 @@ -Feature: Resource with constructor deserializable - In order to build non anemic resource object - As a developer - I should be able to deserialize data into objects with constructors - - @createSchema - Scenario: post a resource built with constructor - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_entity_with_constructors" with body: - """ - { - "foo": "hello", - "bar": "world", - "items": [ - { - "foo": "bar" - } - ] - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be a superset of: - """ - { - "@context": "/contexts/DummyEntityWithConstructor", - "@id": "/dummy_entity_with_constructors/1", - "@type": "DummyEntityWithConstructor", - "id": 1, - "foo": "hello", - "bar": "world", - "items": [ - { - "@type": "DummyObjectWithoutConstructor", - "foo": "bar" - } - ], - "baz": null - } - """ diff --git a/features/serializer/dynamic_groups.feature b/features/serializer/dynamic_groups.feature deleted file mode 100644 index 2454d0c2418..00000000000 --- a/features/serializer/dynamic_groups.feature +++ /dev/null @@ -1,11 +0,0 @@ -@!mongodb -Feature: Dynamic serialization context - In order to customize the Resource representation dynamically - As a developer - I should be able to add and remove groups - - @createSchema - Scenario: - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/relation_group_impact_on_collections/1" - And the JSON node "related.title" should be equal to "foo" diff --git a/features/serializer/empty_array_as_object.feature b/features/serializer/empty_array_as_object.feature deleted file mode 100644 index c0cc8548178..00000000000 --- a/features/serializer/empty_array_as_object.feature +++ /dev/null @@ -1,33 +0,0 @@ -Feature: Serialize empty array as object - In order to have a coherent JSON representation - As a developer - I should be able to serialize some empty array properties as objects - - @createSchema - Scenario: Get a resource with empty array properties as objects - When I add "Content-Type" header equal to "application/ld+json" - And I send a "GET" request to "/empty_array_as_objects/5" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { - "@context": "/contexts/EmptyArrayAsObject", - "@id": "/empty_array_as_objects/6", - "@type": "EmptyArrayAsObject", - "id": 6, - "emptyArray": [], - "emptyArrayAsObject": {}, - "arrayObjectAsArray": [], - "arrayObject": {}, - "stringArray": [ - "foo", - "bar" - ], - "objectArray": { - "foo": 67, - "bar": "baz" - } - } - """ diff --git a/features/serializer/groups_related.feature b/features/serializer/groups_related.feature deleted file mode 100644 index ad2ca49e331..00000000000 --- a/features/serializer/groups_related.feature +++ /dev/null @@ -1,17 +0,0 @@ -@!mongodb -Feature: Groups to embed relations - In order to show embed relations on a Resource - As a client software developer - I need to set up groups on the Resource embed properties - - Scenario: Get a single resource - When I send a "GET" request to "/relation_group_impact_on_collections/1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "related.title" should be equal to "foo" - - Scenario: Get a collection resource not impacted by groups - When I send a "GET" request to "/relation_group_impact_on_collections" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "hydra:member[0].related" should be equal to "/relation_group_impact_on_collection_relations/1" diff --git a/features/serializer/vo_relations.feature b/features/serializer/vo_relations.feature deleted file mode 100644 index 08999440a50..00000000000 --- a/features/serializer/vo_relations.feature +++ /dev/null @@ -1,209 +0,0 @@ -Feature: Value object as ApiResource - In order to keep ApiResource immutable - As a client software developer - I need to be able to use class without setters as ApiResource - - @createSchema - Scenario: Create Value object resource - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/vo_dummy_cars" with body: - """ - { - "mileage": 1500, - "bodyType": "suv", - "make": "CustomCar", - "insuranceCompany": { - "name": "Safe Drive Company" - }, - "drivers": [ - { - "firstName": "John", - "lastName": "Doe" - } - ] - } - """ - Then the response status code should be 201 - And the JSON should be equal to: - """ - { - "@context": "/contexts/VoDummyCar", - "@id": "/vo_dummy_cars/1", - "@type": "VoDummyCar", - "mileage": 1500, - "bodyType": "suv", - "inspections": [], - "make": "CustomCar", - "insuranceCompany": { - "@id": "/vo_dummy_insurance_companies/1", - "@type": "VoDummyInsuranceCompany", - "name": "Safe Drive Company" - }, - "drivers": [ - { - "@id": "/vo_dummy_drivers/1", - "@type": "VoDummyDriver", - "firstName": "John", - "lastName": "Doe" - } - ] - } - """ - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - Scenario: Create Value object with IRI and nullable parameter - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/vo_dummy_inspections" with body: - """ - { - "accepted": true, - "car": "/vo_dummy_cars/1" - } - """ - Then the response status code should be 201 - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "required": ["accepted", "performed", "car"], - "properties": { - "accepted": { - "enum":[true] - }, - "performed": { - "format": "date-time" - }, - "car": { - "enum": ["/vo_dummy_cars/1"] - } - } - } - """ - - Scenario: Update Value object with writable and non writable property (legacy non-standard PUT) - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/vo_dummy_inspections/1" with body: - """ - { - "performed": "2018-08-24 00:00:00", - "accepted": false - } - """ - Then the response status code should be 200 - And the JSON should be equal to: - """ - { - "@context": "/contexts/VoDummyInspection", - "@id": "/vo_dummy_inspections/1", - "@type": "VoDummyInspection", - "accepted": true, - "car": "/vo_dummy_cars/1", - "performed": "2018-08-24T00:00:00+00:00" - } - """ - - Scenario: Update Value object with writable and non writable property - When I add "Content-Type" header equal to "application/merge-patch+json" - And I send a "PATCH" request to "/vo_dummy_inspections/1" with body: - """ - { - "performed": "2018-08-24 00:00:00", - "accepted": false - } - """ - Then the response status code should be 200 - And the JSON should be equal to: - """ - { - "@context": "/contexts/VoDummyInspection", - "@id": "/vo_dummy_inspections/1", - "@type": "VoDummyInspection", - "accepted": true, - "car": "/vo_dummy_cars/1", - "performed": "2018-08-24T00:00:00+00:00" - } - """ - - - @createSchema - Scenario: Create Value object without required params - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/vo_dummy_cars" with body: - """ - { - "mileage": 1500, - "make": "CustomCar", - "insuranceCompany": { - "name": "Safe Drive Company" - } - } - """ - Then the response status code should be 400 - And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" - And the header "Link" should contain '; rel="http://www.w3.org/ns/json-ld#error"' - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@type": { - "type": "string", - "pattern": "^hydra:Error$" - }, - "detail": { - "pattern": "^Cannot create an instance of \"ApiPlatform\\\\Tests\\\\Fixtures\\\\TestBundle\\\\(Document|Entity)\\\\VoDummyCar\" from serialized data because its constructor requires the following parameters to be present : \"\\$drivers\".$" - } - }, - "required": [ - "@type", - "detail" - ] - } - """ - - @createSchema - Scenario: Create Value object without default param - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/vo_dummy_cars" with body: - """ - { - "mileage": 1500, - "make": "CustomCar", - "insuranceCompany": { - "name": "Safe Drive Company" - }, - "drivers": [ - { - "firstName": "John", - "lastName": "Doe" - } - ] - } - """ - Then the response status code should be 201 - And the JSON should be equal to: - """ - { - "@context": "/contexts/VoDummyCar", - "@id": "/vo_dummy_cars/1", - "@type": "VoDummyCar", - "mileage": 1500, - "bodyType": "coupe", - "inspections": [], - "make": "CustomCar", - "insuranceCompany": { - "@id": "/vo_dummy_insurance_companies/1", - "@type": "VoDummyInsuranceCompany", - "name": "Safe Drive Company" - }, - "drivers": [ - { - "@id": "/vo_dummy_drivers/1", - "@type": "VoDummyDriver", - "firstName": "John", - "lastName": "Doe" - } - ] - } - """ - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" diff --git a/tests/Behat/ElasticsearchContext.php b/tests/Behat/ElasticsearchContext.php deleted file mode 100644 index cc8fa176b67..00000000000 --- a/tests/Behat/ElasticsearchContext.php +++ /dev/null @@ -1,143 +0,0 @@ - - * - * 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\Behat; - -use Behat\Behat\Context\Context; -use Elastic\Elasticsearch\Client; -use Elasticsearch\Client as V7Client; -use OpenSearch\Client as OpenSearchClient; -use Symfony\Component\Finder\Finder; - -/** - * @experimental - * - * @author Baptiste Meyer - */ -final class ElasticsearchContext implements Context -{ - public function __construct( - private readonly V7Client|Client|OpenSearchClient $client, // @phpstan-ignore-line - private readonly string $elasticsearchMappingsPath, - private readonly string $elasticsearchFixturesPath, - ) { - } - - /** - * @BeforeScenario - */ - public function initializeElasticsearch(): void - { - static $initialized = false; - - if ($initialized) { - return; - } - - $this->deleteIndexes(); - $this->createIndexesAndMappings(); - $this->loadFixtures(); - - $initialized = true; - } - - /** - * @Given indexes and their mappings are created - */ - public function thereAreIndexes(): void - { - $this->createIndexesAndMappings(); - } - - /** - * @Given indexes are deleted - */ - public function thereAreNoIndexes(): void - { - $this->deleteIndexes(); - } - - /** - * @Given fixtures files are loaded - */ - public function thereAreFixtures(): void - { - $this->loadFixtures(); - } - - private function createIndexesAndMappings(): void - { - $finder = new Finder(); - $finder->files()->in($this->elasticsearchMappingsPath); - - foreach ($finder as $file) { - $this->client->indices()->create([ // @phpstan-ignore-line - 'index' => $file->getBasename('.json'), - 'body' => json_decode($file->getContents(), true, 512, \JSON_THROW_ON_ERROR), - ]); - } - } - - private function deleteIndexes(): void - { - $finder = new Finder(); - $finder->files()->in($this->elasticsearchMappingsPath)->name('*.json'); - - $indexes = []; - - foreach ($finder as $file) { - $indexes[] = $file->getBasename('.json'); - } - - if ([] !== $indexes) { - $this->client->indices()->delete([ // @phpstan-ignore-line - 'index' => implode(',', $indexes), - 'ignore_unavailable' => true, - ]); - } - } - - private function loadFixtures(): void - { - $finder = new Finder(); - $finder->files()->in($this->elasticsearchFixturesPath)->name('*.json'); - - $indexClient = $this->client->indices(); // @phpstan-ignore-line - - foreach ($finder as $file) { - $index = $file->getBasename('.json'); - $bulk = []; - - foreach (json_decode($file->getContents(), true, 512, \JSON_THROW_ON_ERROR) as $document) { - if (null === ($document['id'] ?? null)) { - $bulk[] = ['index' => ['_index' => $index]]; - } else { - $bulk[] = ['create' => ['_index' => $index, '_id' => (string) $document['id']]]; - } - - $bulk[] = $document; - - if (0 === (\count($bulk) % 50)) { - $this->client->bulk(['body' => $bulk]); // @phpstan-ignore-line - $bulk = []; - } - } - - if ($bulk) { - $this->client->bulk(['body' => $bulk]); // @phpstan-ignore-line - } - - $indexClient->refresh(['index' => $index]); - } - } -} diff --git a/tests/Fixtures/app/config/config_elasticsearch.yml b/tests/Fixtures/app/config/config_elasticsearch.yml index 077f03ab209..7e796c10bd5 100644 --- a/tests/Fixtures/app/config/config_elasticsearch.yml +++ b/tests/Fixtures/app/config/config_elasticsearch.yml @@ -16,10 +16,3 @@ services: test.api_platform.elasticsearch.client: parent: api_platform.elasticsearch.client public: true - - ApiPlatform\Tests\Behat\ElasticsearchContext: - public: true - arguments: - $client: '@test.api_platform.elasticsearch.client' - $elasticsearchMappingsPath: '%kernel.project_dir%/../Elasticsearch/Mappings/' - $elasticsearchFixturesPath: '%kernel.project_dir%/../Elasticsearch/Fixtures/' diff --git a/tests/Fixtures/app/config/config_opensearch.yml b/tests/Fixtures/app/config/config_opensearch.yml index 98de050019f..1a167486ae2 100644 --- a/tests/Fixtures/app/config/config_opensearch.yml +++ b/tests/Fixtures/app/config/config_opensearch.yml @@ -17,10 +17,3 @@ services: test.api_platform.elasticsearch.client: parent: api_platform.elasticsearch.client public: true - - ApiPlatform\Tests\Behat\ElasticsearchContext: - public: true - arguments: - $client: '@test.api_platform.elasticsearch.client' - $elasticsearchMappingsPath: '%kernel.project_dir%/../Elasticsearch/Mappings/' - $elasticsearchFixturesPath: '%kernel.project_dir%/../Elasticsearch/Fixtures/' diff --git a/tests/Functional/Elasticsearch/ElasticsearchSetupTrait.php b/tests/Functional/Elasticsearch/ElasticsearchSetupTrait.php new file mode 100644 index 00000000000..c2dc995e1b4 --- /dev/null +++ b/tests/Functional/Elasticsearch/ElasticsearchSetupTrait.php @@ -0,0 +1,112 @@ + + * + * 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\Elasticsearch; + +use Symfony\Component\Finder\Finder; + +trait ElasticsearchSetupTrait +{ + private static bool $elasticsearchInitialized = false; + + protected function skipIfNotElasticsearch(): void + { + if (!\in_array($_SERVER['APP_ENV'] ?? null, ['elasticsearch', 'opensearch'], true)) { + $this->markTestSkipped('Requires APP_ENV=elasticsearch (or opensearch).'); + } + } + + protected function initializeElasticsearch(): void + { + if (self::$elasticsearchInitialized) { + return; + } + + // @phpstan-ignore-next-line service exists only when api_platform.elasticsearch.enabled is true + $client = static::getContainer()->get('test.api_platform.elasticsearch.client'); + $mappingsPath = \dirname(__DIR__, 2).'/Fixtures/Elasticsearch/Mappings/'; + $fixturesPath = \dirname(__DIR__, 2).'/Fixtures/Elasticsearch/Fixtures/'; + + $this->deleteIndexes($client, $mappingsPath); + $this->createIndexesAndMappings($client, $mappingsPath); + $this->loadFixtures($client, $fixturesPath); + + self::$elasticsearchInitialized = true; + } + + private function createIndexesAndMappings(object $client, string $mappingsPath): void + { + $finder = new Finder(); + $finder->files()->in($mappingsPath); + + foreach ($finder as $file) { + $client->indices()->create([ + 'index' => $file->getBasename('.json'), + 'body' => json_decode($file->getContents(), true, 512, \JSON_THROW_ON_ERROR), + ]); + } + } + + private function deleteIndexes(object $client, string $mappingsPath): void + { + $finder = new Finder(); + $finder->files()->in($mappingsPath)->name('*.json'); + + $indexes = []; + + foreach ($finder as $file) { + $indexes[] = $file->getBasename('.json'); + } + + if ([] !== $indexes) { + $client->indices()->delete([ + 'index' => implode(',', $indexes), + 'ignore_unavailable' => true, + ]); + } + } + + private function loadFixtures(object $client, string $fixturesPath): void + { + $finder = new Finder(); + $finder->files()->in($fixturesPath)->name('*.json'); + + $indexClient = $client->indices(); + + foreach ($finder as $file) { + $index = $file->getBasename('.json'); + $bulk = []; + + foreach (json_decode($file->getContents(), true, 512, \JSON_THROW_ON_ERROR) as $document) { + if (null === ($document['id'] ?? null)) { + $bulk[] = ['index' => ['_index' => $index]]; + } else { + $bulk[] = ['create' => ['_index' => $index, '_id' => (string) $document['id']]]; + } + + $bulk[] = $document; + + if (0 === (\count($bulk) % 50)) { + $client->bulk(['body' => $bulk]); + $bulk = []; + } + } + + if ($bulk) { + $client->bulk(['body' => $bulk]); + } + + $indexClient->refresh(['index' => $index]); + } + } +} diff --git a/features/elasticsearch/match_filter.feature b/tests/Functional/Elasticsearch/MatchFilterTest.php similarity index 65% rename from features/elasticsearch/match_filter.feature rename to tests/Functional/Elasticsearch/MatchFilterTest.php index cf0b70413a9..d33585649ac 100644 --- a/features/elasticsearch/match_filter.feature +++ b/tests/Functional/Elasticsearch/MatchFilterTest.php @@ -1,17 +1,49 @@ -@elasticsearch -Feature: Match filter on collections from Elasticsearch - In order to get specific results from a large collections of resources from Elasticsearch - As a client software developer - I need to search for resources matching the text specified - - Scenario: Match filter on a text property - When I send a "GET" request to "/tweets?message=Good%20job" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + + * + * 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\Elasticsearch; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Book; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Genre; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Library; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Tweet; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\User; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class MatchFilterTest extends ApiTestCase +{ + use ElasticsearchSetupTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [User::class, Tweet::class, Library::class, Book::class, Genre::class]; + } + + public function testMatchFilterOnATextProperty(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?message=Good%20job', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Tweet$"}, @@ -52,16 +84,20 @@ } } } - """ - - Scenario: Match filter on a text property - When I send a "GET" request to "/tweets?message%5B%5D=Good%20job&message%5B%5D=run" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testMatchFilterOnATextPropertyWithMultipleValues(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?message%5B%5D=Good%20job&message%5B%5D=run', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Tweet$"}, @@ -115,16 +151,20 @@ } } } - """ - - Scenario: Match filter on a nested property of text type - When I send a "GET" request to "/tweets?author.firstName=Caroline" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testMatchFilterOnANestedPropertyOfTextType(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?author.firstName=Caroline', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Tweet$"}, @@ -165,16 +205,20 @@ } } } - """ - - Scenario: Combining match filters on properties of text type and a nested property of text type - When I send a "GET" request to "/tweets?message%5B%5D=Good%20job&message%5B%5D=run&author.firstName=Caroline" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testCombiningMatchFiltersOnPropertiesOfTextTypeAndANestedPropertyOfTextType(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?message%5B%5D=Good%20job&message%5B%5D=run&author.firstName=Caroline', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Tweet$"}, @@ -215,16 +259,20 @@ } } } - """ - - Scenario: Match filter on a text property with new elasticsearch operations - When I send a "GET" request to "/books?message=Good%20job" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testMatchFilterOnATextPropertyWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?message=Good%20job', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -265,16 +313,20 @@ } } } - """ - - Scenario: Match filter on a text property with new elasticsearch operations - When I send a "GET" request to "/books?message%5B%5D=Good%20job&message%5B%5D=run" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testMatchFilterOnATextPropertyWithMultipleValuesWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?message%5B%5D=Good%20job&message%5B%5D=run', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -328,16 +380,20 @@ } } } - """ - - Scenario: Match filter on a nested property of text type with new elasticsearch operations - When I send a "GET" request to "/books?library.firstName=Caroline" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testMatchFilterOnANestedPropertyOfTextTypeWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?library.firstName=Caroline', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -378,16 +434,20 @@ } } } - """ - - Scenario: Combining match filters on properties of text type and a nested property of text type with new elasticsearch operations - When I send a "GET" request to "/books?message%5B%5D=Good%20job&message%5B%5D=run&library.firstName=Caroline" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testCombiningMatchFiltersOnPropertiesOfTextTypeAndANestedPropertyOfTextTypeWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?message%5B%5D=Good%20job&message%5B%5D=run&library.firstName=Caroline', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -428,16 +488,20 @@ } } } - """ - - Scenario: Match filter on a multi-level nested property of text type with new elasticsearch operations - When I send a "GET" request to "/books?library.relatedGenres.name=Fiction" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testMatchFilterOnAMultiLevelNestedPropertyOfTextTypeWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?library.relatedGenres.name=Fiction', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -478,5 +542,6 @@ } } } - """ - +JSON); + } +} diff --git a/features/elasticsearch/order_filter.feature b/tests/Functional/Elasticsearch/OrderFilterTest.php similarity index 68% rename from features/elasticsearch/order_filter.feature rename to tests/Functional/Elasticsearch/OrderFilterTest.php index 2fe4ac1e14b..3a1ab3d9fa4 100644 --- a/features/elasticsearch/order_filter.feature +++ b/tests/Functional/Elasticsearch/OrderFilterTest.php @@ -1,17 +1,49 @@ -@elasticsearch -Feature: Order filter on collections from Elasticsearch - In order to retrieve ordered large collections of resources from Elasticsearch - As a client software developer - I need to retrieve collections ordered properties - - Scenario: Get collection ordered in ascending order on an identifier property - When I send a "GET" request to "/tweets?order%5Bid%5D=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + + * + * 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\Elasticsearch; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Book; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Genre; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Library; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Tweet; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\User; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class OrderFilterTest extends ApiTestCase +{ + use ElasticsearchSetupTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array { + return [User::class, Tweet::class, Library::class, Book::class, Genre::class]; + } + + public function testGetCollectionOrderedInAscendingOrderOnAnIdentifierProperty(): void + { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?order%5Bid%5D=asc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Tweet$"}, @@ -61,16 +93,20 @@ } } } - """ - - Scenario: Get collection ordered in descending order on an identifier property - When I send a "GET" request to "/tweets?order%5Bid%5D=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInDescendingOrderOnAnIdentifierProperty(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?order%5Bid%5D=desc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Tweet$"}, @@ -120,16 +156,20 @@ } } } - """ - - Scenario: Get collection ordered in ascending order on an identifier property and in ascending order on a nested identifier property - When I send a "GET" request to "/tweets?order%5Bauthor.id%5D=asc&order%5Bid%5D=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInAscendingOrderOnAnIdentifierPropertyAndInAscendingOrderOnANestedIdentifierProperty(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?order%5Bauthor.id%5D=asc&order%5Bid%5D=asc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Tweet$"}, @@ -179,16 +219,20 @@ } } } - """ - - Scenario: Get collection ordered in descending order on an identifier property and in ascending order on a nested identifier property - When I send a "GET" request to "/tweets?order%5Bauthor.id%5D=asc&order%5Bid%5D=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInDescendingOrderOnAnIdentifierPropertyAndInAscendingOrderOnANestedIdentifierProperty(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?order%5Bauthor.id%5D=asc&order%5Bid%5D=desc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Tweet$"}, @@ -238,16 +282,20 @@ } } } - """ - - Scenario: Get collection ordered in ascending order on an identifier property and in descending order on a nested identifier property - When I send a "GET" request to "/tweets?order%5Bauthor.id%5D=desc&order%5Bid%5D=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInAscendingOrderOnAnIdentifierPropertyAndInDescendingOrderOnANestedIdentifierProperty(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?order%5Bauthor.id%5D=desc&order%5Bid%5D=asc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Tweet$"}, @@ -297,16 +345,20 @@ } } } - """ - - Scenario: Get collection ordered in descending order on an identifier property and in descending order on a nested identifier property - When I send a "GET" request to "/tweets?order%5Bauthor.id%5D=desc&order%5Bid%5D=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInDescendingOrderOnAnIdentifierPropertyAndInDescendingOrderOnANestedIdentifierProperty(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?order%5Bauthor.id%5D=desc&order%5Bid%5D=desc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Tweet$"}, @@ -356,16 +408,20 @@ } } } - """ - - Scenario: Get collection ordered in ascending order on an identifier property with new elasticsearch operations - When I send a "GET" request to "/books?order%5Bid%5D=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInAscendingOrderOnAnIdentifierPropertyWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?order%5Bid%5D=asc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -415,16 +471,20 @@ } } } - """ - - Scenario: Get collection ordered in descending order on an identifier property with new elasticsearch operations - When I send a "GET" request to "/books?order%5Bid%5D=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInDescendingOrderOnAnIdentifierPropertyWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?order%5Bid%5D=desc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -474,16 +534,20 @@ } } } - """ - - Scenario: Get collection ordered in ascending order on an identifier property and in ascending order on a nested identifier property with new elasticsearch operations - When I send a "GET" request to "/books?order%5Blibrary.id%5D=asc&order%5Bid%5D=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInAscendingOrderOnAnIdentifierPropertyAndInAscendingOrderOnANestedIdentifierPropertyWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?order%5Blibrary.id%5D=asc&order%5Bid%5D=asc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -533,16 +597,20 @@ } } } - """ - - Scenario: Get collection ordered in descending order on an identifier property and in ascending order on a nested identifier property with new elasticsearch operations - When I send a "GET" request to "/books?order%5Blibrary.id%5D=asc&order%5Bid%5D=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInDescendingOrderOnAnIdentifierPropertyAndInAscendingOrderOnANestedIdentifierPropertyWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?order%5Blibrary.id%5D=asc&order%5Bid%5D=desc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -592,16 +660,20 @@ } } } - """ - - Scenario: Get collection ordered in ascending order on an identifier property and in descending order on a nested identifier property with new elasticsearch operations - When I send a "GET" request to "/books?order%5Blibrary.id%5D=desc&order%5Bid%5D=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInAscendingOrderOnAnIdentifierPropertyAndInDescendingOrderOnANestedIdentifierPropertyWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?order%5Blibrary.id%5D=desc&order%5Bid%5D=asc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -651,16 +723,20 @@ } } } - """ - - Scenario: Get collection ordered in descending order on an identifier property and in descending order on a nested identifier property with new elasticsearch operations - When I send a "GET" request to "/books?order%5Blibrary.id%5D=desc&order%5Bid%5D=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetCollectionOrderedInDescendingOrderOnAnIdentifierPropertyAndInDescendingOrderOnANestedIdentifierPropertyWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?order%5Blibrary.id%5D=desc&order%5Bid%5D=desc', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Book$"}, @@ -710,4 +786,6 @@ } } } - """ +JSON); + } +} diff --git a/features/elasticsearch/read.feature b/tests/Functional/Elasticsearch/ReadTest.php similarity index 81% rename from features/elasticsearch/read.feature rename to tests/Functional/Elasticsearch/ReadTest.php index 226acafaf61..79f4cdf02ce 100644 --- a/features/elasticsearch/read.feature +++ b/tests/Functional/Elasticsearch/ReadTest.php @@ -1,17 +1,49 @@ -@elasticsearch -Feature: Retrieve from Elasticsearch - In order to use an hypermedia API - As a client software developer - I need to be able to retrieve JSON-LD encoded resources from Elasticsearch - - Scenario: Get a resource - When I send a "GET" request to "/users/116b83f8-6c32-48d8-8e28-c5c247532d3f" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ + + * + * 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\Elasticsearch; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Book; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Genre; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Library; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Tweet; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\User; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ReadTest extends ApiTestCase +{ + use ElasticsearchSetupTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array { + return [User::class, Tweet::class, Library::class, Book::class, Genre::class]; + } + + public function testGetAResource(): void + { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/users/116b83f8-6c32-48d8-8e28-c5c247532d3f', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/User", "@id": "/users/116b83f8-6c32-48d8-8e28-c5c247532d3f", "@type": "User", @@ -45,20 +77,30 @@ } ] } - """ - - Scenario: Get a not found exception - When I send a "GET" request to "/users/12345678-abcd-1234-abcdefgh" - Then the response status code should be 404 - - Scenario: Get the first page of a collection - When I send a "GET" request to "/tweets" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ +JSON); + } + + public function testGetANotFoundException(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/users/12345678-abcd-1234-abcdefgh', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testGetTheFirstPageOfACollection(): void + { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/Tweet", "@id": "/tweets", "@type": "hydra:Collection", @@ -164,16 +206,20 @@ ] } } - """ - - Scenario: Get a page of a collection - When I send a "GET" request to "/tweets?page=3" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ +JSON); + } + + public function testGetAPageOfACollection(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?page=3', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/Tweet", "@id": "/tweets", "@type": "hydra:Collection", @@ -280,16 +326,20 @@ ] } } - """ - - Scenario: Get the last page of a collection - When I send a "GET" request to "/tweets?page=7" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ +JSON); + } + + public function testGetTheLastPageOfACollection(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/tweets?page=7', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/Tweet", "@id": "/tweets", "@type": "hydra:Collection", @@ -379,16 +429,20 @@ ] } } - """ - - Scenario: Get a resource with new elasticsearch operations - When I send a "GET" request to "/libraries/116b83f8-6c32-48d8-8e28-c5c247532d3f" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ +JSON); + } + + public function testGetAResourceWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/libraries/116b83f8-6c32-48d8-8e28-c5c247532d3f', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/Library", "@id": "/libraries/116b83f8-6c32-48d8-8e28-c5c247532d3f", "@type": "Library", @@ -422,20 +476,30 @@ } ] } - """ - - Scenario: Get a not found exception with new elasticsearch operations - When I send a "GET" request to "/libraries/12345678-abcd-1234-abcdefgh" - Then the response status code should be 404 - - Scenario: Get the first page of a collection with new elasticsearch operations - When I send a "GET" request to "/books" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ +JSON); + } + + public function testGetANotFoundExceptionWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/libraries/12345678-abcd-1234-abcdefgh', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testGetTheFirstPageOfACollectionWithNewElasticsearchOperations(): void + { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/Book", "@id": "/books", "@type": "hydra:Collection", @@ -553,16 +617,20 @@ ] } } - """ - - Scenario: Get a page of a collection with new elasticsearch operations - When I send a "GET" request to "/books?page=3" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ +JSON); + } + + public function testGetAPageOfACollectionWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?page=3', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/Book", "@id": "/books", "@type": "hydra:Collection", @@ -681,16 +749,20 @@ ] } } - """ - - Scenario: Get the last page of a collection with new elasticsearch operations - When I send a "GET" request to "/books?page=7" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ +JSON); + } + + public function testGetTheLastPageOfACollectionWithNewElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/books?page=7', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/Book", "@id": "/books", "@type": "hydra:Collection", @@ -792,4 +864,6 @@ ] } } - """ +JSON); + } +} diff --git a/features/elasticsearch/term_filter.feature b/tests/Functional/Elasticsearch/TermFilterTest.php similarity index 57% rename from features/elasticsearch/term_filter.feature rename to tests/Functional/Elasticsearch/TermFilterTest.php index 97f72fabd65..7db5145d2d9 100644 --- a/features/elasticsearch/term_filter.feature +++ b/tests/Functional/Elasticsearch/TermFilterTest.php @@ -1,17 +1,49 @@ -@elasticsearch -Feature: Term filter on collections from Elasticsearch - In order to get specific results from a large collections of resources from Elasticsearch - As a client software developer - I need to search for resources containing the exact terms specified - - Scenario: Term filter on an identifier property - When I send a "GET" request to "/users?id=%2Fusers%2Fcf875c95-41ab-48df-af66-38c74db18f72" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + + * + * 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\Elasticsearch; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Book; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Genre; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Library; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\Tweet; +use ApiPlatform\Tests\Fixtures\Elasticsearch\Model\User; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class TermFilterTest extends ApiTestCase +{ + use ElasticsearchSetupTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array { + return [User::class, Tweet::class, Library::class, Book::class, Genre::class]; + } + + public function testTermFilterOnAnIdentifierProperty(): void + { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/users?id=%2Fusers%2Fcf875c95-41ab-48df-af66-38c74db18f72', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/User$"}, @@ -37,16 +69,20 @@ } } } - """ - - Scenario: Term filter on a property of keyword type - When I send a "GET" request to "/users?gender=female" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testTermFilterOnAPropertyOfKeywordType(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/users?gender=female', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/User$"}, @@ -79,16 +115,20 @@ } } } - """ - - Scenario: Combining term filters on a property of integer type and a property of keyword type - When I send a "GET" request to "/users?age=42&gender=female" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testCombiningTermFiltersOnAPropertyOfIntegerTypeAndAPropertyOfKeywordType(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/users?age=42&gender=female', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/User$"}, @@ -124,16 +164,20 @@ } } } - """ - - Scenario: Combining term filters on a property of integer type and a property of keyword type - When I send a "GET" request to "/users?age=42&gender=male" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testCombiningTermFiltersOnAPropertyOfIntegerTypeAndAPropertyOfKeywordTypeReturningNoMatch(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/users?age=42&gender=male', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/User$"}, @@ -153,16 +197,20 @@ } } } - """ - - Scenario: Term filter on a property of text type - When I send a "GET" request to "/users?firstName=xavier" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testTermFilterOnAPropertyOfTextType(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/users?firstName=xavier', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/User$"}, @@ -189,17 +237,20 @@ } } } - """ - - Scenario: Term filter on a nested identifier property - When I send a "GET" request to "/users?tweets.id=%2Ftweets%2Fdcaef1db-225d-442b-960e-5de6984a44be" - Then the response should be in JSON - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testTermFilterOnANestedIdentifierProperty(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/users?tweets.id=%2Ftweets%2Fdcaef1db-225d-442b-960e-5de6984a44be', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/User$"}, @@ -225,17 +276,20 @@ } } } - """ - - Scenario: Term filter on a nested property of date type - When I send a "GET" request to "/users?tweets.date=2018-02-02%2014%3A14%3A14" - Then the response should be in JSON - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testTermFilterOnANestedPropertyOfDateType(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/users?tweets.date=2018-02-02%2014%3A14%3A14', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/User$"}, @@ -261,16 +315,20 @@ } } } - """ - - Scenario: Term filter on an identifier property with elasticsearch operations - When I send a "GET" request to "/libraries?id=%2Flibraries%2Fcf875c95-41ab-48df-af66-38c74db18f72" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testTermFilterOnAnIdentifierPropertyWithElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/libraries?id=%2Flibraries%2Fcf875c95-41ab-48df-af66-38c74db18f72', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Library$"}, @@ -296,16 +354,20 @@ } } } - """ - - Scenario: Term filter on a property of keyword type with elasticsearch operations - When I send a "GET" request to "/libraries?gender=female" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testTermFilterOnAPropertyOfKeywordTypeWithElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/libraries?gender=female', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Library$"}, @@ -338,16 +400,20 @@ } } } - """ - - Scenario: Combining term filters on a property of integer type and a property of keyword type with elasticsearch operations - When I send a "GET" request to "/libraries?age=42&gender=female" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testCombiningTermFiltersOnAPropertyOfIntegerTypeAndAPropertyOfKeywordTypeWithElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/libraries?age=42&gender=female', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Library$"}, @@ -383,16 +449,20 @@ } } } - """ - - Scenario: Combining term filters on a property of integer type and a property of keyword type with elasticsearch operations - When I send a "GET" request to "/libraries?age=42&gender=male" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testCombiningTermFiltersOnAPropertyOfIntegerTypeAndAPropertyOfKeywordTypeReturningNoMatchWithElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/libraries?age=42&gender=male', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Library$"}, @@ -412,16 +482,20 @@ } } } - """ - - Scenario: Term filter on a property of text type with elasticsearch operations - When I send a "GET" request to "/libraries?firstName=xavier" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testTermFilterOnAPropertyOfTextTypeWithElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/libraries?firstName=xavier', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Library$"}, @@ -448,17 +522,20 @@ } } } - """ - - Scenario: Term filter on a nested identifier property with elasticsearch operations - When I send a "GET" request to "/libraries?books.id=%2Fbooks%2Fdcaef1db-225d-442b-960e-5de6984a44be" - Then the response should be in JSON - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testTermFilterOnANestedIdentifierPropertyWithElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/libraries?books.id=%2Fbooks%2Fdcaef1db-225d-442b-960e-5de6984a44be', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Library$"}, @@ -484,17 +561,20 @@ } } } - """ - - Scenario: Term filter on a nested property of date type with elasticsearch operations - When I send a "GET" request to "/libraries?books.date=2018-02-02%2014%3A14%3A14" - Then the response should be in JSON - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testTermFilterOnANestedPropertyOfDateTypeWithElasticsearchOperations(): void { + $this->skipIfNotElasticsearch(); + $this->initializeElasticsearch(); + + $response = self::createClient()->request('GET', '/libraries?books.date=2018-02-02%2014%3A14%3A14', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/Library$"}, @@ -520,4 +600,6 @@ } } } - """ +JSON); + } +} diff --git a/tests/Functional/MongoDb/EmbedManyWithoutTargetDocumentTest.php b/tests/Functional/MongoDb/EmbedManyWithoutTargetDocumentTest.php new file mode 100644 index 00000000000..6d4546c5148 --- /dev/null +++ b/tests/Functional/MongoDb/EmbedManyWithoutTargetDocumentTest.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\Tests\Functional\MongoDb; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyWithEmbedManyOmittingTargetDocument; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class EmbedManyWithoutTargetDocumentTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [DummyWithEmbedManyOmittingTargetDocument::class]; + } + + protected function setUp(): void + { + if (!$this->isMongoDB()) { + $this->markTestSkipped('Requires APP_ENV=mongodb.'); + } + $this->recreateSchema([DummyWithEmbedManyOmittingTargetDocument::class]); + } + + public function testPostHydratesEmbedManyWithoutTargetDocument(): void + { + self::createClient()->request( + 'POST', + '/dummy_with_embed_many_omitting_target_documents', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode([ + 'embeddedDummies' => [ + ['dummyName' => 'foo', 'dummyBoolean' => true, 'dummyDate' => '2020-01-01', 'dummyFloat' => 0.1, 'dummyPrice' => 10], + ['dummyName' => 'bar', 'dummyBoolean' => false, 'dummyDate' => '2021-01-01', 'dummyFloat' => 0.2, 'dummyPrice' => 20], + ], + ]), + ], + ); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/DummyWithEmbedManyOmittingTargetDocument', + '@id' => '/dummy_with_embed_many_omitting_target_documents/1', + '@type' => 'DummyWithEmbedManyOmittingTargetDocument', + 'id' => 1, + 'embeddedDummies' => [ + ['@type' => 'EmbeddableDummy', 'dummyName' => 'foo', 'dummyBoolean' => true, 'dummyDate' => '2020-01-01T00:00:00+00:00', 'dummyFloat' => 0.1, 'dummyPrice' => 10], + ['@type' => 'EmbeddableDummy', 'dummyName' => 'bar', 'dummyBoolean' => false, 'dummyDate' => '2021-01-01T00:00:00+00:00', 'dummyFloat' => 0.2, 'dummyPrice' => 20], + ], + ]); + } +} diff --git a/tests/Functional/MongoDb/NestedReferenceFilterErrorTest.php b/tests/Functional/MongoDb/NestedReferenceFilterErrorTest.php new file mode 100644 index 00000000000..52275103792 --- /dev/null +++ b/tests/Functional/MongoDb/NestedReferenceFilterErrorTest.php @@ -0,0 +1,99 @@ + + * + * 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\MongoDb; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FourthLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ThirdLevel; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NestedReferenceFilterErrorTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [Dummy::class, RelatedDummy::class, ThirdLevel::class, FourthLevel::class]; + } + + protected function setUp(): void + { + if (!$this->isMongoDB()) { + $this->markTestSkipped('Requires APP_ENV=mongodb.'); + } + $this->recreateSchema([Dummy::class, RelatedDummy::class, ThirdLevel::class, FourthLevel::class]); + + $manager = $this->getManager(); + + $fourthLevel = new FourthLevel(); + $fourthLevel->setLevel(4); + $manager->persist($fourthLevel); + + $thirdLevel = new ThirdLevel(); + $thirdLevel->setLevel(3); + $thirdLevel->setFourthLevel($fourthLevel); + $manager->persist($thirdLevel); + + $namedRelatedDummy = new RelatedDummy(); + $namedRelatedDummy->setName('Hello'); + $namedRelatedDummy->setThirdLevel($thirdLevel); + $manager->persist($namedRelatedDummy); + + $relatedDummy = new RelatedDummy(); + $relatedDummy->setThirdLevel($thirdLevel); + $manager->persist($relatedDummy); + + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + $dummy->setRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($relatedDummy); + $manager->persist($dummy); + + $manager->flush(); + $manager->clear(); + } + + public function testOwningSideBadReferenceTriggers500(): void + { + $response = self::createClient()->request('GET', '/dummies?relatedDummy.thirdLevel.badFourthLevel.level=4', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(500); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $body = $response->toArray(false); + $this->assertSame('/contexts/Error', $body['@context']); + $this->assertSame('hydra:Error', $body['@type']); + $this->assertSame("Cannot use reference 'badFourthLevel' in class 'ThirdLevel' for lookup or graphLookup: dbRef references are not supported.", $body['detail']); + $this->assertArrayHasKey('trace', $body); + } + + public function testNonOwningSideBadReferenceTriggers500(): void + { + $response = self::createClient()->request('GET', '/dummies?relatedDummy.thirdLevel.fourthLevel.badThirdLevel.level=3', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(500); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $body = $response->toArray(false); + $this->assertSame('/contexts/Error', $body['@context']); + $this->assertSame('hydra:Error', $body['@type']); + $this->assertSame("Cannot use reference 'badThirdLevel' in class 'FourthLevel' for lookup or graphLookup: dbRef references are not supported.", $body['detail']); + $this->assertArrayHasKey('trace', $body); + } +} diff --git a/tests/Functional/Security/ContentNegotiationErrorsTest.php b/tests/Functional/Security/ContentNegotiationErrorsTest.php new file mode 100644 index 00000000000..e02bfa88145 --- /dev/null +++ b/tests/Functional/Security/ContentNegotiationErrorsTest.php @@ -0,0 +1,110 @@ + + * + * 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\Security; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ContentNegotiationErrorsTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [Dummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([Dummy::class]); + } + + public function testUnsupportedRequestContentTypeReturns415(): void + { + self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'text/plain', 'Accept' => 'application/ld+json'], + 'body' => 'something', + ], + ); + + $this->assertResponseStatusCodeSame(415); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + 'detail' => 'The content-type "text/plain" is not supported. Supported MIME types are "application/ld+json", "application/hal+json", "application/vnd.api+json", "application/xml", "text/xml", "application/json", "text/html", "application/graphql", "multipart/form-data".', + ]); + } + + public function testUnsupportedAcceptHeaderReturns406(): void + { + self::createClient()->request('GET', '/dummies', ['headers' => ['Accept' => 'text/plain']]); + + $this->assertResponseStatusCodeSame(406); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + 'detail' => 'Requested format "text/plain" is not supported. Supported MIME types are "application/ld+json", "application/hal+json", "application/vnd.api+json", "application/xml", "text/xml", "application/json", "text/html", "application/graphql", "multipart/form-data".', + ]); + } + + public function testAcceptHeaderDifferentFromUrlFormatReturns406(): void + { + self::createClient()->request('GET', '/dummies/1.json', ['headers' => ['Accept' => 'text/xml']]); + + $this->assertResponseStatusCodeSame(406); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + 'detail' => 'Requested format "text/xml" is not supported. Supported MIME types are "application/json".', + ]); + } + + public function testInvalidAcceptHeaderReturns406(): void + { + self::createClient()->request('GET', '/dummies/1', ['headers' => ['Accept' => 'invalid']]); + + $this->assertResponseStatusCodeSame(406); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + 'detail' => 'Requested format "invalid" is not supported. Supported MIME types are "application/ld+json", "application/hal+json", "application/vnd.api+json", "application/xml", "text/xml", "application/json", "text/html", "application/graphql", "multipart/form-data".', + ]); + } + + public function testInvalidUrlFormatReturns404(): void + { + self::createClient()->request('GET', '/dummies/1.invalid'); + + $this->assertResponseStatusCodeSame(404); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + 'detail' => 'Format "invalid" is not supported', + ]); + } + + public function testInvalidUrlFormatAndAcceptReturns404(): void + { + self::createClient()->request('GET', '/dummies/1.invalid', ['headers' => ['Accept' => 'text/invalid']]); + + $this->assertResponseStatusCodeSame(404); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + 'detail' => 'Format "invalid" is not supported', + ]); + } +} diff --git a/tests/Functional/Security/SecurityHeadersTest.php b/tests/Functional/Security/SecurityHeadersTest.php new file mode 100644 index 00000000000..cacbf430537 --- /dev/null +++ b/tests/Functional/Security/SecurityHeadersTest.php @@ -0,0 +1,79 @@ + + * + * 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\Security; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class SecurityHeadersTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [Dummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([Dummy::class]); + } + + public function testCollectionResponseIncludesSecurityHeaders(): void + { + self::createClient()->request('GET', '/dummies', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertResponseHeaderSame('x-content-type-options', 'nosniff'); + $this->assertResponseHeaderSame('x-frame-options', 'deny'); + } + + public function testDeserializationErrorResponseIncludesSecurityHeaders(): void + { + self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'body' => '{"name": 1}', + ], + ); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('x-content-type-options', 'nosniff'); + $this->assertResponseHeaderSame('x-frame-options', 'deny'); + } + + public function testValidationErrorResponseIncludesSecurityHeaders(): void + { + self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'body' => '{"name": ""}', + ], + ); + + $this->assertResponseStatusCodeSame(422); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertResponseHeaderSame('x-content-type-options', 'nosniff'); + $this->assertResponseHeaderSame('x-frame-options', 'deny'); + } +} diff --git a/tests/Functional/Security/StrongTypingTest.php b/tests/Functional/Security/StrongTypingTest.php new file mode 100644 index 00000000000..d42b6a8aab9 --- /dev/null +++ b/tests/Functional/Security/StrongTypingTest.php @@ -0,0 +1,224 @@ + + * + * 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\Security; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedOwnedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedOwningDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class StrongTypingTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [Dummy::class, RelatedDummy::class, RelatedOwnedDummy::class, RelatedOwningDummy::class]; + } + + protected function setUp(): void + { + $this->recreateSchema([Dummy::class]); + } + + public function testIgnoreUnsupportedAttributes(): void + { + self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['name' => 'Not existing', 'unsupported' => true]), + ], + ); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals([ + '@context' => '/contexts/Dummy', + '@id' => '/dummies/1', + '@type' => 'Dummy', + 'description' => null, + 'dummy' => null, + 'dummyBoolean' => null, + 'dummyDate' => null, + 'dummyFloat' => null, + 'dummyPrice' => null, + 'relatedDummy' => null, + 'relatedDummies' => [], + 'jsonData' => [], + 'arrayData' => [], + 'name_converted' => null, + 'relatedOwnedDummy' => null, + 'relatedOwningDummy' => null, + 'id' => 1, + 'name' => 'Not existing', + 'alias' => null, + 'foo' => null, + ]); + } + + public function testNullValueForRequiredStringTriggersTypeError(): void + { + self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['name' => null]), + ], + ); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'detail' => 'The type of the "name" attribute must be "string", "NULL" given.', + ]); + } + + public function testStringInsteadOfIriOnRelationTriggersInvalidIri(): void + { + $response = self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['name' => 'Foo', 'relatedDummy' => '1']), + ], + ); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'detail' => 'Invalid IRI "1".', + ]); + $this->assertArrayHasKey('trace', $response->toArray(false)); + } + + public function testInvalidDateStringIsRejected(): void + { + self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['name' => 'Invalid date', 'dummyDate' => 'Invalid']), + ], + ); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + } + + public function testDateWithUnexpectedFormatIsRejected(): void + { + self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['name' => 'Invalid date format', 'dummyDateWithFormat' => '2020-01-01T00:00:00+00:00']), + ], + ); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + } + + public function testStringInsteadOfArrayOnCollectionRelationTriggersTypeError(): void + { + $response = self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['name' => 'Invalid', 'relatedDummies' => 'hello']), + ], + ); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'detail' => 'The type of the "relatedDummies" attribute must be "array", "string" given.', + ]); + $this->assertArrayHasKey('trace', $response->toArray(false)); + } + + public function testAssociativeObjectInsteadOfListOnCollectionTriggersKeyTypeError(): void + { + self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['name' => 'Invalid', 'relatedDummies' => ['a' => new \stdClass(), 'b' => new \stdClass()]]), + ], + ); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'detail' => 'The type of the key "a" must be "int", "string" given.', + ]); + } + + public function testIntegerInsteadOfStringScalarTriggersTypeError(): void + { + self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['name' => 42]), + ], + ); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/Error', + '@type' => 'hydra:Error', + 'detail' => 'The type of the "name" attribute must be "string", "integer" given.', + ]); + } + + public function testIntegerIsAcceptedForFloatProperty(): void + { + self::createClient()->request( + 'POST', + '/dummies', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['name' => 'foo', 'dummyFloat' => 42]), + ], + ); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + } +} diff --git a/tests/Functional/Serializer/ConstructorDeserializationTest.php b/tests/Functional/Serializer/ConstructorDeserializationTest.php new file mode 100644 index 00000000000..d8439a9cfe5 --- /dev/null +++ b/tests/Functional/Serializer/ConstructorDeserializationTest.php @@ -0,0 +1,65 @@ + + * + * 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\Serializer; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyEntityWithConstructor; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ConstructorDeserializationTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [DummyEntityWithConstructor::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('ORM-only fixture; `Entity` → `Document` rewrite mangles "DummyEntityWithConstructor".'); + } + $this->recreateSchema([DummyEntityWithConstructor::class]); + } + + public function testPostHydratesObjectViaConstructor(): void + { + self::createClient()->request( + 'POST', + '/dummy_entity_with_constructors', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['foo' => 'hello', 'bar' => 'world', 'items' => [['foo' => 'bar']]]), + ], + ); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonContains([ + '@context' => '/contexts/DummyEntityWithConstructor', + '@id' => '/dummy_entity_with_constructors/1', + '@type' => 'DummyEntityWithConstructor', + 'id' => 1, + 'foo' => 'hello', + 'bar' => 'world', + 'items' => [['@type' => 'DummyObjectWithoutConstructor', 'foo' => 'bar']], + 'baz' => null, + ]); + } +} diff --git a/tests/Functional/Serializer/DynamicGroupsTest.php b/tests/Functional/Serializer/DynamicGroupsTest.php new file mode 100644 index 00000000000..70bb7de98bd --- /dev/null +++ b/tests/Functional/Serializer/DynamicGroupsTest.php @@ -0,0 +1,49 @@ + + * + * 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\Serializer; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationGroupImpactOnCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationGroupImpactOnCollectionRelation; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class DynamicGroupsTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [RelationGroupImpactOnCollection::class, RelationGroupImpactOnCollectionRelation::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + } + + public function testDynamicGroupContextIncludesNestedField(): void + { + $response = self::createClient()->request('GET', '/relation_group_impact_on_collections/1', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $body = $response->toArray(); + $this->assertSame('foo', $body['related']['title']); + } +} diff --git a/tests/Functional/Serializer/EmptyArrayAsObjectTest.php b/tests/Functional/Serializer/EmptyArrayAsObjectTest.php new file mode 100644 index 00000000000..876a7237056 --- /dev/null +++ b/tests/Functional/Serializer/EmptyArrayAsObjectTest.php @@ -0,0 +1,52 @@ + + * + * 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\Serializer; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Model\EmptyArrayAsObject; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class EmptyArrayAsObjectTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [EmptyArrayAsObject::class]; + } + + public function testGetResourcePreservesEmptyArrayAsObject(): void + { + self::createClient()->request('GET', '/empty_array_as_objects/5', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ + "@context": "/contexts/EmptyArrayAsObject", + "@id": "/empty_array_as_objects/6", + "@type": "EmptyArrayAsObject", + "id": 6, + "emptyArray": [], + "emptyArrayAsObject": {}, + "arrayObjectAsArray": [], + "arrayObject": {}, + "stringArray": ["foo", "bar"], + "objectArray": {"foo": 67, "bar": "baz"} +} +JSON); + } +} diff --git a/features/serializer/group_filter.feature b/tests/Functional/Serializer/GroupFilterTest.php similarity index 52% rename from features/serializer/group_filter.feature rename to tests/Functional/Serializer/GroupFilterTest.php index 95de871d408..6540516b346 100644 --- a/features/serializer/group_filter.feature +++ b/tests/Functional/Serializer/GroupFilterTest.php @@ -1,18 +1,84 @@ -Feature: Filter with serialization groups on items and collections - In order to retrieve, create and update resources or large collections of resources - As a client software developer - I need to retrieve, create and update resources or collections of resources with serialization groups - - @createSchema - Scenario: Get a collection of resources by group dummy_foo without overriding - Given there are 10 dummy group objects - When I send a "GET" request to "/dummy_groups?groups[]=dummy_foo" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + + * + * 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\Serializer; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ORM\EntityManagerInterface; + +final class GroupFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + private static bool $fixturesLoaded = false; + + public static function getResources(): array { + return [DummyGroup::class]; + } + + public static function tearDownAfterClass(): void + { + self::$fixturesLoaded = false; + parent::tearDownAfterClass(); + } + + protected function loadFixtures(): void + { + if (self::$fixturesLoaded) { + return; + } + if ($this->isMongoDB()) { + $this->markTestSkipped('ORM-only fixture; direct EntityManager persist of Entity\\DummyGroup is not portable to DocumentManager.'); + } + self::createClient(); + $this->recreateSchema([DummyGroup::class]); + + /** @var EntityManagerInterface $manager */ + $manager = $this->getManager(); + + for ($i = 1; $i <= 10; ++$i) { + $group = new DummyGroup(); + foreach (['foo', 'bar', 'baz', 'qux'] as $field) { + $group->{$field} = ucfirst($field).' #'.$i; + } + $manager->persist($group); + } + $manager->flush(); + $manager->clear(); + self::$fixturesLoaded = true; + } + + public function testGetACollectionOfResourcesByGroupDummyFooWithoutOverriding(): void + { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups?groups[]=dummy_foo', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -74,16 +140,23 @@ } } } - """ +JSON); + } - Scenario: Get a collection of resources by group dummy_foo with overriding - When I send a "GET" request to "/dummy_groups?override_groups[]=dummy_foo" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetACollectionOfResourcesByGroupDummyFooWithOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups?override_groups[]=dummy_foo', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -136,16 +209,23 @@ } } } - """ +JSON); + } - Scenario: Get a collection of resources by groups dummy_foo, dummy_qux and without overriding - When I send a "GET" request to "/dummy_groups?groups[]=dummy_foo&groups[]=dummy_qux" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetACollectionOfResourcesByGroupsDummyFooDummyQuxAndWithoutOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups?groups[]=dummy_foo&groups[]=dummy_qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -210,16 +290,23 @@ } } } - """ +JSON); + } - Scenario: Get a collection of resources by groups dummy_foo, dummy_qux and with overriding - When I send a "GET" request to "/dummy_groups?override_groups[]=dummy_foo&override_groups[]=dummy_qux" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetACollectionOfResourcesByGroupsDummyFooDummyQuxAndWithOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups?override_groups[]=dummy_foo&override_groups[]=dummy_qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -275,17 +362,23 @@ } } } - """ - +JSON); + } - Scenario: Get a collection of resources by groups dummy_foo, dummy_qux, without overriding and with whitelist - When I send a "GET" request to "/dummy_groups?whitelisted_groups[]=dummy_foo&whitelisted_groups[]=dummy_qux" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetACollectionOfResourcesByGroupsDummyFooDummyQuxWithoutOverridingAndWithWhitelist(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups?whitelisted_groups[]=dummy_foo&whitelisted_groups[]=dummy_qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -347,16 +440,23 @@ } } } - """ +JSON); + } - Scenario: Get a collection of resources by groups dummy_foo, dummy_qux with overriding and with whitelist - When I send a "GET" request to "/dummy_groups?override_whitelisted_groups[]=dummy_foo&override_whitelisted_groups[]=dummy_qux" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetACollectionOfResourcesByGroupsDummyFooDummyQuxWithOverridingAndWithWhitelist(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups?override_whitelisted_groups[]=dummy_foo&override_whitelisted_groups[]=dummy_qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -409,16 +509,23 @@ } } } - """ +JSON); + } - Scenario: Get a collection of resources by group empty and without overriding - When I send a "GET" request to "/dummy_groups?groups[]=" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetACollectionOfResourcesByGroupEmptyAndWithoutOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups?groups[]=', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -480,16 +587,23 @@ } } } - """ +JSON); + } - Scenario: Get a collection of resources by group empty and with overriding - When I send a "GET" request to "/dummy_groups?override_groups[]=" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetACollectionOfResourcesByGroupEmptyAndWithOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups?override_groups[]=', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -539,16 +653,23 @@ } } } - """ +JSON); + } - Scenario: Get a resource by group dummy_foo without overriding - When I send a "GET" request to "/dummy_groups/1?groups[]=dummy_foo" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetAResourceByGroupDummyFooWithoutOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups/1?groups[]=dummy_foo', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -562,16 +683,23 @@ "additionalProperties": false, "required": ["@context", "@id", "@type", "id", "foo", "bar", "baz"] } - """ +JSON); + } - Scenario: Get a resource by group dummy_foo with overriding - When I send a "GET" request to "/dummy_groups/1?override_groups[]=dummy_foo" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetAResourceByGroupDummyFooWithOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups/1?override_groups[]=dummy_foo', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -582,16 +710,23 @@ "additionalProperties": false, "required": ["@context", "@id", "@type", "foo"] } - """ +JSON); + } - Scenario: Get a resource by groups dummy_foo, dummy_qux and without overriding - When I send a "GET" request to "/dummy_groups/1?groups[]=dummy_foo&groups[]=dummy_qux" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetAResourceByGroupsDummyFooDummyQuxAndWithoutOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups/1?groups[]=dummy_foo&groups[]=dummy_qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -606,16 +741,23 @@ "additionalProperties": false, "required": ["@context", "@id", "@type", "id", "foo", "bar", "baz", "qux"] } - """ +JSON); + } - Scenario: Get a resource by groups dummy_foo, dummy_qux and with overriding - When I send a "GET" request to "/dummy_groups/1?override_groups[]=dummy_foo&override_groups[]=dummy_qux" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetAResourceByGroupsDummyFooDummyQuxAndWithOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups/1?override_groups[]=dummy_foo&override_groups[]=dummy_qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -627,16 +769,23 @@ "additionalProperties": false, "required": ["@context", "@id", "@type", "foo", "qux"] } - """ +JSON); + } - Scenario: Get a resource by groups dummy_foo, dummy_qux and without overriding and with whitelist - When I send a "GET" request to "/dummy_groups/1?whitelisted_groups[]=dummy_foo&whitelisted_groups[]=dummy_qux" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetAResourceByGroupsDummyFooDummyQuxAndWithoutOverridingAndWithWhitelist(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups/1?whitelisted_groups[]=dummy_foo&whitelisted_groups[]=dummy_qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -650,16 +799,23 @@ "additionalProperties": false, "required": ["@context", "@id", "@type", "id", "foo", "bar", "baz"] } - """ +JSON); + } - Scenario: Get a resource by groups dummy_foo, dummy_qux and with overriding and with whitelist - When I send a "GET" request to "/dummy_groups/1?override_whitelisted_groups[]=dummy_foo&override_whitelisted_groups[]=dummy_qux" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetAResourceByGroupsDummyFooDummyQuxAndWithOverridingAndWithWhitelist(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups/1?override_whitelisted_groups[]=dummy_foo&override_whitelisted_groups[]=dummy_qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -670,16 +826,23 @@ "additionalProperties": false, "required": ["@context", "@id", "@type", "foo"] } - """ +JSON); + } - Scenario: Get a resource by group empty and without overriding - When I send a "GET" request to "/dummy_groups/1?groups[]=" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetAResourceByGroupEmptyAndWithoutOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups/1?groups[]=', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -693,16 +856,23 @@ "additionalProperties": false, "required": ["@context", "@id", "@type", "id", "foo", "bar", "baz"] } - """ +JSON); + } - Scenario: Get a resource by group empty and with overriding - When I send a "GET" request to "/dummy_groups/1?override_groups[]=" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + public function testGetAResourceByGroupEmptyAndWithOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_groups/1?override_groups[]=', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyGroup$"}, @@ -712,25 +882,30 @@ "additionalProperties": false, "required": ["@context", "@id", "@type"] } - """ +JSON); + } - Scenario: Create a resource by group dummy_foo and without overriding - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_groups?groups[]=dummy_foo" with body: - """ + public function testCreateAResourceByGroupDummyFooAndWithoutOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_groups?groups[]=dummy_foo', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "baz": "Baz", "qux": "Qux" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyGroup", "@id": "/dummy_groups/11", "@type": "DummyGroup", @@ -739,49 +914,59 @@ "bar": "Bar", "baz": null } - """ +JSON); + } - Scenario: Create a resource by group dummy_foo and with overriding - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_groups?override_groups[]=dummy_foo" with body: - """ + public function testCreateAResourceByGroupDummyFooAndWithOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_groups?override_groups[]=dummy_foo', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "baz": "Baz", "qux": "Qux" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyGroup", "@id": "/dummy_groups/12", "@type": "DummyGroup", "foo": "Foo" } - """ +JSON); + } - Scenario: Create a resource by groups dummy_foo, dummy_baz, dummy_qux and without overriding - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_groups?groups[]=dummy_foo&groups[]=dummy_baz&groups[]=dummy_qux" with body: - """ + public function testCreateAResourceByGroupsDummyFooDummyBazDummyQuxAndWithoutOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_groups?groups[]=dummy_foo&groups[]=dummy_baz&groups[]=dummy_qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "baz": "Baz", "qux": "Qux" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyGroup", "@id": "/dummy_groups/13", "@type": "DummyGroup", @@ -791,25 +976,30 @@ "baz": "Baz", "qux": "Qux" } - """ +JSON); + } - Scenario: Create a resource by groups dummy_foo, dummy_baz, dummy_qux and with overriding - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_groups?override_groups[]=dummy_foo&override_groups[]=dummy_baz&override_groups[]=dummy_qux" with body: - """ + public function testCreateAResourceByGroupsDummyFooDummyBazDummyQuxAndWithOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_groups?override_groups[]=dummy_foo&override_groups[]=dummy_baz&override_groups[]=dummy_qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "baz": "Baz", "qux": "Qux" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyGroup", "@id": "/dummy_groups/14", "@type": "DummyGroup", @@ -817,25 +1007,30 @@ "baz": "Baz", "qux": "Qux" } - """ +JSON); + } - Scenario: Create a resource by groups dummy, dummy_baz, without overriding - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_groups?groups[]=dummy&groups[]=dummy_baz" with body: - """ + public function testCreateAResourceByGroupsDummyDummyBazWithoutOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_groups?groups[]=dummy&groups[]=dummy_baz', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "baz": "Baz", "qux": "Qux" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyGroup", "@id": "/dummy_groups/15", "@type": "DummyGroup", @@ -845,25 +1040,30 @@ "baz": "Baz", "qux": "Qux" } - """ +JSON); + } - Scenario: Create a resource by groups dummy, dummy_baz and with overriding - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_groups?override_groups[]=dummy&override_groups[]=dummy_baz" with body: - """ + public function testCreateAResourceByGroupsDummyDummyBazAndWithOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_groups?override_groups[]=dummy&override_groups[]=dummy_baz', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "baz": "Baz", "qux": "Qux" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyGroup", "@id": "/dummy_groups/16", "@type": "DummyGroup", @@ -873,25 +1073,30 @@ "baz": "Baz", "qux": "Qux" } - """ +JSON); + } - Scenario: Create a resource by group empty and without overriding - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_groups?groups[]=" with body: - """ + public function testCreateAResourceByGroupEmptyAndWithoutOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_groups?groups[]=', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "baz": "Baz", "qux": "Qux" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyGroup", "@id": "/dummy_groups/17", "@type": "DummyGroup", @@ -900,48 +1105,58 @@ "bar": "Bar", "baz": null } - """ +JSON); + } - Scenario: Create a resource by group empty and with overriding - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_groups?override_groups[]=" with body: - """ + public function testCreateAResourceByGroupEmptyAndWithOverriding(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_groups?override_groups[]=', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "baz": "Baz", "qux": "Qux" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyGroup", "@id": "/dummy_groups/18", "@type": "DummyGroup" } - """ +JSON); + } - Scenario: Create a resource by groups dummy, dummy_baz, without overriding and with whitelist - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_groups?whitelisted_groups[]=dummy&whitelisted_groups[]=dummy_baz" with body: - """ + public function testCreateAResourceByGroupsDummyDummyBazWithoutOverridingAndWithWhitelist(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_groups?whitelisted_groups[]=dummy&whitelisted_groups[]=dummy_baz', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "baz": "Baz", "qux": "Qux" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyGroup", "@id": "/dummy_groups/19", "@type": "DummyGroup", @@ -950,28 +1165,35 @@ "bar": "Bar", "baz": "Baz" } - """ +JSON); + } - Scenario: Create a resource by groups dummy, dummy_baz, with overriding and with whitelist - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_groups?override_whitelisted_groups[]=dummy&override_whitelisted_groups[]=dummy_baz" with body: - """ + public function testCreateAResourceByGroupsDummyDummyBazWithOverridingAndWithWhitelist(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_groups?override_whitelisted_groups[]=dummy&override_whitelisted_groups[]=dummy_baz', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "baz": "Baz", "qux": "Qux" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyGroup", "@id": "/dummy_groups/20", "@type": "DummyGroup", "baz": "Baz" } - """ +JSON); + } +} diff --git a/tests/Functional/Serializer/GroupsRelatedTest.php b/tests/Functional/Serializer/GroupsRelatedTest.php new file mode 100644 index 00000000000..bd178b68c05 --- /dev/null +++ b/tests/Functional/Serializer/GroupsRelatedTest.php @@ -0,0 +1,67 @@ + + * + * 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\Serializer; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationGroupImpactOnCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationGroupImpactOnCollectionRelation; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class GroupsRelatedTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [RelationGroupImpactOnCollection::class, RelationGroupImpactOnCollectionRelation::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('ORM-only fixture; no Document version of RelationGroupImpactOnCollection.'); + } + } + + public function testItemExposesGroupedNestedProperty(): void + { + $response = self::createClient()->request('GET', '/relation_group_impact_on_collections/1', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $body = $response->toArray(); + $this->assertSame('foo', $body['related']['title']); + } + + public function testCollectionInlinesRelationAsIri(): void + { + $response = self::createClient()->request('GET', '/relation_group_impact_on_collections', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $body = $response->toArray(); + $this->assertSame('/relation_group_impact_on_collection_relations/1', $body['hydra:member'][0]['related']); + } + + public function testDynamicGroupsViaCustomNormalizerAddsGroupedField(): void + { + $response = self::createClient()->request('GET', '/custom_normalizer_relation_group_impact_on_collection', ['headers' => ['Accept' => 'application/ld+json']]); + + $this->assertResponseStatusCodeSame(200); + $body = $response->toArray(); + $this->assertSame('foo', $body['related']['title']); + } +} diff --git a/features/serializer/property_filter.feature b/tests/Functional/Serializer/PropertyFilterTest.php similarity index 50% rename from features/serializer/property_filter.feature rename to tests/Functional/Serializer/PropertyFilterTest.php index 7da85deb692..4c152293362 100644 --- a/features/serializer/property_filter.feature +++ b/tests/Functional/Serializer/PropertyFilterTest.php @@ -1,18 +1,91 @@ -Feature: Filter with serialization attributes on items and collections - In order to retrieve, create and update resources or large collection of resources - As a client software developer - I need to retrieve, create and update resources or collections of resources with serialization attributes - - @createSchema - Scenario: Get a collection of resources by attributes id, foo and bar - Given there are 10 dummy property objects - When I send a "GET" request to "/dummy_properties?properties[]=id&properties[]=foo&properties[]=bar&properties[]=name_converted" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ + + * + * 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\Serializer; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProperty; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ORM\EntityManagerInterface; + +final class PropertyFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [DummyProperty::class, DummyGroup::class]; + } + + private static bool $fixturesLoaded = false; + + protected function loadFixtures(): void { + if (self::$fixturesLoaded) { + return; + } + if ($this->isMongoDB()) { + $this->markTestSkipped('ORM-only fixture; direct EntityManager persist of Entity\\DummyGroup is not portable to DocumentManager.'); + } + self::createClient(); + $this->recreateSchema([DummyProperty::class, DummyGroup::class]); + + /** @var EntityManagerInterface $manager */ + $manager = $this->getManager(); + + for ($i = 1; $i <= 10; ++$i) { + $group = new DummyGroup(); + $property = new DummyProperty(); + + foreach (['foo', 'bar', 'baz'] as $field) { + $property->{$field} = $group->{$field} = ucfirst($field).' #'.$i; + } + $property->nameConverted = "NameConverted #{$i}"; + $property->group = $group; + + $manager->persist($group); + $manager->persist($property); + } + $manager->flush(); + $manager->clear(); + self::$fixturesLoaded = true; + } + + public static function tearDownAfterClass(): void + { + self::$fixturesLoaded = false; + parent::tearDownAfterClass(); + } + + public function testGetACollectionOfResourcesByAttributesIdFooAndBar(): void + { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_properties?properties[]=id&properties[]=foo&properties[]=bar&properties[]=name_converted', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyProperty$"}, @@ -46,16 +119,23 @@ } } } - """ - - Scenario: Get a collection of resources by attributes foo, bar, group.baz and group.qux - When I send a "GET" request to "/dummy_properties?properties[]=foo&properties[]=bar&properties[group][]=baz&properties[group][]=qux" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetACollectionOfResourcesByAttributesFooBarGroupBazAndGroupQux(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_properties?properties[]=foo&properties[]=bar&properties[group][]=baz&properties[group][]=qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyProperty$"}, @@ -96,16 +176,23 @@ } } } - """ - - Scenario: Get a collection of resources by attributes foo, bar - When I send a "GET" request to "/dummy_properties?whitelisted_properties[]=foo&whitelisted_properties[]=bar&whitelisted_properties[]=name_converted" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetACollectionOfResourcesByAttributesFooBar(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_properties?whitelisted_properties[]=foo&whitelisted_properties[]=bar&whitelisted_properties[]=name_converted', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyProperty$"}, @@ -136,16 +223,23 @@ } } } - """ - - Scenario: Get a collection of resources by attributes foo, bar, group.baz and group.qux - When I send a "GET" request to "/dummy_properties?whitelisted_nested_properties[]=foo&whitelisted_nested_properties[]=bar&whitelisted_nested_properties[group][]=baz" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetACollectionOfResourcesByWhitelistedNestedPropertiesFooBarAndGroupBaz(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_properties?whitelisted_nested_properties[]=foo&whitelisted_nested_properties[]=bar&whitelisted_nested_properties[group][]=baz', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyProperty$"}, @@ -185,16 +279,23 @@ } } } - """ - - Scenario: Get a collection of resources by attributes bar not allowed - When I send a "GET" request to "/dummy_properties?whitelisted_properties[]=bar" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetACollectionOfResourcesByAttributesBarNotAllowed(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_properties?whitelisted_properties[]=bar', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyProperty$"}, @@ -223,16 +324,23 @@ } } } - """ - - Scenario: Get a collection of resources by attributes empty - When I send a "GET" request to "/dummy_properties?properties[]=&properties[group][]=" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetACollectionOfResourcesByAttributesEmpty(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_properties?properties[]=&properties[group][]=', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyProperty$"}, @@ -270,16 +378,23 @@ } } } - """ - - Scenario: Get a resource by attributes id, foo and bar - When I send a "GET" request to "/dummy_properties/1?properties[]=id&properties[]=foo&properties[]=bar" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetAResourceByAttributesIdFooAndBar(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_properties/1?properties[]=id&properties[]=foo&properties[]=bar', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyProperty$"}, @@ -292,16 +407,23 @@ "additionalProperties": false, "required": ["@context", "@id", "@type", "id", "foo", "bar"] } - """ - - Scenario: Get a resource by attributes foo, bar, group.baz and group.qux - When I send a "GET" request to "/dummy_properties/1?properties[]=foo&properties[]=bar&properties[group][]=baz&properties[group][]=qux" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetAResourceByAttributesFooBarGroupBazAndGroupQux(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_properties/1?properties[]=foo&properties[]=bar&properties[group][]=baz&properties[group][]=qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyProperty$"}, @@ -323,16 +445,23 @@ "additionalProperties": false, "required": ["@context", "@id", "@type", "foo", "bar", "group"] } - """ - - Scenario: Get a resource by attributes empty - When I send a "GET" request to "/dummy_properties/1?properties[]=&properties[group][]=" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ +JSON); + } + + public function testGetAResourceByAttributesEmpty(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('GET', '/dummy_properties/1?properties[]=&properties[group][]=', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + ], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertMatchesJsonSchema(<<<'JSON' +{ "type": "object", "properties": { "@context": {"pattern": "^/contexts/DummyProperty$"}, @@ -351,36 +480,47 @@ "additionalProperties": false, "required": ["@context", "@id", "@type", "group"] } - """ +JSON); + } - Scenario: Create a resource by attributes foo and bar - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_properties?properties[]=foo&properties[]=bar" with body: - """ + public function testCreateAResourceByAttributesFooAndBar(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_properties?properties[]=foo&properties[]=bar', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar" - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyProperty", "@id": "/dummy_properties/11", "@type": "DummyProperty", "foo": "Foo", "bar": "Bar" } - """ +JSON); + } - Scenario: Create a resource by attributes foo, bar, group.foo, group.baz and group.qux - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_properties?properties[]=foo&properties[]=bar&properties[group][]=foo&properties[group][]=baz&properties[group][]=qux" with body: - """ + public function testCreateAResourceByAttributesFooBarGroupFooGroupBazAndGroupQux(): void { + $this->loadFixtures(); + + $response = self::createClient()->request('POST', '/dummy_properties?properties[]=foo&properties[]=bar&properties[group][]=foo&properties[group][]=baz&properties[group][]=qux', [ + 'headers' => [ + 'Accept' => 'application/ld+json', + 'Content-Type' => 'application/ld+json', + ], + 'body' => '{ "foo": "Foo", "bar": "Bar", "group": { @@ -388,14 +528,13 @@ "baz": "Baz", "qux": "Qux" } - } - """ - Then the response status code should be 201 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be equal to: - """ - { + }', + ]); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ "@context": "/contexts/DummyProperty", "@id": "/dummy_properties/12", "@type": "DummyProperty", @@ -408,4 +547,6 @@ "baz": null } } - """ +JSON); + } +} diff --git a/tests/Functional/Serializer/ValueObjectRelationsTest.php b/tests/Functional/Serializer/ValueObjectRelationsTest.php new file mode 100644 index 00000000000..00d4bfa6af2 --- /dev/null +++ b/tests/Functional/Serializer/ValueObjectRelationsTest.php @@ -0,0 +1,270 @@ + + * + * 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\Serializer; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VoDummyCar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VoDummyDriver; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VoDummyInspection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VoDummyInsuranceCompany; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VoDummyVehicle; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ValueObjectRelationsTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [VoDummyCar::class, VoDummyVehicle::class, VoDummyDriver::class, VoDummyInspection::class, VoDummyInsuranceCompany::class]; + } + + protected function setUp(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('ORM-only fixture; VoDummy hierarchy uses Doctrine ORM-specific cascading expectations.'); + } + $this->recreateSchema(static::getResources()); + } + + public function testPostHydratesValueObjectViaConstructor(): void + { + self::createClient()->request( + 'POST', + '/vo_dummy_cars', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode([ + 'mileage' => 1500, + 'bodyType' => 'suv', + 'make' => 'CustomCar', + 'insuranceCompany' => ['name' => 'Safe Drive Company'], + 'drivers' => [['firstName' => 'John', 'lastName' => 'Doe']], + ]), + ], + ); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ + "@context": "/contexts/VoDummyCar", + "@id": "/vo_dummy_cars/1", + "@type": "VoDummyCar", + "mileage": 1500, + "bodyType": "suv", + "inspections": [], + "make": "CustomCar", + "insuranceCompany": { + "@id": "/vo_dummy_insurance_companies/1", + "@type": "VoDummyInsuranceCompany", + "name": "Safe Drive Company" + }, + "drivers": [{ + "@id": "/vo_dummy_drivers/1", + "@type": "VoDummyDriver", + "firstName": "John", + "lastName": "Doe" + }] +} +JSON); + } + + public function testPostInspectionWithIriRelation(): void + { + $this->createCar(); + + self::createClient()->request( + 'POST', + '/vo_dummy_inspections', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['accepted' => true, 'car' => '/vo_dummy_cars/1']), + ], + ); + + $this->assertResponseStatusCodeSame(201); + $this->assertMatchesJsonSchema(<<<'JSON' +{ + "type": "object", + "required": ["accepted", "performed", "car"], + "properties": { + "accepted": {"enum": [true]}, + "performed": {"format": "date-time"}, + "car": {"enum": ["/vo_dummy_cars/1"]} + } +} +JSON); + } + + public function testLegacyPutKeepsImmutableProperties(): void + { + $this->createCar(); + $this->createInspection(); + + self::createClient()->request( + 'PUT', + '/vo_dummy_inspections/1', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['performed' => '2018-08-24 00:00:00', 'accepted' => false]), + ], + ); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals(<<<'JSON' +{ + "@context": "/contexts/VoDummyInspection", + "@id": "/vo_dummy_inspections/1", + "@type": "VoDummyInspection", + "accepted": true, + "car": "/vo_dummy_cars/1", + "performed": "2018-08-24T00:00:00+00:00" +} +JSON); + } + + public function testPatchKeepsImmutableProperties(): void + { + $this->createCar(); + $this->createInspection(); + + self::createClient()->request( + 'PATCH', + '/vo_dummy_inspections/1', + [ + 'headers' => ['Content-Type' => 'application/merge-patch+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['performed' => '2018-08-24 00:00:00', 'accepted' => false]), + ], + ); + + $this->assertResponseStatusCodeSame(200); + $this->assertJsonEquals(<<<'JSON' +{ + "@context": "/contexts/VoDummyInspection", + "@id": "/vo_dummy_inspections/1", + "@type": "VoDummyInspection", + "accepted": true, + "car": "/vo_dummy_cars/1", + "performed": "2018-08-24T00:00:00+00:00" +} +JSON); + } + + public function testMissingRequiredConstructorParameterReturnsError(): void + { + self::createClient()->request( + 'POST', + '/vo_dummy_cars', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode([ + 'mileage' => 1500, + 'make' => 'CustomCar', + 'insuranceCompany' => ['name' => 'Safe Drive Company'], + ]), + ], + ); + + $this->assertResponseStatusCodeSame(400); + $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); + $this->assertStringContainsString('; rel="http://www.w3.org/ns/json-ld#error"', self::getClient()->getResponse()->headers->get('link') ?? ''); + $this->assertMatchesJsonSchema(<<<'JSON' +{ + "type": "object", + "required": ["@type", "detail"], + "properties": { + "@type": {"type": "string", "pattern": "^hydra:Error$"}, + "detail": {"pattern": "^Cannot create an instance of \"ApiPlatform\\\\Tests\\\\Fixtures\\\\TestBundle\\\\(Document|Entity)\\\\VoDummyCar\" from serialized data because its constructor requires the following parameters to be present : \"\\$drivers\".$"} + } +} +JSON); + } + + public function testDefaultConstructorParameterIsApplied(): void + { + self::createClient()->request( + 'POST', + '/vo_dummy_cars', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode([ + 'mileage' => 1500, + 'make' => 'CustomCar', + 'insuranceCompany' => ['name' => 'Safe Drive Company'], + 'drivers' => [['firstName' => 'John', 'lastName' => 'Doe']], + ]), + ], + ); + + $this->assertResponseStatusCodeSame(201); + $this->assertResponseHeaderSame('content-type', 'application/ld+json; charset=utf-8'); + $this->assertJsonEquals(<<<'JSON' +{ + "@context": "/contexts/VoDummyCar", + "@id": "/vo_dummy_cars/1", + "@type": "VoDummyCar", + "mileage": 1500, + "bodyType": "coupe", + "inspections": [], + "make": "CustomCar", + "insuranceCompany": { + "@id": "/vo_dummy_insurance_companies/1", + "@type": "VoDummyInsuranceCompany", + "name": "Safe Drive Company" + }, + "drivers": [{ + "@id": "/vo_dummy_drivers/1", + "@type": "VoDummyDriver", + "firstName": "John", + "lastName": "Doe" + }] +} +JSON); + } + + private function createCar(): void + { + self::createClient()->request( + 'POST', + '/vo_dummy_cars', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode([ + 'mileage' => 1500, + 'bodyType' => 'suv', + 'make' => 'CustomCar', + 'insuranceCompany' => ['name' => 'Safe Drive Company'], + 'drivers' => [['firstName' => 'John', 'lastName' => 'Doe']], + ]), + ], + ); + } + + private function createInspection(): void + { + self::createClient()->request( + 'POST', + '/vo_dummy_inspections', + [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => json_encode(['accepted' => true, 'car' => '/vo_dummy_cars/1']), + ], + ); + } +} diff --git a/tools/feature_to_phpunit.php b/tools/feature_to_phpunit.php new file mode 100644 index 00000000000..16bcc82b9eb --- /dev/null +++ b/tools/feature_to_phpunit.php @@ -0,0 +1,219 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +$args = array_slice($argv, 1); +$setupHook = ''; + +if ($args && '--setup=' === substr($args[0], 0, 8)) { + $setupHook = substr(array_shift($args), 8); +} + +if (!$args) { + fwrite(\STDERR, "usage: php {$argv[0]} [--setup=callable] [...]\n"); + exit(1); +} + +foreach ($args as $featurePath) { + if (!is_file($featurePath)) { + fwrite(\STDERR, "missing: $featurePath\n"); + exit(2); + } + + $src = file_get_contents($featurePath); + $lines = preg_split('/\r?\n/', $src); + + $scenarios = []; + $cur = null; + $inBody = false; + $body = ''; + $bodyTarget = 'json'; + + $flush = static function () use (&$cur, &$scenarios): void { + if (null !== $cur) { + $scenarios[] = $cur; + } + $cur = null; + }; + + foreach ($lines as $line) { + if (preg_match('/^\s*Scenario:\s*(.+)$/', $line, $m)) { + $flush(); + $cur = ['title' => trim($m[1]), 'url' => null, 'httpMethod' => 'GET', 'status' => 200, 'json' => null, 'jsonMode' => null, 'requestBody' => null, 'contentType' => null, 'expectedContentType' => null, 'jsonNodes' => [], 'jsonNodeExists' => []]; + $inBody = false; + $body = ''; + continue; + } + + if (null === $cur) { + continue; + } + + if ($inBody) { + if (preg_match('/^\s*"""\s*$/', $line)) { + if ('requestBody' === $bodyTarget) { + $cur['requestBody'] = trim($body); + } else { + $cur['json'] = trim($body); + } + $inBody = false; + $body = ''; + $bodyTarget = 'json'; + continue; + } + $body .= $line."\n"; + continue; + } + + if (preg_match('/I add "Content-Type" header equal to "([^"]+)"/', $line, $m)) { + $cur['contentType'] = $m[1]; + continue; + } + + if (preg_match('/the header "Content-Type" should be equal to "([^"]+)"/', $line, $m)) { + $cur['expectedContentType'] = $m[1]; + continue; + } + + if (preg_match('/I send a "([A-Z]+)" request to "([^"]+)"\s+with body:\s*$/', $line, $m)) { + $cur['httpMethod'] = $m[1]; + $cur['url'] = $m[2]; + $bodyTarget = 'requestBody'; + continue; + } + + if (preg_match('/I send a "([A-Z]+)" request to "([^"]+)"/', $line, $m)) { + $cur['httpMethod'] = $m[1]; + $cur['url'] = $m[2]; + continue; + } + + if (preg_match('/response status code should be (\d+)/', $line, $m)) { + $cur['status'] = (int) $m[1]; + continue; + } + + if (preg_match('/the JSON node "([^"]+)" should be equal to "([^"]*)"/', $line, $m)) { + $cur['jsonNodes'][$m[1]] = $m[2]; + continue; + } + + if (preg_match("/the JSON node \"([^\"]+)\" should be equal to '([^']*)'/", $line, $m)) { + $cur['jsonNodes'][$m[1]] = $m[2]; + continue; + } + + if (preg_match('/the JSON node "([^"]+)" should exist/', $line, $m)) { + $cur['jsonNodeExists'][] = $m[1]; + continue; + } + + if (preg_match('/JSON should be equal to:\s*$/', $line)) { + $cur['jsonMode'] = 'equals'; + continue; + } + + if (preg_match('/JSON should be a superset of:\s*$/', $line)) { + $cur['jsonMode'] = 'contains'; + continue; + } + + if (preg_match('/JSON should be valid according to this schema:\s*$/', $line)) { + $cur['jsonMode'] = 'schema'; + continue; + } + + if (preg_match('/^\s*"""\s*$/', $line)) { + $inBody = true; + $body = ''; + continue; + } + } + $flush(); + + $used = []; + foreach ($scenarios as $scenario) { + $base = makeMethodName($scenario['title']); + $name = $base; + $i = 2; + while (isset($used[$name])) { + $name = $base.$i; + ++$i; + } + $used[$name] = true; + $scenario['method'] = $name; + $scenario['setupHook'] = $setupHook; + echo emitMethod($scenario); + } +} + +function emitMethod(array $s): string +{ + $method = $s['method'] ?? makeMethodName($s['title']); + $url = $s['url'] ?? ''; + $status = $s['status']; + $httpMethod = $s['httpMethod'] ?? 'GET'; + + $out = "\n public function {$method}(): void\n {\n"; + if (!empty($s['setupHook'])) { + $out .= " \$this->{$s['setupHook']}();\n\n"; + } + $headers = ['Accept' => 'application/ld+json']; + if (!empty($s['contentType'])) { + $headers['Content-Type'] = $s['contentType']; + } + $requestOptions = []; + foreach ($headers as $k => $v) { + $requestOptions['headers'][$k] = $v; + } + if (!empty($s['requestBody'])) { + $requestOptions['body'] = $s['requestBody']; + } + $requestOptionsExport = var_export($requestOptions, true); + $needsResponse = !empty($s['jsonNodeExists']); + $assignment = $needsResponse ? '$response = ' : ''; + $out .= " {$assignment}self::createClient()->request('{$httpMethod}', ".var_export($url, true).", {$requestOptionsExport});\n\n"; + $out .= " \$this->assertResponseStatusCodeSame({$status});\n"; + if (!empty($s['expectedContentType'])) { + $out .= " \$this->assertResponseHeaderSame('content-type', ".var_export($s['expectedContentType'], true).");\n"; + } + + if (null !== $s['json']) { + $heredoc = "<<<'JSON'\n".$s['json']."\nJSON"; + if ('schema' === $s['jsonMode']) { + $out .= " \$this->assertMatchesJsonSchema({$heredoc});\n"; + } else { + $assert = 'contains' === $s['jsonMode'] ? 'assertJsonContains' : 'assertJsonEquals'; + $out .= " \$this->{$assert}({$heredoc});\n"; + } + } + + if (!empty($s['jsonNodes'])) { + $out .= ' $this->assertJsonContains('.var_export($s['jsonNodes'], true).");\n"; + } + + foreach ($s['jsonNodeExists'] as $node) { + $out .= ' $this->assertArrayHasKey('.var_export($node, true).", \$response->toArray(false));\n"; + } + + $out .= " }\n"; + + return $out; +} + +function makeMethodName(string $title): string +{ + $clean = preg_replace('/[^A-Za-z0-9]+/', ' ', $title); + $words = array_filter(array_map('ucfirst', explode(' ', strtolower($clean)))); + + return 'test'.implode('', $words); +} From d371f65fb0fc24123b8f0bfd6818e300eb6f64fc Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 28 May 2026 15:02:46 +0200 Subject: [PATCH 15/84] chore: drop ad-hoc mongodb deps and the throwaway scaffolder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Behat→PHPUnit migration scaffolder under `tools/` was a one-shot helper; it has no place in the published source tree. `doctrine/mongodb-odm` and `doctrine/mongodb-odm-bundle` are installed on demand by the `mongodb` CI job (and likewise on contributor machines), not via require-dev — the lines added in #8202 made them load unconditionally and bloated the install footprint for everyone else. --- composer.json | 2 - tools/feature_to_phpunit.php | 219 ----------------------------------- 2 files changed, 221 deletions(-) delete mode 100644 tools/feature_to_phpunit.php diff --git a/composer.json b/composer.json index 6ae2bbb50fb..7ffc3c950ee 100644 --- a/composer.json +++ b/composer.json @@ -130,8 +130,6 @@ "doctrine/common": "^3.2.2", "doctrine/dbal": "^4.0", "doctrine/doctrine-bundle": "^2.11 || ^3.1", - "doctrine/mongodb-odm": "^2.16", - "doctrine/mongodb-odm-bundle": "^5.6", "doctrine/orm": "^2.17 || ^3.0", "elasticsearch/elasticsearch": "^7.17 || ^8.4 || ^9.0", "friends-of-behat/mink-browserkit-driver": "^1.3.1", diff --git a/tools/feature_to_phpunit.php b/tools/feature_to_phpunit.php deleted file mode 100644 index 16bcc82b9eb..00000000000 --- a/tools/feature_to_phpunit.php +++ /dev/null @@ -1,219 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -$args = array_slice($argv, 1); -$setupHook = ''; - -if ($args && '--setup=' === substr($args[0], 0, 8)) { - $setupHook = substr(array_shift($args), 8); -} - -if (!$args) { - fwrite(\STDERR, "usage: php {$argv[0]} [--setup=callable] [...]\n"); - exit(1); -} - -foreach ($args as $featurePath) { - if (!is_file($featurePath)) { - fwrite(\STDERR, "missing: $featurePath\n"); - exit(2); - } - - $src = file_get_contents($featurePath); - $lines = preg_split('/\r?\n/', $src); - - $scenarios = []; - $cur = null; - $inBody = false; - $body = ''; - $bodyTarget = 'json'; - - $flush = static function () use (&$cur, &$scenarios): void { - if (null !== $cur) { - $scenarios[] = $cur; - } - $cur = null; - }; - - foreach ($lines as $line) { - if (preg_match('/^\s*Scenario:\s*(.+)$/', $line, $m)) { - $flush(); - $cur = ['title' => trim($m[1]), 'url' => null, 'httpMethod' => 'GET', 'status' => 200, 'json' => null, 'jsonMode' => null, 'requestBody' => null, 'contentType' => null, 'expectedContentType' => null, 'jsonNodes' => [], 'jsonNodeExists' => []]; - $inBody = false; - $body = ''; - continue; - } - - if (null === $cur) { - continue; - } - - if ($inBody) { - if (preg_match('/^\s*"""\s*$/', $line)) { - if ('requestBody' === $bodyTarget) { - $cur['requestBody'] = trim($body); - } else { - $cur['json'] = trim($body); - } - $inBody = false; - $body = ''; - $bodyTarget = 'json'; - continue; - } - $body .= $line."\n"; - continue; - } - - if (preg_match('/I add "Content-Type" header equal to "([^"]+)"/', $line, $m)) { - $cur['contentType'] = $m[1]; - continue; - } - - if (preg_match('/the header "Content-Type" should be equal to "([^"]+)"/', $line, $m)) { - $cur['expectedContentType'] = $m[1]; - continue; - } - - if (preg_match('/I send a "([A-Z]+)" request to "([^"]+)"\s+with body:\s*$/', $line, $m)) { - $cur['httpMethod'] = $m[1]; - $cur['url'] = $m[2]; - $bodyTarget = 'requestBody'; - continue; - } - - if (preg_match('/I send a "([A-Z]+)" request to "([^"]+)"/', $line, $m)) { - $cur['httpMethod'] = $m[1]; - $cur['url'] = $m[2]; - continue; - } - - if (preg_match('/response status code should be (\d+)/', $line, $m)) { - $cur['status'] = (int) $m[1]; - continue; - } - - if (preg_match('/the JSON node "([^"]+)" should be equal to "([^"]*)"/', $line, $m)) { - $cur['jsonNodes'][$m[1]] = $m[2]; - continue; - } - - if (preg_match("/the JSON node \"([^\"]+)\" should be equal to '([^']*)'/", $line, $m)) { - $cur['jsonNodes'][$m[1]] = $m[2]; - continue; - } - - if (preg_match('/the JSON node "([^"]+)" should exist/', $line, $m)) { - $cur['jsonNodeExists'][] = $m[1]; - continue; - } - - if (preg_match('/JSON should be equal to:\s*$/', $line)) { - $cur['jsonMode'] = 'equals'; - continue; - } - - if (preg_match('/JSON should be a superset of:\s*$/', $line)) { - $cur['jsonMode'] = 'contains'; - continue; - } - - if (preg_match('/JSON should be valid according to this schema:\s*$/', $line)) { - $cur['jsonMode'] = 'schema'; - continue; - } - - if (preg_match('/^\s*"""\s*$/', $line)) { - $inBody = true; - $body = ''; - continue; - } - } - $flush(); - - $used = []; - foreach ($scenarios as $scenario) { - $base = makeMethodName($scenario['title']); - $name = $base; - $i = 2; - while (isset($used[$name])) { - $name = $base.$i; - ++$i; - } - $used[$name] = true; - $scenario['method'] = $name; - $scenario['setupHook'] = $setupHook; - echo emitMethod($scenario); - } -} - -function emitMethod(array $s): string -{ - $method = $s['method'] ?? makeMethodName($s['title']); - $url = $s['url'] ?? ''; - $status = $s['status']; - $httpMethod = $s['httpMethod'] ?? 'GET'; - - $out = "\n public function {$method}(): void\n {\n"; - if (!empty($s['setupHook'])) { - $out .= " \$this->{$s['setupHook']}();\n\n"; - } - $headers = ['Accept' => 'application/ld+json']; - if (!empty($s['contentType'])) { - $headers['Content-Type'] = $s['contentType']; - } - $requestOptions = []; - foreach ($headers as $k => $v) { - $requestOptions['headers'][$k] = $v; - } - if (!empty($s['requestBody'])) { - $requestOptions['body'] = $s['requestBody']; - } - $requestOptionsExport = var_export($requestOptions, true); - $needsResponse = !empty($s['jsonNodeExists']); - $assignment = $needsResponse ? '$response = ' : ''; - $out .= " {$assignment}self::createClient()->request('{$httpMethod}', ".var_export($url, true).", {$requestOptionsExport});\n\n"; - $out .= " \$this->assertResponseStatusCodeSame({$status});\n"; - if (!empty($s['expectedContentType'])) { - $out .= " \$this->assertResponseHeaderSame('content-type', ".var_export($s['expectedContentType'], true).");\n"; - } - - if (null !== $s['json']) { - $heredoc = "<<<'JSON'\n".$s['json']."\nJSON"; - if ('schema' === $s['jsonMode']) { - $out .= " \$this->assertMatchesJsonSchema({$heredoc});\n"; - } else { - $assert = 'contains' === $s['jsonMode'] ? 'assertJsonContains' : 'assertJsonEquals'; - $out .= " \$this->{$assert}({$heredoc});\n"; - } - } - - if (!empty($s['jsonNodes'])) { - $out .= ' $this->assertJsonContains('.var_export($s['jsonNodes'], true).");\n"; - } - - foreach ($s['jsonNodeExists'] as $node) { - $out .= ' $this->assertArrayHasKey('.var_export($node, true).", \$response->toArray(false));\n"; - } - - $out .= " }\n"; - - return $out; -} - -function makeMethodName(string $title): string -{ - $clean = preg_replace('/[^A-Za-z0-9]+/', ' ', $title); - $words = array_filter(array_map('ucfirst', explode(' ', strtolower($clean)))); - - return 'test'.implode('', $words); -} From 5ddf94aeb9b560fd981bab4ddf62ad8d16641cd1 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 28 May 2026 16:49:17 +0200 Subject: [PATCH 16/84] feat(jsonld): add resource-level jsonldContext for namespace prefixes (#8204) --- src/JsonLd/ContextBuilder.php | 5 +++++ src/Metadata/ApiResource.php | 21 +++++++++++++++++++ src/Metadata/Delete.php | 2 ++ src/Metadata/Error.php | 2 ++ src/Metadata/ErrorResource.php | 2 ++ .../Extractor/XmlResourceExtractor.php | 1 + .../Extractor/YamlResourceExtractor.php | 1 + src/Metadata/Extractor/schema/resources.xsd | 1 + src/Metadata/Get.php | 2 ++ src/Metadata/GetCollection.php | 2 ++ src/Metadata/HttpOperation.php | 14 +++++++++++++ src/Metadata/McpResource.php | 2 ++ src/Metadata/McpTool.php | 2 ++ src/Metadata/NotExposed.php | 2 ++ src/Metadata/Patch.php | 2 ++ src/Metadata/Post.php | 2 ++ src/Metadata/Put.php | 2 ++ .../Extractor/Adapter/XmlResourceAdapter.php | 5 +++++ .../Tests/Extractor/Adapter/resources.xml | 2 +- .../Tests/Extractor/Adapter/resources.yaml | 4 ++++ .../ResourceMetadataCompatibilityTest.php | 7 +++++++ .../Tests/Extractor/XmlExtractorTest.php | 4 ++++ .../Tests/Extractor/YamlExtractorTest.php | 6 ++++++ .../ApiResource/JsonLd/JsonLdContextDummy.php | 4 ++++ tests/Functional/JsonLd/ContextTest.php | 9 ++++++++ 25 files changed, 105 insertions(+), 1 deletion(-) diff --git a/src/JsonLd/ContextBuilder.php b/src/JsonLd/ContextBuilder.php index 4f35aa67057..65fa1f58985 100644 --- a/src/JsonLd/ContextBuilder.php +++ b/src/JsonLd/ContextBuilder.php @@ -180,6 +180,11 @@ private function generateContextUri(?string $shortName, ?int $referenceType): st private function getResourceContextWithShortname(string $resourceClass, int $referenceType, string $shortName, ?HttpOperation $operation = null): array { $context = $this->getBaseContext($referenceType); + + if ($operation && $jsonldContext = $operation->getJsonldContext()) { + $context = array_merge($context, $jsonldContext); + } + $propertyContext = $operation ? ['normalization_groups' => $operation->getNormalizationContext()['groups'] ?? null, 'denormalization_groups' => $operation->getDenormalizationContext()['groups'] ?? null] : ['normalization_groups' => [], 'denormalization_groups' => []]; foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $propertyName) { diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index eabfdda8fc0..32922afb1d7 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -326,6 +326,14 @@ public function __construct( protected ?array $denormalizationContext = null, protected ?bool $collectDenormalizationErrors = null, protected ?array $hydraContext = null, + /** + * Extra entries to merge into the JSON-LD `@context` for this resource (e.g. namespace prefix declarations). + * + * Example: `jsonldContext: ['dct' => 'http://purl.org/dc/terms/']` + * + * @see https://api-platform.com/docs/core/extending-jsonld-context/ + */ + protected ?array $jsonldContext = null, protected bool|OpenApiOperation|null $openapi = null, /** * The `validationContext` option configures the context of validation for the current ApiResource. @@ -1373,6 +1381,19 @@ public function withHydraContext(array $hydraContext): static return $self; } + public function getJsonldContext(): ?array + { + return $this->jsonldContext; + } + + public function withJsonldContext(array $jsonldContext): static + { + $self = clone $this; + $self->jsonldContext = $jsonldContext; + + return $self; + } + public function getOpenapi(): bool|OpenApiOperation|null { return $this->openapi; diff --git a/src/Metadata/Delete.php b/src/Metadata/Delete.php index b4e55ef6765..3674a5d6fe9 100644 --- a/src/Metadata/Delete.php +++ b/src/Metadata/Delete.php @@ -44,6 +44,7 @@ public function __construct( ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, + ?array $jsonldContext = null, bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, @@ -129,6 +130,7 @@ public function __construct( cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, diff --git a/src/Metadata/Error.php b/src/Metadata/Error.php index dabe1b854d5..c7c34733459 100644 --- a/src/Metadata/Error.php +++ b/src/Metadata/Error.php @@ -44,6 +44,7 @@ public function __construct( ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, + ?array $jsonldContext = null, bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, @@ -123,6 +124,7 @@ public function __construct( cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, diff --git a/src/Metadata/ErrorResource.php b/src/Metadata/ErrorResource.php index 8f1586ac038..c3700713617 100644 --- a/src/Metadata/ErrorResource.php +++ b/src/Metadata/ErrorResource.php @@ -49,6 +49,7 @@ public function __construct( ?array $denormalizationContext = null, ?bool $collectDenormalizationErrors = null, ?array $hydraContext = null, + ?array $jsonldContext = null, OpenApiOperation|bool|null $openapi = null, ?array $validationContext = null, ?array $filters = null, @@ -116,6 +117,7 @@ class: $class, denormalizationContext: $denormalizationContext, collectDenormalizationErrors: $collectDenormalizationErrors, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, validationContext: $validationContext, filters: $filters, diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php index 6ebc1a66bf8..4d3c3206b53 100644 --- a/src/Metadata/Extractor/XmlResourceExtractor.php +++ b/src/Metadata/Extractor/XmlResourceExtractor.php @@ -93,6 +93,7 @@ private function buildExtendedBase(\SimpleXMLElement $resource): array 'schemes' => $this->buildArrayValue($resource, 'scheme'), 'cacheHeaders' => $this->buildCacheHeaders($resource), 'hydraContext' => isset($resource->hydraContext->values) ? $this->buildValues($resource->hydraContext->values) : null, + 'jsonldContext' => isset($resource->jsonldContext->values) ? $this->buildValues($resource->jsonldContext->values) : null, 'openapi' => $this->buildOpenapi($resource), 'paginationViaCursor' => $this->buildPaginationViaCursor($resource), 'exceptionToStatus' => $this->buildExceptionToStatus($resource), diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php index e7dd40093c8..67848c56942 100644 --- a/src/Metadata/Extractor/YamlResourceExtractor.php +++ b/src/Metadata/Extractor/YamlResourceExtractor.php @@ -114,6 +114,7 @@ private function buildExtendedBase(array $resource): array 'types' => $this->buildArrayValue($resource, 'types'), 'cacheHeaders' => $this->buildArrayValue($resource, 'cacheHeaders'), 'hydraContext' => $this->buildArrayValue($resource, 'hydraContext'), + 'jsonldContext' => $this->buildArrayValue($resource, 'jsonldContext'), 'openapi' => $this->buildOpenapi($resource), 'paginationViaCursor' => $this->buildArrayValue($resource, 'paginationViaCursor'), 'exceptionToStatus' => $this->buildArrayValue($resource, 'exceptionToStatus'), diff --git a/src/Metadata/Extractor/schema/resources.xsd b/src/Metadata/Extractor/schema/resources.xsd index 02468b51bd7..8a1644c4790 100644 --- a/src/Metadata/Extractor/schema/resources.xsd +++ b/src/Metadata/Extractor/schema/resources.xsd @@ -479,6 +479,7 @@ + diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index 4babd54eb27..4c59d7ab957 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -44,6 +44,7 @@ public function __construct( ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, + ?array $jsonldContext = null, bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, @@ -128,6 +129,7 @@ public function __construct( cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index 27df4b9ad41..6256366bd27 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -44,6 +44,7 @@ public function __construct( ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, + ?array $jsonldContext = null, bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, @@ -129,6 +130,7 @@ public function __construct( cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index 58d4cf98c7f..32dfa15bb7e 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -164,6 +164,7 @@ public function __construct( protected ?array $cacheHeaders = null, protected ?array $paginationViaCursor = null, protected ?array $hydraContext = null, + protected ?array $jsonldContext = null, protected bool|OpenApiOperation|Webhook|null $openapi = null, protected ?array $exceptionToStatus = null, protected ?array $links = null, @@ -629,6 +630,19 @@ public function withHydraContext(array $hydraContext): static return $self; } + public function getJsonldContext(): ?array + { + return $this->jsonldContext; + } + + public function withJsonldContext(array $jsonldContext): static + { + $self = clone $this; + $self->jsonldContext = $jsonldContext; + + return $self; + } + public function getOpenapi(): bool|OpenApiOperation|Webhook|null { return $this->openapi; diff --git a/src/Metadata/McpResource.php b/src/Metadata/McpResource.php index c36342c1e6b..5be8ab91f35 100644 --- a/src/Metadata/McpResource.php +++ b/src/Metadata/McpResource.php @@ -126,6 +126,7 @@ public function __construct( ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, + ?array $jsonldContext = null, bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?array $links = null, @@ -209,6 +210,7 @@ public function __construct( cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, exceptionToStatus: $exceptionToStatus, links: $links, diff --git a/src/Metadata/McpTool.php b/src/Metadata/McpTool.php index 465da19d76f..f46f7a297d8 100644 --- a/src/Metadata/McpTool.php +++ b/src/Metadata/McpTool.php @@ -122,6 +122,7 @@ public function __construct( ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, + ?array $jsonldContext = null, bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?array $links = null, @@ -205,6 +206,7 @@ public function __construct( cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, exceptionToStatus: $exceptionToStatus, links: $links, diff --git a/src/Metadata/NotExposed.php b/src/Metadata/NotExposed.php index e106aa23b4e..c3422bac243 100644 --- a/src/Metadata/NotExposed.php +++ b/src/Metadata/NotExposed.php @@ -56,6 +56,7 @@ public function __construct( ?array $paginationViaCursor = null, ?array $hydraContext = null, + ?array $jsonldContext = null, bool|OpenApiOperation|Webhook|null $openapi = false, ?array $exceptionToStatus = null, @@ -135,6 +136,7 @@ public function __construct( cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php index 13d7dc442a0..e6147a18dad 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -44,6 +44,7 @@ public function __construct( ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, + ?array $jsonldContext = null, bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, @@ -129,6 +130,7 @@ public function __construct( cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index 419512a851d..e68e4b0ec66 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -44,6 +44,7 @@ public function __construct( ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, + ?array $jsonldContext = null, bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, @@ -130,6 +131,7 @@ public function __construct( cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php index 3ea21ffeadd..73632c786bc 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -44,6 +44,7 @@ public function __construct( ?array $cacheHeaders = null, ?array $paginationViaCursor = null, ?array $hydraContext = null, + ?array $jsonldContext = null, bool|OpenApiOperation|Webhook|null $openapi = null, ?array $exceptionToStatus = null, ?bool $queryParameterValidationEnabled = null, @@ -130,6 +131,7 @@ public function __construct( cacheHeaders: $cacheHeaders, paginationViaCursor: $paginationViaCursor, hydraContext: $hydraContext, + jsonldContext: $jsonldContext, openapi: $openapi, exceptionToStatus: $exceptionToStatus, queryParameterValidationEnabled: $queryParameterValidationEnabled, diff --git a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php index b15d99a4508..6e3a1296f1b 100644 --- a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php +++ b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php @@ -230,6 +230,11 @@ private function buildHydraContext(\SimpleXMLElement $resource, array $values): $this->buildValues($resource->addChild('hydraContext'), $values); } + private function buildJsonldContext(\SimpleXMLElement $resource, array $values): void + { + $this->buildValues($resource->addChild('jsonldContext'), $values); + } + private function buildOpenapi(\SimpleXMLElement $resource, array $values): void { $node = $resource->openapi ?? $resource->addChild('openapi'); diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.xml b/src/Metadata/Tests/Extractor/Adapter/resources.xml index b7e83452477..06c90ebfd20 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.xml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.xml @@ -1,3 +1,3 @@ -someirischemaanotheririschemaCommentapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetapplication/merge-patch+json+ldapplication/merge-patch+json+ld_foo\d+bazhttps
60120AuthorizationAccept-LanguageAcceptcomment:read_collectioncomment:writebazbazbarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbarstringapplication/vnd.ms-excelapplication/merge-patch+jsonapplication/merge-patch+jsonpouet\d+barhttphttps60120AuthorizationAccept-Languagecomment:readcomment:writecomment:custombazbazbarcomment.custom_filterfoobarcustombazcustomquxcomment:read_collectioncomment:writebarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbar/v1/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit ametLorem ipsum dolor sit ametDolor sit amet +someirischemaanotheririschemaCommentapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetapplication/merge-patch+json+ldapplication/merge-patch+json+ld_foo\d+bazhttps
60120AuthorizationAccept-LanguageAcceptcomment:read_collectioncomment:writebazhttp://purl.org/dc/terms/bazbarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbarstringapplication/vnd.ms-excelapplication/merge-patch+jsonapplication/merge-patch+jsonpouet\d+barhttphttps60120AuthorizationAccept-Languagecomment:readcomment:writecomment:custombazhttp://purl.org/dc/terms/bazbarcomment.custom_filterfoobarcustombazcustomquxcomment:read_collectioncomment:writebarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbar/v1/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit ametLorem ipsum dolor sit ametDolor sit amet diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.yaml b/src/Metadata/Tests/Extractor/Adapter/resources.yaml index 30c16895a07..6dc74676c48 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.yaml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.yaml @@ -66,6 +66,8 @@ resources: hydraContext: foo: bar: baz + jsonldContext: + dct: 'http://purl.org/dc/terms/' openapi: extensionProperties: bar: baz @@ -191,6 +193,8 @@ resources: hydraContext: foo: bar: baz + jsonldContext: + dct: 'http://purl.org/dc/terms/' openapi: extensionProperties: bar: baz diff --git a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php index 943790e0ba4..fb4f177d81f 100644 --- a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php +++ b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php @@ -142,6 +142,9 @@ final class ResourceMetadataCompatibilityTest extends TestCase 'hydraContext' => [ 'foo' => ['bar' => 'baz'], ], + 'jsonldContext' => [ + 'dct' => 'http://purl.org/dc/terms/', + ], 'openapi' => [ 'extensionProperties' => [ 'bar' => 'baz', @@ -356,6 +359,9 @@ final class ResourceMetadataCompatibilityTest extends TestCase 'hydraContext' => [ 'foo' => ['bar' => 'baz'], ], + 'jsonldContext' => [ + 'dct' => 'http://purl.org/dc/terms/', + ], 'openapi' => [ 'extensionProperties' => [ 'bar' => 'baz', @@ -501,6 +507,7 @@ final class ResourceMetadataCompatibilityTest extends TestCase 'schemes', 'cacheHeaders', 'hydraContext', + 'jsonldContext', 'openapi', 'paginationViaCursor', 'stateOptions', diff --git a/src/Metadata/Tests/Extractor/XmlExtractorTest.php b/src/Metadata/Tests/Extractor/XmlExtractorTest.php index 9dedfc19906..b9d4dc23594 100644 --- a/src/Metadata/Tests/Extractor/XmlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/XmlExtractorTest.php @@ -106,6 +106,7 @@ public function testValidXML(): void 'parameters' => null, 'jsonStream' => null, 'map' => null, + 'jsonldContext' => null, ], [ 'uriTemplate' => '/users/{author}/comments{._format}', @@ -283,6 +284,7 @@ public function testValidXML(): void 'routeName' => 'custom_route_name', 'jsonStream' => null, 'map' => null, + 'jsonldContext' => null, ], [ 'name' => null, @@ -397,6 +399,7 @@ public function testValidXML(): void 'routeName' => null, 'jsonStream' => null, 'map' => null, + 'jsonldContext' => null, ], ], 'graphQlOperations' => null, @@ -410,6 +413,7 @@ public function testValidXML(): void 'parameters' => null, 'jsonStream' => null, 'map' => null, + 'jsonldContext' => null, ], ], ], $extractor->getResources()); diff --git a/src/Metadata/Tests/Extractor/YamlExtractorTest.php b/src/Metadata/Tests/Extractor/YamlExtractorTest.php index 6384942192e..7d58abe6ba3 100644 --- a/src/Metadata/Tests/Extractor/YamlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/YamlExtractorTest.php @@ -105,6 +105,7 @@ public function testValidYaml(): void 'parameters' => null, 'jsonStream' => null, 'map' => null, + 'jsonldContext' => null, ], ], Program::class => [ @@ -178,6 +179,7 @@ public function testValidYaml(): void 'parameters' => null, 'jsonStream' => null, 'map' => null, + 'jsonldContext' => null, ], [ 'uriTemplate' => '/users/{author}/programs{._format}', @@ -322,6 +324,7 @@ public function testValidYaml(): void 'parameters' => null, 'jsonStream' => null, 'map' => null, + 'jsonldContext' => null, ], [ 'name' => null, @@ -409,6 +412,7 @@ public function testValidYaml(): void 'parameters' => ['author' => new QueryParameter(schema: ['type' => 'string'], required: true, key: 'author', description: 'hello')], 'jsonStream' => null, 'map' => null, + 'jsonldContext' => null, ], ], 'graphQlOperations' => null, @@ -422,6 +426,7 @@ public function testValidYaml(): void 'parameters' => null, 'jsonStream' => null, 'map' => null, + 'jsonldContext' => null, ], ], SingleFileConfigDummy::class => [ @@ -495,6 +500,7 @@ public function testValidYaml(): void 'parameters' => null, 'jsonStream' => null, 'map' => null, + 'jsonldContext' => null, ], ], ], $extractor->getResources()); diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextDummy.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextDummy.php index 18cd539f17a..ab545ac6936 100644 --- a/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextDummy.php +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/JsonLdContextDummy.php @@ -20,6 +20,7 @@ shortName: 'JsonLdContextDummy', provider: [self::class, 'provide'], processor: [self::class, 'process'], + jsonldContext: ['dct' => 'http://purl.org/dc/terms/'], )] class JsonLdContextDummy { @@ -29,6 +30,9 @@ class JsonLdContextDummy #[ApiProperty(iris: ['https://schema.org/name'])] public ?string $name = null; + #[ApiProperty(iris: ['dct:title'])] + public ?string $title = null; + #[ApiProperty(iris: ['https://schema.org/alternateName'])] public ?string $alias = null; diff --git a/tests/Functional/JsonLd/ContextTest.php b/tests/Functional/JsonLd/ContextTest.php index dd768a9cf55..6906f979db2 100644 --- a/tests/Functional/JsonLd/ContextTest.php +++ b/tests/Functional/JsonLd/ContextTest.php @@ -112,4 +112,13 @@ public function testEmbeddedRelationMappingIsPlainString(): void $body = $response->toArray(); $this->assertSame('JsonLdContextDummy/embedded', $body['@context']['embedded']); } + + public function testResourceLevelJsonLdContextAddsNamespacePrefixes(): void + { + $response = self::createClient()->request('GET', '/contexts/JsonLdContextDummy'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertSame('http://purl.org/dc/terms/', $body['@context']['dct']); + $this->assertSame('dct:title', $body['@context']['title']); + } } From c2b990ea4795ada502fa326118ed1baaac4356bb Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 29 May 2026 15:17:08 +0200 Subject: [PATCH 17/84] ci: stale bot via actions to use typed issues (#8208) Probot stale does not understand GitHub issue types. Replace the legacy `.github/stale.yml` with an `actions/github-script` workflow that exempts issues whose type is `Bug` or `Feature` and keeps the prior 60/7 day window plus exempt labels (RFC, Hacktoberfest, EU-FOSSA Hackathon). --- .github/scripts/stale.js | 167 ++++++++++++++++++ .github/stale.yml | 20 --- .github/workflows/release.yml | 2 +- .github/workflows/stale.yml | 33 ++++ CONTRIBUTING.md | 6 +- .../generate-changelog.sh | 0 subtree.sh => tools/subtree.sh | 0 update-js.sh => tools/update-js.sh | 0 8 files changed, 205 insertions(+), 23 deletions(-) create mode 100644 .github/scripts/stale.js delete mode 100644 .github/stale.yml create mode 100644 .github/workflows/stale.yml rename generate-changelog.sh => tools/generate-changelog.sh (100%) rename subtree.sh => tools/subtree.sh (100%) rename update-js.sh => tools/update-js.sh (100%) diff --git a/.github/scripts/stale.js b/.github/scripts/stale.js new file mode 100644 index 00000000000..d48818477d2 --- /dev/null +++ b/.github/scripts/stale.js @@ -0,0 +1,167 @@ +module.exports = async ({ github, context, core }) => { + const DAYS_UNTIL_STALE = 60; + const DAYS_UNTIL_CLOSE = 7; + const STALE_LABEL = 'stale'; + const EXEMPT_LABELS = new Set([ + 'Hacktoberfest', + 'RFC', + '⭐ EU-FOSSA Hackathon', + ]); + const EXEMPT_TYPES = new Set(['Bug', 'Feature']); + const STALE_COMMENT = [ + 'This issue has been automatically marked as stale because it has not had', + 'recent activity. It will be closed if no further activity occurs. Thank you', + 'for your contributions.', + ].join(' '); + const BOT_LOGINS = new Set(['github-actions[bot]', 'github-actions']); + + const DRY_RUN = /^(1|true|yes)$/i.test(process.env.DRY_RUN || ''); + const MAX_ACTIONS_PER_RUN = Number.parseInt(process.env.MAX_ACTIONS_PER_RUN || '25', 10); + + const { owner, repo } = context.repo; + const now = Date.now(); + const staleCutoff = new Date(now - DAYS_UNTIL_STALE * 86400000); + const closeCutoff = new Date(now - DAYS_UNTIL_CLOSE * 86400000); + + let actionsTaken = 0; + const budgetExhausted = () => actionsTaken >= MAX_ACTIONS_PER_RUN; + + async function* iterateOpenIssues() { + let cursor = null; + while (true) { + const data = await github.graphql(` + query($owner: String!, $name: String!, $cursor: String) { + repository(owner: $owner, name: $name) { + issues(first: 100, after: $cursor, states: OPEN, orderBy: {field: UPDATED_AT, direction: ASC}) { + pageInfo { hasNextPage endCursor } + nodes { + number + updatedAt + issueType { name } + labels(first: 50) { nodes { name } } + timelineItems(last: 100, itemTypes: [LABELED_EVENT]) { + nodes { + ... on LabeledEvent { + createdAt + label { name } + } + } + } + } + } + } + }`, { owner, name: repo, cursor }); + + const page = data.repository.issues; + for (const node of page.nodes) yield node; + if (!page.pageInfo.hasNextPage) break; + cursor = page.pageInfo.endCursor; + } + } + + async function hasNonBotActivitySince(issue_number, since) { + const events = await github.paginate( + github.rest.issues.listEventsForTimeline, + { owner, repo, issue_number, per_page: 100 }, + ); + return events.some(e => { + const ts = e.created_at || e.submitted_at; + if (!ts) return false; + if (new Date(ts) <= since) return false; + const actor = e.actor?.login || e.user?.login; + if (actor && BOT_LOGINS.has(actor)) return false; + return true; + }); + } + + function mostRecentStaleAt(issue) { + let latest = null; + for (const e of issue.timelineItems.nodes) { + if (e?.label?.name !== STALE_LABEL) continue; + const at = new Date(e.createdAt); + if (!latest || at > latest) latest = at; + } + return latest; + } + + async function addStale(issue_number) { + if (DRY_RUN) { + core.info(`DRY_RUN would stale #${issue_number}`); + return; + } + await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [STALE_LABEL] }); + await github.rest.issues.createComment({ owner, repo, issue_number, body: STALE_COMMENT }); + } + + async function close(issue_number) { + if (DRY_RUN) { + core.info(`DRY_RUN would close #${issue_number}`); + return; + } + await github.rest.issues.update({ + owner, repo, issue_number, state: 'closed', state_reason: 'not_planned', + }); + } + + async function unstale(issue_number) { + if (DRY_RUN) { + core.info(`DRY_RUN would unstale #${issue_number}`); + return; + } + await github.rest.issues.removeLabel({ + owner, repo, issue_number, name: STALE_LABEL, + }).catch(err => { + if (err.status !== 404) throw err; + }); + } + + const summary = { staled: 0, closed: 0, unstaled: 0, exempt: 0, scanned: 0, skipped: 0 }; + + for await (const issue of iterateOpenIssues()) { + summary.scanned++; + const labels = new Set(issue.labels.nodes.map(l => l.name)); + const typeName = issue.issueType?.name; + const exempt = (typeName && EXEMPT_TYPES.has(typeName)) + || [...labels].some(l => EXEMPT_LABELS.has(l)); + const hasStale = labels.has(STALE_LABEL); + + if (hasStale) { + const staleAt = mostRecentStaleAt(issue); + if (!staleAt) continue; + + const interacted = await hasNonBotActivitySince(issue.number, staleAt); + if (interacted) { + if (budgetExhausted()) { summary.skipped++; continue; } + await unstale(issue.number); + summary.unstaled++; + actionsTaken++; + } else if (staleAt <= closeCutoff) { + if (budgetExhausted()) { summary.skipped++; continue; } + await close(issue.number); + summary.closed++; + actionsTaken++; + } + continue; + } + + if (exempt) { + summary.exempt++; + continue; + } + + if (new Date(issue.updatedAt) <= staleCutoff) { + if (budgetExhausted()) { summary.skipped++; continue; } + await addStale(issue.number); + summary.staled++; + actionsTaken++; + } + } + + const prefix = DRY_RUN ? 'DRY_RUN ' : ''; + core.info( + `${prefix}scanned=${summary.scanned} staled=${summary.staled} ` + + `closed=${summary.closed} unstaled=${summary.unstaled} ` + + `exempt=${summary.exempt} skipped=${summary.skipped} ` + + `budget=${MAX_ACTIONS_PER_RUN}`, + ); +}; diff --git a/.github/stale.yml b/.github/stale.yml deleted file mode 100644 index 6ad93d1570f..00000000000 --- a/.github/stale.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Number of days of inactivity before an issue becomes stale -daysUntilStale: 60 -# Number of days of inactivity before a stale issue is closed -daysUntilClose: 7 -# Issues with these labels will never be considered stale -exemptLabels: - - Hacktoberfest - - bug - - enhancement - - RFC - - ⭐ EU-FOSSA Hackathon -# Label to use when marking an issue as stale -staleLabel: stale -# Comment to post when marking an issue as stale. Set to `false` to disable -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. Thank you - for your contributions. -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: false diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 187e44039c9..8b0669ec0cb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -42,7 +42,7 @@ jobs: echo "$(pwd)" >> $GITHUB_PATH - name: Split to manyrepo - run: find src -maxdepth 3 -name composer.json -print0 | xargs -I '{}' -n 1 -0 bash subtree.sh {} ${{ github.ref }} + run: find src -maxdepth 3 -name composer.json -print0 | xargs -I '{}' -n 1 -0 bash tools/subtree.sh {} ${{ github.ref }} dispatch-distribution-update: name: Dispatch Distribution Update diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 00000000000..c30b7c664bf --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,33 @@ +name: Mark stale issues + +on: + schedule: + - cron: '0 1 * * *' + workflow_dispatch: + inputs: + dry_run: + description: 'Log actions without mutating issues' + type: boolean + default: true + max_actions: + description: 'Maximum mutating actions per run' + type: string + default: '25' + +permissions: + issues: write + contents: read + +jobs: + stale: + runs-on: ubuntu-latest + env: + DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }} + MAX_ACTIONS_PER_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.max_actions || '25' }} + steps: + - uses: actions/checkout@v4 + - uses: actions/github-script@v7 + with: + script: | + const script = require('./.github/scripts/stale.js'); + await script({ github, context, core }); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ff0d70a8cd9..89ba9542382 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -250,11 +250,13 @@ If you include code from another project, please mention it in the Pull Request This section is for maintainers. -1. Update the JavaScript dependencies by running `./update-js.sh` (always check if it works in a browser) +Maintenance scripts live in [`tools/`](tools/). GitHub Actions helper scripts live in [`.github/scripts/`](.github/scripts/). + +1. Update the JavaScript dependencies by running `./tools/update-js.sh` (always check if it works in a browser) 2. Update the `CHANGELOG.md` file (be sure to include Pull Request numbers when appropriate) we use: ```bash -bash generate-changelog.sh v4.1.11 v4.1.12 > CHANGELOG.new +bash tools/generate-changelog.sh v4.1.11 v4.1.12 > CHANGELOG.new mv CHANGELOG.new CHANGELOG.md ``` 4. Update `composer.json` `version` node and use diff --git a/generate-changelog.sh b/tools/generate-changelog.sh similarity index 100% rename from generate-changelog.sh rename to tools/generate-changelog.sh diff --git a/subtree.sh b/tools/subtree.sh similarity index 100% rename from subtree.sh rename to tools/subtree.sh diff --git a/update-js.sh b/tools/update-js.sh similarity index 100% rename from update-js.sh rename to tools/update-js.sh From 67b1c5112c9a3a3a5048b63e839bbb04627f8360 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 29 May 2026 15:45:35 +0200 Subject: [PATCH 18/84] ci(stale): bump checkout to v6 and github-script to v8 (#8213) --- .github/workflows/stale.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index c30b7c664bf..9375d214f4a 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -25,8 +25,8 @@ jobs: DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }} MAX_ACTIONS_PER_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.max_actions || '25' }} steps: - - uses: actions/checkout@v4 - - uses: actions/github-script@v7 + - uses: actions/checkout@v6 + - uses: actions/github-script@v8 with: script: | const script = require('./.github/scripts/stale.js'); From 4911cfd1402b7fecb2389b3392361b0afe496f63 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 29 May 2026 17:50:53 +0200 Subject: [PATCH 19/84] test: migrate doctrine/graphql behat features to ApiTestCase (#8205) --- .github/workflows/ci.yml | 342 +-- AGENTS.md | 9 +- CONTRIBUTING.md | 21 +- behat.yml.dist | 219 -- composer.json | 6 - features/doctrine/boolean_filter.feature | 525 ---- features/doctrine/date_filter.feature | 636 ---- features/doctrine/eager_loading.feature | 99 - features/doctrine/exists_filter.feature | 223 -- features/doctrine/handle_links.feature | 17 - .../issue5722/subresource_without_get.feature | 8 - .../standard_put_entity_inheritence.feature | 25 - features/doctrine/multiple_filter.feature | 48 - features/doctrine/numeric_filter.feature | 219 -- features/doctrine/order_filter.feature | 824 ----- features/doctrine/range_filter.feature | 506 --- features/doctrine/search_filter.feature | 1066 ------- features/doctrine/separated_resource.feature | 116 - features/graphql/authorization.feature | 576 ---- features/graphql/collection.feature | 1109 ------- features/graphql/docs.feature | 10 - features/graphql/filters.feature | 302 -- features/graphql/input_output.feature | 202 -- features/graphql/introspection.feature | 621 ---- features/graphql/mutation.feature | 1071 ------- features/graphql/query.feature | 696 ----- features/graphql/schema.feature | 113 - features/graphql/subscription.feature | 224 -- features/graphql/type.feature | 80 - phpunit.xml.dist | 2 - src/Doctrine/Odm/Tests/AppKernel.php | 1 - src/Doctrine/Orm/Tests/AppKernel.php | 1 - src/GraphQl/Test/GraphQlTestTrait.php | 141 + tests/AGENTS.md | 6 - tests/Behat/CommandContext.php | 106 - tests/Behat/CoverageContext.php | 92 - tests/Behat/DoctrineContext.php | 2707 ----------------- tests/Behat/GraphqlContext.php | 178 -- tests/Behat/HttpCacheContext.php | 91 - tests/Behat/HydraContext.php | 326 -- tests/Behat/JsonApiContext.php | 209 -- tests/Behat/JsonContext.php | 112 - tests/Behat/JsonHalContext.php | 80 - tests/Behat/MercureContext.php | 144 - tests/Behat/XmlContext.php | 43 - .../TestBundle/Document/AbsoluteUrlDummy.php | 2 +- .../TestBundle/Document/NetworkPathDummy.php | 2 +- .../TestBundle/Entity/AbsoluteUrlDummy.php | 2 +- .../TestBundle/Entity/DummyAggregateOffer.php | 4 +- .../Fixtures/TestBundle/Entity/DummyOffer.php | 6 +- .../TestBundle/Entity/DummyProduct.php | 2 +- .../DummyResourceWithComplexConstructor.php | 1 + tests/Fixtures/TestBundle/Entity/Greeting.php | 2 +- .../TestBundle/Entity/NetworkPathDummy.php | 2 +- .../MessengerHandler/Document/RPCHandler.php | 25 + tests/Fixtures/app/AppKernel.php | 12 - tests/Fixtures/app/bootstrap.php | 7 + .../app/config/config_behat_mongodb.yml | 17 - .../Fixtures/app/config/config_behat_orm.yml | 18 - tests/Fixtures/app/config/config_mongodb.yml | 6 + .../Functional/Doctrine/BooleanFilterTest.php | 264 ++ tests/Functional/Doctrine/DateFilterTest.php | 385 +++ .../Functional/Doctrine/EagerLoadingTest.php | 300 ++ .../Functional/Doctrine/ExistsFilterTest.php | 201 ++ tests/Functional/Doctrine/LinkHandlerTest.php | 74 + .../Doctrine/MappedSuperclassPutTest.php | 62 + .../Doctrine/MultipleFilterTest.php | 89 + .../Functional/Doctrine/NumericFilterTest.php | 146 + tests/Functional/Doctrine/OrderFilterTest.php | 314 ++ tests/Functional/Doctrine/RangeFilterTest.php | 123 + .../Functional/Doctrine/SearchFilterTest.php | 802 +++++ .../Doctrine/SeparatedResourceTest.php | 146 + .../EnumDenormalizationValidationTest.php | 7 + .../Functional/GraphQl/AuthorizationTest.php | 590 ++++ tests/Functional/GraphQl/CollectionTest.php | 923 ++++++ tests/Functional/GraphQl/CustomTypeTest.php | 134 + tests/Functional/GraphQl/DocsTest.php | 29 + tests/Functional/GraphQl/FilterTest.php | 528 ++++ .../Functional/GraphQl/Fixtures}/test.gif | Bin tests/Functional/GraphQl/InputOutputTest.php | 236 ++ .../Functional/GraphQl/IntrospectionTest.php | 487 +++ tests/Functional/GraphQl/MutationTest.php | 955 ++++++ tests/Functional/GraphQl/QueryTest.php | 852 ++++++ tests/Functional/GraphQl/SchemaExportTest.php | 174 ++ tests/Functional/GraphQl/SubscriptionTest.php | 254 ++ tests/Functional/MappingTest.php | 2 +- .../NullOnNonNullablePropertyTest.php | 8 + .../SubResource/SubResourceTest.php | 6 +- .../SubResource/SubResourceWithoutGetTest.php | 64 + tests/RecreateSchemaTrait.php | 2 +- tests/SetupClassResourcesTrait.php | 1 + tests/TestSuiteConfigCache.php | 4 +- tests/WithResourcesTrait.php | 45 + 93 files changed, 8417 insertions(+), 14050 deletions(-) delete mode 100644 behat.yml.dist delete mode 100644 features/doctrine/boolean_filter.feature delete mode 100644 features/doctrine/date_filter.feature delete mode 100644 features/doctrine/eager_loading.feature delete mode 100644 features/doctrine/exists_filter.feature delete mode 100644 features/doctrine/handle_links.feature delete mode 100644 features/doctrine/issue5722/subresource_without_get.feature delete mode 100644 features/doctrine/issue6175/standard_put_entity_inheritence.feature delete mode 100644 features/doctrine/multiple_filter.feature delete mode 100644 features/doctrine/numeric_filter.feature delete mode 100644 features/doctrine/order_filter.feature delete mode 100644 features/doctrine/range_filter.feature delete mode 100644 features/doctrine/search_filter.feature delete mode 100644 features/doctrine/separated_resource.feature delete mode 100644 features/graphql/authorization.feature delete mode 100644 features/graphql/collection.feature delete mode 100644 features/graphql/docs.feature delete mode 100644 features/graphql/filters.feature delete mode 100644 features/graphql/input_output.feature delete mode 100644 features/graphql/introspection.feature delete mode 100644 features/graphql/mutation.feature delete mode 100644 features/graphql/query.feature delete mode 100644 features/graphql/schema.feature delete mode 100644 features/graphql/subscription.feature delete mode 100644 features/graphql/type.feature create mode 100644 src/GraphQl/Test/GraphQlTestTrait.php delete mode 100644 tests/Behat/CommandContext.php delete mode 100644 tests/Behat/CoverageContext.php delete mode 100644 tests/Behat/DoctrineContext.php delete mode 100644 tests/Behat/GraphqlContext.php delete mode 100644 tests/Behat/HttpCacheContext.php delete mode 100644 tests/Behat/HydraContext.php delete mode 100644 tests/Behat/JsonApiContext.php delete mode 100644 tests/Behat/JsonContext.php delete mode 100644 tests/Behat/JsonHalContext.php delete mode 100644 tests/Behat/MercureContext.php delete mode 100644 tests/Behat/XmlContext.php create mode 100644 tests/Fixtures/TestBundle/MessengerHandler/Document/RPCHandler.php delete mode 100644 tests/Fixtures/app/config/config_behat_mongodb.yml delete mode 100644 tests/Fixtures/app/config/config_behat_orm.yml create mode 100644 tests/Functional/Doctrine/BooleanFilterTest.php create mode 100644 tests/Functional/Doctrine/DateFilterTest.php create mode 100644 tests/Functional/Doctrine/EagerLoadingTest.php create mode 100644 tests/Functional/Doctrine/ExistsFilterTest.php create mode 100644 tests/Functional/Doctrine/LinkHandlerTest.php create mode 100644 tests/Functional/Doctrine/MappedSuperclassPutTest.php create mode 100644 tests/Functional/Doctrine/MultipleFilterTest.php create mode 100644 tests/Functional/Doctrine/NumericFilterTest.php create mode 100644 tests/Functional/Doctrine/OrderFilterTest.php create mode 100644 tests/Functional/Doctrine/RangeFilterTest.php create mode 100644 tests/Functional/Doctrine/SearchFilterTest.php create mode 100644 tests/Functional/Doctrine/SeparatedResourceTest.php create mode 100644 tests/Functional/GraphQl/AuthorizationTest.php create mode 100644 tests/Functional/GraphQl/CollectionTest.php create mode 100644 tests/Functional/GraphQl/CustomTypeTest.php create mode 100644 tests/Functional/GraphQl/DocsTest.php create mode 100644 tests/Functional/GraphQl/FilterTest.php rename {features/files => tests/Functional/GraphQl/Fixtures}/test.gif (100%) create mode 100644 tests/Functional/GraphQl/InputOutputTest.php create mode 100644 tests/Functional/GraphQl/IntrospectionTest.php create mode 100644 tests/Functional/GraphQl/MutationTest.php create mode 100644 tests/Functional/GraphQl/QueryTest.php create mode 100644 tests/Functional/GraphQl/SchemaExportTest.php create mode 100644 tests/Functional/GraphQl/SubscriptionTest.php create mode 100644 tests/Functional/SubResource/SubResourceWithoutGetTest.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 021767ec54b..62d9ce3f8ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -447,94 +447,8 @@ jobs: cd $(composer ${{matrix.component}} --cwd) ./vendor/bin/phpunit --fail-on-deprecation --display-deprecations --log-junit "/tmp/build/logs/phpunit/junit.xml" - behat: - name: Behat (PHP ${{ matrix.php }} ${{ matrix.shard }}) - runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - matrix: - php: ${{ fromJSON(github.event_name == 'pull_request' && '["8.2","8.5"]' || '["8.2","8.3","8.4","8.5"]') }} - shard: - - graphql-doctrine - include: - - php: '8.5' - shard: graphql-doctrine - coverage: true - fail-fast: false - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - tools: pecl, composer:2.9.8 - extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite - coverage: pcov - ini-values: memory_limit=-1 - - name: Get composer cache directory - id: composercache - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - name: Cache dependencies - uses: actions/cache@v5 - with: - path: ${{ steps.composercache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - name: Update project dependencies - run: | - composer global require soyuka/pmu - composer global config allow-plugins.soyuka/pmu true --no-interaction - composer global link . - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Resolve shard paths - id: shard - run: | - case "${{ matrix.shard }}" in - graphql-doctrine) paths="features/graphql features/doctrine" ;; - esac - echo "paths=$paths" >> $GITHUB_OUTPUT - - name: Run Behat tests (PHP ${{ matrix.php }} ${{ matrix.shard }}) - run: | - mkdir -p build/logs/behat - vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --no-interaction ${{ matrix.coverage && '--profile=default-coverage' || '--profile=default' }} ${{ steps.shard.outputs.paths }} - - name: Merge code coverage reports - if: matrix.coverage - run: | - wget -qO /usr/local/bin/phpcov https://phar.phpunit.de/phpcov-12.phar - chmod +x /usr/local/bin/phpcov - mkdir -p build/coverage - phpcov merge --clover build/logs/behat/clover.xml build/coverage - - name: Upload test artifacts - if: always() - uses: actions/upload-artifact@v6 - with: - name: behat-logs-php${{ matrix.php }}-shard${{ matrix.shard }} - path: build/logs/behat - continue-on-error: true - - name: Upload coverage results to Codecov - if: matrix.coverage - uses: codecov/codecov-action@v5 - with: - token: ${{ secrets.CODECOV_TOKEN }} - directory: build/logs/behat - name: behat-php${{ matrix.php }}-shard${{ matrix.shard }} - flags: behat - fail_ci_if_error: true - continue-on-error: true - - name: Upload coverage results to Coveralls - if: matrix.coverage - env: - COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - composer global require --prefer-dist --no-interaction --no-progress --ansi php-coveralls/php-coveralls - export PATH="$PATH:$HOME/.composer/vendor/bin" - php-coveralls --coverage_clover=build/logs/behat/clover.xml - continue-on-error: true - postgresql: - name: PHPUnit + Behat (PHP ${{ matrix.php }}) (PostgreSQL) + name: PHPUnit (PHP ${{ matrix.php }}) (PostgreSQL) runs-on: ubuntu-latest timeout-minutes: 20 strategy: @@ -581,14 +495,9 @@ jobs: run: tests/Fixtures/app/console cache:clear --ansi - name: Run PHPUnit tests run: vendor/bin/phpunit - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - run: | - vendor/bin/behat --out=std --format=progress --profile=postgres --no-interaction -vv mysql: - name: PHPUnit + Behat (PHP ${{ matrix.php }}) (MySQL) + name: PHPUnit (PHP ${{ matrix.php }}) (MySQL) runs-on: ubuntu-latest timeout-minutes: 20 strategy: @@ -636,13 +545,9 @@ jobs: run: tests/Fixtures/app/console cache:clear --ansi - name: Run PHPUnit tests run: vendor/bin/phpunit - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction --tags '~@!mysql' mongodb: - name: PHPUnit + Behat (PHP ${{ matrix.php }}) (MongoDB) + name: PHPUnit (PHP ${{ matrix.php }}) (MongoDB) runs-on: ubuntu-latest timeout-minutes: 20 strategy: @@ -692,33 +597,20 @@ jobs: run: tests/Fixtures/app/console cache:clear --ansi - name: Run PHPUnit tests run: vendor/bin/phpunit --log-junit build/logs/phpunit/junit.xml --coverage-clover build/logs/phpunit/clover.xml --exclude-group=orm - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - run: | - mkdir -p build/logs/behat - vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --profile=mongodb-coverage --no-interaction - - name: Merge code coverage reports - run: | - wget -qO /usr/local/bin/phpcov https://phar.phpunit.de/phpcov-12.phar - chmod +x /usr/local/bin/phpcov - mkdir -p build/coverage - phpcov merge --clover build/logs/behat/clover.xml build/coverage - continue-on-error: true - name: Upload test artifacts if: always() uses: actions/upload-artifact@v6 with: - name: behat-logs-php${{ matrix.php }} - path: build/logs/behat + name: phpunit-logs-php${{ matrix.php }}-mongodb + path: build/logs/phpunit continue-on-error: true - name: Upload coverage results to Codecov uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - directory: build/logs/behat - name: behat-php${{ matrix.php }} - flags: behat + directory: build/logs/phpunit + name: phpunit-php${{ matrix.php }}-mongodb + flags: phpunit fail_ci_if_error: true continue-on-error: true - name: Upload coverage results to Coveralls @@ -727,11 +619,11 @@ jobs: run: | composer global require --prefer-dist --no-interaction --no-progress --ansi php-coveralls/php-coveralls export PATH="$PATH:$HOME/.composer/vendor/bin" - php-coveralls --coverage_clover=build/logs/behat/clover.xml + php-coveralls --coverage_clover=build/logs/phpunit/clover.xml continue-on-error: true mercure: - name: PHPUnit + Behat (PHP ${{ matrix.php }}) (Mercure) + name: PHPUnit (PHP ${{ matrix.php }}) (Mercure) runs-on: ubuntu-latest timeout-minutes: 20 strategy: @@ -785,31 +677,20 @@ jobs: run: tests/Fixtures/app/console cache:clear --ansi - name: Run PHPUnit tests run: vendor/bin/phpunit --log-junit build/logs/phpunit/junit.xml --coverage-clover build/logs/phpunit/clover.xml --group mercure - - name: Run Behat tests - run: | - mkdir -p build/logs/behat - vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --profile=mercure-coverage --no-interaction - - name: Merge code coverage reports - run: | - wget -qO /usr/local/bin/phpcov https://phar.phpunit.de/phpcov-12.phar - chmod +x /usr/local/bin/phpcov - mkdir -p build/coverage - phpcov merge --clover build/logs/behat/clover.xml build/coverage - continue-on-error: true - name: Upload test artifacts if: always() uses: actions/upload-artifact@v6 with: - name: behat-logs-php${{ matrix.php }} - path: build/logs/behat + name: phpunit-logs-php${{ matrix.php }}-mercure + path: build/logs/phpunit continue-on-error: true - name: Upload coverage results to Codecov uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} - directory: build/logs/behat - name: behat-php${{ matrix.php }} - flags: behat + directory: build/logs/phpunit + name: phpunit-php${{ matrix.php }}-mercure + flags: phpunit fail_ci_if_error: true continue-on-error: true - name: Upload coverage results to Coveralls @@ -818,7 +699,7 @@ jobs: run: | composer global require --prefer-dist --no-interaction --no-progress --ansi php-coveralls/php-coveralls export PATH="$PATH:$HOME/.composer/vendor/bin" - php-coveralls --coverage_clover=build/logs/behat/clover.xml + php-coveralls --coverage_clover=build/logs/phpunit/clover.xml continue-on-error: true elasticsearch: @@ -1029,50 +910,6 @@ jobs: - name: Run PHPUnit tests run: vendor/bin/phpunit --fail-on-deprecation - behat-symfony-next: - name: Behat (PHP ${{ matrix.php }}) (Symfony dev) - runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - matrix: - php: - - '8.5' - fail-fast: false - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - tools: pecl, composer:2.9.8 - extensions: intl, bcmath, curl, openssl, mbstring - coverage: none - ini-values: memory_limit=-1 - - name: Get composer cache directory - id: composercache - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - name: Allow unstable project dependencies - run: composer config minimum-stability dev - - name: Cache dependencies - uses: actions/cache@v5 - with: - path: ${{ steps.composercache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - name: Remove cache - run: rm -Rf tests/Fixtures/app/var/cache/* - - name: Update project dependencies - run: | - composer global require soyuka/pmu - composer global config allow-plugins.soyuka/pmu true --no-interaction - composer global link . - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction - - # remove once behat can be installed with symfony 8.1 phpunit-symfony-edge: name: PHPUnit (PHP ${{ matrix.php }}) (Symfony 8.1) runs-on: ubuntu-latest @@ -1099,8 +936,6 @@ jobs: run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Allow unstable project dependencies run: composer config minimum-stability dev - - name: Drop Behat dev dependencies (incompatible with Symfony 8.1) - run: composer remove --no-update --no-interaction --dev behat/behat behat/mink soyuka/contexts friends-of-behat/symfony-extension friends-of-behat/mink-browserkit-driver friends-of-behat/mink-extension - name: Force Symfony 8.1 dev for framework-bundle and json-streamer run: composer require --dev --no-update --no-interaction "symfony/framework-bundle:8.1.x-dev" "symfony/json-streamer:8.1.x-dev" - name: Cache dependencies @@ -1121,59 +956,6 @@ jobs: - name: Run PHPUnit tests run: vendor/bin/phpunit - windows-behat: - name: Windows Behat (PHP ${{ matrix.php }}) (SQLite) - runs-on: windows-latest - timeout-minutes: 20 - strategy: - matrix: - php: - - '8.5' - fail-fast: false - env: - APP_ENV: sqlite - DATABASE_URL: sqlite:///%kernel.project_dir%/var/data.db - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Setup PHP with pre-release PECL extension - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - tools: pecl, composer:2.9.8 - extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite, fileinfo - coverage: none - ini-values: memory_limit=-1 - - name: Get composer cache directory - id: composercache - shell: bash - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - name: Cache dependencies - uses: actions/cache@v5 - with: - path: ${{ steps.composercache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - name: Keep windows path - id: get-cwd - shell: bash - run: | - cwd=$(php -r 'echo(str_replace("\\", "\\\\", $_SERVER["argv"][1]));' '${{ github.workspace }}') - echo cwd=$cwd >> $GITHUB_OUTPUT - - name: Update project dependencies - shell: bash - run: | - php -m - composer global require soyuka/pmu - composer global config allow-plugins.soyuka/pmu true --no-interaction - composer global link . --working-directory='${{ steps.get-cwd.outputs.cwd }}' - - name: Clear test app cache - shell: bash - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - shell: bash - run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction - phpunit-symfony-lowest: name: PHPUnit (PHP ${{ matrix.php }}) (Symfony lowest) runs-on: ubuntu-latest @@ -1218,48 +1000,6 @@ jobs: env: SYMFONY_DEPRECATIONS_HELPER: max[self]=0&ignoreFile=./tests/.ignored-deprecations - behat-symfony-lowest: - name: Behat (PHP ${{ matrix.php }}) (Symfony lowest) - runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - matrix: - php: - - '8.5' - fail-fast: false - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - tools: pecl, composer:2.9.8 - extensions: intl, bcmath, curl, openssl, mbstring - coverage: none - ini-values: memory_limit=-1 - - name: Get composer cache directory - id: composercache - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - name: Cache dependencies - uses: actions/cache@v5 - with: - path: ${{ steps.composercache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - name: Remove cache - run: rm -Rf tests/Fixtures/app/var/cache/* - - name: Update project dependencies - run: | - composer global require soyuka/pmu - composer global config allow-plugins.soyuka/pmu true --no-interaction - composer global link . --permanent - composer update --prefer-lowest - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests - run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction --tags='~@disableForSymfonyLowest' - phpunit_listeners: name: PHPUnit event listeners (PHP ${{ matrix.php }}) env: @@ -1339,56 +1079,6 @@ jobs: php-coveralls --coverage_clover=build/logs/phpunit/clover.xml continue-on-error: true - behat_listeners: - name: Behat event listeners (PHP ${{ matrix.php }}) - env: - USE_SYMFONY_LISTENERS: 1 - runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - matrix: - php: - - '8.5' - fail-fast: false - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - tools: pecl, composer:2.9.8 - extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite - coverage: pcov - ini-values: memory_limit=-1 - - name: Get composer cache directory - id: composercache - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - name: Cache dependencies - uses: actions/cache@v5 - with: - path: ${{ steps.composercache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} - restore-keys: ${{ runner.os }}-composer- - - name: Update project dependencies - run: | - composer global require soyuka/pmu - composer global config allow-plugins.soyuka/pmu true --no-interaction - composer global link . - - name: Clear test app cache - run: tests/Fixtures/app/console cache:clear --ansi - - name: Run Behat tests (PHP 8) - run: | - mkdir -p build/logs/behat - vendor/bin/behat --out=std --format=progress --format=junit --out=build/logs/behat/junit --profile=symfony_listeners --no-interaction - - name: Upload test artifacts - if: always() - uses: actions/upload-artifact@v6 - with: - name: behat-logs-php${{ matrix.php }} - path: build/logs/behat - continue-on-error: true - openapi: name: OpenAPI runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 4f0d29e0b27..8f0adddc594 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ You are an expert Core Contributor to API Platform, a PHP framework supporting S * Context Retrieval (VectorCode): Before writing new code or asking for clarification, ALWAYS use vectorcode if available to search for existing patterns, interfaces, or similar implementations in the codebase. * Test-First Mandate: Your primary output should be functional tests to expose bugs or verify features. Do not fix bugs unless explicitly requested. -* Execution Restraint: NEVER run the full test suite (Behat or PHPUnit). It is too slow. Only run specific, filtered tests relevant to the current task. +* Execution Restraint: NEVER run the full PHPUnit test suite. It is too slow. Only run specific, filtered tests relevant to the current task. * Fixture Isolation: Do not modify existing fixtures (tests/Fixtures/...). Always create new Entities, DTOs, or Models to prevent regression in other tests. * Git Policy: Do not perform git commits unless explicitly asked. @@ -26,7 +26,7 @@ When to use: 3. Testing Quick-Reference (Default/Symfony) -For advanced configurations (Event Listeners, MongoDB, Behat tuning), refer to `tests/AGENTS.md`. +For advanced configurations (Event Listeners, MongoDB), refer to `tests/AGENTS.md`. Common Commands: @@ -43,12 +43,9 @@ rm -rf tests/Fixtures/app/var/cache/test # indefinitely. Remove them before running tests: find src -name vendor -exec rm -rf {} + -# PHPUnit (Preferred) +# PHPUnit vendor/bin/phpunit --filter testMethodName -# Behat (Legacy) -vendor/bin/behat features/main/crud.feature:120 --format=progress - #Component Testing cd src/Metadata composer link ../../ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 89ba9542382..5c18c931cfe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,7 +87,7 @@ See also the [related documentation for Symfony](https://symfony.com/doc/current When you send a PR, just make sure that: -* You add valid test cases (Behat and PHPUnit). +* You add valid test cases (PHPUnit). * Tests are green. * You make a PR on the related documentation in the [api-platform/docs](https://github.com/api-platform/docs) repository. * You make the PR on the same branch you based your changes on. If you see commits @@ -123,11 +123,11 @@ Only the first commit on a Pull Request need to use a conventional commit, other ### Tests -On `api-platform/core` there are two kinds of tests: unit (`phpunit`) and integration tests (`behat`). +On `api-platform/core` tests are written with `phpunit` (unit tests and functional tests under `tests/Functional`). Note that we stopped using `prophesize` for new tests since 3.2, use `phpunit` stub system. -Both `phpunit` and `behat` are development dependencies and should be available in the `vendor` directory. +`phpunit` is a development dependency and should be available in the `vendor` directory. Recommendations: @@ -157,20 +157,11 @@ Sometimes there might be an error with too many open files when generating cover Coverage will be available in `coverage/index.html`. -#### Behat +To run functional tests for MongoDB: -> [!WARNING] -> Please **do not add new Behat tests**, use a functional test (for example: [ComputedFieldTest](https://github.com/api-platform/core/blob/04d5cff1b28b494ac2e90257a79ce6c045ba82ae/tests/Functional/Doctrine/ComputedFieldTest.php)). + MONGODB_URL=mongodb://localhost:27017 APP_ENV=mongodb vendor/bin/phpunit --group mongodb -The command to launch Behat tests is: - - php -d memory_limit=-1 ./vendor/bin/behat --profile=default --stop-on-failure --format=progress - -If you want to launch Behat tests for MongoDB, the command is: - - MONGODB_URL=mongodb://localhost:27017 APP_ENV=mongodb php -d memory_limit=-1 ./vendor/bin/behat --profile=mongodb --stop-on-failure --format=progress - -To get more details about an error, replace `--format=progress` by `-vvv`. You may run a mongo instance using docker: +You may run a mongo instance using docker: docker run -p 27017:27017 mongo:latest diff --git a/behat.yml.dist b/behat.yml.dist deleted file mode 100644 index a771434c534..00000000000 --- a/behat.yml.dist +++ /dev/null @@ -1,219 +0,0 @@ -default: - suites: - default: - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '~@postgres&&~@mongodb&&~@elasticsearch&&~@controller&&~@mercure&&~@query_parameter_validator' - extensions: - 'FriendsOfBehat\SymfonyExtension': - bootstrap: 'tests/Fixtures/app/bootstrap.php' - kernel: - environment: 'test' - debug: true - class: AppKernel - path: 'tests/Fixtures/app/AppKernel.php' - 'Behat\MinkExtension': - base_url: 'http://example.com/' - files_path: 'features/files' - sessions: - default: - symfony: ~ - 'Behatch\Extension': ~ - -postgres: - suites: - default: false - postgres: &postgres-suite - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '~@sqlite&&~@mongodb&&~@elasticsearch&&~@controller&&~@mercure&&~@query_parameter_validator' - -mongodb: - suites: - default: false - mongodb: &mongodb-suite - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '~@sqlite&&~@elasticsearch&&~@!mongodb&&~@mercure&&~@controller&&~@query_parameter_validator' - -mercure: - suites: - default: false - mercure: &mercure-suite - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '@mercure' - -default-coverage: - suites: - default: &default-coverage-suite - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\CoverageContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - -mongodb-coverage: - suites: - default: false - mongodb: &mongodb-coverage-suite - <<: *mongodb-suite - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\CoverageContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - -mercure-coverage: - suites: - default: false - mongodb: &mercure-coverage-suite - <<: *mercure-suite - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\CoverageContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - -legacy: - suites: - default: - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '~@postgres&&~@mongodb&&~@elasticsearch&&~@link_security&&~@use_listener&&~@query_parameter_validator' - extensions: - 'FriendsOfBehat\SymfonyExtension': - bootstrap: 'tests/Fixtures/app/bootstrap.php' - kernel: - environment: 'test' - debug: true - class: AppKernel - path: 'tests/Fixtures/app/AppKernel.php' - 'Behat\MinkExtension': - base_url: 'http://example.com/' - files_path: 'features/files' - sessions: - default: - symfony: ~ - 'Behatch\Extension': ~ - -symfony_listeners: - suites: - default: - contexts: - - 'ApiPlatform\Tests\Behat\CommandContext' - - 'ApiPlatform\Tests\Behat\DoctrineContext' - - 'ApiPlatform\Tests\Behat\GraphqlContext' - - 'ApiPlatform\Tests\Behat\JsonContext' - - 'ApiPlatform\Tests\Behat\HydraContext' - - 'ApiPlatform\Tests\Behat\HttpCacheContext' - - 'ApiPlatform\Tests\Behat\JsonApiContext' - - 'ApiPlatform\Tests\Behat\JsonHalContext' - - 'ApiPlatform\Tests\Behat\MercureContext' - - 'ApiPlatform\Tests\Behat\XmlContext' - - 'Behat\MinkExtension\Context\MinkContext' - - 'behatch:context:rest' - filters: - tags: '~@postgres&&~@mongodb&&~@elasticsearch&&~@mercure&&~@query_parameter_validator' - extensions: - 'FriendsOfBehat\SymfonyExtension': - bootstrap: 'tests/Fixtures/app/bootstrap.php' - kernel: - environment: 'test' - debug: true - class: AppKernel - path: 'tests/Fixtures/app/AppKernel.php' - 'Behat\MinkExtension': - base_url: 'http://example.com/' - files_path: 'features/files' - sessions: - default: - symfony: ~ - 'Behatch\Extension': ~ diff --git a/composer.json b/composer.json index 7ffc3c950ee..89a068313e3 100644 --- a/composer.json +++ b/composer.json @@ -125,16 +125,11 @@ "willdurand/negotiation": "^3.1" }, "require-dev": { - "behat/behat": "^3.11", - "behat/mink": "^1.9", "doctrine/common": "^3.2.2", "doctrine/dbal": "^4.0", "doctrine/doctrine-bundle": "^2.11 || ^3.1", "doctrine/orm": "^2.17 || ^3.0", "elasticsearch/elasticsearch": "^7.17 || ^8.4 || ^9.0", - "friends-of-behat/mink-browserkit-driver": "^1.3.1", - "friends-of-behat/mink-extension": "^2.2", - "friends-of-behat/symfony-extension": "^2.1", "friendsofphp/php-cs-fixer": "^3.93", "guzzlehttp/guzzle": "^6.0 || ^7.0", "illuminate/config": "^11.0 || ^12.0 || ^13.0", @@ -160,7 +155,6 @@ "psr/log": "^1.0 || ^2.0 || ^3.0", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", - "soyuka/contexts": "^3.3.10", "soyuka/pmu": "^0.2.0", "soyuka/stubs-mongodb": "^1.0", "symfony/asset": "^6.4 || ^7.0 || ^8.0", diff --git a/features/doctrine/boolean_filter.feature b/features/doctrine/boolean_filter.feature deleted file mode 100644 index e3332eea7bd..00000000000 --- a/features/doctrine/boolean_filter.feature +++ /dev/null @@ -1,525 +0,0 @@ -Feature: Boolean filter on collections - In order to retrieve ordered large collections of resources - As a client software developer - I need to retrieve collections with boolean value - - @createSchema - Scenario: Get collection by dummyBoolean true - Given there are 15 dummy objects with dummyBoolean true - And there are 10 dummy objects with dummyBoolean false - When I send a "GET" request to "/dummies?dummyBoolean=true" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyBoolean=true"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 15 - - Scenario: Get collection by dummyBoolean true - When I send a "GET" request to "/dummies?dummyBoolean=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyBoolean=1"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 15 - - Scenario: Get collection by dummyBoolean false - When I send a "GET" request to "/dummies?dummyBoolean=false" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/16$"}, - {"pattern": "^/dummies/17$"}, - {"pattern": "^/dummies/18$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyBoolean=false"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 10 - - Scenario: Get collection by dummyBoolean false - When I send a "GET" request to "/dummies?dummyBoolean=0" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/16$"}, - {"pattern": "^/dummies/17$"}, - {"pattern": "^/dummies/18$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyBoolean=0"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 10 - - Scenario: Get collection by embeddedDummy.dummyBoolean true - Given there are 15 embedded dummy objects with embeddedDummy.dummyBoolean true - And there are 10 embedded dummy objects with embeddedDummy.dummyBoolean false - When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyBoolean=true" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/1$"}, - {"pattern": "^/embedded_dummies/2$"}, - {"pattern": "^/embedded_dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyBoolean=true"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 15 - - Scenario: Get collection by embeddedDummy.dummyBoolean true - When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyBoolean=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/1$"}, - {"pattern": "^/embedded_dummies/2$"}, - {"pattern": "^/embedded_dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyBoolean=1"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 15 - - Scenario: Get collection by embeddedDummy.dummyBoolean false - When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyBoolean=false" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/16$"}, - {"pattern": "^/embedded_dummies/17$"}, - {"pattern": "^/embedded_dummies/18$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyBoolean=false"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 10 - - Scenario: Get collection by embeddedDummy.dummyBoolean false - When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyBoolean=0" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/16$"}, - {"pattern": "^/embedded_dummies/17$"}, - {"pattern": "^/embedded_dummies/18$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyBoolean=0"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 10 - - Scenario: Get collection by association with embed relatedDummy.embeddedDummy.dummyBoolean true - Given there are 15 embedded dummy objects with relatedDummy.embeddedDummy.dummyBoolean true - And there are 10 embedded dummy objects with relatedDummy.embeddedDummy.dummyBoolean false - When I send a "GET" request to "/embedded_dummies?relatedDummy.embeddedDummy.dummyBoolean=true" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/26$"}, - {"pattern": "^/embedded_dummies/27$"}, - {"pattern": "^/embedded_dummies/28$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?relatedDummy.embeddedDummy\\.dummyBoolean=true"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 15 - - Scenario: Get collection filtered by non valid properties - When I send a "GET" request to "/dummies?unknown=0" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?unknown=0"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 25 - - When I send a "GET" request to "/dummies?unknown=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?unknown=1"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - And the JSON node "hydra:totalItems" should be equal to 25 - - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 5 convertedBoolean objects - When I send a "GET" request to "/converted_booleans?name_converted=false" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedBoolean"}, - "@id": {"pattern": "^/converted_booleans"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_booleans/(2|4)$"}, - "@type": {"pattern": "^ConvertedBoolean"}, - "name_converted": {"type": "boolean"}, - "id": {"type": "integer", "minimum":2, "maximum": 4} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_booleans\\?name_converted=false"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_booleans\\{\\?name_converted\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": {"pattern": "^name_converted$"}, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "minItems": 1, - "maxItems": 1, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ diff --git a/features/doctrine/date_filter.feature b/features/doctrine/date_filter.feature deleted file mode 100644 index 7d8d1a906f8..00000000000 --- a/features/doctrine/date_filter.feature +++ /dev/null @@ -1,636 +0,0 @@ -Feature: Date filter on collections - In order to retrieve large collections of resources filtered by date - As a client software developer - I need to retrieve collections filtered by date - - @createSchema - Scenario: Get collection filtered by date - Given there are 30 dummy objects with dummyDate - When I send a "GET" request to "/dummies?dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/28$"}, - {"pattern": "^/dummies/29$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 2, - "maxItems": 2 - }, - "hydra:totalItems": {"type":"number", "minimum": 2, "maximum": 2}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bafter%5D=2015-04-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - When I send a "GET" request to "/dummies?dummyDate[before]=2015-04-05" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 3, - "maxItems": 3 - }, - "hydra:totalItems": {"type":"number", "minimum": 5, "maximum": 5}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bbefore%5D=2015-04-05&page=1$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"] - } - } - } - """ - - When I send a "GET" request to "/dummies?dummyDate[after]=2015-04-28T00:00:00%2B00:00" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/28$"}, - {"pattern": "^/dummies/29$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 2, - "maxItems": 2 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bafter%5D=2015-04-28T00%3A00%3A00%2B00%3A00$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - When I send a "GET" request to "/dummies?dummyDate[before]=2015-04-05Z" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 3, - "maxItems": 3 - }, - "hydra:totalItems": {"type":"number", "minimum": 5, "maximum": 5}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bbefore%5D=2015-04-05Z&page=1$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"] - } - } - } - """ - - Scenario: Search for entities within a range - # The order should not influence the search - When I send a "GET" request to "/dummies?dummyDate[before]=2015-04-05&dummyDate[after]=2015-04-05" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/5$"} - }, - "required": ["@id"] - }, - "minItems": 1, - "maxItems": 1 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bbefore%5D=2015-04-05&dummyDate%5Bafter%5D=2015-04-05$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - When I send a "GET" request to "/dummies?dummyDate[after]=2015-04-05&dummyDate[before]=2015-04-05" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/5$"} - }, - "required": ["@id"] - }, - "minItems": 1, - "maxItems": 1 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bafter%5D=2015-04-05&dummyDate%5Bbefore%5D=2015-04-05$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - Scenario: Search for entities within an impossible range - When I send a "GET" request to "/dummies?dummyDate[after]=2015-04-06&dummyDate[before]=2015-04-04" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "maxItems": 0 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyDate%5Bafter%5D=2015-04-06&dummyDate%5Bbefore%5D=2015-04-04$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - Scenario: Get collection filtered by association date - Given there are 30 dummy objects with dummyDate and relatedDummy - When I send a "GET" request to "/dummies?relatedDummy.dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/58$"}, - {"pattern": "^/dummies/59$"}, - {"pattern": "^/dummies/60$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 3, - "maxItems": 3 - }, - "hydra:totalItems": {"type":"number", "minimum": 3, "maximum": 3}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummy\\.dummyDate%5Bafter%5D=2015-04-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - When I send a "GET" request to "/dummies?relatedDummy.dummyDate[after]=2015-04-28&relatedDummy_dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/58$"}, - {"pattern": "^/dummies/59$"}, - {"pattern": "^/dummies/60$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 3, - "maxItems": 3 - }, - "hydra:totalItems": {"type":"number", "minimum": 3, "maximum": 3}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummy\\.dummyDate%5Bafter%5D=2015-04-28&relatedDummy_dummyDate%5Bafter%5D=2015-04-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - When I send a "GET" request to "/dummies?relatedDummy.dummyDate[after]=2015-04-28T00:00:00%2B00:00" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/58$"}, - {"pattern": "^/dummies/59$"}, - {"pattern": "^/dummies/60$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 3, - "maxItems": 3 - }, - "hydra:totalItems": {"type":"number", "minimum": 3, "maximum": 3}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummy\\.dummyDate%5Bafter%5D=2015-04-28T00%3A00%3A00%2B00%3A00$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - @createSchema - Scenario: Get collection filtered by association date - Given there are 2 dummy objects with dummyDate and relatedDummy - When I send a "GET" request to "/dummies?relatedDummy.dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "maxItems": 0 - }, - "hydra:totalItems": {"type":"number", "maximum": 0}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummy\\.dummyDate%5Bafter%5D=2015-04-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - @createSchema - Scenario: Get collection filtered by date that is not a datetime - Given there are 30 dummydate objects with dummyDate - When I send a "GET" request to "/dummy_dates?dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the JSON node "hydra:totalItems" should be equal to 3 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - @createSchema - Scenario: Get collection filtered by date that is not a datetime including null after - Given there are 3 dummydate objects with nullable dateIncludeNullAfter - When I send a "GET" request to "/dummy_dates?dateIncludeNullAfter[after]=2015-04-02" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:totalItems" should be equal to 2 - And the JSON node "hydra:member[0].dateIncludeNullAfter" should be equal to "2015-04-02T00:00:00+00:00" - And the JSON node "hydra:member[1].dateIncludeNullAfter" should be null - When I send a "GET" request to "/dummy_dates?dateIncludeNullAfter[before]=2015-04-02" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:totalItems" should be equal to 2 - And the JSON node "hydra:member[0].dateIncludeNullAfter" should be equal to "2015-04-01T00:00:00+00:00" - And the JSON node "hydra:member[1].dateIncludeNullAfter" should be equal to "2015-04-02T00:00:00+00:00" - - @createSchema - Scenario: Get collection filtered by date that is not a datetime including null before - Given there are 3 dummydate objects with nullable dateIncludeNullBefore - When I send a "GET" request to "/dummy_dates?dateIncludeNullBefore[before]=2015-04-01" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:totalItems" should be equal to 2 - And the JSON node "hydra:member[0].dateIncludeNullBefore" should be equal to "2015-04-01T00:00:00+00:00" - And the JSON node "hydra:member[1].dateIncludeNullBefore" should be null - When I send a "GET" request to "/dummy_dates?dateIncludeNullBefore[after]=2015-04-01" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:totalItems" should be equal to 2 - And the JSON node "hydra:member[0].dateIncludeNullBefore" should be equal to "2015-04-01T00:00:00+00:00" - And the JSON node "hydra:member[1].dateIncludeNullBefore" should be equal to "2015-04-02T00:00:00+00:00" - - @createSchema - Scenario: Get collection filtered by date that is not a datetime including null before and after - Given there are 3 dummydate objects with nullable dateIncludeNullBeforeAndAfter - When I send a "GET" request to "/dummy_dates?dateIncludeNullBeforeAndAfter[before]=2015-04-01" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:totalItems" should be equal to 2 - And the JSON node "hydra:member[0].dateIncludeNullBeforeAndAfter" should be equal to "2015-04-01T00:00:00+00:00" - And the JSON node "hydra:member[1].dateIncludeNullBeforeAndAfter" should be null - When I send a "GET" request to "/dummy_dates?dateIncludeNullBeforeAndAfter[after]=2015-04-02" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:totalItems" should be equal to 2 - And the JSON node "hydra:member[0].dateIncludeNullBeforeAndAfter" should be equal to "2015-04-02T00:00:00+00:00" - And the JSON node "hydra:member[1].dateIncludeNullBeforeAndAfter" should be null - - @createSchema - Scenario: Get collection filtered by date that is an immutable date variant - Given there are 30 dummyimmutabledate objects with dummyDate - When I send a "GET" request to "/dummy_immutable_dates?dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the JSON node "hydra:totalItems" should be equal to 3 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - @createSchema - Scenario: Get collection filtered by embedded date - Given there are 29 embedded dummy objects with dummyDate and embeddedDummy - When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyDate[after]=2015-04-28" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/28$"}, - {"pattern": "^/embedded_dummies/29$"} - ] - } - }, - "required": ["@id"] - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyDate%5Bafter%5D=2015-04-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - } - } - } - """ - - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 30 convertedDate objects - When I send a "GET" request to "/converted_dates?name_converted[strictly_after]=2015-04-28" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedDate"}, - "@id": {"pattern": "^/converted_dates"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_dates/(29|30)$"}, - "@type": {"pattern": "^ConvertedDate"}, - "name_converted": {"type": "string"}, - "id": {"type": "integer", "minimum":29, "maximum": 30} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_dates\\?name_converted%5Bstrictly_after%5D=2015\\-04\\-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_dates\\{\\?.*name_converted\\[before\\],name_converted\\[strictly_before\\],name_converted\\[after\\],name_converted\\[strictly_after\\].*\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": {"pattern": "^name_converted(\\[(strictly_)?(before|after)\\])$"}, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "minItems": 4, - "maxItems": 4, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ diff --git a/features/doctrine/eager_loading.feature b/features/doctrine/eager_loading.feature deleted file mode 100644 index ee7e73ff6a1..00000000000 --- a/features/doctrine/eager_loading.feature +++ /dev/null @@ -1,99 +0,0 @@ -@!mongodb -Feature: Eager Loading - In order to have better performance - As a client software developer - The eager loading should be enabled - - @createSchema - Scenario: Eager loading for a relation - Given there is a RelatedDummy with 2 friends - When I send a "GET" request to "/related_dummies/1" - Then the response status code should be 200 - And the DQL should be equal to: - """ - SELECT o, thirdLevel_a1, relatedToDummyFriend_a3, fourthLevel_a2, dummyFriend_a4 - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o - LEFT JOIN o.thirdLevel thirdLevel_a1 - LEFT JOIN thirdLevel_a1.fourthLevel fourthLevel_a2 - LEFT JOIN o.relatedToDummyFriend relatedToDummyFriend_a3 - LEFT JOIN relatedToDummyFriend_a3.dummyFriend dummyFriend_a4 - WHERE o.id = :id_p1 - """ - - Scenario: Eager loading for the search filter - Given there is a dummy object with a fourth level relation - When I send a "GET" request to "/dummies?relatedDummy.thirdLevel.level=3" - Then the response status code should be 200 - And the DQL should be equal to: - """ - SELECT o - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy o - INNER JOIN o.relatedDummy relatedDummy_a1 - INNER JOIN relatedDummy_a1.thirdLevel thirdLevel_a2 - WHERE o IN( - SELECT o_a3 - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy o_a3 - INNER JOIN o_a3.relatedDummy relatedDummy_a4 - INNER JOIN relatedDummy_a4.thirdLevel thirdLevel_a5 - WHERE thirdLevel_a5.level = :level_p1 - ) - ORDER BY o.id ASC - """ - - Scenario: Eager loading for a relation and a search filter - Given there is a RelatedDummy with 2 friends - When I send a "GET" request to "/related_dummies?relatedToDummyFriend.dummyFriend=2" - Then the response status code should be 200 - And the DQL should be equal to: - """ - SELECT o, thirdLevel_a4, relatedToDummyFriend_a1, fourthLevel_a5, dummyFriend_a6 - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o - INNER JOIN o.relatedToDummyFriend relatedToDummyFriend_a1 - LEFT JOIN o.thirdLevel thirdLevel_a4 - LEFT JOIN thirdLevel_a4.fourthLevel fourthLevel_a5 - INNER JOIN relatedToDummyFriend_a1.dummyFriend dummyFriend_a6 - WHERE o IN( - SELECT o_a2 - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o_a2 - INNER JOIN o_a2.relatedToDummyFriend relatedToDummyFriend_a3 - WHERE relatedToDummyFriend_a3.dummyFriend = :dummyFriend_p1 - ) - ORDER BY o.id ASC - """ - - Scenario: Eager loading for a relation and a property filter with multiple relations - Given there is a dummy travel - When I send a "GET" request to "/dummy_travels/1?properties[]=confirmed&properties[car][]=brand&properties[passenger][]=nickname" - Then the response status code should be 200 - And the JSON node "confirmed" should be equal to "true" - And the JSON node "car.carBrand" should be equal to "DummyBrand" - And the JSON node "passenger.nickname" should be equal to "Tom" - And the DQL should be equal to: - """ - SELECT o, car_a1, passenger_a2 - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTravel o - LEFT JOIN o.car car_a1 - LEFT JOIN o.passenger passenger_a2 - WHERE o.id = :id_p1 - """ - - Scenario: Eager loading for a relation with complex sub-query filter - Given there is a RelatedDummy with 2 friends - When I send a "GET" request to "/related_dummies?complex_sub_query_filter=1" - Then the response status code should be 200 - And the DQL should be equal to: - """ - SELECT o, thirdLevel_a3, relatedToDummyFriend_a5, fourthLevel_a4, dummyFriend_a6 - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o - LEFT JOIN o.thirdLevel thirdLevel_a3 - LEFT JOIN thirdLevel_a3.fourthLevel fourthLevel_a4 - LEFT JOIN o.relatedToDummyFriend relatedToDummyFriend_a5 - LEFT JOIN relatedToDummyFriend_a5.dummyFriend dummyFriend_a6 - WHERE o.id IN ( - SELECT related_dummy_a1.id - FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy related_dummy_a1 - INNER JOIN related_dummy_a1.relatedToDummyFriend related_to_dummy_friend_a2 - WITH related_to_dummy_friend_a2.name = :name_p1 - ) - ORDER BY o.id ASC - """ diff --git a/features/doctrine/exists_filter.feature b/features/doctrine/exists_filter.feature deleted file mode 100644 index e99f2ff445a..00000000000 --- a/features/doctrine/exists_filter.feature +++ /dev/null @@ -1,223 +0,0 @@ -Feature: Exists filter on collections - In order to retrieve large collections of resources - As a client software developer - I need to retrieve collections with properties that exist or not - - @createSchema - Scenario: Get collection where a property does not exist - Given there are 15 dummy objects with dummyBoolean true - When I send a "GET" request to "/dummies?exists[dummyBoolean]=0" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "maximum": 0}, - "hydra:member": { - "type": "array", - "maxItems": 0 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?exists%5BdummyBoolean%5D=0$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection where a property does exist - When I send a "GET" request to "/dummies?exists[dummyBoolean]=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "minimum": 15, "maximum": 15}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/(1|2|3)$"} - }, - "required": ["@id"] - }, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?exists%5BdummyBoolean%5D=1&page=1$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Use exists filter with a empty relation collection - Given there are 3 dummy objects having each 0 relatedDummies - And there are 2 dummy objects having each 3 relatedDummies - When I send a "GET" request to "/dummies?exists[relatedDummies]=0" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "minimum": 3, "maximum": 3}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/(1|2|3)$"} - }, - "required": ["@id"] - }, - "minItems": 3, - "maxItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?exists%5BrelatedDummies%5D=0$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Use exists filter with a non empty relation collection - When I send a "GET" request to "/dummies?exists[relatedDummies]=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:totalItems": {"type":"number", "minimum": 2, "maximum": 2}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies/(4|5)$"} - }, - "required": ["@id"] - }, - "minItems": 2, - "maxItems": 2 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?exists%5BrelatedDummies%5D=1$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 4 convertedString objects - When I send a "GET" request to "/converted_strings?exists[name_converted]=true" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedString"}, - "@id": {"pattern": "^/converted_strings"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_strings/(1|3)$"}, - "@type": {"pattern": "^ConvertedString"}, - "name_converted": {"pattern": "^name#(1|3)$"}, - "id": {"type": "integer", "minimum":1, "maximum": 3} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_strings\\?exists%5Bname_converted%5D=true"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_strings\\{\\?exists\\[name_converted\\]\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": {"pattern": "^exists\\[name_converted\\]$"}, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "minItems": 1, - "maxItems": 1, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ diff --git a/features/doctrine/handle_links.feature b/features/doctrine/handle_links.feature deleted file mode 100644 index ebfa7b10e4f..00000000000 --- a/features/doctrine/handle_links.feature +++ /dev/null @@ -1,17 +0,0 @@ -Feature: Use a link handler to retrieve a resource - - @createSchema - Scenario: Get collection - Given there are a few link handled dummies - When I send a "GET" request to "/link_handled_dummies" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "hydra:totalItems" should be equal to 1 - - @createSchema - Scenario: Get item - Given there are a few link handled dummies - When I send a "GET" request to "/link_handled_dummies/1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "slug" should be equal to "foo" diff --git a/features/doctrine/issue5722/subresource_without_get.feature b/features/doctrine/issue5722/subresource_without_get.feature deleted file mode 100644 index ff54949e926..00000000000 --- a/features/doctrine/issue5722/subresource_without_get.feature +++ /dev/null @@ -1,8 +0,0 @@ -Feature: Get a subresource from inverse side that has no item operation - - @!mongodb - @createSchema - Scenario: Get a subresource from inverse side that has no item operation - Given there are logs on an event - When I send a "GET" request to "/events/03af3507-271e-4cca-8eee-6244fb06e95b/logs" - Then the response status code should be 200 diff --git a/features/doctrine/issue6175/standard_put_entity_inheritence.feature b/features/doctrine/issue6175/standard_put_entity_inheritence.feature deleted file mode 100644 index 07d0d7e88cb..00000000000 --- a/features/doctrine/issue6175/standard_put_entity_inheritence.feature +++ /dev/null @@ -1,25 +0,0 @@ -Feature: Update properties of a resource that are inherited with standard PUT operation - - @!mongodb - @createSchema - Scenario: Update properties of a resource that are inherited with standard PUT operation - Given there is a dummy entity with a mapped superclass - When I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/dummy_mapped_subclasses/1" with body: - """ - { - "foo": "updated value" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON should be equal to: - """ - { - "@context": "/contexts/DummyMappedSubclass", - "@id": "/dummy_mapped_subclasses/1", - "@type": "DummyMappedSubclass", - "id": 1, - "foo": "updated value" - } - """ diff --git a/features/doctrine/multiple_filter.feature b/features/doctrine/multiple_filter.feature deleted file mode 100644 index d98e36cf264..00000000000 --- a/features/doctrine/multiple_filter.feature +++ /dev/null @@ -1,48 +0,0 @@ -Feature: Multiple filters on collections - In order to retrieve large collections of filtered resources - As a client software developer - I need to retrieve collections filtered by multiple parameters - - @createSchema - Scenario: Get collection filtered by multiple parameters - Given there are 30 dummy objects with dummyDate and dummyBoolean true - And there are 20 dummy objects with dummyDate and dummyBoolean false - When I send a "GET" request to "/dummies?dummyDate[after]=2015-04-28&dummyBoolean=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/28$"}, - {"pattern": "^/dummies/29$"} - ] - } - } - }, - "minItems": 2, - "maxItems": 2 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyBoolean=1&dummyDate%5Bafter%5D=2015-04-28$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - diff --git a/features/doctrine/numeric_filter.feature b/features/doctrine/numeric_filter.feature deleted file mode 100644 index ec449ae0be7..00000000000 --- a/features/doctrine/numeric_filter.feature +++ /dev/null @@ -1,219 +0,0 @@ -Feature: Numeric filter on collections - In order to retrieve ordered large collections of resources - As a client software developer - I need to retrieve collections with numerical value - - @createSchema - Scenario: Get collection by dummyPrice=9.99 - Given there are 10 dummy objects with dummyPrice - When I send a "GET" request to "/dummies?dummyPrice=9.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/5$"}, - {"pattern": "^/dummies/9$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 3, "maximum": 3}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice=9.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection by multiple dummyPrice - Given there are 10 dummy objects with dummyPrice - When I send a "GET" request to "/dummies?dummyPrice[]=9.99&dummyPrice[]=12.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/5$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 6, "maximum": 6}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5B%5D=9.99&dummyPrice%5B%5D=12.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection by non-numeric dummyPrice=marty - Given there are 10 dummy objects with dummyPrice - When I send a "GET" request to "/dummies?dummyPrice=marty" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 20, "maximum": 20}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice=marty"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 5 convertedInteger objects - When I send a "GET" request to "/converted_integers?name_converted[]=2&name_converted[]=3" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedInteger$"}, - "@id": {"pattern": "^/converted_integers$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers/(2|3)$"}, - "@type": {"pattern": "^ConvertedInteger$"}, - "name_converted": {"type": "integer"}, - "id": {"type": "integer", "minimum":2, "maximum": 3} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers\\?name_converted%5B%5D=2&name_converted%5B%5D=3$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_integers\\{\\?.*name_converted,name_converted\\[\\].*\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": { - "oneOf": [ - {"pattern": "^name_converted(\\[(between|gt|gte|lt|lte)?\\])?$"}, - {"pattern": "^order\\[name_converted\\]$"} - ] - }, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "minItems": 8, - "maxItems": 8, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ - diff --git a/features/doctrine/order_filter.feature b/features/doctrine/order_filter.feature deleted file mode 100644 index 4d3d5587b97..00000000000 --- a/features/doctrine/order_filter.feature +++ /dev/null @@ -1,824 +0,0 @@ -Feature: Order filter on collections - In order to retrieve ordered large collections of resources - As a client software developer - I need to retrieve collections ordered properties - - @createSchema - Scenario: Get collection ordered in ascending order on an integer property and on which order filter has been enabled in whitelist mode - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?order[id]=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bid%5D=asc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered in descending order on an integer property and on which order filter has been enabled in whitelist mode - When I send a "GET" request to "/dummies?order[id]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/30$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/29$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/28$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bid%5D=desc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered in ascending order on a string property and on which order filter has been enabled in whitelist mode - When I send a "GET" request to "/dummies?order[name]=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/10$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/11$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bname%5D=asc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered in descending order on a string property and on which order filter has been enabled in whitelist mode - When I send a "GET" request to "/dummies?order[name]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/9$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/8$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/7$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bname%5D=desc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered collection on several property keep the order - # Adding 30 more data with the same name - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?order[name]=desc&order[id]=desc" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/39$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/9$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/38$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bname%5D=desc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered in ascending order on an association and on which order filter has been enabled in whitelist mode - Given there are 30 dummy objects with relatedDummy - When I send a "GET" request to "/dummies?order[relatedDummy]=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5BrelatedDummy%5D=asc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered in ascending order on an embedded and on which order filter has been enabled in whitelist mode - Given there are 30 dummy objects with embeddedDummy - When I send a "GET" request to "/embedded_dummies?order[embeddedDummy]=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/embedded_dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/embedded_dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/embedded_dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?order%5BembeddedDummy%5D=asc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Get collection ordered by default configured order on a embedded string property and on which order filter has been enabled in whitelist mode with default descending order - When I send a "GET" request to "/embedded_dummies?order[embeddedDummy.dummyName]" - Then the response status code should be 422 - - Scenario: Get collection ordered by a non valid properties and on which order filter has been enabled in whitelist mode - When I send a "GET" request to "/dummies?order[alias]=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Balias%5D=asc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - When I send a "GET" request to "/dummies?order[alias]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Balias%5D=desc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - When I send a "GET" request to "/dummies?order[unknown]=asc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bunknown%5D=asc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - When I send a "GET" request to "/dummies?order[unknown]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/3$" - } - } - } - ], - "additionalItems": false, - "maxItems": 3, - "minItems": 3 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5Bunknown%5D=desc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection ordered in descending order on a related property - Given there are 2 dummy objects with relatedDummy - When I send a "GET" request to "/dummies?order[relatedDummy.name]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/2$" - } - } - }, - { - "type": "object", - "properties": { - "@id": { - "type": "string", - "pattern": "^/dummies/1$" - } - } - } - ], - "additionalItems": false, - "maxItems": 2, - "minItems": 2 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?order%5BrelatedDummy.name%5D=desc"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 3 convertedInteger objects - When I send a "GET" request to "/converted_integers?order[name_converted]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedInteger$"}, - "@id": {"pattern": "^/converted_integers$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": [ - { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers/3$"}, - "@type": {"pattern": "^ConvertedInteger$"}, - "name_converted": {"type": "integer"}, - "id": {"type": "integer", "minimum":3, "maximum": 3} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers/2$"}, - "@type": {"pattern": "^ConvertedInteger$"}, - "name_converted": {"type": "integer"}, - "id": {"type": "integer", "minimum":2, "maximum": 2} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers/1$"}, - "@type": {"pattern": "^ConvertedInteger$"}, - "name_converted": {"type": "integer"}, - "id": {"type": "integer", "minimum":1, "maximum": 1} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - } - ], - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 3, "maximum": 3}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers\\?order%5Bname_converted%5D=desc$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_integers\\{\\?.*order\\[name_converted\\].*\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": { - "oneOf": [ - {"pattern": "^order\\[name_converted\\]$"}, - {"pattern": "^name_converted(\\[(between|gt|gte|lt|lte)?\\])?$"} - ] - }, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "minItems": 8, - "maxItems": 8, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ - - # See https://github.com/api-platform/core/pull/3673 - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 3 convertedInteger objects - When I send a "GET" request to "/converted_integers?order[]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" diff --git a/features/doctrine/range_filter.feature b/features/doctrine/range_filter.feature deleted file mode 100644 index 9a9ec12d074..00000000000 --- a/features/doctrine/range_filter.feature +++ /dev/null @@ -1,506 +0,0 @@ -Feature: Range filter on collections - In order to filter results from large collections of resources - As a client software developer - I need to filter collections by range - - @createSchema - Scenario: Get collection filtered by range (between) - Given there are 30 dummy objects with dummyPrice - When I send a "GET" request to "/dummies?dummyPrice[between]=12.99..15.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"}, - {"pattern": "^/dummies/6$"}, - {"pattern": "^/dummies/7$"}, - {"pattern": "^/dummies/10$"}, - {"pattern": "^/dummies/11$"}, - {"pattern": "^/dummies/14$"}, - {"pattern": "^/dummies/15$"}, - {"pattern": "^/dummies/18$"}, - {"pattern": "^/dummies/19$"}, - {"pattern": "^/dummies/22$"}, - {"pattern": "^/dummies/23$"}, - {"pattern": "^/dummies/26$"}, - {"pattern": "^/dummies/27$"}, - {"pattern": "^/dummies/30$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 15, "maximum": 15}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bbetween%5D=12.99..15.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection filtered by range (between the same values) - Given there are 30 dummy objects with dummyPrice - When I send a "GET" request to "/dummies?dummyPrice[between]=12.99..12.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/6$"}, - {"pattern": "^/dummies/10$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 8, "maximum": 8}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bbetween%5D=12.99..12.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter by range (between) with invalid format - When I send a "GET" request to "/dummies?dummyPrice[between]=9.99..12.99..15.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "pattern": "^/dummies/([1-9]|[12][0-9]|30)$" - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 30, "maximum": 30}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bbetween%5D=9.99..12.99..15.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter for entities by range (less than) - When I send a "GET" request to "/dummies?dummyPrice[lt]=12.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/5$"}, - {"pattern": "^/dummies/9$"}, - {"pattern": "^/dummies/13$"}, - {"pattern": "^/dummies/17$"}, - {"pattern": "^/dummies/21$"}, - {"pattern": "^/dummies/25$"}, - {"pattern": "^/dummies/29$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 8, "maximum": 8}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Blt%5D=12.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter for entities by range (less than or equal) - When I send a "GET" request to "/dummies?dummyPrice[lte]=12.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/5$"}, - {"pattern": "^/dummies/6$"}, - {"pattern": "^/dummies/9$"}, - {"pattern": "^/dummies/10$"}, - {"pattern": "^/dummies/13$"}, - {"pattern": "^/dummies/14$"}, - {"pattern": "^/dummies/17$"}, - {"pattern": "^/dummies/18$"}, - {"pattern": "^/dummies/21$"}, - {"pattern": "^/dummies/22$"}, - {"pattern": "^/dummies/25$"}, - {"pattern": "^/dummies/26$"}, - {"pattern": "^/dummies/29$"}, - {"pattern": "^/dummies/30$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 16, "maximum": 16}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Blte%5D=12.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter for entities by range (greater than) - When I send a "GET" request to "/dummies?dummyPrice[gt]=15.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/4$"}, - {"pattern": "^/dummies/8$"}, - {"pattern": "^/dummies/12$"}, - {"pattern": "^/dummies/15$"}, - {"pattern": "^/dummies/20$"}, - {"pattern": "^/dummies/24$"}, - {"pattern": "^/dummies/28$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 7, "maximum": 7}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bgt%5D=15.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter for entities by range (greater than or equal) - When I send a "GET" request to "/dummies?dummyPrice[gte]=15.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/3$"}, - {"pattern": "^/dummies/4$"}, - {"pattern": "^/dummies/7$"}, - {"pattern": "^/dummies/8$"}, - {"pattern": "^/dummies/11$"}, - {"pattern": "^/dummies/12$"}, - {"pattern": "^/dummies/14$"}, - {"pattern": "^/dummies/15$"}, - {"pattern": "^/dummies/19$"}, - {"pattern": "^/dummies/20$"}, - {"pattern": "^/dummies/23$"}, - {"pattern": "^/dummies/24$"}, - {"pattern": "^/dummies/27$"}, - {"pattern": "^/dummies/28$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 14, "maximum": 14}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bgte%5D=15.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter for entities by range (greater than and less than) - When I send a "GET" request to "/dummies?dummyPrice[gt]=12.99&dummyPrice[lt]=19.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/3$"}, - {"pattern": "^/dummies/7$"}, - {"pattern": "^/dummies/11$"}, - {"pattern": "^/dummies/15$"}, - {"pattern": "^/dummies/19$"}, - {"pattern": "^/dummies/23$"}, - {"pattern": "^/dummies/27$"} - ] - } - } - }, - "minItems": 3, - "maxItems": 3, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "number", "minimum": 7, "maximum": 7}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bgt%5D=12.99&dummyPrice%5Blt%5D=19.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Filter for entities within an impossible range - When I send a "GET" request to "/dummies?dummyPrice[gt]=19.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "maxItems": 0 - }, - "hydra:totalItems": {"type": "number", "maximum": 0}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?dummyPrice%5Bgt%5D=19.99$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection filtered using a name converter - Given there are 5 convertedInteger objects - When I send a "GET" request to "/converted_integers?name_converted[lte]=2" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedInteger$"}, - "@id": {"pattern": "^/converted_integers$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers/(1|2)$"}, - "@type": {"pattern": "^ConvertedInteger$"}, - "name_converted": {"type": "integer"}, - "id": {"type": "integer", "minimum":1, "maximum": 2} - }, - "required": ["@id", "@type", "name_converted", "id"], - "additionalProperties": false - }, - "minItems": 2, - "maxItems": 2, - "uniqueItems": true - }, - "hydra:totalItems": {"type": "integer", "minimum": 2, "maximum": 2}, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_integers\\?name_converted%5Blte%5D=2$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - }, - "required": ["@id", "@type"], - "additionalProperties": false - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_integers\\{\\?.*name_converted\\[between\\],name_converted\\[gt\\],name_converted\\[gte\\],name_converted\\[lt\\],name_converted\\[lte\\].*\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": { - "oneOf": [ - {"pattern": "^name_converted(\\[(between|gt|gte|lt|lte)?\\])?$"}, - {"pattern": "^order\\[name_converted\\]$"} - ] - }, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "minItems": 8, - "maxItems": 8, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ diff --git a/features/doctrine/search_filter.feature b/features/doctrine/search_filter.feature deleted file mode 100644 index 50718f81963..00000000000 --- a/features/doctrine/search_filter.feature +++ /dev/null @@ -1,1066 +0,0 @@ -Feature: Search filter on collections - In order to get specific result from a large collections of resources - As a client software developer - I need to search for collections properties - - @createSchema - Scenario: Test ManyToMany with filter on join table - Given there is a RelatedDummy with 4 friends - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/related_dummies?relatedToDummyFriend.dummyFriend=/dummy_friends/4" - Then the response status code should be 200 - And the JSON node "_embedded.item" should have 1 element - And the JSON node "_embedded.item[0].id" should be equal to the number 1 - And the JSON node "_embedded.item[0]._links.relatedToDummyFriend" should have 4 elements - And the JSON node "_embedded.item[0]._embedded.relatedToDummyFriend" should have 4 elements - - @createSchema - Scenario: Test #944 - Given there is a DummyCar entity with related colors - When I send a "GET" request to "/dummy_cars?colors.prop=red" - Then the response status code should be 200 - And the JSON should be equal to: - """ - { - "@context": "/contexts/DummyCar", - "@id": "/dummy_cars", - "@type": "hydra:Collection", - "hydra:member": [ - { - "@id": "/dummy_cars/1", - "@type": "DummyCar", - "colors": [ - { - "@id": "/dummy_car_colors/1", - "@type": "DummyCarColor", - "prop": "red" - }, - { - "@id": "/dummy_car_colors/2", - "@type": "DummyCarColor", - "prop": "blue" - } - ], - "secondColors": [ - { - "@id": "/dummy_car_colors/1", - "@type": "DummyCarColor", - "prop": "red" - }, - { - "@id": "/dummy_car_colors/2", - "@type": "DummyCarColor", - "prop": "blue" - } - ], - "thirdColors": [ - { - "@id": "/dummy_car_colors/1", - "@type": "DummyCarColor", - "prop": "red" - }, - { - "@id": "/dummy_car_colors/2", - "@type": "DummyCarColor", - "prop": "blue" - } - ], - "uuid": [], - "carBrand": "DummyBrand" - } - ], - "hydra:totalItems": 1, - "hydra:view": { - "@id": "/dummy_cars?colors.prop=red", - "@type": "hydra:PartialCollectionView" - }, - "hydra:search": { - "@type": "hydra:IriTemplate", - "hydra:template": "/dummy_cars{?availableAt[before],availableAt[strictly_before],availableAt[after],availableAt[strictly_after],canSell,foobar[],foobargroups[],foobargroups_override[],colors.prop,colors,colors[],secondColors,secondColors[],thirdColors,thirdColors[],uuid,uuid[],name,brand,brand[]}", - "hydra:variableRepresentation": "BasicRepresentation", - "hydra:mapping": [ - { - "@type": "IriTemplateMapping", - "variable": "availableAt[before]", - "property": "availableAt", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "availableAt[strictly_before]", - "property": "availableAt", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "availableAt[after]", - "property": "availableAt", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "availableAt[strictly_after]", - "property": "availableAt", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "canSell", - "property": "canSell", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "foobar[]", - "property": null, - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "foobargroups[]", - "property": null, - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "foobargroups_override[]", - "property": null, - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "colors.prop", - "property": "colors.prop", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "colors", - "property": "colors", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "colors[]", - "property": "colors", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "secondColors", - "property": "secondColors", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "secondColors[]", - "property": "secondColors", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "thirdColors", - "property": "thirdColors", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "thirdColors[]", - "property": "thirdColors", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "uuid", - "property": "uuid", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "uuid[]", - "property": "uuid", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "name", - "property": "name", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "brand", - "property": "brand", - "required": false - }, - { - "@type": "IriTemplateMapping", - "variable": "brand[]", - "property": "brand", - "required": false - } - ] - } - } - """ - - @createSchema - Scenario: Search collection by name (partial) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?name=my" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?name=my"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search collection by name (partial) - Given there are 30 embedded dummy objects - When I send a "GET" request to "/embedded_dummies?embeddedDummy.dummyName=my" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/EmbeddedDummy$"}, - "@id": {"pattern": "^/embedded_dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/embedded_dummies/1$"}, - {"pattern": "^/embedded_dummies/2$"}, - {"pattern": "^/embedded_dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/embedded_dummies\\?embeddedDummy\\.dummyName=my"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search collection by name (partial multiple values) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?name[]=2&name[]=3" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"}, - {"pattern": "^/dummies/12$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?name%5B%5D=2&name%5B%5D=3"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search collection by name (partial case insensitive) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?dummy=somedummytest1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "dummy": { - "pattern": "^SomeDummyTest\\d{1,2}$" - } - } - } - } - } - } - """ - - @createSchema - Scenario: Search collection by alias (start) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?alias=Ali" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?alias=Ali"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search collection by alias (start multiple values) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?description[]=Sma&description[]=Not" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?description%5B%5D=Sma&description%5B%5D=Not"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @sqlite - @createSchema - Scenario: Search collection by description (word_start) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?description=smart" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?description=smart"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - @sqlite - Scenario: Search collection by description (word_start multiple values) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?description[]=smart&description[]=so" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?description%5B%5D=smart&description%5B%5D=so"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - # note on Postgres compared to sqlite the LIKE clause is case sensitive - @postgres - @createSchema - Scenario: Search collection by description (word_start) - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?description=smart" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/4$"}, - {"pattern": "^/dummies/6$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?description=smart"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search for entities within an impossible range - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?name=MuYm" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "maxItems": 0 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?name=MuYm$"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @sqlite - @createSchema - Scenario: Search for entities with an existing collection route name - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?relatedDummies=dummy_cars" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array" - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummies=dummy_cars"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search related collection by name - Given there are 3 dummy objects having each 3 relatedDummies - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?relatedDummies.name=RelatedDummy1" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "_embedded.item" should have 3 elements - And the JSON node "_embedded.item[0]._links.relatedDummies" should have 3 elements - And the JSON node "_embedded.item[1]._links.relatedDummies" should have 3 elements - And the JSON node "_embedded.item[2]._links.relatedDummies" should have 3 elements - - @createSchema - Scenario: Search by related collection id - Given there are 2 dummy objects having each 2 relatedDummies - When I add "Accept" header equal to "application/hal+json" - And I send a "GET" request to "/dummies?relatedDummies=3" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "totalItems" should be equal to "1" - And the JSON node "_links.item" should have 1 element - And the JSON node "_links.item[0].href" should be equal to "/dummies/2" - - @createSchema - Scenario: Get collection by id equals 9.99 which is not possible - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?id=9.99" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?id=9.99"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection by id 10 - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?id=10" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/10$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?id=10"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Get collection ordered by a non valid properties - When I send a "GET" request to "/dummies?unknown=0" - Given there are 30 dummy objects - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?unknown=0"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - When I send a "GET" request to "/dummies?unknown=1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/1$"}, - {"pattern": "^/dummies/2$"}, - {"pattern": "^/dummies/3$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?unknown=1"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Search at third level - Given there is a dummy object with a fourth level relation - When I send a "GET" request to "/dummies?relatedDummy.thirdLevel.level=3" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/31$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummy.thirdLevel.level=3"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - Scenario: Search at fourth level - When I send a "GET" request to "/dummies?relatedDummy.thirdLevel.fourthLevel.level=4" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/31$"} - ] - } - } - } - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?relatedDummy.thirdLevel.fourthLevel.level=4"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - } - } - } - """ - - @createSchema - Scenario: Search collection on a property using a name converted - Given there are 30 dummy objects - When I send a "GET" request to "/dummies?name_converted=Converted 3" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/Dummy$"}, - "@id": {"pattern": "^/dummies$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/dummies/3$"}, - {"pattern": "^/dummies/30$"} - ] - }, - "required": ["@id"] - } - }, - "minItems": 2, - "maxItems": 2 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/dummies\\?name_converted=Converted%203"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/dummies\\{\\?.*name_converted.*}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": {"pattern": "^name_converted$"}, - "property": {"pattern": "^name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "additionalItems": true, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ - - - @createSchema - Scenario: Search collection on a property using a nested name converted - Given there are 30 convertedOwner objects with convertedRelated - When I send a "GET" request to "/converted_owners?name_converted.name_converted=Converted 3" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/ConvertedOwner$"}, - "@id": {"pattern": "^/converted_owners$"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@id": { - "oneOf": [ - {"pattern": "^/converted_owners/3$"}, - {"pattern": "^/converted_owners/30$"} - ] - }, - "name_converted": { - "oneOf": [ - {"pattern": "^/converted_relateds/3$"}, - {"pattern": "^/converted_relateds/30$"} - ] - }, - "required": ["@id", "name_converted"] - } - }, - "minItems": 2, - "maxItems": 2 - }, - "hydra:view": { - "type": "object", - "properties": { - "@id": {"pattern": "^/converted_owners\\?name_converted.name_converted=Converted%203"}, - "@type": {"pattern": "^hydra:PartialCollectionView$"} - } - }, - "hydra:search": { - "type": "object", - "properties": { - "@type": {"pattern": "^hydra:IriTemplate$"}, - "hydra:template": {"pattern": "^/converted_owners\\{\\?.*name_converted\\.name_converted.*\\}$"}, - "hydra:variableRepresentation": {"pattern": "^BasicRepresentation$"}, - "hydra:mapping": { - "type": "array", - "items": { - "type": "object", - "properties": { - "@type": {"pattern": "^IriTemplateMapping$"}, - "variable": {"pattern": "^name_converted\\.name_converted"}, - "property": {"pattern": "^name_converted\\.name_converted$"}, - "required": {"type": "boolean"} - }, - "required": ["@type", "variable", "property", "required"], - "additionalProperties": false - }, - "additionalItems": true, - "uniqueItems": true - } - }, - "additionalProperties": false, - "required": ["@type", "hydra:template", "hydra:variableRepresentation", "hydra:mapping"] - }, - "additionalProperties": false, - "required": ["@context", "@id", "@type", "hydra:member", "hydra:totalItems", "hydra:view", "hydra:search"] - } - } - """ - - @createSchema - Scenario: Search by date (#4128) - Given there are 3 dummydate objects with dummyDate - When I send a "GET" request to "/dummy_dates?dummyDate=2015-04-01" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "hydra:totalItems" should be equal to 1 - - @!mongodb - @createSchema - Scenario: Custom search filters can use Doctrine Expressions as join conditions - Given there is a dummy object with 3 relatedDummies and their thirdLevel - When I send a "GET" request to "/dummy_resource_with_custom_filter?custom=3" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "hydra:totalItems" should be equal to 1 - - @!mongodb - @createSchema - Scenario: Search on nested sub-entity that doesn't use "id" as its ORM identifier - Given there is a dummy entity with a sub entity with id "stringId" and name "someName" - When I send a "GET" request to "/dummy_with_subresource?subEntity=/dummy_subresource/stringId" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "hydra:totalItems" should be equal to 1 - - @!mongodb - @createSchema - Scenario: Filters can use UUIDs - Given there is a group object with uuid "61817181-0ecc-42fb-a6e7-d97f2ddcb344" and 2 users - And there is a group object with uuid "32510d53-f737-4e70-8d9d-58e292c871f8" and 1 users - When I send a "GET" request to "/issue5735/issue5735_users?groups[]=/issue5735/groups/61817181-0ecc-42fb-a6e7-d97f2ddcb344&groups[]=/issue5735/groups/32510d53-f737-4e70-8d9d-58e292c871f8" - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "hydra:totalItems" should be equal to 3 diff --git a/features/doctrine/separated_resource.feature b/features/doctrine/separated_resource.feature deleted file mode 100644 index 90ba193fd68..00000000000 --- a/features/doctrine/separated_resource.feature +++ /dev/null @@ -1,116 +0,0 @@ -Feature: Use state options to use an entity that is not a resource - In order to work with resources and a doctrine entity - As a client software developer - I need to retrieve a CRUD by specifying an entity class - - @!mongodb - @createSchema - Scenario: Get collection - Given there are 5 separated entities - When I send a "GET" request to "/separated_entities" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - Then the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/SeparatedEntity"}, - "@id": {"pattern": "^/separated_entities"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object" - } - }, - "hydra:totalItems": {"type":"number"}, - "hydra:view": { - "type": "object" - } - } - } - """ - - @!mongodb - @createSchema - Scenario: Get ordered collection - Given there are 5 separated entities - When I send a "GET" request to "/separated_entities?order[value]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:member[0].value" should be equal to "5" - - @!mongodb - @createSchema - Scenario: Get item - Given there are 5 separated entities - When I send a "GET" request to "/separated_entities/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - - @!mongodb - @createSchema - Scenario: Get all EntityClassAndCustomProviderResources - Given there are 1 separated entities - When I send a "GET" request to "/entityClassAndCustomProviderResources" - Then the response status code should be 200 - - @!mongodb - @createSchema - Scenario: Get one EntityClassAndCustomProviderResource - Given there are 1 separated entities - When I send a "GET" request to "/entityClassAndCustomProviderResources/1" - Then the response status code should be 200 - - @mongodb - @createSchema - Scenario: Get collection - Given there are 5 separated entities - When I send a "GET" request to "/separated_documents" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - Then the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "@context": {"pattern": "^/contexts/SeparatedDocument"}, - "@id": {"pattern": "^/separated_documents"}, - "@type": {"pattern": "^hydra:Collection$"}, - "hydra:member": { - "type": "array", - "items": { - "type": "object" - } - }, - "hydra:totalItems": {"type":"number"}, - "hydra:view": { - "type": "object" - } - } - } - """ - - @mongodb - @createSchema - Scenario: Get ordered collection - Given there are 5 separated entities - When I send a "GET" request to "/separated_documents?order[value]=desc" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - And the JSON node "hydra:member[0].value" should be equal to "5" - - @mongodb - @createSchema - Scenario: Get item - Given there are 5 separated entities - When I send a "GET" request to "/separated_documents/1" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" diff --git a/features/graphql/authorization.feature b/features/graphql/authorization.feature deleted file mode 100644 index f1e918b5242..00000000000 --- a/features/graphql/authorization.feature +++ /dev/null @@ -1,576 +0,0 @@ -Feature: Authorization checking - In order to use the GraphQL API - As a client software user - I need to be authorized to access a given resource. - - @createSchema - Scenario: An anonymous user tries to retrieve a secured item - Given there are 1 SecuredDummy objects - When I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - title - description - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.securedDummy" should be null - - Scenario: An anonymous user tries to retrieve a secured collection - Given there are 1 SecuredDummy objects - When I send the following GraphQL request: - """ - { - securedDummies { - edges { - node { - title - description - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.securedDummies" should be null - - Scenario: An admin can retrieve a secured collection - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - { - securedDummies { - edges { - node { - title - description - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummies" should exist - And the JSON node "data.securedDummies" should not be null - - Scenario: An anonymous user cannot retrieve a secured collection - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummies { - edges { - node { - title - description - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummies" should be null - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.securedDummies" should be null - - Scenario: An anonymous user tries to create a resource they are not allowed to - When I send the following GraphQL request: - """ - mutation { - createSecuredDummy(input: {owner: "me", title: "Hi", description: "Desc", adminOnlyProperty: "secret", clientMutationId: "auth"}) { - securedDummy { - title - owner - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Only admins can create a secured dummy." - And the JSON node "data.createSecuredDummy" should be null - - @createSchema - Scenario: An admin can access a secured collection relation - Given there are 1 SecuredDummy objects owned by admin with related dummies - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - relatedDummies { - edges { - node { - id - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.relatedDummies" should have 1 element - - Scenario: An admin can access a secured relation - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - relatedDummy { - id - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.relatedDummy" should exist - And the JSON node "data.securedDummy.relatedDummy" should not be null - - @createSchema - Scenario: A user can't access a secured collection relation - Given there are 1 SecuredDummy objects owned by dunglas with related dummies - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - relatedDummies { - edges { - node { - id - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.relatedDummies" should be null - - Scenario: A user can't access a secured relation - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - relatedDummy { - id - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.relatedDummy" should be null - - Scenario: A user can't access a secured relation resource directly - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - relatedSecuredDummy(id: "/related_secured_dummies/1") { - id - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.relatedSecuredDummy" should be null - - Scenario: A user can't access a secured relation resource collection directly - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - relatedSecuredDummies { - edges { - node { - id - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.relatedSecuredDummies" should be null - - Scenario: A user can access a secured collection relation - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - relatedSecuredDummies { - edges { - node { - id - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.relatedSecuredDummies" should have 1 element - - Scenario: A user can access a secured relation - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - relatedSecuredDummy { - id - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.relatedSecuredDummy" should exist - And the JSON node "data.securedDummy.relatedSecuredDummy" should not be null - - Scenario: A user can access a non-secured collection relation - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - publicRelatedSecuredDummies { - edges { - node { - id - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.publicRelatedSecuredDummies" should have 1 element - - Scenario: A user can access a non-secured relation - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - When I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - publicRelatedSecuredDummy { - id - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.publicRelatedSecuredDummy" should exist - And the JSON node "data.securedDummy.publicRelatedSecuredDummy" should not be null - - @createSchema - Scenario: An admin can create a secured resource - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - mutation { - createSecuredDummy(input: {owner: "someone", title: "Hi", description: "Desc", adminOnlyProperty: "secret"}) { - securedDummy { - id - title - owner - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createSecuredDummy.securedDummy.owner" should be equal to "someone" - - Scenario: An admin can create another secured resource - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - mutation { - createSecuredDummy(input: {owner: "dunglas", title: "Hi", description: "Desc", adminOnlyProperty: "secret"}) { - securedDummy { - id - title - owner - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createSecuredDummy.securedDummy.owner" should be equal to "dunglas" - - Scenario: An admin can create a secured resource with an owner-only property if they will be the owner - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - mutation { - createSecuredDummy(input: {owner: "admin", title: "Hi", description: "Desc", adminOnlyProperty: "secret", ownerOnlyProperty: "it works"}) { - securedDummy { - ownerOnlyProperty - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createSecuredDummy.securedDummy.ownerOnlyProperty" should be equal to the string "it works" - And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - { - securedDummies { - edges { - node { - ownerOnlyProperty - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.securedDummies.edges[2].node.ownerOnlyProperty" should be equal to "it works" - - Scenario: An admin can't create a secured resource with an owner-only property if they won't be the owner - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - mutation { - createSecuredDummy(input: {owner: "dunglas", title: "Hi", description: "Desc", adminOnlyProperty: "secret", ownerOnlyProperty: "should not be set"}) { - securedDummy { - ownerOnlyProperty - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createSecuredDummy.securedDummy.ownerOnlyProperty" should exist - And the JSON node "data.createSecuredDummy.securedDummy.ownerOnlyProperty" should be null - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/4") { - ownerOnlyProperty - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.securedDummy.ownerOnlyProperty" should be equal to "" - - Scenario: A user cannot retrieve an item they doesn't own - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/1") { - owner - title - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.securedDummy" should be null - - Scenario: A user can retrieve an item they owns - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/2") { - owner - title - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.owner" should be equal to the string "dunglas" - - Scenario: An admin can see a secured admin-only property on an object they don't own - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/2") { - owner - title - adminOnlyProperty - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.adminOnlyProperty" should exist - And the JSON node "data.securedDummy.adminOnlyProperty" should not be null - - Scenario: A user can't see a secured admin-only property on an object they own - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/2") { - owner - title - adminOnlyProperty - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.adminOnlyProperty" should be null - - Scenario: A user can see a secured owner-only property on an object they own - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/2") { - ownerOnlyProperty - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.ownerOnlyProperty" should exist - And the JSON node "data.securedDummy.ownerOnlyProperty" should not be null - - Scenario: A user can update a secured owner-only property on an object they own - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - mutation { - updateSecuredDummy(input: {id: "/secured_dummies/2", ownerOnlyProperty: "updated"}) { - securedDummy { - ownerOnlyProperty - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateSecuredDummy.securedDummy.ownerOnlyProperty" should be equal to the string "updated" - And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/2") { - ownerOnlyProperty - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.securedDummy.ownerOnlyProperty" should be equal to the string "updated" - - Scenario: An admin can't see a secured owner-only property on an object they don't own - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - { - securedDummy(id: "/secured_dummies/2") { - ownerOnlyProperty - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.securedDummy.ownerOnlyProperty" should be null - - Scenario: A user can't assign to themself an item they doesn't own - When I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" - And I send the following GraphQL request: - """ - mutation { - updateSecuredDummy(input: {id: "/secured_dummies/1", owner: "kitten"}) { - securedDummy { - id - title - owner - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.updateSecuredDummy" should be null - - Scenario: A user can update an item they owns and transfer it - When I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" - And I send the following GraphQL request: - """ - mutation { - updateSecuredDummy(input: {id: "/secured_dummies/2", owner: "vincent"}) { - securedDummy { - id - title - owner - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateSecuredDummy.securedDummy.owner" should be equal to the string "vincent" diff --git a/features/graphql/collection.feature b/features/graphql/collection.feature deleted file mode 100644 index afc2dc097ec..00000000000 --- a/features/graphql/collection.feature +++ /dev/null @@ -1,1109 +0,0 @@ -Feature: GraphQL collection support - - @createSchema - Scenario: Retrieve a collection through a GraphQL query - Given there are 4 dummy objects with relatedDummy and its thirdLevel - When I send the following GraphQL request: - """ - { - dummies { - ...dummyFields - } - } - fragment dummyFields on DummyCursorConnection { - edges { - node { - id - name - relatedDummy { - name - thirdLevel { - id - level - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[2].node.name" should be equal to "Dummy #3" - And the JSON node "data.dummies.edges[2].node.relatedDummy.name" should be equal to "RelatedDummy #3" - And the JSON node "data.dummies.edges[2].node.relatedDummy.thirdLevel.level" should be equal to 3 - - @createSchema - Scenario: Retrieve an nonexistent collection through a GraphQL query - When I send the following GraphQL request: - """ - { - dummies { - edges { - node { - name - } - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 0 element - And the JSON node "data.dummies.pageInfo.endCursor" should be null - And the JSON node "data.dummies.pageInfo.startCursor" should be null - And the JSON node "data.dummies.pageInfo.hasNextPage" should be false - And the JSON node "data.dummies.pageInfo.hasPreviousPage" should be false - - @createSchema - Scenario: Retrieve a collection with a nested collection through a GraphQL query - Given there are 4 dummy objects having each 3 relatedDummies - When I send the following GraphQL request: - """ - { - dummies { - edges { - node { - name - relatedDummies { - edges { - node { - name - } - } - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[2].node.name" should be equal to "Dummy #3" - And the JSON node "data.dummies.edges[2].node.relatedDummies.edges[1].node.name" should be equal to "RelatedDummy23" - - @createSchema - Scenario: Retrieve a collection with a nested collection (inverse side) through a GraphQL query - Given there is a video game with music groups - When I send the following GraphQL request: - """ - { - musicGroups { - edges { - node { - name - videoGames { - edges { - node { - name - } - } - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.musicGroups.edges[0].node.name" should be equal to "Sum 41" - And the JSON node "data.musicGroups.edges[0].node.videoGames.edges[0].node.name" should be equal to "Guitar Hero" - And the JSON node "data.musicGroups.edges[1].node.name" should be equal to "Franz Ferdinand" - And the JSON node "data.musicGroups.edges[1].node.videoGames.edges[0].node.name" should be equal to "Guitar Hero" - - @createSchema - Scenario: Retrieve a collection and an item through a GraphQL query - Given there are 3 dummy objects with dummyDate - And there are 2 dummy group objects - When I send the following GraphQL request: - """ - { - dummies { - edges { - node { - name - dummyDate - } - } - } - dummyGroup(id: "/dummy_groups/2") { - foo - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[1].node.name" should be equal to "Dummy #2" - And the JSON node "data.dummies.edges[1].node.dummyDate" should be equal to "2015-04-02" - And the JSON node "data.dummyGroup.foo" should be equal to "Foo #2" - - @createSchema - Scenario: Retrieve a specific number of items in a collection through a GraphQL query - Given there are 4 dummy objects - When I send the following GraphQL request: - """ - { - dummies(first: 2) { - edges { - node { - name - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 2 elements - - @createSchema - Scenario: Retrieve a specific number of items in a nested collection through a GraphQL query - Given there are 2 dummy objects having each 5 relatedDummies - When I send the following GraphQL request: - """ - { - dummies(first: 1) { - edges { - node { - name - relatedDummies(first: 2) { - edges { - node { - name - } - } - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges" should have 2 elements - - @createSchema - Scenario: Paginate through collections through a GraphQL query - Given there are 4 dummy objects having each 4 relatedDummies - When I send the following GraphQL request: - """ - { - dummies(first: 2) { - edges { - node { - name - relatedDummies(first: 2) { - edges { - node { - name - } - cursor - } - totalCount - pageInfo { - endCursor - hasNextPage - } - } - } - cursor - } - totalCount - pageInfo { - endCursor - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.pageInfo.endCursor" should be equal to "MQ==" - And the JSON node "data.dummies.pageInfo.hasNextPage" should be true - And the JSON node "data.dummies.totalCount" should be equal to 4 - And the JSON node "data.dummies.edges[1].node.name" should be equal to "Dummy #2" - And the JSON node "data.dummies.edges[1].cursor" should be equal to "MQ==" - And the JSON node "data.dummies.edges[1].node.relatedDummies.pageInfo.endCursor" should be equal to "MQ==" - And the JSON node "data.dummies.edges[1].node.relatedDummies.pageInfo.hasNextPage" should be true - And the JSON node "data.dummies.edges[1].node.relatedDummies.totalCount" should be equal to 4 - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy12" - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].cursor" should be equal to "MA==" - When I send the following GraphQL request: - """ - { - dummies(first: 2, after: "MQ==") { - edges { - node { - name - relatedDummies(first: 2, after: "MA==") { - edges { - node { - name - } - cursor - } - pageInfo { - endCursor - hasNextPage - } - } - } - cursor - } - pageInfo { - endCursor - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[0].node.name" should be equal to "Dummy #3" - And the JSON node "data.dummies.edges[0].cursor" should be equal to "Mg==" - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy24" - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].cursor" should be equal to "MQ==" - When I send the following GraphQL request: - """ - { - dummies(first: 2, after: "Mg==") { - edges { - node { - name - relatedDummies(first: 3, after: "MQ==") { - edges { - node { - name - } - cursor - } - pageInfo { - endCursor - hasNextPage - } - } - } - cursor - } - pageInfo { - endCursor - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.pageInfo.hasNextPage" should be false - And the JSON node "data.dummies.pageInfo.endCursor" should be equal to "Mw==" - And the JSON node "data.dummies.edges[0].node.name" should be equal to "Dummy #4" - And the JSON node "data.dummies.edges[0].cursor" should be equal to "Mw==" - And the JSON node "data.dummies.edges[0].node.relatedDummies.pageInfo.hasNextPage" should be false - And the JSON node "data.dummies.edges[0].node.relatedDummies.pageInfo.endCursor" should be equal to "Mw==" - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges" should have 2 elements - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges[1].node.name" should be equal to "RelatedDummy44" - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges[1].cursor" should be equal to "Mw==" - When I send the following GraphQL request: - """ - { - dummies(first: 2, after: "Mw==") { - edges { - node { - name - relatedDummies(first: 1, after: "MQ==") { - edges { - node { - name - } - cursor - } - pageInfo { - endCursor - hasNextPage - } - } - } - cursor - } - pageInfo { - endCursor - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 0 element - - @createSchema - Scenario: Paginate backwards through collections through a GraphQL query - Given there are 4 dummy objects having each 4 relatedDummies - When I send the following GraphQL request: - """ - { - dummies(last: 2) { - edges { - node { - name - relatedDummies(last: 2) { - edges { - node { - name - } - cursor - } - totalCount - pageInfo { - startCursor - hasPreviousPage - } - } - } - cursor - } - totalCount - pageInfo { - startCursor - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.pageInfo.startCursor" should be equal to "Mg==" - And the JSON node "data.dummies.pageInfo.hasPreviousPage" should be true - And the JSON node "data.dummies.totalCount" should be equal to 4 - And the JSON node "data.dummies.edges[1].node.name" should be equal to "Dummy #4" - And the JSON node "data.dummies.edges[1].cursor" should be equal to "Mw==" - And the JSON node "data.dummies.edges[1].node.relatedDummies.pageInfo.startCursor" should be equal to "Mg==" - And the JSON node "data.dummies.edges[1].node.relatedDummies.pageInfo.hasPreviousPage" should be true - And the JSON node "data.dummies.edges[1].node.relatedDummies.totalCount" should be equal to 4 - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy34" - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].cursor" should be equal to "Mg==" - When I send the following GraphQL request: - """ - { - dummies(last: 2, before: "Mw==") { - edges { - node { - name - relatedDummies(last: 2, before: "Mg==") { - edges { - node { - name - } - cursor - } - pageInfo { - startCursor - hasPreviousPage - } - } - } - cursor - } - pageInfo { - startCursor - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[0].node.name" should be equal to "Dummy #2" - And the JSON node "data.dummies.edges[0].cursor" should be equal to "MQ==" - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy13" - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges[0].cursor" should be equal to "MA==" - When I send the following GraphQL request: - """ - { - dummies(last: 2, before: "MQ==") { - edges { - node { - name - relatedDummies(last: 3, before: "Mg==") { - edges { - node { - name - } - cursor - } - pageInfo { - startCursor - hasPreviousPage - } - } - } - cursor - } - pageInfo { - startCursor - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.pageInfo.hasPreviousPage" should be false - And the JSON node "data.dummies.pageInfo.startCursor" should be equal to "MA==" - And the JSON node "data.dummies.edges[0].node.name" should be equal to "Dummy #1" - And the JSON node "data.dummies.edges[0].cursor" should be equal to "MA==" - And the JSON node "data.dummies.edges[0].node.relatedDummies.pageInfo.hasPreviousPage" should be false - And the JSON node "data.dummies.edges[0].node.relatedDummies.pageInfo.startCursor" should be equal to "MA==" - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges" should have 2 elements - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges[1].node.name" should be equal to "RelatedDummy21" - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges[1].cursor" should be equal to "MQ==" - When I send the following GraphQL request: - """ - { - dummies(last: 2, before: "MA==") { - edges { - node { - name - relatedDummies(last: 1, before: "MQ==") { - edges { - node { - name - } - cursor - } - pageInfo { - startCursor - hasPreviousPage - } - } - } - cursor - } - pageInfo { - startCursor - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 0 element - - @!mongodb - @createSchema - Scenario: Paginate through a collection through a GraphQL query with a partial pagination - Given there are 4 of these so many objects - When I send the following GraphQL request: - """ - { - soManies(first: 2) { - edges { - node { - content - } - cursor - } - totalCount - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.soManies.pageInfo.startCursor" should be equal to "MA==" - And the JSON node "data.soManies.pageInfo.endCursor" should be equal to "MQ==" - And the JSON node "data.soManies.pageInfo.hasNextPage" should be false - And the JSON node "data.soManies.pageInfo.hasPreviousPage" should be false - And the JSON node "data.soManies.totalCount" should be equal to 0 - And the JSON node "data.soManies.edges[1].node.content" should be equal to "Many #2" - And the JSON node "data.soManies.edges[1].cursor" should be equal to "MQ==" - When I send the following GraphQL request: - """ - { - soManies(first: 2, after: "MQ==") { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.soManies.pageInfo.startCursor" should be equal to "Mg==" - And the JSON node "data.soManies.pageInfo.endCursor" should be equal to "Mw==" - And the JSON node "data.soManies.pageInfo.hasNextPage" should be false - And the JSON node "data.soManies.pageInfo.hasPreviousPage" should be true - And the JSON node "data.soManies.edges[0].node.content" should be equal to "Many #3" - And the JSON node "data.soManies.edges[0].cursor" should be equal to "Mg==" - When I send the following GraphQL request: - """ - { - soManies(first: 2, after: "Mg==") { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.soManies.edges" should have 1 element - And the JSON node "data.soManies.pageInfo.startCursor" should be equal to "Mw==" - And the JSON node "data.soManies.pageInfo.endCursor" should be equal to "Mw==" - And the JSON node "data.soManies.pageInfo.hasNextPage" should be false - And the JSON node "data.soManies.pageInfo.hasPreviousPage" should be true - And the JSON node "data.soManies.edges[0].node.content" should be equal to "Many #4" - And the JSON node "data.soManies.edges[0].cursor" should be equal to "Mw==" - When I send the following GraphQL request: - """ - { - soManies(first: 2, after: "Mw==") { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.soManies.edges" should have 0 element - And the JSON node "data.soManies.pageInfo.startCursor" should be equal to "NA==" - And the JSON node "data.soManies.pageInfo.endCursor" should be equal to "Mw==" - And the JSON node "data.soManies.pageInfo.hasNextPage" should be false - And the JSON node "data.soManies.pageInfo.hasPreviousPage" should be true - - @createSchema - Scenario: Retrieve a collection with pagination disabled - Given there are 4 foo objects with fake names - When I send the following GraphQL request: - """ - { - foos { - id - name - bar - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.foos[3].id" should be equal to "/foos/4" - And the JSON node "data.foos[3].name" should be equal to "Separativeness" - And the JSON node "data.foos[3].bar" should be equal to "Sit" - - Scenario: Custom collection query - Given there are 2 dummyCustomQuery objects - When I send the following GraphQL request: - """ - { - testCollectionDummyCustomQueries { - edges { - node { - message - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testCollectionDummyCustomQueries": { - "edges": [ - { - "node": {"message": "Success!"} - }, - { - "node": {"message": "Success!"} - } - ] - } - } - } - """ - - @createSchema - Scenario: Custom collection query with read and serialize set to false - Given there are 2 dummyCustomQuery objects - When I send the following GraphQL request: - """ - { - testCollectionNoReadAndSerializeDummyCustomQueries { - edges { - node { - message - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testCollectionNoReadAndSerializeDummyCustomQueries": { - "edges": [] - } - } - } - """ - - @createSchema - Scenario: Custom collection query with custom arguments - Given there are 2 dummyCustomQuery objects - When I send the following GraphQL request: - """ - { - testCollectionCustomArgumentsDummyCustomQueries(customArgumentString: "A string") { - edges { - node { - message - customArgs - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testCollectionCustomArgumentsDummyCustomQueries": { - "edges": [ - { - "node": {"message": "Success!", "customArgs": {"customArgumentString": "A string"}} - }, - { - "node": {"message": "Success!", "customArgs": {"customArgumentString": "A string"}} - } - ] - } - } - } - """ - - @!mongodb - @createSchema - Scenario: Retrieve an item with composite primitive identifiers through a GraphQL query - Given there are composite primitive identifiers objects - When I send the following GraphQL request: - """ - { - compositePrimitiveItem(id: "/composite_primitive_items/name=Bar;year=2017") { - description - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.compositePrimitiveItem.description" should be equal to "This is bar." - - @!mongodb - @createSchema - Scenario: Retrieve an item with composite identifiers through a GraphQL query - Given there are Composite identifier objects - When I send the following GraphQL request: - """ - { - compositeRelation(id: "/composite_relations/compositeItem=1;compositeLabel=1") { - value - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.compositeRelation.value" should be equal to "somefoobardummy" - - @createSchema - Scenario: Retrieve a collection using name converter - Given there are 4 dummy objects - When I send the following GraphQL request: - """ - { - dummies { - edges { - node { - name_converted - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[1].node.name_converted" should be equal to "Converted 2" - - @createSchema - Scenario: Retrieve a collection with different serialization groups for item_query and collection_query - Given there are 3 dummy with different GraphQL serialization groups objects - When I send the following GraphQL request: - """ - { - dummyDifferentGraphQlSerializationGroups { - edges { - node { - name - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[0].node.name" should exist - And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[1].node.name" should exist - And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[2].node.name" should exist - And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[0].node.title" should not exist - And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[1].node.title" should not exist - And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[2].node.title" should not exist - - @createSchema - Scenario: Retrieve a paginated collection using page-based pagination - Given there are 5 fooDummy objects with fake names - When I send the following GraphQL request: - """ - { - fooDummies(page: 1) { - collection { - id - name - } - paginationInfo { - itemsPerPage - lastPage - totalCount - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 3 elements - And the JSON node "data.fooDummies.collection[0].id" should exist - And the JSON node "data.fooDummies.collection[0].name" should exist - And the JSON node "data.fooDummies.collection[1].id" should exist - And the JSON node "data.fooDummies.collection[1].name" should exist - And the JSON node "data.fooDummies.collection[2].id" should exist - And the JSON node "data.fooDummies.collection[2].name" should exist - And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3 - And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2 - And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5 - And the JSON node "data.fooDummies.paginationInfo.hasNextPage" should be true - When I send the following GraphQL request: - """ - { - fooDummies(page: 2) { - collection { - id - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 2 elements - When I send the following GraphQL request: - """ - { - fooDummies(page: 3) { - collection { - id - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 0 elements - - @createSchema - Scenario: Retrieve a paginated collection using page-based pagination and client-defined limit - Given there are 5 fooDummy objects with fake names - When I send the following GraphQL request: - """ - { - fooDummies(page: 1, itemsPerPage: 2) { - collection { - id - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 2 elements - And the JSON node "data.fooDummies.collection[0].id" should exist - And the JSON node "data.fooDummies.collection[0].name" should exist - And the JSON node "data.fooDummies.collection[1].id" should exist - And the JSON node "data.fooDummies.collection[1].name" should exist - When I send the following GraphQL request: - """ - { - fooDummies(page: 2, itemsPerPage: 2) { - collection { - id - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 2 elements - When I send the following GraphQL request: - """ - { - fooDummies(page: 3, itemsPerPage: 2) { - collection { - id - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 1 element - - @createSchema - Scenario: Retrieve paginated collections using mixed pagination - Given there are 5 fooDummy objects with fake names - When I send the following GraphQL request: - """ - { - fooDummies(page: 1) { - collection { - id - name - soManies(first: 2) { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - paginationInfo { - itemsPerPage - lastPage - totalCount - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 3 elements - And the JSON node "data.fooDummies.collection[2].id" should exist - And the JSON node "data.fooDummies.collection[2].name" should exist - And the JSON node "data.fooDummies.collection[2].soManies" should exist - And the JSON node "data.fooDummies.collection[2].soManies.edges" should have 2 elements - And the JSON node "data.fooDummies.collection[2].soManies.edges[1].node.content" should be equal to "So many 1" - And the JSON node "data.fooDummies.collection[2].soManies.pageInfo.startCursor" should be equal to "MA==" - And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3 - And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2 - And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5 - And the JSON node "data.fooDummies.paginationInfo.hasNextPage" should be true - When I send the following GraphQL request: - """ - { - fooDummies(page: 2) { - collection { - id - name - soManies(first: 2) { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - paginationInfo { - itemsPerPage - lastPage - totalCount - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 2 elements - And the JSON node "data.fooDummies.collection[1].id" should exist - And the JSON node "data.fooDummies.collection[1].name" should exist - And the JSON node "data.fooDummies.collection[1].soManies" should exist - And the JSON node "data.fooDummies.collection[1].soManies.edges" should have 2 elements - And the JSON node "data.fooDummies.collection[1].soManies.edges[1].node.content" should be equal to "So many 1" - And the JSON node "data.fooDummies.collection[1].soManies.pageInfo.startCursor" should be equal to "MA==" - And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3 - And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2 - And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5 - And the JSON node "data.fooDummies.paginationInfo.hasNextPage" should be false - - @createSchema - Scenario: Retrieve paginated collections using only hasNextPage - Given there are 4 fooDummy objects with fake names - When I send the following GraphQL request: - """ - { - fooDummies(page: 1, itemsPerPage: 2) { - collection { - id - name - soManies(first: 2) { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - paginationInfo { - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.collection" should have 2 elements - And the JSON node "data.fooDummies.collection[1].id" should exist - And the JSON node "data.fooDummies.collection[1].name" should exist - And the JSON node "data.fooDummies.collection[1].soManies" should exist - And the JSON node "data.fooDummies.collection[1].soManies.edges" should have 2 elements - And the JSON node "data.fooDummies.collection[1].soManies.edges[1].node.content" should be equal to "So many 1" - And the JSON node "data.fooDummies.collection[1].soManies.pageInfo.startCursor" should be equal to "MA==" - And the JSON node "data.fooDummies.paginationInfo.hasNextPage" should be true - When I send the following GraphQL request: - """ - { - fooDummies(page: 2) { - collection { - id - name - soManies(first: 2) { - edges { - node { - content - } - cursor - } - pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage - } - } - } - paginationInfo { - hasNextPage - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.fooDummies.paginationInfo.hasNextPage" should be false diff --git a/features/graphql/docs.feature b/features/graphql/docs.feature deleted file mode 100644 index 7c54a7343f0..00000000000 --- a/features/graphql/docs.feature +++ /dev/null @@ -1,10 +0,0 @@ -Feature: Documentation support - In order to play with GraphQL - As a client software developer - I want to reach the GraphQL documentation - - Scenario: Retrieve the OpenAPI documentation - Given I add "Accept" header equal to "text/html" - And I send a "GET" request to "/graphql" - Then the response status code should be 200 - And the header "Content-Type" should be equal to "text/html; charset=utf-8" diff --git a/features/graphql/filters.feature b/features/graphql/filters.feature deleted file mode 100644 index b5927c6598d..00000000000 --- a/features/graphql/filters.feature +++ /dev/null @@ -1,302 +0,0 @@ -Feature: Collections filtering - In order to retrieve subsets of collections - As an API consumer - I need to be able to set filters - - @createSchema - Scenario: Retrieve a collection filtered using the boolean filter - Given there is 1 dummy object with dummyBoolean true - And there is 1 dummy object with dummyBoolean false - When I send the following GraphQL request: - """ - { - dummies(dummyBoolean: false) { - edges { - node { - id - dummyBoolean - } - } - } - } - """ - Then the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.edges[0].node.dummyBoolean" should be false - - @createSchema - Scenario: Retrieve a collection filtered using the exists filter - Given there are 3 dummy objects - And there are 2 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - { - dummies(exists: [{relatedDummy: true}]) { - edges { - node { - id - relatedDummy { - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the JSON node "data.dummies.edges" should have 2 elements - And the JSON node "data.dummies.edges[0].node.relatedDummy" should have 1 element - - @createSchema - Scenario: Retrieve a collection filtered using the date filter - Given there are 3 dummy objects with dummyDate - When I send the following GraphQL request: - """ - { - dummies(dummyDate: [{after: "2015-04-02"}]) { - edges { - node { - id - dummyDate - } - } - } - } - """ - Then the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.edges[0].node.dummyDate" should be equal to "2015-04-02" - - @createSchema - Scenario: Retrieve a collection filtered using the search filter - Given there are 10 dummy objects - When I send the following GraphQL request: - """ - { - dummies(name: "#2") { - edges { - node { - id - name - } - } - } - } - """ - Then the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.edges[0].node.id" should be equal to "/dummies/2" - - @createSchema - Scenario: Retrieve a collection filtered using the search filter with an int - Given there are 4 dummy objects having each 3 relatedDummies - When I send the following GraphQL request: - """ - { - dummies(name: "Dummy #1") { - totalCount - edges { - node { - name - relatedDummies(age: 31) { - totalCount - edges { - node { - id - name - age - } - } - } - } - } - } - } - """ - Then the JSON node "data.dummies.totalCount" should be equal to 1 - And the JSON node "data.dummies.edges[0].node.relatedDummies.totalCount" should be equal to 1 - And the JSON node "data.dummies.edges[0].node.relatedDummies.edges[0].node.age" should be equal to "31" - - @createSchema - Scenario: Retrieve a collection filtered using the search filter and a name converter - Given there are 10 dummy objects - When I send the following GraphQL request: - """ - { - dummies(name_converted: "Converted 2") { - edges { - node { - id - name - name_converted - } - } - } - } - """ - Then the JSON node "data.dummies.edges" should have 1 element - And the JSON node "data.dummies.edges[0].node.id" should be equal to "/dummies/2" - And the JSON node "data.dummies.edges[0].node.name_converted" should be equal to "Converted 2" - - @createSchema - Scenario: Retrieve a collection filtered using the search filter and a name converter - Given there are 20 convertedOwner objects with convertedRelated - When I send the following GraphQL request: - """ - { - convertedOwners(name_converted__name_converted: "Converted 2") { - edges { - node { - id - name_converted { - name_converted - } - } - } - } - } - """ - Then the JSON node "data.convertedOwners.edges" should have 2 element - And the JSON node "data.convertedOwners.edges[0].node.id" should be equal to "/converted_owners/2" - And the JSON node "data.convertedOwners.edges[0].node.name_converted.name_converted" should be equal to "Converted 2" - And the JSON node "data.convertedOwners.edges[1].node.id" should be equal to "/converted_owners/20" - And the JSON node "data.convertedOwners.edges[1].node.name_converted.name_converted" should be equal to "Converted 20" - - @createSchema - Scenario: Retrieve a nested collection filtered using the search filter - Given there are 3 dummy objects having each 3 relatedDummies - When I send the following GraphQL request: - """ - { - dummies { - edges { - node { - id - relatedDummies(name: "RelatedDummy13") { - edges { - node { - id - name - } - } - } - } - } - } - } - """ - Then the JSON node "data.dummies.edges[0].node.relatedDummies.edges" should have 0 elements - And the JSON node "data.dummies.edges[1].node.relatedDummies.edges" should have 0 elements - And the JSON node "data.dummies.edges[2].node.relatedDummies.edges" should have 1 element - And the JSON node "data.dummies.edges[2].node.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy13" - - @createSchema - Scenario: Use a filter of a nested collection - Given there is a DummyCar entity with related colors - When I send the following GraphQL request: - """ - { - dummyCar(id: "/dummy_cars/1") { - id - colors(prop: "blue") { - edges { - node { - id - prop - } - } - } - } - } - """ - Then the JSON node "data.dummyCar.colors.edges" should have 1 element - And the JSON node "data.dummyCar.colors.edges[0].node.prop" should be equal to "blue" - - @createSchema - Scenario: Retrieve a collection filtered using the related search filter - Given there are 1 dummy objects having each 2 relatedDummies - And there are 1 dummy objects having each 3 relatedDummies - When I send the following GraphQL request: - """ - { - dummies(relatedDummies__name: "RelatedDummy31") { - edges { - node { - id - } - } - } - } - """ - And the response status code should be 200 - And the JSON node "data.dummies.edges" should have 1 element - - @createSchema - Scenario: Retrieve a collection ordered using nested properties - Given there are 2 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - { - dummies(order: [{relatedDummy__name: "DESC"}]) { - edges { - node { - name - relatedDummy { - id - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[0].node.name" should be equal to "Dummy #2" - And the JSON node "data.dummies.edges[1].node.name" should be equal to "Dummy #1" - - @createSchema - Scenario: Retrieve a collection ordered correctly given the order of the argument - Given there are dummies with similar properties - When I send the following GraphQL request: - """ - { - dummies(order: [{description: "ASC"}, {name: "ASC"}]) { - edges { - node { - id - name - description - } - } - } - } - """ - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges[0].node.name" should be equal to "baz" - And the JSON node "data.dummies.edges[0].node.description" should be equal to "bar" - And the JSON node "data.dummies.edges[1].node.name" should be equal to "foo" - And the JSON node "data.dummies.edges[1].node.description" should be equal to "bar" - - @createSchema - Scenario: Retrieve a collection filtered using the related search filter with two values and exact strategy - Given there are 3 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - { - dummies(relatedDummy__name_list: ["RelatedDummy #1", "RelatedDummy #2"]) { - edges { - node { - id - name - relatedDummy { - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummies.edges" should have 2 element - And the JSON node "data.dummies.edges[0].node.relatedDummy.name" should be equal to "RelatedDummy #1" - And the JSON node "data.dummies.edges[1].node.relatedDummy.name" should be equal to "RelatedDummy #2" diff --git a/features/graphql/input_output.feature b/features/graphql/input_output.feature deleted file mode 100644 index aac22be3f3c..00000000000 --- a/features/graphql/input_output.feature +++ /dev/null @@ -1,202 +0,0 @@ -Feature: GraphQL DTO input and output - In order to use the GraphQL API - As a client software developer - I need to be able to use DTOs on my resources as Input or Output objects. - - @createSchema - Scenario: Retrieve an Output with GraphQL - Given there is a RelatedDummy with 0 friends - When I add "Content-Type" header equal to "application/ld+json" - And I send a "POST" request to "/dummy_dto_input_outputs" with body: - """ - { - "foo": "test", - "bar": 1, - "relatedDummies": ["/related_dummies/1"] - } - """ - Then the response status code should be 201 - And the JSON should be a superset of: - """ - { - "@context": { - "@vocab": "http://example.com/docs.jsonld#", - "hydra": "http://www.w3.org/ns/hydra/core#", - "id": "OutputDto/id", - "baz": "OutputDto/baz", - "bat": "OutputDto/bat", - "relatedDummies": "OutputDto/relatedDummies" - }, - "@type": "OutputDto", - "id": 1, - "baz": 1, - "bat": "test", - "relatedDummies": [ - { - "@id": "/related_dummies/1", - "@type": "https://schema.org/Product", - "name": "RelatedDummy with friends", - "dummyDate": null, - "thirdLevel": null, - "relatedToDummyFriend": [], - "dummyBoolean": null, - "embeddedDummy": { - "@type": "EmbeddableDummy", - "dummyName": null, - "dummyBoolean": null, - "dummyDate": null, - "dummyFloat": null, - "dummyPrice": null, - "symfony": null - }, - "id": 1, - "symfony": "symfony", - "age": null - } - ] - } - """ - When I send the following GraphQL request: - """ - { - dummyDtoInputOutput(id: "/dummy_dto_input_outputs/1") { - _id, id, baz, - relatedDummies { - edges { - node { - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "dummyDtoInputOutput": { - "_id": 1, - "id": "/dummy_dto_input_outputs/1", - "baz": 1, - "relatedDummies": { - "edges": [ - { - "node": { - "name": "RelatedDummy with friends" - } - } - ] - } - } - } - } - """ - - Scenario: Create an item with custom input and output - When I send the following GraphQL request: - """ - mutation { - createDummyDtoInputOutput(input: {foo: "A foo", bar: 4, clientMutationId: "myId"}) { - dummyDtoInputOutput { - baz, - bat - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "createDummyDtoInputOutput": { - "dummyDtoInputOutput": { - "baz": 4, - "bat": "A foo" - }, - "clientMutationId": "myId" - } - } - } - """ - - Scenario: Create an item using custom inputClass & disabled outputClass - Given there are 2 dummyDtoNoOutput objects - When I send the following GraphQL request: - """ - mutation { - createDummyDtoNoOutput(input: {foo: "A new one", bar: 3, clientMutationId: "myId"}) { - dummyDtoNoOutput { - id - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be a superset of: - """ - { - "errors": [ - { - "message": "Cannot query field \"id\" on type \"DummyDtoNoOutput\".", - "locations": [ - { - "line": 4, - "column": 7 - } - ] - } - ] - } - """ - - Scenario: Cannot create an item with input fields using disabled inputClass - When I send the following GraphQL request: - """ - mutation { - createDummyDtoNoInput(input: {lorem: "A new one", ipsum: 3, clientMutationId: "myId"}) { - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].message" should match '/^Field "lorem" is not defined by type "?createDummyDtoNoInputInput"?\.$/' - And the JSON node "errors[1].message" should match '/^Field "ipsum" is not defined by type "?createDummyDtoNoInputInput"?\.$/' - - Scenario: Use messenger with GraphQL and an input where the handler gives a synchronous result - When I send the following GraphQL request: - """ - mutation { - createMessengerWithInput(input: {var: "test"}) { - messengerWithInput { id, name } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "createMessengerWithInput": { - "messengerWithInput": { - "id": "/messenger_with_inputs/1", - "name": "test" - } - } - } - } - """ diff --git a/features/graphql/introspection.feature b/features/graphql/introspection.feature deleted file mode 100644 index 356fc67c577..00000000000 --- a/features/graphql/introspection.feature +++ /dev/null @@ -1,621 +0,0 @@ -Feature: GraphQL introspection support - - @createSchema - Scenario: Execute an empty GraphQL query - When I send a "GET" request to "/graphql" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 400 - And the JSON node "errors[0].message" should be equal to "GraphQL query is not valid." - - Scenario: Introspect the GraphQL schema - When I send the query to introspect the schema - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.__schema.types" should exist - And the JSON node "data.__schema.queryType.name" should be equal to "Query" - And the JSON node "data.__schema.mutationType.name" should be equal to "Mutation" - - Scenario: Introspect types - When I send the following GraphQL request: - """ - { - type1: __type(name: "DummyProduct") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - type2: __type(name: "DummyAggregateOfferCursorConnection") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - type3: __type(name: "DummyAggregateOfferEdge") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.type1.description" should be equal to "Dummy Product." - And the JSON node "data.type1.fields" should contain: - """ - { - "name":"offers", - "type":{ - "name":"DummyAggregateOfferCursorConnection", - "kind":"OBJECT", - "ofType":null - } - } - """ - And the JSON node "data.type2.fields" should contain: - """ - { - "name":"edges", - "type":{ - "name":null, - "kind":"LIST", - "ofType":{ - "name":"DummyAggregateOfferEdge", - "kind":"OBJECT" - } - } - } - """ - And the JSON node "data.type3.fields" should contain: - """ - { - "name":"node", - "type":{ - "name":"DummyAggregateOffer", - "kind":"OBJECT", - "ofType":null - } - } - """ - And the JSON node "data.type3.fields" should contain: - """ - { - "name":"cursor", - "type":{ - "name":null, - "kind":"NON_NULL", - "ofType":{ - "name":"String", - "kind":"SCALAR" - } - } - } - """ - - Scenario: Introspect types with different serialization groups for item_query and collection_query - When I send the following GraphQL request: - """ - { - type1: __type(name: "DummyDifferentGraphQlSerializationGroupCollection") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - type2: __type(name: "DummyDifferentGraphQlSerializationGroupItem") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.type1.description" should be equal to "Dummy with different serialization groups for item_query and collection_query." - And the JSON node "data.type1.fields[3].name" should not exist - And the JSON node "data.type2.fields[3].name" should be equal to "title" - - Scenario: Introspect deprecated queries - When I send the following GraphQL request: - """ - { - __type (name: "Query") { - name - fields(includeDeprecated: true) { - name - isDeprecated - deprecationReason - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the GraphQL field "deprecatedResource" is deprecated for the reason "This resource is deprecated" - And the GraphQL field "deprecatedResources" is deprecated for the reason "This resource is deprecated" - - Scenario: Introspect deprecated mutations - When I send the following GraphQL request: - """ - { - __type (name: "Mutation") { - name - fields(includeDeprecated: true) { - name - isDeprecated - deprecationReason - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the GraphQL field "deleteDeprecatedResource" is deprecated for the reason "This resource is deprecated" - And the GraphQL field "updateDeprecatedResource" is deprecated for the reason "This resource is deprecated" - And the GraphQL field "createDeprecatedResource" is deprecated for the reason "This resource is deprecated" - - Scenario: Introspect a deprecated field - When I send the following GraphQL request: - """ - { - __type(name: "DeprecatedResource") { - fields(includeDeprecated: true) { - name - isDeprecated - deprecationReason - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the GraphQL field "deprecatedField" is deprecated for the reason "This field is deprecated" - - Scenario: Retrieve the Relay's node interface - When I send the following GraphQL request: - """ - { - __type(name: "Node") { - name - kind - fields { - name - type { - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "__type": { - "name": "Node", - "kind": "INTERFACE", - "fields": [ - { - "name": "id", - "type": { - "kind": "NON_NULL", - "ofType": { - "name": "ID", - "kind": "SCALAR" - } - } - } - ] - } - } - } - """ - - Scenario: Retrieve the Relay's node field - When I send the following GraphQL request: - """ - { - __schema { - queryType { - fields { - name - type { - name - kind - } - args { - name - type { - kind - ofType { - name - kind - } - } - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.__schema.queryType.fields[0].name" should be equal to "node" - And the JSON node "data.__schema.queryType.fields[0].type.name" should be equal to "Node" - And the JSON node "data.__schema.queryType.fields[0].type.kind" should be equal to "INTERFACE" - And the JSON node "data.__schema.queryType.fields[0].args[0].name" should be equal to "id" - And the JSON node "data.__schema.queryType.fields[0].args[0].type.kind" should be equal to "NON_NULL" - And the JSON node "data.__schema.queryType.fields[0].args[0].type.ofType.name" should be equal to "ID" - And the JSON node "data.__schema.queryType.fields[0].args[0].type.ofType.kind" should be equal to "SCALAR" - - Scenario: Introspect an Iterable type field - When I send the following GraphQL request: - """ - { - __type(name: "Dummy") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.__type.fields" should contain: - """ - { - "name":"jsonData", - "type":{ - "name":"Iterable", - "kind":"SCALAR", - "ofType":null - } - } - """ - - Scenario: Retrieve entity - using serialization groups - fields - When I send the following GraphQL request: - """ - { - typeQuery: __type(name: "DummyGroup") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - typeCreateInput: __type(name: "createDummyGroupInput") { - description, - inputFields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - typeCreatePayload: __type(name: "createDummyGroupPayload") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - typeCreatePayloadData: __type(name: "createDummyGroupPayloadData") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.typeQuery.fields" should have 2 elements - And the JSON node "data.typeQuery.fields[0].name" should be equal to "id" - And the JSON node "data.typeQuery.fields[1].name" should be equal to "foo" - And the JSON node "data.typeCreateInput.inputFields" should have 3 elements - And the JSON node "data.typeCreateInput.inputFields[0].name" should be equal to "bar" - And the JSON node "data.typeCreateInput.inputFields[1].name" should be equal to "baz" - And the JSON node "data.typeCreateInput.inputFields[2].name" should be equal to "clientMutationId" - And the JSON node "data.typeCreatePayload.fields" should have 2 elements - And the JSON node "data.typeCreatePayload.fields[0].name" should be equal to "dummyGroup" - And the JSON node "data.typeCreatePayload.fields[0].type.name" should be equal to "createDummyGroupPayloadData" - And the JSON node "data.typeCreatePayload.fields[1].name" should be equal to "clientMutationId" - And the JSON node "data.typeCreatePayloadData.fields" should have 2 elements - And the JSON node "data.typeCreatePayloadData.fields[0].name" should be equal to "id" - And the JSON node "data.typeCreatePayloadData.fields[1].name" should be equal to "bar" - - Scenario: Retrieve nested mutation payload data fields - When I send the following GraphQL request: - """ - { - typeCreatePayload: __type(name: "createDummyPropertyPayload") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - typeCreatePayloadData: __type(name: "createDummyPropertyPayloadData") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - typeCreateNestedPayload: __type(name: "createDummyGroupNestedPayload") { - description, - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.typeCreatePayload.fields" should be equal to: - """ - [ - { - "name":"dummyProperty", - "type":{ - "name":"createDummyPropertyPayloadData", - "kind":"OBJECT", - "ofType":null - } - }, - { - "name":"clientMutationId", - "type":{ - "name":"String", - "kind":"SCALAR", - "ofType":null - } - } - ] - """ - And the JSON node "data.typeCreatePayloadData.fields" should contain: - """ - { - "name":"group", - "type":{ - "name":"createDummyGroupNestedPayload", - "kind":"OBJECT", - "ofType":null - } - } - """ - And the JSON node "data.typeCreateNestedPayload.fields" should contain: - """ - { - "name":"id", - "type":{ - "name":null, - "kind":"NON_NULL", - "ofType":{ - "name":"ID", - "kind":"SCALAR" - } - } - } - """ - - Scenario: Retrieve a type name through a GraphQL query - Given there are 4 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - { - dummy: dummy(id: "/dummies/3") { - name - relatedDummy { - id - name - __typename - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummy.name" should be equal to "Dummy #3" - And the JSON node "data.dummy.relatedDummy.name" should be equal to "RelatedDummy #3" - And the JSON node "data.dummy.relatedDummy.__typename" should be equal to "RelatedDummy" - - Scenario: Introspect a type available only through relations - When I send the following GraphQL request: - """ - { - typeNotAvailable: __type(name: "VoDummyInspectionCursorConnection") { - description - } - typeOwner: __type(name: "VoDummyCar") { - description, - fields { - name - type { - name - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.typeNotAvailable" should be null - And the JSON node "data.typeOwner.fields[1].type.name" should be equal to "VoDummyInspectionCursorConnection" - - Scenario: Introspect an enum - When I send the following GraphQL request: - """ - { - person: __type(name: "Person") { - name - fields { - name - type { - name - description - enumValues { - name - description - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.person.fields[1].type.name" should be equal to "GenderTypeEnum" - #And the JSON node "data.person.fields[1].type.description" should be equal to "An enumeration of genders." - And the JSON node "data.person.fields[1].type.enumValues[0].name" should be equal to "MALE" - #And the JSON node "data.person.fields[1].type.enumValues[0].description" should be equal to "The male gender." - And the JSON node "data.person.fields[1].type.enumValues[1].name" should be equal to "FEMALE" - And the JSON node "data.person.fields[1].type.enumValues[1].description" should be equal to "The female gender." - - Scenario: Introspect an enum resource - When I send the following GraphQL request: - """ - { - videoGame: __type(name: "VideoGame") { - name - fields { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.videoGame.fields[3].type.ofType.name" should be equal to "GamePlayMode" diff --git a/features/graphql/mutation.feature b/features/graphql/mutation.feature deleted file mode 100644 index 7df064279c1..00000000000 --- a/features/graphql/mutation.feature +++ /dev/null @@ -1,1071 +0,0 @@ -Feature: GraphQL mutation support - - @createSchema - Scenario: Introspect types - When I send the following GraphQL request: - """ - { - __type(name: "Mutation") { - fields { - name - description - type { - name - kind - } - args { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "object", - "required": [ - "__type" - ], - "properties": { - "__type": { - "type": "object", - "required": [ - "fields" - ], - "properties": { - "fields": { - "type": "array", - "minItems": 1, - "items": { - "oneOf": [ - { - "type": "object", - "required": [ - "name", - "description", - "type", - "args" - ], - "properties": { - "name": { - "pattern": "^create[A-z0-9]+$" - }, - "description": { - "pattern": "^Creates a [A-z0-9]+.$" - }, - "type": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^create[A-z0-9]+Payload$" - }, - "kind": { - "enum": ["OBJECT"] - } - } - }, - "args": { - "type": "array", - "minItems": 1, - "maxItems": 1, - "items": [ - { - "type": "object", - "required": [ - "name", - "type" - ], - "properties": { - "name": { - "enum": ["input"] - }, - "type": { - "type": "object", - "required": [ - "kind", - "ofType" - ], - "properties": { - "kind": { - "enum": ["NON_NULL"] - }, - "ofType": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^create[A-z0-9]+Input$" - }, - "kind": { - "enum": ["INPUT_OBJECT"] - } - } - } - } - } - } - } - ] - } - } - }, - { - "type": "object", - "required": [ - "name", - "description", - "type", - "args" - ], - "properties": { - "name": { - "pattern": "^update[A-z0-9]+$" - }, - "description": { - "pattern": "^Updates a [A-z0-9]+.$" - }, - "type": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^update[A-z0-9]+Payload$" - }, - "kind": { - "enum": ["OBJECT"] - } - } - }, - "args": { - "type": "array", - "minItems": 1, - "maxItems": 1, - "items": [ - { - "type": "object", - "required": [ - "name", - "type" - ], - "properties": { - "name": { - "enum": ["input"] - }, - "type": { - "type": "object", - "required": [ - "kind", - "ofType" - ], - "properties": { - "kind": { - "enum": ["NON_NULL"] - }, - "ofType": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^update[A-z0-9]+Input$" - }, - "kind": { - "enum": ["INPUT_OBJECT"] - } - } - } - } - } - } - } - ] - } - } - }, - { - "type": "object", - "required": [ - "name", - "description", - "type", - "args" - ], - "properties": { - "name": { - "pattern": "^delete[A-z0-9]+$" - }, - "description": { - "pattern": "^Deletes a [A-z0-9]+.$" - }, - "type": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^delete[A-z0-9]+Payload$" - }, - "kind": { - "enum": ["OBJECT"] - } - } - }, - "args": { - "type": "array", - "minItems": 1, - "maxItems": 1, - "items": [ - { - "type": "object", - "required": [ - "name", - "type" - ], - "properties": { - "name": { - "enum": ["input"] - }, - "type": { - "type": "object", - "required": [ - "kind", - "ofType" - ], - "properties": { - "kind": { - "enum": ["NON_NULL"] - }, - "ofType": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^delete[A-z0-9]+Input$" - }, - "kind": { - "enum": ["INPUT_OBJECT"] - } - } - } - } - } - } - } - ] - } - } - }, - { - "type": "object", - "required": [ - "name", - "description", - "type", - "args" - ], - "properties": { - "name": { - "pattern": "^(?!create|update|delete)[A-z0-9]+$" - }, - "description": { - "pattern": "^(?!Create|Update|Delete)[A-z0-9]+s a [A-z0-9]+.$" - }, - "type": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^(?!create|update|delete)[A-z0-9]+Payload$" - }, - "kind": { - "enum": ["OBJECT"] - } - } - }, - "args": { - "type": "array", - "minItems": 1, - "maxItems": 1, - "items": [ - { - "type": "object", - "required": [ - "name", - "type" - ], - "properties": { - "name": { - "enum": ["input"] - }, - "type": { - "type": "object", - "required": [ - "kind", - "ofType" - ], - "properties": { - "kind": { - "enum": ["NON_NULL"] - }, - "ofType": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^(?!create|update|delete)[A-z0-9]+Input$" - }, - "kind": { - "enum": ["INPUT_OBJECT"] - } - } - } - } - } - } - } - ] - } - } - } - ] - } - } - } - } - } - } - } - } - """ - - Scenario: Create an item - When I send the following GraphQL request: - """ - mutation { - createFoo(input: {name: "A new one", bar: "new", clientMutationId: "myId"}) { - foo { - id - _id - __typename - name - bar - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createFoo.foo.id" should be equal to "/foos/1" - And the JSON node "data.createFoo.foo._id" should be equal to 1 - And the JSON node "data.createFoo.foo.__typename" should be equal to "Foo" - And the JSON node "data.createFoo.foo.name" should be equal to "A new one" - And the JSON node "data.createFoo.foo.bar" should be equal to "new" - And the JSON node "data.createFoo.clientMutationId" should be equal to "myId" - - Scenario: Create an item without a clientMutationId - When I send the following GraphQL request: - """ - mutation { - createFoo(input: {name: "Created without mutation id", bar: "works"}) { - foo { - id - name - bar - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createFoo.foo.id" should be equal to "/foos/2" - And the JSON node "data.createFoo.foo.name" should be equal to "Created without mutation id" - And the JSON node "data.createFoo.foo.bar" should be equal to "works" - - Scenario: Create an item with a relation to an existing resource - Given there are 1 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - mutation { - createDummy(input: {name: "A dummy", foo: [], relatedDummy: "/related_dummies/1", name_converted: "Converted" clientMutationId: "myId"}) { - dummy { - id - name - foo - relatedDummy { - name - __typename - } - name_converted - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createDummy.dummy.id" should be equal to "/dummies/2" - And the JSON node "data.createDummy.dummy.name" should be equal to "A dummy" - And the JSON node "data.createDummy.dummy.foo" should have 0 elements - And the JSON node "data.createDummy.dummy.relatedDummy.name" should be equal to "RelatedDummy #1" - And the JSON node "data.createDummy.dummy.relatedDummy.__typename" should be equal to "RelatedDummy" - And the JSON node "data.createDummy.dummy.name_converted" should be equal to "Converted" - And the JSON node "data.createDummy.clientMutationId" should be equal to "myId" - - Scenario: Create an item with an iterable field - When I send the following GraphQL request: - """ - mutation { - createDummy(input: {name: "A dummy", foo: [], jsonData: {bar:{baz:3,qux:[7.6,false,null]}}, arrayData: ["bar", "baz"], clientMutationId: "myId"}) { - dummy { - id - name - foo - jsonData - arrayData - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createDummy.dummy.id" should be equal to "/dummies/3" - And the JSON node "data.createDummy.dummy.name" should be equal to "A dummy" - And the JSON node "data.createDummy.dummy.foo" should have 0 elements - And the JSON node "data.createDummy.dummy.jsonData.bar.baz" should be equal to the number 3 - And the JSON node "data.createDummy.dummy.jsonData.bar.qux[0]" should be equal to the number 7.6 - And the JSON node "data.createDummy.dummy.jsonData.bar.qux[1]" should be false - And the JSON node "data.createDummy.dummy.jsonData.bar.qux[2]" should be null - And the JSON node "data.createDummy.dummy.arrayData[1]" should be equal to baz - And the JSON node "data.createDummy.clientMutationId" should be equal to "myId" - - Scenario: Create an item with an enum - When I send the following GraphQL request: - """ - mutation { - createPerson(input: {name: "Mob", genderType: FEMALE}) { - person { - id - name - genderType - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createPerson.person.id" should be equal to "/people/1" - And the JSON node "data.createPerson.person.name" should be equal to "Mob" - And the JSON node "data.createPerson.person.genderType" should be equal to "FEMALE" - - @!mongodb - Scenario: Create an item with an enum collection - When I send the following GraphQL request: - """ - mutation { - createPerson(input: {name: "Harry", academicGrades: [BACHELOR, MASTER]}) { - person { - id - name - genderType - academicGrades - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createPerson.person.id" should be equal to "/people/2" - And the JSON node "data.createPerson.person.name" should be equal to "Harry" - And the JSON node "data.createPerson.person.genderType" should be equal to "MALE" - And the JSON node "data.createPerson.person.academicGrades" should have 2 elements - And the JSON node "data.createPerson.person.academicGrades[0]" should be equal to "BACHELOR" - And the JSON node "data.createPerson.person.academicGrades[1]" should be equal to "MASTER" - - Scenario: Create an item with an enum as a resource - When I send the following GraphQL request: - """ - { - gamePlayModes { - id - name - } - gamePlayMode(id: "/game_play_modes/SINGLE_PLAYER") { - name - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.gamePlayModes" should have 3 elements - And the JSON node "data.gamePlayModes[2].id" should be equal to "/game_play_modes/SINGLE_PLAYER" - And the JSON node "data.gamePlayModes[2].name" should be equal to "SINGLE_PLAYER" - And the JSON node "data.gamePlayMode.name" should be equal to "SINGLE_PLAYER" - When I send the following GraphQL request: - """ - mutation { - createVideoGame(input: {name: "Baten Kaitos", playMode: "/game_play_modes/SINGLE_PLAYER"}) { - videoGame { - id - name - playMode { - id - name - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createVideoGame.videoGame.id" should be equal to "/video_games/1" - And the JSON node "data.createVideoGame.videoGame.name" should be equal to "Baten Kaitos" - And the JSON node "data.createVideoGame.videoGame.playMode.id" should be equal to "/game_play_modes/SINGLE_PLAYER" - And the JSON node "data.createVideoGame.videoGame.playMode.name" should be equal to "SINGLE_PLAYER" - - Scenario: Delete an item through a mutation - When I send the following GraphQL request: - """ - mutation { - deleteFoo(input: {id: "/foos/1", clientMutationId: "anotherId"}) { - foo { - id - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.deleteFoo.foo.id" should be equal to "/foos/1" - And the JSON node "data.deleteFoo.clientMutationId" should be equal to "anotherId" - - Scenario: Trigger an error trying to delete item of different resource - When I send the following GraphQL request: - """ - mutation { - deleteFoo(input: {id: "/dummies/1", clientMutationId: "myId"}) { - foo { - id - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].message" should be equal to 'Item "/dummies/1" did not match expected type "Foo".' - - @!mongodb - Scenario: Delete an item with composite identifiers through a mutation - Given there are Composite identifier objects - When I send the following GraphQL request: - """ - mutation { - deleteCompositeRelation(input: {id: "/composite_relations/compositeItem=1;compositeLabel=1", clientMutationId: "myId"}) { - compositeRelation { - id - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.deleteCompositeRelation.compositeRelation.id" should be equal to "/composite_relations/compositeItem=1;compositeLabel=1" - And the JSON node "data.deleteCompositeRelation.clientMutationId" should be equal to "myId" - - @createSchema - Scenario: Modify an item through a mutation - Given there are 1 dummy objects having each 2 relatedDummies - When I send the following GraphQL request: - """ - mutation { - updateDummy(input: {id: "/dummies/1", description: "Modified description.", dummyDate: "2018-06-05T00:00:00+00:00", clientMutationId: "myId"}) { - dummy { - id - name - description - dummyDate - relatedDummies { - edges { - node { - name - } - } - } - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateDummy.dummy.id" should be equal to "/dummies/1" - And the JSON node "data.updateDummy.dummy.name" should be equal to "Dummy #1" - And the JSON node "data.updateDummy.dummy.description" should be equal to "Modified description." - And the JSON node "data.updateDummy.dummy.dummyDate" should be equal to "2018-06-05" - And the JSON node "data.updateDummy.dummy.relatedDummies.edges[0].node.name" should be equal to "RelatedDummy11" - And the JSON node "data.updateDummy.clientMutationId" should be equal to "myId" - - @createSchema - @!mongodb - Scenario: Modify an item with embedded object through a mutation - Given there is a fooDummy objects with fake names and embeddable - When I send the following GraphQL request: - """ - mutation { - updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", embeddedFoo: {dummyName: "Embedded name"}, clientMutationId: "myId"}) { - fooDummy { - id - name - embeddedFoo { - dummyName - } - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateFooDummy.fooDummy.name" should be equal to "modifiedName" - And the JSON node "data.updateFooDummy.fooDummy.embeddedFoo.dummyName" should be equal to "Embedded name" - And the JSON node "data.updateFooDummy.clientMutationId" should be equal to "myId" - - @createSchema - Scenario: Try to modify a non writable property through a mutation - Given there is a fooDummy objects with fake names and embeddable - When I send the following GraphQL request: - """ - mutation { - updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", nonWritableProp: "written", embeddedFoo: {dummyName: "Embedded name"}, clientMutationId: "myId"}) { - fooDummy { - id - name - embeddedFoo { - dummyName - } - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].message" should match '/^Field "nonWritableProp" is not defined by type "?updateFooDummyInput"?\.$/' - - @createSchema - @!mongodb - Scenario: Try to modify a non writable embedded property through a mutation - Given there is a fooDummy objects with fake names and embeddable - When I send the following GraphQL request: - """ - mutation { - updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", embeddedFoo: {dummyName: "Embedded name", nonWritableProp: "written"}, clientMutationId: "myId"}) { - fooDummy { - id - name - embeddedFoo { - dummyName - } - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].message" should match '/^Field "nonWritableProp" is not defined by type "?FooEmbeddableNestedInput"?\.$/' - - @!mongodb - Scenario: Modify an item with composite identifiers through a mutation - Given there are Composite identifier objects - When I send the following GraphQL request: - """ - mutation { - updateCompositeRelation(input: {id: "/composite_relations/compositeItem=1;compositeLabel=2", value: "Modified value.", clientMutationId: "myId"}) { - compositeRelation { - id - value - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateCompositeRelation.compositeRelation.id" should be equal to "/composite_relations/compositeItem=1;compositeLabel=2" - And the JSON node "data.updateCompositeRelation.compositeRelation.value" should be equal to "Modified value." - And the JSON node "data.updateCompositeRelation.clientMutationId" should be equal to "myId" - - Scenario: Create an item with a custom UUID - When I send the following GraphQL request: - """ - mutation { - createWritableId(input: {_id: "c6b722fe-0331-48c4-a214-f81f9f1ca082", name: "Foo", clientMutationId: "m"}) { - writableId { - id - _id - name - } - clientMutationId - } - } - """ - Then the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createWritableId.writableId.id" should be equal to "/writable_ids/c6b722fe-0331-48c4-a214-f81f9f1ca082" - And the JSON node "data.createWritableId.writableId._id" should be equal to "c6b722fe-0331-48c4-a214-f81f9f1ca082" - And the JSON node "data.createWritableId.writableId.name" should be equal to "Foo" - And the JSON node "data.createWritableId.clientMutationId" should be equal to "m" - - @!mongodb - Scenario: Update an item with a custom UUID - When I send the following GraphQL request: - """ - mutation { - updateWritableId(input: {id: "/writable_ids/c6b722fe-0331-48c4-a214-f81f9f1ca082", _id: "f8a708b2-310f-416c-9aef-b1b5719dfa47", name: "Foo", clientMutationId: "m"}) { - writableId { - id - _id - name - } - clientMutationId - } - } - """ - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateWritableId.writableId.id" should be equal to "/writable_ids/f8a708b2-310f-416c-9aef-b1b5719dfa47" - And the JSON node "data.updateWritableId.writableId._id" should be equal to "f8a708b2-310f-416c-9aef-b1b5719dfa47" - And the JSON node "data.updateWritableId.writableId.name" should be equal to "Foo" - And the JSON node "data.updateWritableId.clientMutationId" should be equal to "m" - - Scenario: Use serialization groups - Given there are 1 dummy group objects - When I send the following GraphQL request: - """ - mutation { - createDummyGroup(input: {bar: "Bar", baz: "Baz", clientMutationId: "myId"}) { - dummyGroup { - id - bar - __typename - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createDummyGroup.dummyGroup.id" should be equal to "/dummy_groups/2" - And the JSON node "data.createDummyGroup.dummyGroup.bar" should be equal to "Bar" - And the JSON node "data.createDummyGroup.dummyGroup.__typename" should be equal to "createDummyGroupPayloadData" - And the JSON node "data.createDummyGroup.clientMutationId" should be equal to "myId" - - @createSchema - Scenario: Use serialization groups with relations - Given there is 1 dummy object with relatedDummy and its thirdLevel - And there is a RelatedDummy with 2 friends - And there is a dummy object with a fourth level relation - When I send the following GraphQL request: - """ - mutation { - updateRelatedDummy(input: { - id: "/related_dummies/2", - symfony: "laravel", - thirdLevel: { - fourthLevel: "/fourth_levels/1" - } - }) { - relatedDummy { - id - symfony - thirdLevel { - id - fourthLevel { - id - __typename - } - __typename - } - relatedToDummyFriend { - edges { - node { - name - } - } - __typename - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateRelatedDummy.relatedDummy.id" should be equal to "/related_dummies/2" - And the JSON node "data.updateRelatedDummy.relatedDummy.symfony" should be equal to "laravel" - And the JSON node "data.updateRelatedDummy.relatedDummy.thirdLevel.id" should be equal to "/third_levels/3" - And the JSON node "data.updateRelatedDummy.relatedDummy.thirdLevel.__typename" should be equal to "updateThirdLevelNestedPayload" - And the JSON node "data.updateRelatedDummy.relatedDummy.thirdLevel.fourthLevel.id" should be equal to "/fourth_levels/1" - And the JSON node "data.updateRelatedDummy.relatedDummy.thirdLevel.fourthLevel.__typename" should be equal to "updateFourthLevelNestedPayload" - And the JSON node "data.updateRelatedDummy.relatedDummy.relatedToDummyFriend.__typename" should be equal to "updateRelatedToDummyFriendNestedPayloadCursorConnection" - And the JSON node "data.updateRelatedDummy.relatedDummy.relatedToDummyFriend.edges[0].node.name" should be equal to "Relation-1" - And the JSON node "data.updateRelatedDummy.relatedDummy.relatedToDummyFriend.edges[1].node.name" should be equal to "Relation-2" - - Scenario: Trigger a validation error - When I send the following GraphQL request: - """ - mutation { - createDummy(input: {name: "", foo: [], clientMutationId: "myId"}) { - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to "422" - And the JSON node "errors[0].message" should be equal to "name: This value should not be blank." - And the JSON node "errors[0].extensions.violations" should exist - And the JSON node "errors[0].extensions.violations[0].path" should be equal to "name" - And the JSON node "errors[0].extensions.violations[0].message" should be equal to "This value should not be blank." - - @createSchema - Scenario: Execute a custom mutation - Given there are 1 dummyCustomMutation objects - When I send the following GraphQL request: - """ - mutation { - sumDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { - dummyCustomMutation { - id - result - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.sumDummyCustomMutation.dummyCustomMutation.result" should be equal to "8" - - @createSchema - Scenario: Execute a not persisted custom mutation (resolver returns null) - Given there are 1 dummyCustomMutation objects - When I send the following GraphQL request: - """ - mutation { - sumNotPersistedDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { - dummyCustomMutation { - id - result - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.sumNotPersistedDummyCustomMutation.dummyCustomMutation" should be null - - Scenario: Execute a not persisted custom mutation (write set to false) with custom result - When I send the following GraphQL request: - """ - mutation { - sumNoWriteCustomResultDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { - dummyCustomMutation { - id - result - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.sumNoWriteCustomResultDummyCustomMutation.dummyCustomMutation.result" should be equal to "1234" - - Scenario: Execute a custom mutation with read, deserialize, validate and serialize set to false - When I send the following GraphQL request: - """ - mutation { - sumOnlyPersistDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { - dummyCustomMutation { - id - result - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.sumOnlyPersistDummyCustomMutation.dummyCustomMutation" should be null - - Scenario: Execute a custom mutation with custom arguments - When I send the following GraphQL request: - """ - mutation { - testCustomArgumentsDummyCustomMutation(input: {operandC: 18, clientMutationId: "myId"}) { - dummyCustomMutation { - result - } - clientMutationId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.testCustomArgumentsDummyCustomMutation.dummyCustomMutation.result" should be equal to "18" - And the JSON node "data.testCustomArgumentsDummyCustomMutation.clientMutationId" should be equal to "myId" - - Scenario: Uploading a file with a custom mutation - Given I have the following file for a GraphQL request: - | name | file | - | file | test.gif | - And I have the following GraphQL multipart request map: - """ - { - "file": ["variables.file"] - } - """ - When I send the following GraphQL multipart request operations: - """ - { - "query": "mutation($file: Upload!) { uploadMediaObject(input: {file: $file}) { mediaObject { id contentUrl } } }", - "variables": { - "file": null - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the JSON node "data.uploadMediaObject.mediaObject.contentUrl" should be equal to "test.gif" - - Scenario: Uploading multiple files with a custom mutation - Given I have the following files for a GraphQL request: - | name | file | - | 0 | test.gif | - | 1 | test.gif | - | 2 | test.gif | - And I have the following GraphQL multipart request map: - """ - { - "0": ["variables.files.0"], - "1": ["variables.files.1"], - "2": ["variables.files.2"] - } - """ - When I send the following GraphQL multipart request operations: - """ - { - "query": "mutation($files: [Upload!]!) { uploadMultipleMediaObject(input: {files: $files}) { mediaObject { id contentUrl } } }", - "variables": { - "files": [ - null, - null, - null - ] - } - } - """ - Then the response status code should be 200 - And the JSON node "data.uploadMultipleMediaObject.mediaObject.contentUrl" should be equal to "test.gif" - - @!mongodb - Scenario: Delete an invalid item through a mutation - When I send the following GraphQL request: - """ - mutation { - deleteActivityLog(input: {id: "/activity_logs/1"}) { - activityLog { - id - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors" should not exist - And the JSON node "data.deleteActivityLog.activityLog" should exist - - @!mongodb - Scenario: Mutation should run before validation - When I send the following GraphQL request: - """ - mutation { - createActivityLog(input: {name: ""}) { - activityLog { - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.createActivityLog.activityLog.name" should be equal to "hi" diff --git a/features/graphql/query.feature b/features/graphql/query.feature deleted file mode 100644 index 732540a65cb..00000000000 --- a/features/graphql/query.feature +++ /dev/null @@ -1,696 +0,0 @@ -Feature: GraphQL query support - - @createSchema - Scenario: Execute a basic GraphQL query - Given there are 2 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - { - dummy(id: "/dummies/1") { - id - name - name_converted - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummy.id" should be equal to "/dummies/1" - And the JSON node "data.dummy.name" should be equal to "Dummy #1" - And the JSON node "data.dummy.name_converted" should be equal to "Converted 1" - - @createSchema - Scenario: Retrieve an item with different relations to the same resource - Given there are 2 multiRelationsDummy objects having each 1 manyToOneRelation, 2 manyToManyRelations, 3 oneToManyRelations and 4 embeddedRelations - When I send the following GraphQL request: - """ - { - multiRelationsDummy(id: "/multi_relations_dummies/2") { - id - name - manyToOneRelation { - id - name - } - manyToOneResolveRelation { - id - name - } - manyToManyRelations { - edges{ - node { - id - name - } - } - } - oneToManyRelations { - edges{ - node { - id - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.multiRelationsDummy.id" should be equal to "/multi_relations_dummies/2" - And the JSON node "data.multiRelationsDummy.name" should be equal to "Dummy #2" - And the JSON node "data.multiRelationsDummy.manyToOneRelation.id" should not be null - And the JSON node "data.multiRelationsDummy.manyToOneRelation.name" should be equal to "RelatedManyToOneDummy #2" - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges" should have 2 element - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[1].node.id" should not be null - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[0].node.name" should match "#RelatedManyToManyDummy(1|2)2#" - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[1].node.name" should match "#RelatedManyToManyDummy(1|2)2#" - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges" should have 3 element - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[1].node.id" should not be null - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[0].node.name" should match "#RelatedOneToManyDummy(1|3)2#" - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[2].node.name" should match "#RelatedOneToManyDummy(1|3)2#" - - @createSchema - Scenario: Retrieve embedded collections - Given there are 2 multiRelationsDummy objects having each 1 manyToOneRelation, 2 manyToManyRelations, 3 oneToManyRelations and 4 embeddedRelations - When I send the following GraphQL request: - """ - { - multiRelationsDummy(id: "/multi_relations_dummies/2") { - id - name - manyToOneRelation { - id - name - } - manyToOneResolveRelation { - id - name - } - manyToManyRelations { - edges{ - node { - id - name - } - } - } - oneToManyRelations { - edges{ - node { - id - name - } - } - } - nestedCollection { - name - } - nestedPaginatedCollection { - edges{ - node { - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors" should not exist - And the JSON node "data.multiRelationsDummy.id" should be equal to "/multi_relations_dummies/2" - And the JSON node "data.multiRelationsDummy.name" should be equal to "Dummy #2" - And the JSON node "data.multiRelationsDummy.manyToOneRelation.id" should not be null - And the JSON node "data.multiRelationsDummy.manyToOneRelation.name" should be equal to "RelatedManyToOneDummy #2" - And the JSON node "data.multiRelationsDummy.manyToOneResolveRelation.id" should not be null - And the JSON node "data.multiRelationsDummy.manyToOneResolveRelation.name" should be equal to "RelatedManyToOneResolveDummy #2" - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges" should have 2 element - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[1].node.id" should not be null - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[0].node.name" should match "#RelatedManyToManyDummy(1|2)2#" - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges[1].node.name" should match "#RelatedManyToManyDummy(1|2)2#" - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges" should have 3 element - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[1].node.id" should not be null - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[0].node.name" should match "#RelatedOneToManyDummy(1|3)2#" - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges[2].node.name" should match "#RelatedOneToManyDummy(1|3)2#" - And the JSON node "data.multiRelationsDummy.nestedCollection[0].name" should be equal to "NestedDummy1" - And the JSON node "data.multiRelationsDummy.nestedCollection[1].name" should be equal to "NestedDummy2" - And the JSON node "data.multiRelationsDummy.nestedCollection[2].name" should be equal to "NestedDummy3" - And the JSON node "data.multiRelationsDummy.nestedCollection[3].name" should be equal to "NestedDummy4" - And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges" should have 4 element - And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges[0].node.name" should be equal to "NestedPaginatedDummy1" - And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges[1].node.name" should be equal to "NestedPaginatedDummy2" - And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges[2].node.name" should be equal to "NestedPaginatedDummy3" - And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges[3].node.name" should be equal to "NestedPaginatedDummy4" - - @createSchema - Scenario: Retrieve an item with different relations (all unset) - Given there are 2 multiRelationsDummy objects having each 0 manyToOneRelation, 0 manyToManyRelations, 0 oneToManyRelations and 0 embeddedRelations - When I send the following GraphQL request: - """ - { - multiRelationsDummy(id: "/multi_relations_dummies/2") { - id - name - manyToOneRelation { - id - name - } - manyToOneResolveRelation { - id - name - } - manyToManyRelations { - edges{ - node { - id - name - } - } - } - oneToManyRelations { - edges{ - node { - id - name - } - } - } - nestedCollection { - name - } - nestedPaginatedCollection { - edges{ - node { - name - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors" should not exist - And the JSON node "data.multiRelationsDummy.id" should be equal to "/multi_relations_dummies/2" - And the JSON node "data.multiRelationsDummy.name" should be equal to "Dummy #2" - And the JSON node "data.multiRelationsDummy.manyToOneRelation" should be null - And the JSON node "data.multiRelationsDummy.manyToOneResolveRelation" should be null - And the JSON node "data.multiRelationsDummy.manyToManyRelations.edges" should have 0 element - And the JSON node "data.multiRelationsDummy.oneToManyRelations.edges" should have 0 element - And the JSON node "data.multiRelationsDummy.nestedCollection" should have 0 element - And the JSON node "data.multiRelationsDummy.nestedPaginatedCollection.edges" should have 0 element - - @createSchema @!mongodb - Scenario: Retrieve an item with child relation to the same resource - Given there are tree dummies - When I send the following GraphQL request: - """ - { - treeDummies { - edges { - node { - id - children { - totalCount - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors" should not exist - And the JSON node "data.treeDummies.edges[0].node.id" should be equal to "/tree_dummies/1" - And the JSON node "data.treeDummies.edges[0].node.children.totalCount" should be equal to "1" - And the JSON node "data.treeDummies.edges[1].node.id" should be equal to "/tree_dummies/2" - And the JSON node "data.treeDummies.edges[1].node.children.totalCount" should be equal to "0" - - @createSchema - Scenario: Retrieve a Relay Node - Given there are 2 dummy objects with relatedDummy - When I send the following GraphQL request: - """ - { - node(id: "/dummies/1") { - id - ... on Dummy { - name - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.node.id" should be equal to "/dummies/1" - And the JSON node "data.node.name" should be equal to "Dummy #1" - - @createSchema - Scenario: Retrieve an item with an iterable field - Given there are 2 dummy objects with relatedDummy - Given there are 2 dummy objects with JSON and array data - When I send the following GraphQL request: - """ - { - dummy(id: "/dummies/3") { - id - name - jsonData - arrayData - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummy.id" should be equal to "/dummies/3" - And the JSON node "data.dummy.name" should be equal to "Dummy #1" - And the JSON node "data.dummy.jsonData.foo" should have 2 elements - And the JSON node "data.dummy.jsonData.bar" should be equal to 5 - And the JSON node "data.dummy.arrayData[2]" should be equal to baz - - @createSchema - Scenario: Retrieve an item with an iterable null field - Given there are 2 dummy with null JSON objects - When I send the following GraphQL request: - """ - { - withJsonDummy(id: "/with_json_dummies/2") { - id - json - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.withJsonDummy.id" should be equal to "/with_json_dummies/2" - And the JSON node "data.withJsonDummy.json" should be null - - @createSchema - Scenario: Retrieve an item through a GraphQL query with variables - Given there are 2 dummy objects with relatedDummy - When I have the following GraphQL request: - """ - query DummyWithId($itemId: ID = "/dummies/1") { - dummyItem: dummy(id: $itemId) { - id - name - relatedDummy { - id - name - } - } - } - """ - And I send the GraphQL request with variables: - """ - { - "itemId": "/dummies/2" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummyItem.id" should be equal to "/dummies/2" - And the JSON node "data.dummyItem.name" should be equal to "Dummy #2" - And the JSON node "data.dummyItem.relatedDummy.id" should be equal to "/related_dummies/2" - And the JSON node "data.dummyItem.relatedDummy.name" should be equal to "RelatedDummy #2" - - Scenario: Run a specific operation through a GraphQL query - When I have the following GraphQL request: - """ - query DummyWithId1 { - dummyItem: dummy(id: "/dummies/1") { - name - } - } - query DummyWithId2 { - dummyItem: dummy(id: "/dummies/2") { - id - name - } - } - """ - And I send the GraphQL request with operationName "DummyWithId2" - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummyItem.id" should be equal to "/dummies/2" - And the JSON node "data.dummyItem.name" should be equal to "Dummy #2" - And I send the GraphQL request with operationName "DummyWithId1" - And the JSON node "data.dummyItem.name" should be equal to "Dummy #1" - - Scenario: Use serialization groups - Given there are 1 dummy group objects - When I send the following GraphQL request: - """ - { - dummyGroup(id: "/dummy_groups/1") { - foo - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummyGroup.foo" should be equal to "Foo #1" - - Scenario: Query a serialized name - Given there is a DummyCar entity with related colors - When I send the following GraphQL request: - """ - { - dummyCar(id: "/dummy_cars/1") { - carBrand - } - } - """ - Then the JSON node "data.dummyCar.carBrand" should be equal to "DummyBrand" - - Scenario: Fetch only the internal id - When I send the following GraphQL request: - """ - { - dummy(id: "/dummies/1") { - _id - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummy._id" should be equal to "1" - - Scenario: Retrieve an nonexistent item through a GraphQL query - When I send the following GraphQL request: - """ - { - dummy(id: "/dummies/5") { - name - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummy" should be null - - Scenario: Retrieve an nonexistent IRI through a GraphQL query - When I send the following GraphQL request: - """ - { - foo(id: "/foo/1") { - name - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the GraphQL debug message should be equal to 'No route matches "/foo/1".' - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "properties": { - "errors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "message": {"type": "string"}, - "extensions": { - "type": "object", - "properties": { - "debugMessage": {"type": "string"}, - "file": {"type": "string"}, - "line": {"type": "integer"}, - "trace": { - "type": "array", - "items": { - "type": "object", - "properties": { - "file": {"type": "string"}, - "line": {"type": "integer"}, - "call": {"type": ["string", "null"]}, - "function": {"type": ["string", "null"]} - }, - "additionalProperties": false - }, - "minItems": 1 - } - } - }, - "locations": {"type": "array"}, - "path": {"type": "array"} - }, - "required": [ - "message", - "extensions", - "locations", - "path" - ] - }, - "minItems": 1, - "maxItems": 1 - } - } - } - """ - - Scenario: Use outputClass instead of resource class through a GraphQL query - Given there are 2 dummyDtoNoInput objects - When I send the following GraphQL request: - """ - { - dummyDtoNoInputs { - edges { - node { - baz - bat - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "dummyDtoNoInputs": { - "edges": [ - { - "node": { - "baz": 0.33, - "bat": "DummyDtoNoInput foo #1" - } - }, - { - "node": { - "baz": 0.67, - "bat": "DummyDtoNoInput foo #2" - } - } - ] - } - } - } - """ - - @createSchema - Scenario: Disable outputClass leads to an empty response through a GraphQL query - Given there are 2 dummyDtoNoOutput objects - When I send the following GraphQL request: - """ - { - dummyDtoNoInputs { - edges { - node { - baz - bat - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "dummyDtoNoInputs": { - "edges": [] - } - } - } - """ - - Scenario: Custom not retrieved item query - When I send the following GraphQL request: - """ - { - testNotRetrievedItemDummyCustomQuery { - message - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testNotRetrievedItemDummyCustomQuery": { - "message": "Success (not retrieved)!" - } - } - } - """ - - Scenario: Custom item query with read and serialize set to false - When I send the following GraphQL request: - """ - { - testNoReadAndSerializeItemDummyCustomQuery(id: "/not_used") { - message - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testNoReadAndSerializeItemDummyCustomQuery": null - } - } - """ - - Scenario: Custom item query - Given there are 2 dummyCustomQuery objects - When I send the following GraphQL request: - """ - { - testItemDummyCustomQuery(id: "/dummy_custom_queries/1") { - message - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testItemDummyCustomQuery": { - "message": "Success!" - } - } - } - """ - - Scenario: Custom item query with custom arguments - Given there are 2 dummyCustomQuery objects - When I send the following GraphQL request: - """ - { - testItemCustomArgumentsDummyCustomQuery( - id: "/dummy_custom_queries/1", - customArgumentBool: true, - customArgumentInt: 3, - customArgumentString: "A string", - customArgumentFloat: 2.6, - customArgumentIntArray: [4], - customArgumentCustomType: "2019-05-24T00:00:00+00:00" - ) { - message - customArgs - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be equal to: - """ - { - "data": { - "testItemCustomArgumentsDummyCustomQuery": { - "message": "Success!", - "customArgs": { - "id": "/dummy_custom_queries/1", - "customArgumentBool": true, - "customArgumentInt": 3, - "customArgumentString": "A string", - "customArgumentFloat": 2.6, - "customArgumentIntArray": [4], - "customArgumentCustomType": "2019-05-24T00:00:00+00:00" - } - } - } - } - """ - - @createSchema - Scenario: Retrieve an item with different serialization groups for item_query and collection_query - Given there are 1 dummy with different GraphQL serialization groups objects - When I send the following GraphQL request: - """ - { - dummyDifferentGraphQlSerializationGroup(id: "/dummy_different_graph_ql_serialization_groups/1") { - name - title - } - } - """ - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummyDifferentGraphQlSerializationGroup.name" should be equal to "Name #1" - And the JSON node "data.dummyDifferentGraphQlSerializationGroup.title" should be equal to "Title #1" - - Scenario: Call security after resolver - When I send the following GraphQL request: - """ - { - getSecurityAfterResolver(id: "/security_after_resolvers/1") { - name - } - } - """ - Then the response status code should be 200 - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.getSecurityAfterResolver.name" should be equal to "test" - - - Scenario: Call security after resolver with 403 error (ensure /2 does not match securityAfterResolver) - When I send the following GraphQL request: - """" - { - getSecurityAfterResolver(id: "/security_after_resolvers/2") { - name - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].extensions.status" should be equal to 403 - And the JSON node "errors[0].message" should be equal to "Access Denied." - And the JSON node "data.getSecurityAfterResolver.name" should not exist diff --git a/features/graphql/schema.feature b/features/graphql/schema.feature deleted file mode 100644 index 86a48cebd13..00000000000 --- a/features/graphql/schema.feature +++ /dev/null @@ -1,113 +0,0 @@ -Feature: GraphQL schema-related features - - @createSchema - Scenario: Export the GraphQL schema in SDL - When I run the command "api:graphql:export" - Then the command output should contain: - """ - ###Dummy Friend.### - type DummyFriend implements Node { - id: ID! - - ###The id### - _id: Int! - - ###The dummy name### - name: String! - } - """ - And the command output should contain: - """ - ###Cursor connection for DummyFriend.### - type DummyFriendCursorConnection { - edges: [DummyFriendEdge] - pageInfo: DummyFriendPageInfo! - totalCount: Int! - } - - ###Edge of DummyFriend.### - type DummyFriendEdge { - node: DummyFriend - cursor: String! - } - - ###Information about the current page.### - type DummyFriendPageInfo { - endCursor: String - startCursor: String - hasNextPage: Boolean! - hasPreviousPage: Boolean! - } - """ - And the command output should contain: - """ - ###Updates a DummyFriend.### - updateDummyFriend(input: updateDummyFriendInput!): updateDummyFriendPayload - - ###Deletes a DummyFriend.### - deleteDummyFriend(input: deleteDummyFriendInput!): deleteDummyFriendPayload - - ###Creates a DummyFriend.### - createDummyFriend(input: createDummyFriendInput!): createDummyFriendPayload - """ - And the command output should contain: - """ - ###Updates a DummyFriend.### - input updateDummyFriendInput { - id: ID! - - ###The dummy name### - name: String - clientMutationId: String - } - """ - And the command output should contain: - """ - ###Updates a DummyFriend.### - type updateDummyFriendPayload { - dummyFriend: DummyFriend - clientMutationId: String - } - """ - And the command output should contain: - """ - ###Deletes a DummyFriend.### - input deleteDummyFriendInput { - id: ID! - clientMutationId: String - } - - ###Deletes a DummyFriend.### - type deleteDummyFriendPayload { - dummyFriend: DummyFriend - clientMutationId: String - } - """ - And the command output should contain: - """ - ###Creates a DummyFriend.### - input createDummyFriendInput { - ###The dummy name### - name: String! - clientMutationId: String - } - - ###Creates a DummyFriend.### - type createDummyFriendPayload { - dummyFriend: DummyFriend - clientMutationId: String - } - """ - And the command output should contain: - """ - "Updates a OptionalRequiredDummy." - input updateOptionalRequiredDummyInput { - id: ID! - thirdLevel: updateThirdLevelNestedInput - thirdLevelRequired: updateThirdLevelNestedInput! - - "Get relatedToDummyFriend." - relatedToDummyFriend: [updateRelatedToDummyFriendNestedInput] - clientMutationId: String - } - """ diff --git a/features/graphql/subscription.feature b/features/graphql/subscription.feature deleted file mode 100644 index 75863ec04bf..00000000000 --- a/features/graphql/subscription.feature +++ /dev/null @@ -1,224 +0,0 @@ -Feature: GraphQL subscription support - - @createSchema - Scenario: Introspect subscription type - When I send the following GraphQL request: - """ - { - __type(name: "Subscription") { - fields { - name - description - type { - name - kind - } - args { - name - type { - name - kind - ofType { - name - kind - } - } - } - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON should be valid according to this schema: - """ - { - "type": "object", - "required": [ - "data" - ], - "properties": { - "data": { - "type": "object", - "required": [ - "__type" - ], - "properties": { - "__type": { - "type": "object", - "required": [ - "fields" - ], - "properties": { - "fields": { - "type": "array", - "minItems": 1, - "items": { - "type": "object", - "required": [ - "name", - "description", - "type", - "args" - ], - "properties": { - "name": { - "pattern": "^update[A-z0-9]+Subscribe" - }, - "description": { - "pattern": "^Subscribes to the update event of a [A-z0-9]+.$" - }, - "type": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^update[A-z0-9]+SubscriptionPayload$" - }, - "kind": { - "enum": ["OBJECT"] - } - } - }, - "args": { - "type": "array", - "minItems": 1, - "maxItems": 1, - "items": [ - { - "type": "object", - "required": [ - "name", - "type" - ], - "properties": { - "name": { - "enum": ["input"] - }, - "type": { - "type": "object", - "required": [ - "kind", - "ofType" - ], - "properties": { - "kind": { - "enum": ["NON_NULL"] - }, - "ofType": { - "type": "object", - "required": [ - "name", - "kind" - ], - "properties": { - "name": { - "pattern": "^update[A-z0-9]+SubscriptionInput$" - }, - "kind": { - "enum": ["INPUT_OBJECT"] - } - } - } - } - } - } - } - ] - } - } - } - } - } - } - } - } - } - } - """ - - Scenario: Subscribe to updates - Given there are 2 dummy mercure objects - When I send the following GraphQL request: - """ - subscription { - updateDummyMercureSubscribe(input: {id: "/dummy_mercures/1", clientSubscriptionId: "myId"}) { - dummyMercure { - id - name - relatedDummy { - name - } - } - mercureUrl - clientSubscriptionId - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateDummyMercureSubscribe.dummyMercure.id" should be equal to "/dummy_mercures/1" - And the JSON node "data.updateDummyMercureSubscribe.dummyMercure.name" should be equal to "Dummy Mercure #1" - And the JSON node "data.updateDummyMercureSubscribe.mercureUrl" should match "@^https://demo.mercure.rocks\?topic=http://example.com/subscriptions/[a-f0-9]+$@" - And the JSON node "data.updateDummyMercureSubscribe.clientSubscriptionId" should be equal to "myId" - - When I send the following GraphQL request: - """ - subscription { - updateDummyMercureSubscribe(input: {id: "/dummy_mercures/2"}) { - dummyMercure { - id - } - mercureUrl - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateDummyMercureSubscribe.dummyMercure.id" should be equal to "/dummy_mercures/2" - And the JSON node "data.updateDummyMercureSubscribe.mercureUrl" should match "@^https://demo.mercure.rocks\?topic=http://example.com/subscriptions/[a-f0-9]+$@" - - Scenario: Receive Mercure updates with different payloads from subscriptions (legacy PUT in non-standard mode) - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/dummy_mercures/1" with body: - """ - { - "name": "Dummy Mercure #1 updated" - } - """ - Then the following Mercure update with topics "http://example.com/subscriptions/[a-f0-9]+" should have been sent: - """ - { - "dummyMercure": { - "id": 1, - "name": "Dummy Mercure #1 updated", - "relatedDummy": { - "name": "RelatedDummy #1" - } - } - } - """ - - When I add "Accept" header equal to "application/ld+json" - And I add "Content-Type" header equal to "application/ld+json" - And I send a "PUT" request to "/dummy_mercures/2" with body: - """ - { - "name": "Dummy Mercure #2 updated" - } - """ - Then the following Mercure update with topics "http://example.com/subscriptions/[a-f0-9]+" should have been sent: - """ - { - "dummyMercure": { - "id": 2 - } - } - """ diff --git a/features/graphql/type.feature b/features/graphql/type.feature deleted file mode 100644 index 03a072785d5..00000000000 --- a/features/graphql/type.feature +++ /dev/null @@ -1,80 +0,0 @@ -Feature: GraphQL type support - - @createSchema - Scenario: Use a custom type for a field - Given there are 2 dummy objects with dummyDate - When I send the following GraphQL request: - """ - { - dummy(id: "/dummies/1") { - dummyDate - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.dummy.dummyDate" should be equal to "2015-04-01" - - Scenario: Use a custom type for an input field - When I send the following GraphQL request: - """ - mutation { - updateDummy(input: {id: "/dummies/1", dummyDate: "2019-05-24T00:00:00+00:00"}) { - dummy { - dummyDate - } - } - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateDummy.dummy.dummyDate" should be equal to "2019-05-24" - - Scenario: Use a custom type for a query variable - When I have the following GraphQL request: - """ - mutation UpdateDummyDate($itemId: ID!, $itemDate: DateTime!) { - updateDummy(input: {id: $itemId, dummyDate: $itemDate}) { - dummy { - dummyDate - } - } - } - """ - And I send the GraphQL request with variables: - """ - { - "itemId": "/dummies/1", - "itemDate": "2017-11-14T00:00:00+00:00" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "data.updateDummy.dummy.dummyDate" should be equal to "2017-11-14" - - Scenario: Use a custom type for a query variable and use a bad value - When I have the following GraphQL request: - """ - mutation UpdateDummyDate($itemId: ID!, $itemDate: DateTime!) { - updateDummy(input: {id: $itemId, dummyDate: $itemDate}) { - dummy { - dummyDate - } - } - } - """ - And I send the GraphQL request with variables: - """ - { - "itemId": "/dummies/1", - "itemDate": "bad date" - } - """ - Then the response status code should be 200 - And the response should be in JSON - And the header "Content-Type" should be equal to "application/json" - And the JSON node "errors[0].message" should contain 'Variable "$itemDate" got invalid value "bad date";' - And the JSON node "errors[0].message" should contain 'DateTime cannot represent non date value: "bad date"' diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 9f177ef37f1..aa1d500db50 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -12,7 +12,6 @@ - @@ -40,7 +39,6 @@ tests - features vendor .php-cs-fixer.dist.php diff --git a/src/Doctrine/Odm/Tests/AppKernel.php b/src/Doctrine/Odm/Tests/AppKernel.php index 773a4e31592..039813186e5 100644 --- a/src/Doctrine/Odm/Tests/AppKernel.php +++ b/src/Doctrine/Odm/Tests/AppKernel.php @@ -33,7 +33,6 @@ public function __construct(string $environment, bool $debug) { parent::__construct($environment, $debug); - // patch for behat/symfony2-extension not supporting %env(APP_ENV)% $this->environment = $_SERVER['APP_ENV'] ?? $environment; } diff --git a/src/Doctrine/Orm/Tests/AppKernel.php b/src/Doctrine/Orm/Tests/AppKernel.php index 66c5948a28c..3abb43d2880 100644 --- a/src/Doctrine/Orm/Tests/AppKernel.php +++ b/src/Doctrine/Orm/Tests/AppKernel.php @@ -33,7 +33,6 @@ public function __construct(string $environment, bool $debug) { parent::__construct($environment, $debug); - // patch for behat/symfony2-extension not supporting %env(APP_ENV)% $this->environment = $_SERVER['APP_ENV'] ?? $environment; } diff --git a/src/GraphQl/Test/GraphQlTestTrait.php b/src/GraphQl/Test/GraphQlTestTrait.php new file mode 100644 index 00000000000..925cacdfa11 --- /dev/null +++ b/src/GraphQl/Test/GraphQlTestTrait.php @@ -0,0 +1,141 @@ + + * + * 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\GraphQl\Test; + +use GraphQL\Type\Introspection; +use PHPUnit\Framework\Assert; +use PHPUnit\Framework\ExpectationFailedException; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * Helpers for functional GraphQL tests. + * + * Designed to be mixed into a class that exposes a static `createClient()` returning + * an HTTP client with a `request()` method (e.g. ApiPlatform\Symfony\Bundle\Test\ApiTestCase). + */ +trait GraphQlTestTrait +{ + /** + * @param array $variables + * @param array $headers + */ + protected function executeGraphQl(string $query, array $variables = [], ?string $operationName = null, array $headers = []): ResponseInterface + { + $payload = ['query' => $query]; + + if ($variables) { + $payload['variables'] = $variables; + } + + if (null !== $operationName) { + $payload['operationName'] = $operationName; + } + + $options = ['json' => $payload]; + + if ($headers) { + $options['headers'] = $headers; + } + + return static::createClient()->request('POST', '/graphql', $options); + } + + /** + * @param array $headers + */ + protected function introspectSchema(array $headers = []): ResponseInterface + { + return $this->executeGraphQl(Introspection::getIntrospectionQuery(), [], null, $headers); + } + + /** + * Send a `multipart/form-data` GraphQL request following the + * graphql-multipart-request-spec (https://github.com/jaydenseric/graphql-multipart-request-spec). + * + * @param array $files Map of file marker => absolute file path or UploadedFile + * @param array $headers + */ + protected function executeGraphQlMultipart(string $operations, string $map, array $files, array $headers = []): ResponseInterface + { + return static::createClient()->request('POST', '/graphql', [ + 'headers' => ['Content-Type' => 'multipart/form-data'] + $headers, + 'extra' => [ + 'parameters' => ['operations' => $operations, 'map' => $map], + 'files' => $files, + ], + ]); + } + + /** + * @param array{errors?: list} $data + */ + protected function assertGraphQlError(array $data, string $expectedMessage, int $index = 0): void + { + if (!isset($data['errors'][$index])) { + throw new ExpectationFailedException(\sprintf('No GraphQL error at index %d.', $index)); + } + + Assert::assertSame($expectedMessage, $data['errors'][$index]['message'] ?? null); + } + + /** + * Mirrors the Behat `the GraphQL debug message should be equal to` step: + * looks under `errors[$i].extensions.debugMessage` first, falls back to + * `errors[$i].debugMessage` for graphql-php < 15. + * + * @param array{errors?: list>} $data + */ + protected function assertGraphQlDebugMessage(array $data, string $expectedDebugMessage, int $index = 0): void + { + if (!isset($data['errors'][$index])) { + throw new ExpectationFailedException(\sprintf('No GraphQL error at index %d.', $index)); + } + + $error = $data['errors'][$index]; + $debug = $error['extensions']['debugMessage'] ?? $error['debugMessage'] ?? null; + + Assert::assertSame($expectedDebugMessage, $debug); + } + + /** + * Assert that a field returned by a `__type(name: ...) { fields { ... } }` query is + * flagged as deprecated with the given reason. + * + * @param array{data?: array{__type?: array{fields?: list>}}} $data + */ + protected function assertGraphQlFieldDeprecated(array $data, string $fieldName, string $reason): void + { + $fields = $data['data']['__type']['fields'] ?? null; + + if (!\is_array($fields)) { + throw new ExpectationFailedException('Expected response to contain "data.__type.fields".'); + } + + foreach ($fields as $field) { + if (($field['name'] ?? null) !== $fieldName) { + continue; + } + + if (true === ($field['isDeprecated'] ?? null) && $reason === ($field['deprecationReason'] ?? null)) { + Assert::assertTrue(true); + + return; + } + + throw new ExpectationFailedException(\sprintf('Field "%s" is not deprecated with reason "%s".', $fieldName, $reason)); + } + + throw new ExpectationFailedException(\sprintf('Field "%s" not found in "data.__type.fields".', $fieldName)); + } +} diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 89440051446..5b27935eed7 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -24,12 +24,6 @@ To run tests against MongoDB: ## Execution Guidelines -### Behat (Functional) - -* **Progress Format:** ALWAYS use \--format=progress. Without this, output verbosity increases execution time from \~10m to \~30m. -* **Tags:** Filter efficiently: vendor/bin/behat \--tags=@pagination \--format=progress -* **Debugging:** Only drop \--format=progress if you need to debug a *single* scenario using \-vvv. - ### PHPUnit * **Filtering:** Never run the full suite. Always filter by class or path. diff --git a/tests/Behat/CommandContext.php b/tests/Behat/CommandContext.php deleted file mode 100644 index 666f387410a..00000000000 --- a/tests/Behat/CommandContext.php +++ /dev/null @@ -1,106 +0,0 @@ - - * - * 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\Behat; - -use Behat\Behat\Context\Context; -use Behat\Gherkin\Node\PyStringNode; -use Behat\Gherkin\Node\TableNode; -use GraphQL\Error\Error; -use PHPUnit\Framework\Assert; -use Symfony\Bundle\FrameworkBundle\Console\Application; -use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Tester\CommandTester; -use Symfony\Component\HttpKernel\KernelInterface; - -/** - * Context for Symfony commands. - * - * @author Alan Poulain - */ -final class CommandContext implements Context -{ - private ?Application $application = null; - - private ?CommandTester $commandTester = null; - - public function __construct(private KernelInterface $kernel) - { - } - - /** - * @When I run the command :command - */ - public function iRunTheCommand(string $command): void - { - $command = $this->getApplication()->find($command); - - $this->getCommandTester($command)->execute([]); - } - - /** - * @When I run the command :command with options: - */ - public function iRunTheCommandWithOptions(string $command, TableNode $options): void - { - $command = $this->getApplication()->find($command); - - $this->getCommandTester($command)->execute($options->getRowsHash()); - } - - /** - * @Then the command output should be: - */ - public function theCommandOutputShouldBe(PyStringNode $expectedOutput): void - { - Assert::assertEquals($expectedOutput->getRaw(), $this->commandTester->getDisplay()); - } - - /** - * @Then the command output should contain: - */ - public function theCommandOutputShouldContain(PyStringNode $expectedOutput): void - { - // graphql-php < 15 - if (\defined(Error::class.'::CATEGORY_GRAPHQL')) { - $expectedOutput = str_replace('###', '"""', $expectedOutput->getRaw()); - } else { - $expectedOutput = str_replace('###', '"', $expectedOutput->getRaw()); - } - - Assert::assertStringContainsString($expectedOutput, $this->commandTester->getDisplay()); - } - - public function setKernel(KernelInterface $kernel): void - { - $this->kernel = $kernel; - } - - public function getApplication(): Application - { - if (null !== $this->application) { - return $this->application; - } - - $this->application = new Application($this->kernel); - - return $this->application; - } - - private function getCommandTester(Command $command): CommandTester - { - $this->commandTester = new CommandTester($command); - - return $this->commandTester; - } -} diff --git a/tests/Behat/CoverageContext.php b/tests/Behat/CoverageContext.php deleted file mode 100644 index ee5c171cd3d..00000000000 --- a/tests/Behat/CoverageContext.php +++ /dev/null @@ -1,92 +0,0 @@ - - * - * 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\Behat; - -use Behat\Behat\Context\Context; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; -use SebastianBergmann\CodeCoverage\CodeCoverage; -use SebastianBergmann\CodeCoverage\Driver\Selector; -use SebastianBergmann\CodeCoverage\Filter; -use SebastianBergmann\CodeCoverage\Report\PHP; -use Symfony\Component\Finder\Finder; - -/** - * Behat coverage. - * - * @author eliecharra - * @author Kévin Dunglas - * @copyright Adapted from https://gist.github.com/eliecharra/9c8b3ba57998b50e14a6 - */ -final class CoverageContext implements Context -{ - /** - * @var CodeCoverage - */ - private static $coverage; - - /** - * @BeforeSuite - */ - public static function setup(): void - { - $filter = new Filter(); - $finder = - (new Finder()) - ->in(__DIR__.'/../../src') - ->exclude([ - 'src/Core/Bridge/Symfony/Maker/Resources/skeleton', - 'tests/Fixtures/app/var', - 'docs/guides', - 'docs/var', - 'src/Doctrine/Orm/Tests/var', - 'src/Doctrine/Odm/Tests/var', - ]) - ->append([ - 'tests/Fixtures/app/console', - ]) - ->files() - ->name('*.php'); - - foreach ($finder as $file) { - $filter->includeFile((string) $file); - } - - self::$coverage = new CodeCoverage((new Selector())->forLineCoverage($filter), $filter); - } - - /** - * @AfterSuite - */ - public static function teardown(): void - { - $feature = getenv('FEATURE') ?: 'behat'; - (new PHP())->process(self::$coverage, __DIR__."/../../build/coverage/coverage-$feature.cov"); - } - - /** - * @BeforeScenario - */ - public function before(BeforeScenarioScope $scope): void - { - self::$coverage->start("{$scope->getFeature()->getTitle()}::{$scope->getScenario()->getTitle()}"); - } - - /** - * @AfterScenario - */ - public function after(): void - { - self::$coverage->stop(); - } -} diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php deleted file mode 100644 index 4639644beb4..00000000000 --- a/tests/Behat/DoctrineContext.php +++ /dev/null @@ -1,2707 +0,0 @@ - - * - * 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\Behat; - -use ApiPlatform\Tests\Fixtures\TestBundle\Doctrine\Orm\EntityManager; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\AbsoluteUrlDummy as AbsoluteUrlDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\AbsoluteUrlRelationDummy as AbsoluteUrlRelationDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Address as AddressDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Answer as AnswerDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Book as BookDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Comment as CommentDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\CompositeItem as CompositeItemDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\CompositeLabel as CompositeLabelDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\CompositePrimitiveItem as CompositePrimitiveItemDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\CompositeRelation as CompositeRelationDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedBoolean as ConvertedBoolDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedDate as ConvertedDateDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedInteger as ConvertedIntegerDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedOwner as ConvertedOwnerDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedRelated as ConvertedRelatedDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedString as ConvertedStringDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Customer as CustomerDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\CustomMultipleIdentifierDummy as CustomMultipleIdentifierDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyAggregateOffer as DummyAggregateOfferDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCar as DummyCarDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCarColor as DummyCarColorDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCustomMutation as DummyCustomMutationDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCustomQuery as DummyCustomQueryDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDate as DummyDateDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDifferentGraphQlSerializationGroup as DummyDifferentGraphQlSerializationGroupDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoCustom as DummyDtoCustomDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoNoInput as DummyDtoNoInputDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoNoOutput as DummyDtoNoOutputDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyFriend as DummyFriendDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyGroup as DummyGroupDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyImmutableDate as DummyImmutableDateDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyMercure as DummyMercureDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyOffer as DummyOfferDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyPassenger as DummyPassengerDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyProduct as DummyProductDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyProperty as DummyPropertyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyTableInheritanceNotApiResourceChild as DummyTableInheritanceNotApiResourceChildDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyTravel as DummyTravelDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddableDummy as EmbeddableDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddedDummy as EmbeddedDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\FileConfigDummy as FileConfigDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Foo as FooDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\FooDummy as FooDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\FooEmbeddable as FooEmbeddableDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\FourthLevel as FourthLevelDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Greeting as GreetingDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\InitializeInput as InitializeInputDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\IriOnlyDummy as IriOnlyDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\LinkHandledDummy as LinkHandledDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MaxDepthDummy as MaxDepthDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsDummy as MultiRelationsDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsNested as MultiRelationsNestedDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsNestedPaginated as MultiRelationsNestedPaginatedDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsRelatedDummy as MultiRelationsRelatedDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MultiRelationsResolveDummy as MultiRelationsResolveDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\MusicGroup as MusicGroupDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\NetworkPathDummy as NetworkPathDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\NetworkPathRelationDummy as NetworkPathRelationDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Order as OrderDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\PatchDummyRelation as PatchDummyRelationDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Payment as PaymentDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Person as PersonDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\PersonToPet as PersonToPetDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Pet as PetDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Product as ProductDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Program as ProgramDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\PropertyCollectionIriOnly as PropertyCollectionIriOnlyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\PropertyCollectionIriOnlyRelation as PropertyCollectionIriOnlyRelationDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\PropertyUriTemplateOneToOneRelation as PropertyUriTemplateOneToOneRelationDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Question as QuestionDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedLinkedDummy as RelatedLinkedDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedOwnedDummy as RelatedOwnedDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedOwningDummy as RelatedOwningDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedSecuredDummy as RelatedSecuredDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedToDummyFriend as RelatedToDummyFriendDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelationEmbedder as RelationEmbedderDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\SecuredDummy as SecuredDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\SeparatedEntity as SeparatedEntityDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\SoMany as SoManyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\Taxon as TaxonDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\ThirdLevel as ThirdLevelDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\UrlEncodedId as UrlEncodedIdDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\User as UserDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\VideoGame as VideoGameDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\WithJsonDummy as WithJsonDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AbsoluteUrlDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\AbsoluteUrlRelationDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Address; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Answer; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Book; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Comment; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeItem; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeLabel; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositePrimitiveItem; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeRelation; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedBoolean; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedDate; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedInteger; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedOwner; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedRelated; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedString; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Customer; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CustomMultipleIdentifierDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyAggregateOffer; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCarColor; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCustomMutation; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCustomQuery; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDate; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDifferentGraphQlSerializationGroup; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoCustom; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoNoInput; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoNoOutput; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyImmutableDate; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyMappedSubclass; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyMercure; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyOffer; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyPassenger; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProduct; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProperty; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummySubEntity; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceNotApiResourceChild; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTravel; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyWithSubEntity; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddedDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EntityClassWithDateTime; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ExternalUser; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FileConfigDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FooDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FooEmbeddable; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FourthLevel; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Greeting; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\InitializeInput; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\InternalUser; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\IriOnlyDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5722\Event; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5722\ItemLog; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5735\Group; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue6039\Issue6039EntityUser; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\LinkHandledDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MaxDepthDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsNested; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsNestedPaginated; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsRelatedDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsResolveDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MusicGroup; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NetworkPathDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\NetworkPathRelationDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Order; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PaginationEntity; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PatchDummyRelation; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Payment; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Person; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PersonToPet; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Pet; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Product; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Program; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnly; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyCollectionIriOnlyRelation; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\PropertyUriTemplateOneToOneRelation; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Question; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RamseyUuidDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedLinkedDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedOwnedDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedOwningDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedSecuredDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationEmbedder; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelationMultiple; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SeparatedEntity; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Site; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SoMany; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SymfonyUuidDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Taxon; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\TreeDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UrlEncodedId; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\User; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\UuidIdentifierDummy; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VideoGame; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\WithJsonDummy; -use Behat\Behat\Context\Context; -use Behat\Gherkin\Node\PyStringNode; -use Doctrine\Common\Collections\ArrayCollection; -use Doctrine\ODM\MongoDB\DocumentManager; -use Doctrine\ODM\MongoDB\Query\Builder; -use Doctrine\ODM\MongoDB\SchemaManager; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\ORM\Mapping\ClassMetadata; -use Doctrine\ORM\Tools\SchemaTool; -use Doctrine\Persistence\ManagerRegistry; -use Doctrine\Persistence\ObjectManager; -use Ramsey\Uuid\Uuid; -use Symfony\Component\Uid\Uuid as SymfonyUuid; - -/** - * Defines application features from the specific context. - */ -final class DoctrineContext implements Context -{ - private ObjectManager $manager; - private ?SchemaTool $schemaTool; - private ?SchemaManager $schemaManager; - - /** - * Initializes context. - * - * Every scenario gets its own context instance. - * You can also pass arbitrary arguments to the - * context constructor through behat.yml. - */ - public function __construct(private readonly ManagerRegistry $doctrine, private readonly mixed $passwordHasher) - { - $this->manager = $doctrine->getManager(); - $this->schemaTool = $this->manager instanceof EntityManagerInterface ? new SchemaTool($this->manager) : null; - $this->schemaManager = $this->manager instanceof DocumentManager ? $this->manager->getSchemaManager() : null; - } - - /** - * @BeforeScenario @createSchema - */ - public function createDatabase(): void - { - /** @var ClassMetadata[] $classes */ - $classes = $this->manager->getMetadataFactory()->getAllMetadata(); - - if ($this->isOrm()) { - $this->schemaTool->dropSchema($classes); - $this->schemaTool->createSchema($classes); - } - - if ($this->isOdm()) { - $this->schemaManager->dropDatabases(); - } - - $this->doctrine->getManager()->clear(); - } - - /** - * @Then the DQL should be equal to: - */ - public function theDqlShouldBeEqualTo(PyStringNode $dql): void - { - /** @var EntityManager $manager */ - $manager = $this->doctrine->getManager(); - - $actualDql = $manager::$dql; - - $expectedDql = preg_replace('/\(\R */', '(', (string) $dql); - $expectedDql = preg_replace('/\R *\)/', ')', $expectedDql); - $expectedDql = preg_replace('/\R */', ' ', $expectedDql); - - if ($expectedDql !== $actualDql) { - throw new \RuntimeException("The DQL:\n'$actualDql' is not equal to:\n'$expectedDql'"); - } - } - - /** - * @Given there are :nb dummy objects - */ - public function thereAreDummyObjects(int $nb): void - { - $descriptions = ['Smart dummy.', 'Not so smart dummy.']; - - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setDummy('SomeDummyTest'.$i); - $dummy->setDescription($descriptions[($i - 1) % 2]); - $dummy->nameConverted = 'Converted '.$i; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb pagination entities - */ - public function thereArePaginationEntities(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $paginationEntity = new PaginationEntity(); - $this->manager->persist($paginationEntity); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb of these so many objects - */ - public function thereAreOfTheseSoManyObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $soMany = $this->buildSoMany(); - $soMany->content = 'Many #'.$i; - - $this->manager->persist($soMany); - } - - $this->manager->flush(); - } - - /** - * @When some dummy table inheritance data but not api resource child are created - */ - public function someDummyTableInheritanceDataButNotApiResourceChildAreCreated(): void - { - $dummy = $this->buildDummyTableInheritanceNotApiResourceChild(); - $dummy->setName('Foobarbaz inheritance'); - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there are :nb foo objects with fake names - */ - public function thereAreFooObjectsWithFakeNames(int $nb): void - { - $names = ['Hawsepipe', 'Sthenelus', 'Ephesian', 'Separativeness', 'Balbo']; - $bars = ['Lorem', 'Ipsum', 'Dolor', 'Sit', 'Amet']; - - for ($i = 0; $i < $nb; ++$i) { - $foo = $this->buildFoo(); - $foo->setName($names[$i]); - $foo->setBar($bars[$i]); - - $this->manager->persist($foo); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb fooDummy objects with fake names - */ - public function thereAreFooDummyObjectsWithFakeNames(int $nb, $embedd = false): void - { - $names = ['Hawsepipe', 'Ephesian', 'Sthenelus', 'Separativeness', 'Balbo']; - $dummies = ['Lorem', 'Ipsum', 'Dolor', 'Sit', 'Amet']; - - for ($i = 0; $i < $nb; ++$i) { - $dummy = $this->buildDummy(); - $dummy->setName($dummies[$i]); - - $foo = $this->buildFooDummy(); - $foo->setName($names[$i]); - if ($embedd) { - $embeddedFoo = $this->buildFooEmbeddable(); - $embeddedFoo->setDummyName('embedded'.$names[$i]); - $foo->setEmbeddedFoo($embeddedFoo); - } - $foo->setDummy($dummy); - for ($j = 0; $j < 3; ++$j) { - $soMany = $this->buildSoMany(); - $soMany->content = "So many $j"; - $soMany->fooDummy = $foo; - $foo->soManies->add($soMany); - } - - $this->manager->persist($foo); - } - - $this->manager->flush(); - } - - /** - * @Given there is a fooDummy objects with fake names and embeddable - */ - public function thereAreFooDummyObjectsWithFakeNamesAndEmbeddable(): void - { - $this->thereAreFooDummyObjectsWithFakeNames(1, true); - } - - /** - * @Given there are :nb dummy group objects - */ - public function thereAreDummyGroupObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyGroup = $this->buildDummyGroup(); - - foreach (['foo', 'bar', 'baz', 'qux'] as $property) { - $dummyGroup->{$property} = ucfirst($property).' #'.$i; - } - - $this->manager->persist($dummyGroup); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy property objects - */ - public function thereAreDummyPropertyObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyProperty = $this->buildDummyProperty(); - $dummyGroup = $this->buildDummyGroup(); - - foreach (['foo', 'bar', 'baz'] as $property) { - $dummyProperty->{$property} = $dummyGroup->{$property} = ucfirst($property).' #'.$i; - } - $dummyProperty->nameConverted = "NameConverted #$i"; - - $dummyProperty->group = $dummyGroup; - - $this->manager->persist($dummyGroup); - $this->manager->persist($dummyProperty); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy property objects with a shared group - */ - public function thereAreDummyPropertyObjectsWithASharedGroup(int $nb): void - { - $dummyGroup = $this->buildDummyGroup(); - foreach (['foo', 'bar', 'baz'] as $property) { - $dummyGroup->{$property} = ucfirst($property).' #shared'; - } - $this->manager->persist($dummyGroup); - - for ($i = 1; $i <= $nb; ++$i) { - $dummyProperty = $this->buildDummyProperty(); - - foreach (['foo', 'bar', 'baz'] as $property) { - $dummyProperty->{$property} = ucfirst($property).' #'.$i; - } - - $dummyProperty->group = $dummyGroup; - $this->manager->persist($dummyProperty); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy property objects with different number of related groups - */ - public function thereAreDummyPropertyObjectsWithADifferentNumberRelatedGroups(int $nb): void - { - $dummyGroups = []; - for ($i = 1; $i <= $nb; ++$i) { - $dummyGroup = $this->buildDummyGroup(); - $dummyProperty = $this->buildDummyProperty(); - - foreach (['foo', 'bar', 'baz'] as $property) { - $dummyProperty->{$property} = $dummyGroup->{$property} = ucfirst($property).' #'.$i; - } - - $this->manager->persist($dummyGroup); - $dummyGroups[$i] = $dummyGroup; - - for ($j = 1; $j <= $i; ++$j) { - $dummyProperty->groups[] = $dummyGroups[$j]; - } - - $this->manager->persist($dummyProperty); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy property objects with :nb2 groups - */ - public function thereAreDummyPropertyObjectsWithGroups(int $nb, int $nb2): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyProperty = $this->buildDummyProperty(); - $dummyGroup = $this->buildDummyGroup(); - - foreach (['foo', 'bar', 'baz'] as $property) { - $dummyProperty->{$property} = $dummyGroup->{$property} = ucfirst($property).' #'.$i; - } - - $dummyProperty->group = $dummyGroup; - - $this->manager->persist($dummyGroup); - for ($j = 1; $j <= $nb2; ++$j) { - $dummyGroup = $this->buildDummyGroup(); - - foreach (['foo', 'bar', 'baz'] as $property) { - $dummyGroup->{$property} = ucfirst($property).' #'.$i.$j; - } - - $dummyProperty->groups[] = $dummyGroup; - $this->manager->persist($dummyGroup); - } - - $this->manager->persist($dummyProperty); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb embedded dummy objects - */ - public function thereAreEmbeddedDummyObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildEmbeddedDummy(); - $dummy->setName('Dummy #'.$i); - - $embeddableDummy = $this->buildEmbeddableDummy(); - $embeddableDummy->setDummyName('Dummy #'.$i); - $dummy->setEmbeddedDummy($embeddableDummy); - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with relatedDummy - */ - public function thereAreDummyObjectsWithRelatedDummy(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #'.$i); - - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->nameConverted = "Converted $i"; - $dummy->setRelatedDummy($relatedDummy); - - $this->manager->persist($relatedDummy); - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are dummies with similar properties - */ - public function thereAreDummiesWithSimilarProperties(): void - { - $dummy1 = $this->buildDummy(); - $dummy1->setName('foo'); - $dummy1->setDescription('bar'); - - $dummy2 = $this->buildDummy(); - $dummy2->setName('baz'); - $dummy2->setDescription('qux'); - - $dummy3 = $this->buildDummy(); - $dummy3->setName('foo'); - $dummy3->setDescription('qux'); - - $dummy4 = $this->buildDummy(); - $dummy4->setName('baz'); - $dummy4->setDescription('bar'); - - $this->manager->persist($dummy1); - $this->manager->persist($dummy2); - $this->manager->persist($dummy3); - $this->manager->persist($dummy4); - $this->manager->flush(); - } - - /** - * @Given there are :nb dummyDtoNoInput objects - */ - public function thereAreDummyDtoNoInputObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyDto = $this->buildDummyDtoNoInput(); - $dummyDto->lorem = 'DummyDtoNoInput foo #'.$i; - $dummyDto->ipsum = round($i / 3, 2); - - $this->manager->persist($dummyDto); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummyDtoNoOutput objects - */ - public function thereAreDummyDtoNoOutputObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyDto = $this->buildDummyDtoNoOutput(); - $dummyDto->lorem = 'DummyDtoNoOutput foo #'.$i; - $dummyDto->ipsum = (string) ($i / 3); - - $this->manager->persist($dummyDto); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummyCustomQuery objects - */ - public function thereAreDummyCustomQueryObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyCustomQuery = $this->buildDummyCustomQuery(); - - $this->manager->persist($dummyCustomQuery); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummyCustomMutation objects - */ - public function thereAreDummyCustomMutationObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $customMutationDummy = $this->buildDummyCustomMutation(); - $customMutationDummy->setOperandA(3); - - $this->manager->persist($customMutationDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with JSON and array data - */ - public function thereAreDummyObjectsWithJsonData(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setJsonData(['foo' => ['bar', 'baz'], 'bar' => 5]); - $dummy->setArrayData(['foo', 'bar', 'baz']); - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy with null JSON objects - */ - public function thereAreDummyWithNullJsonObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildWithJsonDummy(); - $dummy->json = null; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with relatedDummy and its thirdLevel - * @Given there is :nb dummy object with relatedDummy and its thirdLevel - */ - public function thereAreDummyObjectsWithRelatedDummyAndItsThirdLevel(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $thirdLevel = $this->buildThirdLevel(); - - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #'.$i); - $relatedDummy->setThirdLevel($thirdLevel); - - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setRelatedDummy($relatedDummy); - - $this->manager->persist($thirdLevel); - $this->manager->persist($relatedDummy); - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there is a dummy object with :nb relatedDummies and their thirdLevel - */ - public function thereIsADummyObjectWithRelatedDummiesAndTheirThirdLevel(int $nb): void - { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy with relations'); - - for ($i = 1; $i <= $nb; ++$i) { - $thirdLevel = $this->buildThirdLevel(); - - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #'.$i); - $relatedDummy->setThirdLevel($thirdLevel); - - $dummy->addRelatedDummy($relatedDummy); - - $this->manager->persist($thirdLevel); - $this->manager->persist($relatedDummy); - } - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there is a dummy object with :nb relatedDummies with same thirdLevel - */ - public function thereIsADummyObjectWithRelatedDummiesWithSameThirdLevel(int $nb): void - { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy with relations'); - $thirdLevel = $this->buildThirdLevel(); - - for ($i = 1; $i <= $nb; ++$i) { - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #'.$i); - $relatedDummy->setThirdLevel($thirdLevel); - - $dummy->addRelatedDummy($relatedDummy); - - $this->manager->persist($relatedDummy); - } - $this->manager->persist($thirdLevel); - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with embeddedDummy - */ - public function thereAreDummyObjectsWithEmbeddedDummy(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $embeddableDummy = $this->buildEmbeddableDummy(); - $embeddableDummy->setDummyName('EmbeddedDummy #'.$i); - - $dummy = $this->buildEmbeddedDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setEmbeddedDummy($embeddableDummy); - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects having each :nbrelated relatedDummies - */ - public function thereAreDummyObjectsWithRelatedDummies(int $nb, int $nbrelated): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - - for ($j = 1; $j <= $nbrelated; ++$j) { - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy'.$j.$i); - $relatedDummy->setAge((int) ($j.$i)); - $this->manager->persist($relatedDummy); - - $dummy->addRelatedDummy($relatedDummy); - } - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb multiRelationsDummy objects having each :nbmtor manyToOneRelation, :nbmtmr manyToManyRelations, :nbotmr oneToManyRelations and :nber embeddedRelations - */ - public function thereAreMultiRelationsDummyObjectsHavingEachAManyToOneRelationManyToManyRelationsOneToManyRelationsAndEmbeddedRelations(int $nb, int $nbmtor, int $nbmtmr, int $nbotmr, int $nber): void - { - for ($i = 1; $i <= $nb; ++$i) { - $relatedDummy = $this->buildMultiRelationsRelatedDummy(); - $relatedDummy->name = 'RelatedManyToOneDummy #'.$i; - - $resolveDummy = $this->buildMultiRelationsResolveDummy(); - $resolveDummy->name = 'RelatedManyToOneResolveDummy #'.$i; - - $dummy = $this->buildMultiRelationsDummy(); - $dummy->name = 'Dummy #'.$i; - - if ($nbmtor) { - $dummy->setManyToOneRelation($relatedDummy); - $dummy->setManyToOneResolveRelation($resolveDummy); - } - - for ($j = 1; $j <= $nbmtmr; ++$j) { - $manyToManyItem = $this->buildMultiRelationsRelatedDummy(); - $manyToManyItem->name = 'RelatedManyToManyDummy'.$j.$i; - $this->manager->persist($manyToManyItem); - - $dummy->addManyToManyRelation($manyToManyItem); - } - - for ($j = 1; $j <= $nbotmr; ++$j) { - $oneToManyItem = $this->buildMultiRelationsRelatedDummy(); - $oneToManyItem->name = 'RelatedOneToManyDummy'.$j.$i; - $oneToManyItem->setOneToManyRelation($dummy); - $this->manager->persist($oneToManyItem); - - $dummy->addOneToManyRelation($oneToManyItem); - } - - $nested = new ArrayCollection(); - for ($j = 1; $j <= $nber; ++$j) { - $embeddedItem = $this->buildMultiRelationsNested(); - $embeddedItem->name = 'NestedDummy'.$j; - $nested->add($embeddedItem); - } - $dummy->setNestedCollection($nested); - - $nestedPaginated = new ArrayCollection(); - for ($j = 1; $j <= $nber; ++$j) { - $embeddedItem = $this->buildMultiRelationsNestedPaginated(); - $embeddedItem->name = 'NestedPaginatedDummy'.$j; - $nestedPaginated->add($embeddedItem); - } - $dummy->setNestedPaginatedCollection($nestedPaginated); - - $this->manager->persist($relatedDummy); - $this->manager->persist($resolveDummy); - $this->manager->persist($dummy); - } - $this->manager->flush(); - } - - /** - * @Given there are tree dummies - */ - public function thereAreTreeDummies(): void - { - $parentDummy = new TreeDummy(); - $this->manager->persist($parentDummy); - - $childDummy = new TreeDummy(); - $childDummy->setParent($parentDummy); - - $this->manager->persist($childDummy); - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with dummyDate - * @Given there is :nb dummy object with dummyDate - */ - public function thereAreDummyObjectsWithDummyDate(int $nb): void - { - $descriptions = ['Smart dummy.', 'Not so smart dummy.']; - - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setDescription($descriptions[($i - 1) % 2]); - - // Last Dummy has a null date - if ($nb !== $i) { - $dummy->setDummyDate($date); - } - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with dummyDate and dummyBoolean :bool - */ - public function thereAreDummyObjectsWithDummyDateAndDummyBoolean(int $nb, string $bool): void - { - $descriptions = ['Smart dummy.', 'Not so smart dummy.']; - - if (\in_array($bool, ['true', '1', 1], true)) { - $bool = true; - } elseif (\in_array($bool, ['false', '0', 0], true)) { - $bool = false; - } else { - $expected = ['true', 'false', '1', '0']; - throw new \InvalidArgumentException(\sprintf('Invalid boolean value for "%s" property, expected one of ( "%s" )', $bool, implode('" | "', $expected))); - } - - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setDescription($descriptions[($i - 1) % 2]); - $dummy->setDummyBoolean($bool); - - // Last Dummy has a null date - if ($nb !== $i) { - $dummy->setDummyDate($date); - } - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with dummyDate and relatedDummy - */ - public function thereAreDummyObjectsWithDummyDateAndRelatedDummy(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #'.$i); - $relatedDummy->setDummyDate($date); - - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setRelatedDummy($relatedDummy); - // Last Dummy has a null date - if ($nb !== $i) { - $dummy->setDummyDate($date); - } - - $this->manager->persist($relatedDummy); - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb embedded dummy objects with dummyDate and embeddedDummy - */ - public function thereAreDummyObjectsWithDummyDateAndEmbeddedDummy(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $embeddableDummy = $this->buildEmbeddableDummy(); - $embeddableDummy->setDummyName('Embeddable #'.$i); - $embeddableDummy->setDummyDate($date); - - $dummy = $this->buildEmbeddedDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setEmbeddedDummy($embeddableDummy); - // Last Dummy has a null date - if ($nb !== $i) { - $dummy->setDummyDate($date); - } - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb convertedDate objects - */ - public function thereAreconvertedDateObjectsWith(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $convertedDate = $this->buildConvertedDate(); - $convertedDate->nameConverted = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $this->manager->persist($convertedDate); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb convertedString objects - */ - public function thereAreconvertedStringObjectsWith(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $convertedString = $this->buildConvertedString(); - $convertedString->nameConverted = ($i % 2) ? "name#$i" : null; - - $this->manager->persist($convertedString); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb convertedBoolean objects - */ - public function thereAreconvertedBooleanObjectsWith(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $convertedBoolean = $this->buildConvertedBoolean(); - $convertedBoolean->nameConverted = (bool) ($i % 2); - - $this->manager->persist($convertedBoolean); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb convertedInteger objects - */ - public function thereAreconvertedIntegerObjectsWith(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $convertedInteger = $this->buildConvertedInteger(); - $convertedInteger->nameConverted = $i; - - $this->manager->persist($convertedInteger); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with dummyPrice - */ - public function thereAreDummyObjectsWithDummyPrice(int $nb): void - { - $descriptions = ['Smart dummy.', 'Not so smart dummy.']; - $prices = ['9.99', '12.99', '15.99', '19.99']; - - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setDescription($descriptions[($i - 1) % 2]); - $dummy->setDummyPrice($prices[($i - 1) % 4]); - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy objects with dummyBoolean :bool - * @Given there is :nb dummy object with dummyBoolean :bool - */ - public function thereAreDummyObjectsWithDummyBoolean(int $nb, string $bool): void - { - if (\in_array($bool, ['true', '1', 1], true)) { - $bool = true; - } elseif (\in_array($bool, ['false', '0', 0], true)) { - $bool = false; - } else { - $expected = ['true', 'false', '1', '0']; - throw new \InvalidArgumentException(\sprintf('Invalid boolean value for "%s" property, expected one of ( "%s" )', $bool, implode('" | "', $expected))); - } - $descriptions = ['Smart dummy.', 'Not so smart dummy.']; - - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildDummy(); - $dummy->setName('Dummy #'.$i); - $dummy->setAlias('Alias #'.($nb - $i)); - $dummy->setDescription($descriptions[($i - 1) % 2]); - $dummy->setDummyBoolean($bool); - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb embedded dummy objects with embeddedDummy.dummyBoolean :bool - */ - public function thereAreDummyObjectsWithEmbeddedDummyBoolean(int $nb, string $bool): void - { - if (\in_array($bool, ['true', '1', 1], true)) { - $bool = true; - } elseif (\in_array($bool, ['false', '0', 0], true)) { - $bool = false; - } else { - $expected = ['true', 'false', '1', '0']; - throw new \InvalidArgumentException(\sprintf('Invalid boolean value for "%s" property, expected one of ( "%s" )', $bool, implode('" | "', $expected))); - } - - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildEmbeddedDummy(); - $dummy->setName('Embedded Dummy #'.$i); - $embeddableDummy = $this->buildEmbeddableDummy(); - $embeddableDummy->setDummyName('Embedded Dummy #'.$i); - $embeddableDummy->setDummyBoolean($bool); - $dummy->setEmbeddedDummy($embeddableDummy); - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb embedded dummy objects with relatedDummy.embeddedDummy.dummyBoolean :bool - */ - public function thereAreDummyObjectsWithRelationEmbeddedDummyBoolean(int $nb, string $bool): void - { - if (\in_array($bool, ['true', '1', 1], true)) { - $bool = true; - } elseif (\in_array($bool, ['false', '0', 0], true)) { - $bool = false; - } else { - $expected = ['true', 'false', '1', '0']; - throw new \InvalidArgumentException(\sprintf('Invalid boolean value for "%s" property, expected one of ( "%s" )', $bool, implode('" | "', $expected))); - } - - for ($i = 1; $i <= $nb; ++$i) { - $dummy = $this->buildEmbeddedDummy(); - $dummy->setName('Embedded Dummy #'.$i); - $embeddableDummy = $this->buildEmbeddableDummy(); - $embeddableDummy->setDummyName('Embedded Dummy #'.$i); - $embeddableDummy->setDummyBoolean($bool); - - $relationDummy = $this->buildRelatedDummy(); - $relationDummy->setEmbeddedDummy($embeddableDummy); - - $dummy->setRelatedDummy($relationDummy); - - $this->manager->persist($relationDummy); - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb SecuredDummy objects - */ - public function thereAreSecuredDummyObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $securedDummy = $this->buildSecuredDummy(); - $securedDummy->setTitle("#$i"); - $securedDummy->setDescription("Hello #$i"); - $securedDummy->setOwner('notexist'); - - $this->manager->persist($securedDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb SecuredDummy objects owned by :ownedby with related dummies - */ - public function thereAreSecuredDummyObjectsOwnedByWithRelatedDummies(int $nb, string $ownedby): void - { - for ($i = 1; $i <= $nb; ++$i) { - $securedDummy = $this->buildSecuredDummy(); - $securedDummy->setTitle("#$i"); - $securedDummy->setDescription("Hello #$i"); - $securedDummy->setOwner($ownedby); - - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy'); - $this->manager->persist($relatedDummy); - - $relatedSecuredDummy = $this->buildRelatedSecureDummy(); - $this->manager->persist($relatedSecuredDummy); - - $publicRelatedSecuredDummy = $this->buildRelatedSecureDummy(); - $this->manager->persist($publicRelatedSecuredDummy); - - $relatedLinkedDummy = $this->buildRelatedLinkedDummy(); - $this->manager->persist($relatedLinkedDummy); - - $securedDummy->addRelatedDummy($relatedDummy); - $securedDummy->setRelatedDummy($relatedDummy); - $securedDummy->addRelatedSecuredDummy($relatedSecuredDummy); - $securedDummy->setRelatedSecuredDummy($relatedSecuredDummy); - $securedDummy->addPublicRelatedSecuredDummy($publicRelatedSecuredDummy); - $securedDummy->setPublicRelatedSecuredDummy($publicRelatedSecuredDummy); - $relatedLinkedDummy->setSecuredDummy($securedDummy); - - $this->manager->persist($securedDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there is a RelationEmbedder object - */ - public function thereIsARelationEmbedderObject(): void - { - $relationEmbedder = $this->buildRelationEmbedder(); - - $this->manager->persist($relationEmbedder); - $this->manager->flush(); - } - - /** - * @Given there is a Dummy Object mapped by UUID - */ - public function thereIsADummyObjectMappedByUUID(): void - { - $dummy = new UuidIdentifierDummy(); - $dummy->setName('My Dummy'); - $dummy->setUuid('41B29566-144B-11E6-A148-3E1D05DEFE78'); - - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there are Composite identifier objects - */ - public function thereIsACompositeIdentifierObject(): void - { - $item = $this->buildCompositeItem(); - $item->setField1('foobar'); - $this->manager->persist($item); - $this->manager->flush(); - - for ($i = 0; $i < 4; ++$i) { - $label = $this->buildCompositeLabel(); - $label->setValue('foo-'.$i); - - $rel = $this->buildCompositeRelation(); - $rel->setCompositeLabel($label); - $rel->setCompositeItem($item); - $rel->setValue('somefoobardummy'); - - $this->manager->persist($label); - // since doctrine 2.6 we need existing identifiers on relations - $this->manager->flush(); - $this->manager->persist($rel); - } - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there are composite primitive identifiers objects - */ - public function thereAreCompositePrimitiveIdentifiersObjects(): void - { - $foo = $this->buildCompositePrimitiveItem('Foo', 2016); - $foo->setDescription('This is foo.'); - $this->manager->persist($foo); - - $bar = $this->buildCompositePrimitiveItem('Bar', 2017); - $bar->setDescription('This is bar.'); - $this->manager->persist($bar); - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is a FileConfigDummy object - */ - public function thereIsAFileConfigDummyObject(): void - { - $fileConfigDummy = $this->buildFileConfigDummy(); - $fileConfigDummy->setName('ConfigDummy'); - $fileConfigDummy->setFoo('Foo'); - - $this->manager->persist($fileConfigDummy); - $this->manager->flush(); - } - - /** - * @Given there is a DummyCar entity with related colors - */ - public function thereIsAFooEntityWithRelatedBars(): void - { - $foo = $this->buildDummyCar(); - $foo->setName('mustli'); - $foo->setCanSell(true); - $foo->setAvailableAt(new \DateTime()); - $this->manager->persist($foo); - $this->manager->flush(); - - if (\is_object($foo->getId())) { - $this->manager->persist($foo->getId()); - $this->manager->flush(); - } - - $bar1 = $this->buildDummyCarColor(); - $bar1->setProp('red'); - $bar1->setCar($foo); - $this->manager->persist($bar1); - $this->manager->flush(); - - $bar2 = $this->buildDummyCarColor(); - $bar2->setProp('blue'); - $bar2->setCar($foo); - $this->manager->persist($bar2); - $this->manager->flush(); - - $foo->setColors(new ArrayCollection([$bar1, $bar2])); - $this->manager->persist($foo); - $this->manager->flush(); - } - - /** - * @Given there is a dummy travel - */ - public function thereIsADummyTravel(): void - { - $car = $this->buildDummyCar(); - $car->setName('model x'); - $car->setCanSell(true); - $car->setAvailableAt(new \DateTime()); - $this->manager->persist($car); - - $passenger = $this->buildDummyPassenger(); - $passenger->nickname = 'Tom'; - $this->manager->persist($passenger); - - $travel = $this->buildDummyTravel(); - $travel->car = $car; - $travel->passenger = $passenger; - $travel->confirmed = true; - $this->manager->persist($travel); - - $this->manager->flush(); - } - - /** - * @Given there is a RelatedDummy with :nb friends - */ - public function thereIsARelatedDummyWithFriends(int $nb): void - { - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy with friends'); - $this->manager->persist($relatedDummy); - $this->manager->flush(); - - for ($i = 1; $i <= $nb; ++$i) { - $friend = $this->buildDummyFriend(); - $friend->setName('Friend-'.$i); - - $this->manager->persist($friend); - // since doctrine 2.6 we need existing identifiers on relations - // See https://github.com/doctrine/doctrine2/pull/6701 - $this->manager->flush(); - - $relation = $this->buildRelatedToDummyFriend(); - $relation->setName('Relation-'.$i); - $relation->setDummyFriend($friend); - $relation->setRelatedDummy($relatedDummy); - - $relatedDummy->addRelatedToDummyFriend($relation); - - $this->manager->persist($relation); - } - - $relatedDummy2 = $this->buildRelatedDummy(); - $relatedDummy2->setName('RelatedDummy without friends'); - $this->manager->persist($relatedDummy2); - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is an answer :answer to the question :question - */ - public function thereIsAnAnswerToTheQuestion(string $a, string $q): void - { - $answer = $this->buildAnswer(); - $answer->setContent($a); - - $question = $this->buildQuestion(); - $question->setContent($q); - $question->setAnswer($answer); - $answer->addRelatedQuestion($question); - - $this->manager->persist($answer); - $this->manager->persist($question); - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is a UrlEncodedId resource - */ - public function thereIsAUrlEncodedIdResource(): void - { - $urlEncodedIdResource = ($this->isOrm() ? new UrlEncodedId() : new UrlEncodedIdDocument()); - $this->manager->persist($urlEncodedIdResource); - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is a Program - */ - public function thereIsAProgram(): void - { - $this->thereArePrograms(1); - } - - /** - * @Given there are :nb Programs - */ - public function thereArePrograms(int $nb): void - { - $author = $this->doctrine->getRepository($this->isOrm() ? User::class : UserDocument::class)->find(1); - if (null === $author) { - $author = $this->isOrm() ? new User() : new UserDocument(); - $author->setEmail('john.doe@example.com'); - $author->setFullname('John DOE'); - $author->setPlainPassword('p4$$w0rd'); - - $this->manager->persist($author); - $this->manager->flush(); - } - - if ($this->isOrm()) { - $count = $this->doctrine->getRepository(Program::class)->count(['author' => $author]); - } else { - /** @var Builder */ - $qb = $this->doctrine->getRepository(ProgramDocument::class) - ->createQueryBuilder('f'); - $count = $qb->field('author')->equals($author) - ->count()->getQuery()->execute(); - } - - for ($i = (int) $count + 1; $i <= $nb; ++$i) { - $program = $this->isOrm() ? new Program() : new ProgramDocument(); - $program->name = "Lorem ipsum $i"; - $program->date = new \DateTimeImmutable(\sprintf('2015-03-0%dT10:00:00+00:00', $i)); - $program->author = $author; - - $this->manager->persist($program); - } - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is a Comment - */ - public function thereIsAComment(): void - { - $this->thereAreComments(1); - } - - /** - * @Given there are :nb Comments - */ - public function thereAreComments(int $nb): void - { - $author = $this->doctrine->getRepository($this->isOrm() ? User::class : UserDocument::class)->find(1); - if (null === $author) { - $author = $this->isOrm() ? new User() : new UserDocument(); - $author->setEmail('john.doe@example.com'); - $author->setFullname('John DOE'); - $author->setPlainPassword('p4$$w0rd'); - - $this->manager->persist($author); - $this->manager->flush(); - } - - if ($this->isOrm()) { - $count = $this->doctrine->getRepository(Comment::class)->count(['author' => $author]); - } else { - /** @var Builder $qb */ - $qb = $this->doctrine->getRepository(CommentDocument::class) - ->createQueryBuilder('f'); - - $count = $qb->field('author')->equals($author) - ->count()->getQuery()->execute(); - } - - for ($i = (int) $count + 1; $i <= $nb; ++$i) { - $comment = $this->isOrm() ? new Comment() : new CommentDocument(); - $comment->comment = "Lorem ipsum dolor sit amet $i"; - $comment->date = new \DateTimeImmutable(\sprintf('2015-03-0%dT10:00:00+00:00', $i)); - $comment->author = $author; - - $this->manager->persist($comment); - } - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Then the password :password for user :user should be hashed - */ - public function thePasswordForUserShouldBeHashed(string $password, string $user): void - { - $user = $this->doctrine->getRepository($this->isOrm() ? User::class : UserDocument::class)->find($user); - if (!$this->passwordHasher->isPasswordValid($user, $password)) { - throw new \Exception('User password mismatch'); - } - } - - /** - * @Given I have a product with offers - */ - public function createProductWithOffers(): void - { - $offer = $this->buildDummyOffer(); - $offer->setId(1); - $offer->setValue(2); - - $aggregate = $this->buildDummyAggregateOffer(); - $aggregate->setValue(1); - $aggregate->addOffer($offer); - - $product = $this->buildDummyProduct(); - $product->setId(2); - $product->setName('Dummy product'); - $product->addOffer($aggregate); - - $relatedProduct = $this->buildDummyProduct(); - $relatedProduct->setName('Dummy related product'); - $relatedProduct->setId(1); - $relatedProduct->setParent($product); - - $product->addRelatedProduct($relatedProduct); - - $this->manager->persist($relatedProduct); - $this->manager->persist($product); - $this->manager->flush(); - } - - /** - * @Given there are people having pets - */ - public function createPeopleWithPets(): void - { - $personToPet = $this->buildPersonToPet(); - - $person = $this->buildPerson(); - $person->name = 'foo'; - - $pet = $this->buildPet(); - $pet->name = 'bar'; - - $personToPet->person = $person; - $personToPet->pet = $pet; - - $this->manager->persist($person); - $this->manager->persist($pet); - // since doctrine 2.6 we need existing identifiers on relations - $this->manager->flush(); - $this->manager->persist($personToPet); - - $person->pets->add($personToPet); - $this->manager->persist($person); - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummydate objects with dummyDate - * @Given there is :nb dummydate object with dummyDate - */ - public function thereAreDummyDateObjectsWithDummyDate(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $dummy = $this->buildDummyDate(); - $dummy->dummyDate = $date; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummydate objects with nullable dateIncludeNullAfter - * @Given there is :nb dummydate object with nullable dateIncludeNullAfter - */ - public function thereAreDummyDateObjectsWithNullableDateIncludeNullAfter(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $dummy = $this->buildDummyDate(); - $dummy->dummyDate = $date; - $dummy->dateIncludeNullAfter = 0 === $i % 3 ? null : $date; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummydate objects with nullable dateIncludeNullBefore - * @Given there is :nb dummydate object with nullable dateIncludeNullBefore - */ - public function thereAreDummyDateObjectsWithNullableDateIncludeNullBefore(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $dummy = $this->buildDummyDate(); - $dummy->dummyDate = $date; - $dummy->dateIncludeNullBefore = 0 === $i % 3 ? null : $date; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummydate objects with nullable dateIncludeNullBeforeAndAfter - * @Given there is :nb dummydate object with nullable dateIncludeNullBeforeAndAfter - */ - public function thereAreDummyDateObjectsWithNullableDateIncludeNullBeforeAndAfter(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - - $dummy = $this->buildDummyDate(); - $dummy->dummyDate = $date; - $dummy->dateIncludeNullBeforeAndAfter = 0 === $i % 3 ? null : $date; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummyimmutabledate objects with dummyDate - */ - public function thereAreDummyImmutableDateObjectsWithDummyDate(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $date = new \DateTimeImmutable(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); - $dummy = $this->buildDummyImmutableDate(); - $dummy->dummyDate = $date; - - $this->manager->persist($dummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy with different GraphQL serialization groups objects - */ - public function thereAreDummyWithDifferentGraphQlSerializationGroupsObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $dummyDifferentGraphQlSerializationGroup = $this->buildDummyDifferentGraphQlSerializationGroup(); - $dummyDifferentGraphQlSerializationGroup->setName('Name #'.$i); - $dummyDifferentGraphQlSerializationGroup->setTitle('Title #'.$i); - $this->manager->persist($dummyDifferentGraphQlSerializationGroup); - } - - $this->manager->flush(); - } - - /** - * @Given there is a ramsey identified resource with uuid :uuid - * - * @param non-empty-string $uuid - */ - public function thereIsARamseyIdentifiedResource(string $uuid): void - { - $dummy = new RamseyUuidDummy(Uuid::fromString($uuid)); - - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there is a Symfony dummy identified resource with uuid :uuid - */ - public function thereIsASymfonyDummyIdentifiedResource(string $uuid): void - { - $dummy = new SymfonyUuidDummy(SymfonyUuid::fromString($uuid)); - - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there is a dummy object with a fourth level relation - */ - public function thereIsADummyObjectWithAFourthLevelRelation(): void - { - $fourthLevel = $this->buildFourthLevel(); - $fourthLevel->setLevel(4); - $this->manager->persist($fourthLevel); - - $thirdLevel = $this->buildThirdLevel(); - $thirdLevel->setLevel(3); - $thirdLevel->setFourthLevel($fourthLevel); - $this->manager->persist($thirdLevel); - - $namedRelatedDummy = $this->buildRelatedDummy(); - $namedRelatedDummy->setName('Hello'); - $namedRelatedDummy->setThirdLevel($thirdLevel); - $this->manager->persist($namedRelatedDummy); - - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setThirdLevel($thirdLevel); - $this->manager->persist($relatedDummy); - - $dummy = $this->buildDummy(); - $dummy->setName('Dummy with relations'); - $dummy->setRelatedDummy($namedRelatedDummy); - $dummy->addRelatedDummy($namedRelatedDummy); - $dummy->addRelatedDummy($relatedDummy); - $this->manager->persist($dummy); - - $this->manager->flush(); - } - - /** - * @Given there is a RelatedOwnedDummy object with OneToOne relation - */ - public function thereIsARelatedOwnedDummy(): void - { - $relatedOwnedDummy = $this->buildRelatedOwnedDummy(); - $this->manager->persist($relatedOwnedDummy); - - $dummy = $this->buildDummy(); - $dummy->setName('plop'); - $dummy->setRelatedOwnedDummy($relatedOwnedDummy); - $this->manager->persist($dummy); - - $this->manager->flush(); - } - - /** - * @Given there is a RelatedOwningDummy object with OneToOne relation - */ - public function thereIsARelatedOwningDummy(): void - { - $dummy = $this->buildDummy(); - $dummy->setName('plop'); - $this->manager->persist($dummy); - - $relatedOwningDummy = $this->buildRelatedOwningDummy(); - $relatedOwningDummy->setOwnedDummy($dummy); - $this->manager->persist($relatedOwningDummy); - - $this->manager->flush(); - } - - /** - * @Given there is a person named :name greeting with a :message message - */ - public function thereIsAPersonWithAGreeting(string $name, string $message): void - { - $person = $this->buildPerson(); - $person->name = $name; - - $greeting = $this->buildGreeting(); - $greeting->message = $message; - $greeting->sender = $person; - - $this->manager->persist($person); - $this->manager->persist($greeting); - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is a max depth dummy with :level level of descendants - */ - public function thereIsAMaxDepthDummyWithLevelOfDescendants(int $level): void - { - $maxDepthDummy = $this->buildMaxDepthDummy(); - $maxDepthDummy->name = "level $level"; - $this->manager->persist($maxDepthDummy); - - for ($i = 1; $i <= $level; ++$i) { - $maxDepthDummy = $maxDepthDummy->child = $this->buildMaxDepthDummy(); - $maxDepthDummy->name = 'level '.($i + 1); - $this->manager->persist($maxDepthDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there is a DummyDtoCustom - */ - public function thereIsADummyDtoCustom(): void - { - $this->thereAreNbDummyDtoCustom(1); - } - - /** - * @Given there are :nb DummyDtoCustom - */ - public function thereAreNbDummyDtoCustom($nb): void - { - for ($i = 0; $i < $nb; ++$i) { - $dto = $this->isOrm() ? new DummyDtoCustom() : new DummyDtoCustomDocument(); - $dto->lorem = 'test'; - $dto->ipsum = (string) ($i + 1); - $this->manager->persist($dto); - } - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there is an order with same customer and recipient - */ - public function thereIsAnOrderWithSameCustomerAndRecipient(): void - { - $customer = $this->isOrm() ? new Customer() : new CustomerDocument(); - $customer->name = 'customer_name'; - - $address1 = $this->isOrm() ? new Address() : new AddressDocument(); - $address1->name = 'foo'; - $address2 = $this->isOrm() ? new Address() : new AddressDocument(); - $address2->name = 'bar'; - - $order = $this->isOrm() ? new Order() : new OrderDocument(); - $order->recipient = $customer; - $order->customer = $customer; - - $customer->addresses->add($address1); - $customer->addresses->add($address2); - - $this->manager->persist($address1); - $this->manager->persist($address2); - $this->manager->persist($customer); - $this->manager->persist($order); - - $this->manager->flush(); - $this->manager->clear(); - } - - /** - * @Given there are :nb sites with internal owner - */ - public function thereAreSitesWithInternalOwner(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $internalUser = new InternalUser(); - $internalUser->setFirstname('Internal'); - $internalUser->setLastname('User'); - $internalUser->setEmail('john.doe@example.com'); - $internalUser->setInternalId('INT'); - $site = new Site(); - $site->setTitle('title'); - $site->setDescription('description'); - $site->setOwner($internalUser); - $this->manager->persist($site); - } - $this->manager->flush(); - } - - /** - * @Given there are :nb sites with external owner - */ - public function thereAreSitesWithExternalOwner(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $externalUser = new ExternalUser(); - $externalUser->setFirstname('External'); - $externalUser->setLastname('User'); - $externalUser->setEmail('john.doe@example.com'); - $externalUser->setExternalId('EXT'); - $site = new Site(); - $site->setTitle('title'); - $site->setDescription('description'); - $site->setOwner($externalUser); - $this->manager->persist($site); - } - $this->manager->flush(); - } - - /** - * @Given there is the following taxon: - */ - public function thereIsTheFollowingTaxon(PyStringNode $dataNode): void - { - $data = json_decode((string) $dataNode, true, 512, \JSON_THROW_ON_ERROR); - - $taxon = $this->isOrm() ? new Taxon() : new TaxonDocument(); - $taxon->setCode($data['code']); - $this->manager->persist($taxon); - - $this->manager->flush(); - } - - /** - * @Given there is the following product: - */ - public function thereIsTheFollowingProduct(PyStringNode $dataNode): void - { - $data = json_decode((string) $dataNode, true, 512, \JSON_THROW_ON_ERROR); - - $product = $this->isOrm() ? new Product() : new ProductDocument(); - $product->setCode($data['code']); - if (isset($data['mainTaxon'])) { - $mainTaxonCode = str_replace('/taxa/', '', $data['mainTaxon']); - $mainTaxon = $this->manager->getRepository($this->isOrm() ? Taxon::class : TaxonDocument::class)->findOneBy([ - 'code' => $mainTaxonCode, - ]); - $product->setMainTaxon($mainTaxon); - } - $this->manager->persist($product); - - $this->manager->flush(); - } - - /** - * @Given there are :nb convertedOwner objects with convertedRelated - */ - public function thereAreConvertedOwnerObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $related = $this->buildConvertedRelated(); - $related->nameConverted = 'Converted '.$i; - - $owner = $this->buildConvertedOwner(); - $owner->nameConverted = $related; - - $this->manager->persist($related); - $this->manager->persist($owner); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb dummy mercure objects - */ - public function thereAreDummyMercureObjects(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy #'.$i); - - $dummyMercure = $this->buildDummyMercure(); - $dummyMercure->name = "Dummy Mercure #$i"; - $dummyMercure->description = 'Description'; - $dummyMercure->relatedDummy = $relatedDummy; - - $this->manager->persist($relatedDummy); - $this->manager->persist($dummyMercure); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb iriOnlyDummies - */ - public function thereAreIriOnlyDummies(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $iriOnlyDummy = $this->buildIriOnlyDummy(); - $iriOnlyDummy->setFoo('bar'.$nb); - $this->manager->persist($iriOnlyDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are propertyCollectionIriOnly with relations - */ - public function thereAreResourcesWithPropertyUriTemplates(): void - { - $propertyCollectionIriOnlyRelation1 = $this->isOrm() ? new PropertyCollectionIriOnlyRelation() : new PropertyCollectionIriOnlyRelationDocument(); - $propertyCollectionIriOnlyRelation1->name = 'asb1'; - - $propertyCollectionIriOnlyRelation2 = $this->isOrm() ? new PropertyCollectionIriOnlyRelation() : new PropertyCollectionIriOnlyRelationDocument(); - $propertyCollectionIriOnlyRelation2->name = 'asb2'; - - $propertyToOneRelation = $this->isOrm() ? new PropertyUriTemplateOneToOneRelation() : new PropertyUriTemplateOneToOneRelationDocument(); - $propertyToOneRelation->name = 'xarguš'; - - $propertyCollectionIriOnly = $this->isOrm() ? new PropertyCollectionIriOnly() : new PropertyCollectionIriOnlyDocument(); - $propertyCollectionIriOnly->addPropertyCollectionIriOnlyRelation($propertyCollectionIriOnlyRelation1); - $propertyCollectionIriOnly->addPropertyCollectionIriOnlyRelation($propertyCollectionIriOnlyRelation2); - $propertyCollectionIriOnly->setToOneRelation($propertyToOneRelation); - - $this->manager->persist($propertyCollectionIriOnly); - $this->manager->persist($propertyCollectionIriOnlyRelation1); - $this->manager->persist($propertyCollectionIriOnlyRelation2); - $this->manager->persist($propertyToOneRelation); - $this->manager->flush(); - } - - /** - * @Given there are :nb absoluteUrlDummy objects with a related absoluteUrlRelationDummy - */ - public function thereAreAbsoluteUrlDummies(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $absoluteUrlRelationDummy = $this->buildAbsoluteUrlRelationDummy(); - $absoluteUrlDummy = $this->buildAbsoluteUrlDummy(); - $absoluteUrlDummy->absoluteUrlRelationDummy = $absoluteUrlRelationDummy; - - $this->manager->persist($absoluteUrlRelationDummy); - $this->manager->persist($absoluteUrlDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there are :nb networkPathDummy objects with a related networkPathRelationDummy - */ - public function thereAreNetworkPathDummies(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $networkPathRelationDummy = $this->buildNetworkPathRelationDummy(); - $networkPathDummy = $this->buildNetworkPathDummy(); - $networkPathDummy->networkPathRelationDummy = $networkPathRelationDummy; - - $this->manager->persist($networkPathRelationDummy); - $this->manager->persist($networkPathDummy); - } - - $this->manager->flush(); - } - - /** - * @Given there is an InitializeInput object with id :id - */ - public function thereIsAnInitializeInput(int $id): void - { - $initializeInput = $this->buildInitializeInput(); - $initializeInput->id = $id; - $initializeInput->manager = 'Orwell'; - $initializeInput->name = '1984'; - - $this->manager->persist($initializeInput); - $this->manager->flush(); - } - - /** - * @Given there is a PatchDummyRelation - */ - public function thereIsAPatchDummyRelation(): void - { - $dummy = $this->buildPatchDummyRelation(); - $related = $this->buildRelatedDummy(); - $this->manager->persist($related); - $this->manager->flush(); - $dummy->setRelated($related); - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there is a book - */ - public function thereIsABook(): void - { - $book = $this->buildBook(); - $book->name = '1984'; - $book->isbn = '9780451524935'; - $this->manager->persist($book); - $this->manager->flush(); - } - - /** - * @Given there is a custom multiple identifier dummy - */ - public function thereIsACustomMultipleIdentifierDummy(): void - { - $dummy = $this->buildCustomMultipleIdentifierDummy(); - $dummy->setName('Orwell'); - $dummy->setFirstId(1); - $dummy->setSecondId(2); - - $this->manager->persist($dummy); - $this->manager->flush(); - } - - /** - * @Given there is a payment - */ - public function thereIsAPayment(): void - { - $this->manager->persist($this->buildPayment('123.45')); - $this->manager->flush(); - } - - /** - * @Given there are :nb separated entities - */ - public function thereAreSeparatedEntities(int $nb): void - { - for ($i = 1; $i <= $nb; ++$i) { - $entity = $this->buildSeparatedEntity(); - $entity->value = (string) $i; - $this->manager->persist($entity); - } - $this->manager->flush(); - } - - /** - * @Given there is a video game with music groups - */ - public function thereAreVideoGamesWithMusicGroups(): void - { - $sum41 = $this->buildMusicGroup(); - $sum41->name = 'Sum 41'; - $this->manager->persist($sum41); - $franz = $this->buildMusicGroup(); - $franz->name = 'Franz Ferdinand'; - $this->manager->persist($franz); - - $videoGame = $this->buildVideoGame(); - $videoGame->name = 'Guitar Hero'; - $videoGame->addMusicGroup($sum41); - $videoGame->addMusicGroup($franz); - $this->manager->persist($videoGame); - $this->manager->flush(); - } - - /** - * @Given there is a relationMultiple object - */ - public function thereIsARelationMultipleObject(): void - { - $first = $this->buildDummy(); - $first->setId(1); - $first->setName('foo'); - $second = $this->buildDummy(); - $second->setId(2); - $second->setName('bar'); - - $relationMultiple = (new RelationMultiple()); - $relationMultiple->first = $first; - $relationMultiple->second = $second; - - $this->manager->persist($first); - $this->manager->persist($second); - $this->manager->persist($relationMultiple); - - $this->manager->flush(); - } - - /** - * @Given there is a dummy object with many multiple relation - */ - public function thereIsADummyObjectWithManyMultipleRelation(): void - { - $first = $this->buildDummy(); - $first->setId(1); - $first->setName('foo'); - $second = $this->buildDummy(); - $second->setId(2); - $second->setName('bar'); - $third = $this->buildDummy(); - $third->setId(3); - $third->setName('foobar'); - - $relationMultiple1 = (new RelationMultiple()); - $relationMultiple1->first = $first; - $relationMultiple1->second = $second; - - $relationMultiple2 = (new RelationMultiple()); - $relationMultiple2->first = $first; - $relationMultiple2->second = $third; - - $this->manager->persist($first); - $this->manager->persist($second); - $this->manager->persist($third); - $this->manager->persist($relationMultiple1); - $this->manager->persist($relationMultiple2); - - $this->manager->flush(); - } - - /** - * @Given there is a resource using entityClass with a DateTime attribute - */ - public function thereIsAResourceUsingEntityClassAndDateTime(): void - { - $entity = new EntityClassWithDateTime(); - $entity->setStart(new \DateTime()); - $this->manager->persist($entity); - $this->manager->flush(); - } - - /** - * @Given there is a dummy entity with a sub entity with id :strId and name :name - */ - public function thereIsADummyWithSubEntity(string $strId, string $name): void - { - $subEntity = new DummySubEntity($strId, $name); - $mainEntity = new DummyWithSubEntity(); - $mainEntity->setSubEntity($subEntity); - $mainEntity->setName('main'); - $this->manager->persist($subEntity); - $this->manager->persist($mainEntity); - $this->manager->flush(); - } - - /** - * @Given there is a group object with uuid :uuid and :nbUsers users - */ - public function thereIsAGroupWithUuidAndNUsers(string $uuid, int $nbUsers): void - { - $group = new Group(); - $group->setUuid(SymfonyUuid::fromString($uuid)); - - $this->manager->persist($group); - - for ($i = 0; $i < $nbUsers; ++$i) { - $user = new \ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5735\Issue5735User(); - $user->addGroup($group); - $this->manager->persist($user); - } - - // add another user not in this group - $user = new \ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5735\Issue5735User(); - $this->manager->persist($user); - - $this->manager->flush(); - } - - /** - * @Given there are logs on an event - */ - public function thereAreLogsOnAnEvent(): void - { - $entity = new Event(); - $entity->logs = new ArrayCollection([new ItemLog(), new ItemLog()]); - $entity->uuid = Uuid::fromString('03af3507-271e-4cca-8eee-6244fb06e95b'); - $this->manager->persist($entity); - foreach ($entity->logs as $log) { - $log->item = $entity; - $this->manager->persist($log); - } - - $this->manager->flush(); - } - - /** - * @Given there are a few link handled dummies - */ - public function thereAreAFewLinkHandledDummies(): void - { - $this->manager->persist($this->buildLinkHandledDummy('foo')); - $this->manager->persist($this->buildLinkHandledDummy('bar')); - $this->manager->persist($this->buildLinkHandledDummy('baz')); - $this->manager->persist($this->buildLinkHandledDummy('foz')); - $this->manager->flush(); - } - - /** - * @Given there is a dummy entity with a mapped superclass - */ - public function thereIsADummyEntityWithAMappedSuperclass(): void - { - $entity = new DummyMappedSubclass(); - $this->manager->persist($entity); - $this->manager->flush(); - } - - /** - * @Given there are issue6039 users - */ - public function thereAreIssue6039Users(): void - { - $entity = new Issue6039EntityUser(); - $entity->name = 'test'; - $entity->bar = 'test'; - $this->manager->persist($entity); - $entity = new Issue6039EntityUser(); - $entity->name = 'test2'; - $entity->bar = 'test'; - $this->manager->persist($entity); - $this->manager->flush(); - } - - private function isOrm(): bool - { - return null !== $this->schemaTool; - } - - private function isOdm(): bool - { - return null !== $this->schemaManager; - } - - private function buildAnswer(): Answer|AnswerDocument - { - return $this->isOrm() ? new Answer() : new AnswerDocument(); - } - - private function buildCompositeItem(): CompositeItem|CompositeItemDocument - { - return $this->isOrm() ? new CompositeItem() : new CompositeItemDocument(); - } - - private function buildCompositeLabel(): CompositeLabel|CompositeLabelDocument - { - return $this->isOrm() ? new CompositeLabel() : new CompositeLabelDocument(); - } - - private function buildCompositePrimitiveItem(string $name, int $year): CompositePrimitiveItem|CompositePrimitiveItemDocument - { - return $this->isOrm() ? new CompositePrimitiveItem($name, $year) : new CompositePrimitiveItemDocument($name, $year); - } - - private function buildCompositeRelation(): CompositeRelation|CompositeRelationDocument - { - return $this->isOrm() ? new CompositeRelation() : new CompositeRelationDocument(); - } - - private function buildDummy(): Dummy|DummyDocument - { - return $this->isOrm() ? new Dummy() : new DummyDocument(); - } - - private function buildDummyTableInheritanceNotApiResourceChild(): DummyTableInheritanceNotApiResourceChild|DummyTableInheritanceNotApiResourceChildDocument - { - return $this->isOrm() ? new DummyTableInheritanceNotApiResourceChild() : new DummyTableInheritanceNotApiResourceChildDocument(); - } - - private function buildDummyAggregateOffer(): DummyAggregateOffer|DummyAggregateOfferDocument - { - return $this->isOrm() ? new DummyAggregateOffer() : new DummyAggregateOfferDocument(); - } - - private function buildDummyCar(): DummyCar|DummyCarDocument - { - return $this->isOrm() ? new DummyCar() : new DummyCarDocument(); - } - - private function buildDummyCarColor(): DummyCarColor|DummyCarColorDocument - { - return $this->isOrm() ? new DummyCarColor() : new DummyCarColorDocument(); - } - - private function buildDummyPassenger(): DummyPassenger|DummyPassengerDocument - { - return $this->isOrm() ? new DummyPassenger() : new DummyPassengerDocument(); - } - - private function buildDummyTravel(): DummyTravel|DummyTravelDocument - { - return $this->isOrm() ? new DummyTravel() : new DummyTravelDocument(); - } - - private function buildDummyDate(): DummyDate|DummyDateDocument - { - return $this->isOrm() ? new DummyDate() : new DummyDateDocument(); - } - - private function buildDummyImmutableDate(): DummyImmutableDate|DummyImmutableDateDocument - { - return $this->isOrm() ? new DummyImmutableDate() : new DummyImmutableDateDocument(); - } - - private function buildDummyDifferentGraphQlSerializationGroup(): DummyDifferentGraphQlSerializationGroup|DummyDifferentGraphQlSerializationGroupDocument - { - return $this->isOrm() ? new DummyDifferentGraphQlSerializationGroup() : new DummyDifferentGraphQlSerializationGroupDocument(); - } - - private function buildDummyDtoNoInput(): DummyDtoNoInput|DummyDtoNoInputDocument - { - return $this->isOrm() ? new DummyDtoNoInput() : new DummyDtoNoInputDocument(); - } - - private function buildDummyDtoNoOutput(): DummyDtoNoOutput|DummyDtoNoOutputDocument - { - return $this->isOrm() ? new DummyDtoNoOutput() : new DummyDtoNoOutputDocument(); - } - - private function buildDummyCustomQuery(): DummyCustomQuery|DummyCustomQueryDocument - { - return $this->isOrm() ? new DummyCustomQuery() : new DummyCustomQueryDocument(); - } - - private function buildDummyCustomMutation(): DummyCustomMutation|DummyCustomMutationDocument - { - return $this->isOrm() ? new DummyCustomMutation() : new DummyCustomMutationDocument(); - } - - private function buildDummyFriend(): DummyFriend|DummyFriendDocument - { - return $this->isOrm() ? new DummyFriend() : new DummyFriendDocument(); - } - - private function buildDummyGroup(): DummyGroup|DummyGroupDocument - { - return $this->isOrm() ? new DummyGroup() : new DummyGroupDocument(); - } - - private function buildDummyOffer(): DummyOffer|DummyOfferDocument - { - return $this->isOrm() ? new DummyOffer() : new DummyOfferDocument(); - } - - private function buildDummyProduct(): DummyProduct|DummyProductDocument - { - return $this->isOrm() ? new DummyProduct() : new DummyProductDocument(); - } - - private function buildDummyProperty(): DummyProperty|DummyPropertyDocument - { - return $this->isOrm() ? new DummyProperty() : new DummyPropertyDocument(); - } - - private function buildEmbeddableDummy(): EmbeddableDummy|EmbeddableDummyDocument - { - return $this->isOrm() ? new EmbeddableDummy() : new EmbeddableDummyDocument(); - } - - private function buildEmbeddedDummy(): EmbeddedDummy|EmbeddedDummyDocument - { - return $this->isOrm() ? new EmbeddedDummy() : new EmbeddedDummyDocument(); - } - - private function buildFileConfigDummy(): FileConfigDummy|FileConfigDummyDocument - { - return $this->isOrm() ? new FileConfigDummy() : new FileConfigDummyDocument(); - } - - private function buildFoo(): Foo|FooDocument - { - return $this->isOrm() ? new Foo() : new FooDocument(); - } - - private function buildFooDummy(): FooDummy|FooDummyDocument - { - return $this->isOrm() ? new FooDummy() : new FooDummyDocument(); - } - - private function buildFooEmbeddable(): FooEmbeddable|FooEmbeddableDocument - { - return $this->isOrm() ? new FooEmbeddable() : new FooEmbeddableDocument(); - } - - private function buildFourthLevel(): FourthLevel|FourthLevelDocument - { - return $this->isOrm() ? new FourthLevel() : new FourthLevelDocument(); - } - - private function buildGreeting(): Greeting|GreetingDocument - { - return $this->isOrm() ? new Greeting() : new GreetingDocument(); - } - - private function buildIriOnlyDummy(): IriOnlyDummy|IriOnlyDummyDocument - { - return $this->isOrm() ? new IriOnlyDummy() : new IriOnlyDummyDocument(); - } - - private function buildMaxDepthDummy(): MaxDepthDummy|MaxDepthDummyDocument - { - return $this->isOrm() ? new MaxDepthDummy() : new MaxDepthDummyDocument(); - } - - private function buildPerson(): Person|PersonDocument - { - return $this->isOrm() ? new Person() : new PersonDocument(); - } - - private function buildPersonToPet(): PersonToPet|PersonToPetDocument - { - return $this->isOrm() ? new PersonToPet() : new PersonToPetDocument(); - } - - private function buildPet(): Pet|PetDocument - { - return $this->isOrm() ? new Pet() : new PetDocument(); - } - - private function buildQuestion(): Question|QuestionDocument - { - return $this->isOrm() ? new Question() : new QuestionDocument(); - } - - private function buildRelatedDummy(): RelatedDummy|RelatedDummyDocument - { - return $this->isOrm() ? new RelatedDummy() : new RelatedDummyDocument(); - } - - private function buildRelatedOwnedDummy(): RelatedOwnedDummy|RelatedOwnedDummyDocument - { - return $this->isOrm() ? new RelatedOwnedDummy() : new RelatedOwnedDummyDocument(); - } - - private function buildRelatedOwningDummy(): RelatedOwningDummy|RelatedOwningDummyDocument - { - return $this->isOrm() ? new RelatedOwningDummy() : new RelatedOwningDummyDocument(); - } - - private function buildRelatedToDummyFriend(): RelatedToDummyFriend|RelatedToDummyFriendDocument - { - return $this->isOrm() ? new RelatedToDummyFriend() : new RelatedToDummyFriendDocument(); - } - - private function buildRelatedLinkedDummy(): RelatedLinkedDummy|RelatedLinkedDummyDocument - { - return $this->isOrm() ? new RelatedLinkedDummy() : new RelatedLinkedDummyDocument(); - } - - private function buildRelationEmbedder(): RelationEmbedder|RelationEmbedderDocument - { - return $this->isOrm() ? new RelationEmbedder() : new RelationEmbedderDocument(); - } - - private function buildSecuredDummy(): SecuredDummy|SecuredDummyDocument - { - return $this->isOrm() ? new SecuredDummy() : new SecuredDummyDocument(); - } - - private function buildRelatedSecureDummy(): RelatedSecuredDummy|RelatedSecuredDummyDocument - { - return $this->isOrm() ? new RelatedSecuredDummy() : new RelatedSecuredDummyDocument(); - } - - private function buildSoMany(): SoMany|SoManyDocument - { - return $this->isOrm() ? new SoMany() : new SoManyDocument(); - } - - private function buildThirdLevel(): ThirdLevel|ThirdLevelDocument - { - return $this->isOrm() ? new ThirdLevel() : new ThirdLevelDocument(); - } - - private function buildConvertedDate(): ConvertedDate|ConvertedDateDocument - { - return $this->isOrm() ? new ConvertedDate() : new ConvertedDateDocument(); - } - - private function buildConvertedBoolean(): ConvertedBoolean|ConvertedBoolDocument - { - return $this->isOrm() ? new ConvertedBoolean() : new ConvertedBoolDocument(); - } - - private function buildConvertedInteger(): ConvertedInteger|ConvertedIntegerDocument - { - return $this->isOrm() ? new ConvertedInteger() : new ConvertedIntegerDocument(); - } - - private function buildConvertedString(): ConvertedString|ConvertedStringDocument - { - return $this->isOrm() ? new ConvertedString() : new ConvertedStringDocument(); - } - - private function buildConvertedOwner(): ConvertedOwner|ConvertedOwnerDocument - { - return $this->isOrm() ? new ConvertedOwner() : new ConvertedOwnerDocument(); - } - - private function buildConvertedRelated(): ConvertedRelated|ConvertedRelatedDocument - { - return $this->isOrm() ? new ConvertedRelated() : new ConvertedRelatedDocument(); - } - - private function buildDummyMercure(): DummyMercure|DummyMercureDocument - { - return $this->isOrm() ? new DummyMercure() : new DummyMercureDocument(); - } - - private function buildAbsoluteUrlDummy(): AbsoluteUrlDummyDocument|AbsoluteUrlDummy - { - return $this->isOrm() ? new AbsoluteUrlDummy() : new AbsoluteUrlDummyDocument(); - } - - private function buildAbsoluteUrlRelationDummy(): AbsoluteUrlRelationDummyDocument|AbsoluteUrlRelationDummy - { - return $this->isOrm() ? new AbsoluteUrlRelationDummy() : new AbsoluteUrlRelationDummyDocument(); - } - - private function buildNetworkPathDummy(): NetworkPathDummyDocument|NetworkPathDummy - { - return $this->isOrm() ? new NetworkPathDummy() : new NetworkPathDummyDocument(); - } - - private function buildNetworkPathRelationDummy(): NetworkPathRelationDummyDocument|NetworkPathRelationDummy - { - return $this->isOrm() ? new NetworkPathRelationDummy() : new NetworkPathRelationDummyDocument(); - } - - private function buildInitializeInput(): InitializeInput|InitializeInputDocument - { - return $this->isOrm() ? new InitializeInput() : new InitializeInputDocument(); - } - - private function buildPatchDummyRelation(): PatchDummyRelation|PatchDummyRelationDocument - { - return $this->isOrm() ? new PatchDummyRelation() : new PatchDummyRelationDocument(); - } - - private function buildBook(): BookDocument|Book - { - return $this->isOrm() ? new Book() : new BookDocument(); - } - - private function buildCustomMultipleIdentifierDummy(): CustomMultipleIdentifierDummy|CustomMultipleIdentifierDummyDocument - { - return $this->isOrm() ? new CustomMultipleIdentifierDummy() : new CustomMultipleIdentifierDummyDocument(); - } - - private function buildWithJsonDummy(): WithJsonDummy|WithJsonDummyDocument - { - return $this->isOrm() ? new WithJsonDummy() : new WithJsonDummyDocument(); - } - - private function buildPayment(string $amount): Payment|PaymentDocument - { - return $this->isOrm() ? new Payment($amount) : new PaymentDocument($amount); - } - - private function buildMultiRelationsDummy(): MultiRelationsDummy|MultiRelationsDummyDocument - { - return $this->isOrm() ? new MultiRelationsDummy() : new MultiRelationsDummyDocument(); - } - - private function buildMultiRelationsRelatedDummy(): MultiRelationsRelatedDummy|MultiRelationsRelatedDummyDocument - { - return $this->isOrm() ? new MultiRelationsRelatedDummy() : new MultiRelationsRelatedDummyDocument(); - } - - private function buildMultiRelationsNested(): MultiRelationsNested|MultiRelationsNestedDocument - { - return $this->isOrm() ? new MultiRelationsNested() : new MultiRelationsNestedDocument(); - } - - private function buildMultiRelationsNestedPaginated(): MultiRelationsNestedPaginated|MultiRelationsNestedPaginatedDocument - { - return $this->isOrm() ? new MultiRelationsNestedPaginated() : new MultiRelationsNestedPaginatedDocument(); - } - - private function buildMultiRelationsResolveDummy(): MultiRelationsResolveDummy|MultiRelationsResolveDummyDocument - { - return $this->isOrm() ? new MultiRelationsResolveDummy() : new MultiRelationsResolveDummyDocument(); - } - - private function buildMusicGroup(): MusicGroup|MusicGroupDocument - { - return $this->isOrm() ? new MusicGroup() : new MusicGroupDocument(); - } - - private function buildVideoGame(): VideoGame|VideoGameDocument - { - return $this->isOrm() ? new VideoGame() : new VideoGameDocument(); - } - - private function buildSeparatedEntity(): SeparatedEntity|SeparatedEntityDocument - { - return $this->isOrm() ? new SeparatedEntity() : new SeparatedEntityDocument(); - } - - private function buildLinkHandledDummy(string $slug): LinkHandledDummy|LinkHandledDummyDocument - { - return $this->isOrm() ? new LinkHandledDummy($slug) : new LinkHandledDummyDocument($slug); - } -} diff --git a/tests/Behat/GraphqlContext.php b/tests/Behat/GraphqlContext.php deleted file mode 100644 index ca644baaff9..00000000000 --- a/tests/Behat/GraphqlContext.php +++ /dev/null @@ -1,178 +0,0 @@ - - * - * 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\Behat; - -use Behat\Behat\Context\Context; -use Behat\Behat\Context\Environment\InitializedContextEnvironment; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; -use Behat\Gherkin\Node\PyStringNode; -use Behat\Gherkin\Node\TableNode; -use Behatch\Context\RestContext; -use Behatch\HttpCall\Request; -use GraphQL\Error\Error; -use GraphQL\Type\Introspection; -use PHPUnit\Framework\ExpectationFailedException; - -/** - * Context for GraphQL. - * - * @author Alan Poulain - */ -final class GraphqlContext implements Context -{ - private ?RestContext $restContext = null; - private ?JsonContext $jsonContext = null; - - private array $graphqlRequest; - - private ?int $graphqlLine = null; // @phpstan-ignore-line - - public function __construct(private readonly Request $request) - { - } - - /** - * Gives access to the Behatch context. - * - * @BeforeScenario - */ - public function gatherContexts(BeforeScenarioScope $scope): void - { - /** @var InitializedContextEnvironment $environment */ - $environment = $scope->getEnvironment(); - /** @var RestContext $restContext */ - $restContext = $environment->getContext(RestContext::class); - $this->restContext = $restContext; - /** @var JsonContext $jsonContext */ - $jsonContext = $environment->getContext(JsonContext::class); - $this->jsonContext = $jsonContext; - } - - /** - * @When I have the following GraphQL request: - */ - public function IHaveTheFollowingGraphqlRequest(PyStringNode $request): void - { - $this->graphqlRequest = ['query' => $request->getRaw()]; - $this->graphqlLine = $request->getLine(); - } - - /** - * @When I send the following GraphQL request: - */ - public function ISendTheFollowingGraphqlRequest(PyStringNode $request): void - { - $this->IHaveTheFollowingGraphqlRequest($request); - $this->sendGraphqlRequest(); - } - - /** - * @When I send the GraphQL request with variables: - */ - public function ISendTheGraphqlRequestWithVariables(PyStringNode $variables): void - { - $this->graphqlRequest['variables'] = $variables->getRaw(); - $this->sendGraphqlRequest(); - } - - /** - * @When I send the GraphQL request with operationName :operationName - */ - public function ISendTheGraphqlRequestWithOperation(string $operationName): void - { - $this->graphqlRequest['operationName'] = $operationName; - $this->sendGraphqlRequest(); - } - - /** - * @Given I have the following file(s) for a GraphQL request: - */ - public function iHaveTheFollowingFilesForAGraphqlRequest(TableNode $table): void - { - $files = []; - - foreach ($table->getHash() as $row) { - if (!isset($row['name'], $row['file'])) { - throw new \InvalidArgumentException('You must provide a "name" and "file" column in your table node.'); - } - - $files[$row['name']] = $this->restContext->getMinkParameter('files_path').\DIRECTORY_SEPARATOR.$row['file']; - } - - $this->graphqlRequest['files'] = $files; - } - - /** - * @Given I have the following GraphQL multipart request map: - */ - public function iHaveTheFollowingGraphqlMultipartRequestMap(PyStringNode $string): void - { - $this->graphqlRequest['map'] = $string->getRaw(); - } - - /** - * @When I send the following GraphQL multipart request operations: - */ - public function iSendTheFollowingGraphqlMultipartRequestOperations(PyStringNode $string): void - { - $params = []; - $params['operations'] = $string->getRaw(); - $params['map'] = $this->graphqlRequest['map']; - - $this->request->setHttpHeader('Content-type', 'multipart/form-data'); - $this->request->send('POST', '/graphql', $params, $this->graphqlRequest['files']); - } - - /** - * @When I send the query to introspect the schema - */ - public function ISendTheQueryToIntrospectTheSchema(): void - { - $this->graphqlRequest = ['query' => Introspection::getIntrospectionQuery()]; - $this->sendGraphqlRequest(); - } - - /** - * @Then the GraphQL field :fieldName is deprecated for the reason :reason - */ - public function theGraphQLFieldIsDeprecatedForTheReason(string $fieldName, string $reason): void - { - foreach (json_decode($this->request->getContent(), true, 512, \JSON_THROW_ON_ERROR)['data']['__type']['fields'] as $field) { - if ($fieldName === $field['name'] && $field['isDeprecated'] && $reason === $field['deprecationReason']) { - return; - } - } - - throw new ExpectationFailedException(\sprintf('The field "%s" is not deprecated.', $fieldName)); - } - - /** - * @Then the GraphQL debug message should be equal to :expectedDebugMessage - */ - public function theGraphQLDebugMessageShouldBeEqualTo(string $expectedDebugMessage): void - { - $jsonNode = 'errors[0].extensions.debugMessage'; - // graphql-php < 15 - if (\defined(Error::class.'::CATEGORY_INTERNAL')) { - $jsonNode = 'errors[0].debugMessage'; - } - - $this->jsonContext->theJsonNodeShouldBeEqualTo($jsonNode, $expectedDebugMessage); - } - - private function sendGraphqlRequest(): void - { - $this->restContext->iSendARequestTo('GET', '/graphql?'.http_build_query($this->graphqlRequest)); - } -} diff --git a/tests/Behat/HttpCacheContext.php b/tests/Behat/HttpCacheContext.php deleted file mode 100644 index d06ba3414eb..00000000000 --- a/tests/Behat/HttpCacheContext.php +++ /dev/null @@ -1,91 +0,0 @@ - - * - * 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\Behat; - -use ApiPlatform\Tests\Fixtures\TestBundle\HttpCache\TagCollectorCustom; -use Behat\Behat\Context\Context; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; -use Behat\Mink\Driver\BrowserKitDriver; -use Behat\MinkExtension\Context\MinkContext; -use FriendsOfBehat\SymfonyExtension\Context\Environment\InitializedSymfonyExtensionEnvironment; -use PHPUnit\Framework\ExpectationFailedException; -use Symfony\Bundle\FrameworkBundle\KernelBrowser; -use Symfony\Component\DependencyInjection\ContainerInterface; - -/** - * @author Kévin Dunglas - */ -final class HttpCacheContext implements Context -{ - public function __construct(private ContainerInterface $driverContainer) - { - } - - /** - * @BeforeScenario @customTagCollector - */ - public function registerCustomTagCollector(BeforeScenarioScope $scope): void - { - $this->disableReboot($scope); - /** @phpstan-ignore-next-line */ - $iriConverter = $this->driverContainer->get('api_platform.iri_converter'); - $this->driverContainer->set('api_platform.http_cache.tag_collector', new TagCollectorCustom($iriConverter)); - } - - /** - * @Then :iris IRIs should be purged - */ - public function irisShouldBePurged(string $iris): void - { - $purger = $this->driverContainer->get('test.api_platform.http_cache.purger'); - - $iris = explode(',', $iris); - sort($iris); - $iris = implode(',', $iris); - - $purgedIris = $purger->getIris(); - sort($purgedIris); - $purgedIris = implode(',', $purgedIris); - - $purger->clear(); - - if ($iris !== $purgedIris) { - throw new ExpectationFailedException(\sprintf('IRIs "%s" does not match expected "%s".', $purgedIris, $iris)); - } - } - - /** - * this is necessary to allow overriding services - * see https://github.com/FriendsOfBehat/SymfonyExtension/issues/149 for details. - */ - private function disableReboot(BeforeScenarioScope $scope): void - { - $env = $scope->getEnvironment(); - if (!$env instanceof InitializedSymfonyExtensionEnvironment) { - return; - } - - $driver = $env->getContext(MinkContext::class)->getSession()->getDriver(); - if (!$driver instanceof BrowserKitDriver) { - return; - } - - $client = $driver->getClient(); - if (!$client instanceof KernelBrowser) { - return; - } - - $client->disableReboot(); - } -} diff --git a/tests/Behat/HydraContext.php b/tests/Behat/HydraContext.php deleted file mode 100644 index a0425ac2b13..00000000000 --- a/tests/Behat/HydraContext.php +++ /dev/null @@ -1,326 +0,0 @@ - - * - * 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\Behat; - -use Behat\Behat\Context\Context; -use Behat\Behat\Context\Environment\InitializedContextEnvironment; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; -use Behatch\Context\RestContext; -use PHPUnit\Framework\Assert; -use PHPUnit\Framework\ExpectationFailedException; -use Symfony\Component\PropertyAccess\PropertyAccessorInterface; - -final class HydraContext implements Context -{ - private ?RestContext $restContext = null; - - public function __construct(private readonly PropertyAccessorInterface $propertyAccessor) - { - } - - /** - * Gives access to the Behatch context. - * - * @BeforeScenario - */ - public function gatherContexts(BeforeScenarioScope $scope): void - { - /** - * @var InitializedContextEnvironment $environment - */ - $environment = $scope->getEnvironment(); - /** - * @var RestContext $restContext - */ - $restContext = $environment->getContext(RestContext::class); - $this->restContext = $restContext; - } - - /** - * @Then the Hydra class :class exists - */ - public function assertTheHydraClassExist(string $className): void - { - try { - $this->getClassInfo($className); - } catch (\InvalidArgumentException $e) { - throw new ExpectationFailedException(\sprintf('The class "%s" doesn\'t exist.', $className), null, $e); - } - } - - /** - * @Then the Hydra class :class doesn't exist - */ - public function assertTheHydraClassNotExist(string $className): void - { - try { - $this->getClassInfo($className); - } catch (\InvalidArgumentException) { - return; - } - - throw new ExpectationFailedException(\sprintf('The class "%s" exists.', $className)); - } - - /** - * @Then the boolean value of the node :node of the Hydra class :class is true - */ - public function assertBooleanNodeValueIs(string $nodeName, string $className): void - { - Assert::assertTrue($this->propertyAccessor->getValue($this->getClassInfo($className), $nodeName)); - } - - /** - * @Then the value of the node :node of the Hydra class :class is :value - */ - public function assertNodeValueIs(string $nodeName, string $className, string $value): void - { - Assert::assertEquals( - $this->propertyAccessor->getValue($this->getClassInfo($className), $nodeName), - $value - ); - } - - /** - * @Then the boolean value of the node :node of the property :prop of the Hydra class :class is true - */ - public function assertPropertyNodeValueIsTrue(string $nodeName, string $propertyName, string $className): void - { - Assert::assertTrue($this->propertyAccessor->getValue($this->getPropertyInfo($propertyName, $className), $nodeName)); - } - - /** - * @Then the value of the node :node of the property :prop of the Hydra class :class is :value - */ - public function assertPropertyNodeValueIs(string $nodeName, string $propertyName, string $className, string $value): void - { - Assert::assertEquals( - $this->propertyAccessor->getValue($this->getPropertyInfo($propertyName, $className), $nodeName), - $value - ); - } - - /** - * @Then the boolean value of the node :node of the operation :operation of the Hydra class :class is true - */ - public function assertOperationNodeBooleanValueIs(string $nodeName, string $operationMethod, string $className): void - { - Assert::assertTrue($this->propertyAccessor->getValue($this->getOperation($operationMethod, $className), $nodeName)); - } - - /** - * @Then the value of the node :node of the operation :operation of the Hydra class :class is :value - */ - public function assertOperationNodeValueIs(string $nodeName, string $operationMethod, string $className, string $value): void - { - Assert::assertEquals( - $this->propertyAccessor->getValue($this->getOperation($operationMethod, $className), $nodeName), - $value - ); - } - - /** - * @Then the value of the node :node of the operation :operation of the Hydra class :class contains :value - */ - public function assertOperationNodeValueContains(string $nodeName, string $operationMethod, string $className, string $value): void - { - $property = $this->getOperation($operationMethod, $className); - - Assert::assertContains($value, $this->propertyAccessor->getValue($property, $nodeName)); - } - - /** - * @Then :nb operations are available for Hydra class :class - */ - public function assertNbOperationsExist(int $nb, string $className): void - { - Assert::assertEquals($nb, \count($this->getOperations($className))); - } - - /** - * @Then :nb properties are available for Hydra class :class - */ - public function assertNbPropertiesExist(int $nb, string $className): void - { - Assert::assertEquals($nb, \count($this->getProperties($className))); - } - - /** - * @Then :prop property doesn't exist for the Hydra class :class - */ - public function assertPropertyNotExist(string $propertyName, string $className): void - { - try { - $this->getPropertyInfo($propertyName, $className); - } catch (\InvalidArgumentException) { - return; - } - - throw new ExpectationFailedException(\sprintf('Property "%s" of class "%s" exists.', $propertyName, $className)); - } - - /** - * @Then :prop property is readable for Hydra class :class - */ - public function assertPropertyIsReadable(string $propertyName, string $className): void - { - if (!$this->getPropertyInfo($propertyName, $className)->{'hydra:readable'}) { - throw new ExpectationFailedException(\sprintf('Property "%s" of class "%s" is not readable', $propertyName, $className)); - } - } - - /** - * @Then :prop property is not readable for Hydra class :class - */ - public function assertPropertyIsNotReadable(string $propertyName, string $className): void - { - if ($this->getPropertyInfo($propertyName, $className)->{'hydra:readable'}) { - throw new ExpectationFailedException(\sprintf('Property "%s" of class "%s" is readable', $propertyName, $className)); - } - } - - /** - * @Then :prop property is writable for Hydra class :class - */ - public function assertPropertyIsWritable(string $propertyName, string $className): void - { - if (!$this->getPropertyInfo($propertyName, $className)->{'hydra:writeable'}) { - throw new ExpectationFailedException(\sprintf('Property "%s" of class "%s" is not writable', $propertyName, $className)); - } - } - - /** - * @Then :prop property is required for Hydra class :class - */ - public function assertPropertyIsRequired(string $propertyName, string $className): void - { - if (!$this->getPropertyInfo($propertyName, $className)->{'hydra:required'}) { - throw new ExpectationFailedException(\sprintf('Property "%s" of class "%s" is not required', $propertyName, $className)); - } - } - - /** - * @Then :prop property is not required for Hydra class :class - */ - public function assertPropertyIsNotRequired(string $propertyName, string $className): void - { - if ($this->getPropertyInfo($propertyName, $className)->{'hydra:required'}) { - throw new ExpectationFailedException(\sprintf('Property "%s" of class "%s" is required', $propertyName, $className)); - } - } - - /** - * Gets information about a property. - * - * @throws \InvalidArgumentException - */ - private function getPropertyInfo(string $propertyName, string $className): \stdClass - { - foreach ($this->getProperties($className) as $property) { - if ($property->{'hydra:title'} === $propertyName) { - return $property; - } - } - - throw new \InvalidArgumentException(\sprintf('Property "%s" of class "%s" doesn\'t exist', $propertyName, $className)); - } - - /** - * Gets an operation by its method name. - * - * @throws \InvalidArgumentException - */ - private function getOperation(string $method, string $className): \stdClass - { - foreach ($this->getOperations($className) as $operation) { - if ($operation->{'hydra:method'} === $method) { - return $operation; - } - } - - throw new \InvalidArgumentException(\sprintf('Operation "%s" of class "%s" doesn\'t exist.', $method, $className)); - } - - /** - * Gets all operations of a given class. - */ - private function getOperations(string $className): array - { - return $this->getClassInfo($className)->{'hydra:supportedOperation'} ?? []; - } - - /** - * Gets all properties of a given class. - */ - private function getProperties(string $className): array - { - return $this->getClassInfo($className)->{'hydra:supportedProperty'} ?? []; - } - - /** - * Gets information about a class. - * - * @throws \InvalidArgumentException - */ - private function getClassInfo(string $className): \stdClass - { - $json = $this->getLastJsonResponse(); - - if (isset($json->{'hydra:supportedClass'})) { - foreach ($json->{'hydra:supportedClass'} as $classData) { - if ($classData->{'hydra:title'} === $className) { - return $classData; - } - } - } - - throw new \InvalidArgumentException(\sprintf('Class %s cannot be found in the vocabulary', $className)); - } - - /** - * Gets the last JSON response. - * - * @throws \RuntimeException - */ - private function getLastJsonResponse(): \stdClass - { - if (null === $decoded = json_decode($this->restContext->getMink()->getSession()->getDriver()->getContent(), null, 512, \JSON_THROW_ON_ERROR)) { - throw new \RuntimeException('JSON response seems to be invalid'); - } - - return $decoded; - } - - /** - * @Then the Hydra context matches the online resource :url - */ - public function assertHydraContextIsCorrect(string $url): void - { - $opts = [ - 'http' => [ - 'method' => 'GET', - 'header' => "User-Agent: Mozilla/5.0\r\n", - ], - ]; - - $context = stream_context_create($opts); - $upstream = json_decode(file_get_contents($url, false, $context)); - $actual = $this->getLastJsonResponse(); - $local = $actual->{'@context'}[0]; - Assert::assertEquals( - $upstream, - $local - ); - } -} diff --git a/tests/Behat/JsonApiContext.php b/tests/Behat/JsonApiContext.php deleted file mode 100644 index 7cd50646c57..00000000000 --- a/tests/Behat/JsonApiContext.php +++ /dev/null @@ -1,209 +0,0 @@ - - * - * 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\Behat; - -use ApiPlatform\Tests\Fixtures\TestBundle\Document\CircularReference as CircularReferenceDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyFriend as DummyFriendDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CircularReference; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; -use Behat\Behat\Context\Context; -use Behat\Behat\Context\Environment\InitializedContextEnvironment; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; -use Behatch\Context\RestContext; -use Behatch\Json\Json; -use Behatch\Json\JsonInspector; -use Doctrine\ORM\EntityManagerInterface; -use Doctrine\Persistence\ManagerRegistry; -use Doctrine\Persistence\ObjectManager; -use JsonSchema\Validator; -use PHPUnit\Framework\ExpectationFailedException; - -final class JsonApiContext implements Context -{ - private ?RestContext $restContext = null; - private readonly Validator $validator; - private readonly JsonInspector $inspector; - private readonly string $jsonApiSchemaFile; - private readonly ObjectManager $manager; - - public function __construct(ManagerRegistry $doctrine, string $jsonApiSchemaFile) - { - if (!is_file($jsonApiSchemaFile)) { - throw new \InvalidArgumentException('The JSON API schema doesn\'t exist.'); - } - - $this->validator = new Validator(); - $this->inspector = new JsonInspector('javascript'); - $this->jsonApiSchemaFile = $jsonApiSchemaFile; - $this->manager = $doctrine->getManager(); - } - - /** - * Gives access to the Behatch context. - * - * @BeforeScenario - */ - public function gatherContexts(BeforeScenarioScope $scope): void - { - /** - * @var InitializedContextEnvironment $environment - */ - $environment = $scope->getEnvironment(); - /** - * @var RestContext $restContext - */ - $restContext = $environment->getContext(RestContext::class); - $this->restContext = $restContext; - } - - /** - * @Then the JSON should be valid according to the JSON API schema - */ - public function theJsonShouldBeValidAccordingToTheJsonApiSchema(): void - { - $json = $this->getJson()->getContent(); - $this->validator->validate($json, (object) ['$ref' => "file://{$this->jsonApiSchemaFile}"]); - - if (!$this->validator->isValid()) { - throw new ExpectationFailedException('The JSON is not valid according to the JSON API schema.'); - } - } - - /** - * @Then the JSON node :node should be an empty array - */ - public function theJsonNodeShouldBeAnEmptyArray(string $node): void - { - $actual = $this->getValueOfNode($node); - if (null !== $actual && [] !== $actual) { - throw new ExpectationFailedException(\sprintf('The node value is `%s`', json_encode($actual, \JSON_THROW_ON_ERROR))); - } - } - - /** - * @Then the JSON node :node should be a number - */ - public function theJsonNodeShouldBeANumber(string $node): void - { - if (!is_numeric($actual = $this->getValueOfNode($node))) { - throw new ExpectationFailedException(\sprintf('The node value is `%s`', json_encode($actual, \JSON_THROW_ON_ERROR))); - } - } - - /** - * @Then the JSON node :node should not be an empty string - */ - public function theJsonNodeShouldNotBeAnEmptyString(string $node): void - { - if ('' === $actual = $this->getValueOfNode($node)) { - throw new ExpectationFailedException(\sprintf('The node value is `%s`', json_encode($actual))); - } - } - - /** - * @Then the JSON node :node should be sorted - * @Then the JSON should be sorted - */ - public function theJsonNodeShouldBeSorted(string $node = ''): void - { - $actual = (array) $this->getValueOfNode($node); - - $expected = $actual; - ksort($expected); - - if ($actual !== $expected) { - throw new ExpectationFailedException(\sprintf('The json node "%s" is not sorted by keys', $node)); - } - } - - /** - * @Given there is a RelatedDummy - */ - public function thereIsARelatedDummy(): void - { - $relatedDummy = $this->buildRelatedDummy(); - $relatedDummy->setName('RelatedDummy with no friends'); - - $this->manager->persist($relatedDummy); - $this->manager->flush(); - } - - /** - * @Given there is a DummyFriend - */ - public function thereIsADummyFriend(): void - { - $friend = $this->buildDummyFriend(); - $friend->setName('DummyFriend'); - - $this->manager->persist($friend); - $this->manager->flush(); - } - - /** - * @Given there is a CircularReference - */ - public function thereIsACircularReference(): void - { - $circularReference = $this->buildCircularReference(); - $circularReference->parent = $circularReference; - - $circularReferenceBis = $this->buildCircularReference(); - $circularReferenceBis->parent = $circularReference; - - $circularReference->children->add($circularReference); - $circularReference->children->add($circularReferenceBis); - - $this->manager->persist($circularReference); - $this->manager->persist($circularReferenceBis); - $this->manager->flush(); - } - - private function getValueOfNode(string $node) - { - return $this->inspector->evaluate($this->getJson(), $node); - } - - private function getJson(): Json - { - return new Json($this->getContent()); - } - - private function getContent(): string - { - return $this->restContext->getMink()->getSession()->getDriver()->getContent(); - } - - private function isOrm(): bool - { - return $this->manager instanceof EntityManagerInterface; - } - - private function buildCircularReference(): CircularReference|CircularReferenceDocument - { - return $this->isOrm() ? new CircularReference() : new CircularReferenceDocument(); - } - - private function buildDummyFriend(): DummyFriend|DummyFriendDocument - { - return $this->isOrm() ? new DummyFriend() : new DummyFriendDocument(); - } - - private function buildRelatedDummy(): RelatedDummy|RelatedDummyDocument - { - return $this->isOrm() ? new RelatedDummy() : new RelatedDummyDocument(); - } -} diff --git a/tests/Behat/JsonContext.php b/tests/Behat/JsonContext.php deleted file mode 100644 index 4450465fd08..00000000000 --- a/tests/Behat/JsonContext.php +++ /dev/null @@ -1,112 +0,0 @@ - - * - * 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\Behat; - -use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; -use Behat\Gherkin\Node\PyStringNode; -use Behat\Mink\Exception\ExpectationException; -use Behatch\Context\JsonContext as BaseJsonContext; -use Behatch\HttpCall\HttpCallResultPool; -use Behatch\Json\Json; -use PHPUnit\Framework\Assert; - -final class JsonContext extends BaseJsonContext -{ - public function __construct(HttpCallResultPool $httpCallResultPool) - { - parent::__construct($httpCallResultPool); - } - - /** - * @Then the JSON node :node should contain: - */ - public function theJsonNodeShouldContainContent(string $node, PyStringNode $content): void - { - $actual = $this->getJson(); - - try { - $expected = new Json($content); - } catch (\Exception $e) { - throw new ExpectationException('The expected JSON is not valid.', $this->getSession()->getDriver(), $e); - } - - $actualContent = $this->inspector->evaluate($actual, $node); - - if (!is_iterable($actualContent)) { - throw new ExpectationException(\sprintf("The JSON is equal to:\n%s", json_encode($actualContent, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_PRETTY_PRINT)), $this->getSession()->getDriver()); - } - - foreach ($actualContent as $itemContent) { - try { - $this->assertEquals($expected->getContent(), $itemContent, ' '); - } catch (ExpectationException) { - continue; - } - - return; - } - - throw new ExpectationException("The JSON node \"{$node}\" does not contain the expected content.", $this->getSession()->getDriver()); - } - - /** - * @Then the JSON node :node should be equal to: - */ - public function theJsonNodeShouldBeEqualToContent(string $node, PyStringNode $content): void - { - $actual = $this->getJson(); - - try { - $expected = new Json($content); - } catch (\Exception $e) { - throw new ExpectationException('The expected JSON is not valid.', $this->getSession()->getDriver(), $e); - } - - $actualContent = $this->inspector->evaluate($actual, $node); - - $this->assertEquals( - $expected->getContent(), - $actualContent, - \sprintf("The JSON node \"%s\" is equal to:\n%s", $node, json_encode($actualContent, \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE | \JSON_PRETTY_PRINT)) - ); - } - - public function theJsonShouldBeEqualTo(PyStringNode $content): void - { - $actual = $this->getJson(); - - try { - $expected = new Json($content); - } catch (\Exception) { - throw new ExpectationException('The expected JSON is not valid.', $this->getSession()->getDriver()); - } - - $this->assertEquals( - $expected->getContent(), - $actual->getContent(), - "The JSON is equal to:\n{$actual->encode()}" - ); - } - - /** - * @Then /^the JSON should be a superset of:$/ - */ - public function theJsonIsASupersetOf(PyStringNode $content): void - { - $array = json_decode($this->httpCallResultPool->getResult()->getValue(), true, 512, \JSON_THROW_ON_ERROR); - $subset = json_decode($content->getRaw(), true, 512, \JSON_THROW_ON_ERROR); - - method_exists(Assert::class, 'assertArraySubset') ? Assert::assertArraySubset($subset, $array) : ApiTestCase::assertArraySubset($subset, $array); - } -} diff --git a/tests/Behat/JsonHalContext.php b/tests/Behat/JsonHalContext.php deleted file mode 100644 index 91cff357660..00000000000 --- a/tests/Behat/JsonHalContext.php +++ /dev/null @@ -1,80 +0,0 @@ - - * - * 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\Behat; - -use Behat\Behat\Context\Context; -use Behat\Behat\Context\Environment\InitializedContextEnvironment; -use Behat\Behat\Hook\Scope\BeforeScenarioScope; -use Behatch\Context\RestContext; -use Behatch\Json\Json; -use JsonSchema\Validator; -use PHPUnit\Framework\ExpectationFailedException; - -final class JsonHalContext implements Context -{ - private ?RestContext $restContext = null; - private readonly Validator $validator; - private readonly string $schemaFile; - - public function __construct(string $schemaFile) - { - if (!is_file($schemaFile)) { - throw new \InvalidArgumentException('The JSON HAL schema doesn\'t exist.'); - } - - $this->validator = new Validator(); - $this->schemaFile = $schemaFile; - } - - /** - * Gives access to the Behatch context. - * - * @BeforeScenario - */ - public function gatherContexts(BeforeScenarioScope $scope): void - { - /** - * @var InitializedContextEnvironment $environment - */ - $environment = $scope->getEnvironment(); - /** - * @var RestContext $restContext - */ - $restContext = $environment->getContext(RestContext::class); - $this->restContext = $restContext; - } - - /** - * @Then the JSON should be valid according to the JSON HAL schema - */ - public function theJsonShouldBeValidAccordingToTheJsonHALSchema(): void - { - $json = $this->getJson()->getContent(); - $this->validator->validate($json, (object) ['$ref' => "file://{$this->schemaFile}"]); - - if (!$this->validator->isValid()) { - throw new ExpectationFailedException('The JSON is not valid according to the HAL+JSON schema.'); - } - } - - private function getJson(): Json - { - return new Json($this->getContent()); - } - - private function getContent(): string - { - return $this->restContext->getMink()->getSession()->getDriver()->getContent(); - } -} diff --git a/tests/Behat/MercureContext.php b/tests/Behat/MercureContext.php deleted file mode 100644 index 2dbd68f8775..00000000000 --- a/tests/Behat/MercureContext.php +++ /dev/null @@ -1,144 +0,0 @@ - - * - * 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\Behat; - -use ApiPlatform\Tests\Fixtures\TestBundle\Mercure\TestHub; -use Behat\Behat\Context\Context; -use Behat\Gherkin\Node\PyStringNode; -use Behat\Gherkin\Node\TableNode; -use PHPUnit\Framework\Assert; -use Psr\Container\ContainerInterface; -use Symfony\Component\Mercure\Update; - -/** - * Context for Mercure. - * - * @author Alan Poulain - */ -final class MercureContext implements Context -{ - public function __construct(private readonly ContainerInterface $driverContainer) - { - } - - /** - * @Then :number Mercure updates should have been sent - * @Then :number Mercure update should have been sent - */ - public function mercureUpdatesShouldHaveBeenSent(int $number): void - { - $updateHandler = $this->getMercureTestHub(); - $total = \count($updateHandler->getUpdates()); - - if (0 === $total) { - throw new \RuntimeException('No Mercure update has been sent.'); - } - - Assert::assertEquals($number, $total, \sprintf('Expected %d Mercure updates to be sent, got %d.', $number, $total)); - } - - /** - * @Then the first Mercure update should have topics: - * @Then the Mercure update should have topics: - */ - public function firstMercureUpdateShouldHaveTopics(TableNode $table): void - { - $this->mercureUpdateShouldHaveTopics(1, $table); - } - - /** - * @Then the first Mercure update should have data: - * @Then the Mercure update should have data: - */ - public function firstMercureUpdateShouldHaveData(PyStringNode $data): void - { - $this->mercureUpdateShouldHaveData(1, $data); - } - - /** - * @Then the Mercure update number :index should have topics: - */ - public function mercureUpdateShouldHaveTopics(int $index, TableNode $table): void - { - $updateHandler = $this->getMercureTestHub(); - $updates = $updateHandler->getUpdates(); - - if (0 === \count($updates)) { - throw new \RuntimeException('No Mercure update has been sent.'); - } - - if (!isset($updates[$index - 1])) { - throw new \RuntimeException(\sprintf('Mercure update #%d does not exist.', $index)); - } - /** @var Update $update */ - $update = $updates[$index - 1]; - Assert::assertEquals(array_keys($table->getRowsHash()), array_values($update->getTopics())); - } - - /** - * @Then the Mercure update number :index should have data: - */ - public function mercureUpdateShouldHaveData(int $index, PyStringNode $data): void - { - $updateHandler = $this->getMercureTestHub(); - $updates = $updateHandler->getUpdates(); - - if (0 === \count($updates)) { - throw new \RuntimeException('No Mercure update has been sent.'); - } - - if (!isset($updates[$index - 1])) { - throw new \RuntimeException(\sprintf('Mercure update #%d does not exist.', $index)); - } - /** @var Update $update */ - $update = $updates[$index - 1]; - Assert::assertJsonStringEqualsJsonString($data->getRaw(), $update->getData()); - } - - /** - * @Then the following Mercure update with topics :topics should have been sent: - */ - public function theFollowingMercureUpdateShouldHaveBeenSent(string $topics, PyStringNode $update): void - { - $topics = explode(',', $topics); - $update = json_decode($update->getRaw(), true, 512, \JSON_THROW_ON_ERROR); - - $updateHandler = $this->getMercureTestHub(); - foreach ($updateHandler->getUpdates() as $sentUpdate) { - $toMatchTopics = \count($topics); - foreach ($sentUpdate->getTopics() as $sentTopic) { - foreach ($topics as $topic) { - if (preg_match("@$topic@", (string) $sentTopic)) { - --$toMatchTopics; - } - } - } - - if ($toMatchTopics > 0) { - continue; - } - - if ($sentUpdate->getData() === json_encode($update, \JSON_THROW_ON_ERROR)) { - return; - } - } - - throw new \RuntimeException('Mercure update has not been sent.'); - } - - private function getMercureTestHub(): TestHub - { - return $this->driverContainer->get('mercure.hub.default.test_hub'); - } -} diff --git a/tests/Behat/XmlContext.php b/tests/Behat/XmlContext.php deleted file mode 100644 index 33a811470d2..00000000000 --- a/tests/Behat/XmlContext.php +++ /dev/null @@ -1,43 +0,0 @@ - - * - * 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\Behat; - -use Behat\Gherkin\Node\PyStringNode; -use Behatch\Context\XmlContext as BaseXmlContext; -use Symfony\Component\Serializer\Encoder\XmlEncoder; - -final class XmlContext extends BaseXmlContext -{ - private readonly XmlEncoder $xmlEncoder; - - public function __construct() - { - $this->xmlEncoder = new XmlEncoder(); - } - - /** - * @Then the XML should be equal to: - */ - public function theXmlShouldBeEqualTo(PyStringNode $content): void - { - $expected = $this->xmlEncoder->decode((string) $content, 'xml'); - $actual = $this->xmlEncoder->decode($actualXml = $this->getSession()->getPage()->getContent(), 'xml'); - - $this->assertEquals( - $expected, - $actual, - "The XML is equal to:\n{$actualXml}" - ); - } -} diff --git a/tests/Fixtures/TestBundle/Document/AbsoluteUrlDummy.php b/tests/Fixtures/TestBundle/Document/AbsoluteUrlDummy.php index 62a18c4ab6c..9e9e6b893f6 100644 --- a/tests/Fixtures/TestBundle/Document/AbsoluteUrlDummy.php +++ b/tests/Fixtures/TestBundle/Document/AbsoluteUrlDummy.php @@ -20,7 +20,7 @@ use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; #[ApiResource(urlGenerationStrategy: UrlGeneratorInterface::ABS_URL)] -#[ApiResource(uriTemplate: '/absolute_url_relation_dummies/{id}/absolute_url_dummies{._format}', uriVariables: ['id' => new Link(fromClass: AbsoluteUrlRelationDummy::class, identifiers: ['id'], toProperty: 'absoluteUrlRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, operations: [new GetCollection()])] +#[ApiResource(shortName: 'AbsoluteUrlDummySubresource', uriTemplate: '/absolute_url_relation_dummies/{id}/absolute_url_dummies{._format}', uriVariables: ['id' => new Link(fromClass: AbsoluteUrlRelationDummy::class, identifiers: ['id'], toProperty: 'absoluteUrlRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, operations: [new GetCollection()])] #[ODM\Document] class AbsoluteUrlDummy { diff --git a/tests/Fixtures/TestBundle/Document/NetworkPathDummy.php b/tests/Fixtures/TestBundle/Document/NetworkPathDummy.php index 84bc5353cc5..9b8e8736943 100644 --- a/tests/Fixtures/TestBundle/Document/NetworkPathDummy.php +++ b/tests/Fixtures/TestBundle/Document/NetworkPathDummy.php @@ -20,7 +20,7 @@ use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; #[ApiResource(urlGenerationStrategy: UrlGeneratorInterface::NET_PATH)] -#[ApiResource(uriTemplate: '/network_path_relation_dummies/{id}/network_path_dummies{._format}', uriVariables: ['id' => new Link(fromClass: NetworkPathRelationDummy::class, identifiers: ['id'], toProperty: 'networkPathRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, operations: [new GetCollection()])] +#[ApiResource(shortName: 'NetworkPathDummySubresource', uriTemplate: '/network_path_relation_dummies/{id}/network_path_dummies{._format}', uriVariables: ['id' => new Link(fromClass: NetworkPathRelationDummy::class, identifiers: ['id'], toProperty: 'networkPathRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, operations: [new GetCollection()])] #[ODM\Document] class NetworkPathDummy { diff --git a/tests/Fixtures/TestBundle/Entity/AbsoluteUrlDummy.php b/tests/Fixtures/TestBundle/Entity/AbsoluteUrlDummy.php index a632d81dc83..4eeb455e2d7 100644 --- a/tests/Fixtures/TestBundle/Entity/AbsoluteUrlDummy.php +++ b/tests/Fixtures/TestBundle/Entity/AbsoluteUrlDummy.php @@ -20,7 +20,7 @@ use Doctrine\ORM\Mapping as ORM; #[ApiResource(urlGenerationStrategy: UrlGeneratorInterface::ABS_URL)] -#[ApiResource(uriTemplate: '/absolute_url_relation_dummies/{id}/absolute_url_dummies{._format}', uriVariables: ['id' => new Link(fromClass: AbsoluteUrlRelationDummy::class, identifiers: ['id'], toProperty: 'absoluteUrlRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, operations: [new GetCollection()])] +#[ApiResource(shortName: 'AbsoluteUrlDummySubresource', uriTemplate: '/absolute_url_relation_dummies/{id}/absolute_url_dummies{._format}', uriVariables: ['id' => new Link(fromClass: AbsoluteUrlRelationDummy::class, identifiers: ['id'], toProperty: 'absoluteUrlRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::ABS_URL, operations: [new GetCollection()])] #[ORM\Entity] class AbsoluteUrlDummy { diff --git a/tests/Fixtures/TestBundle/Entity/DummyAggregateOffer.php b/tests/Fixtures/TestBundle/Entity/DummyAggregateOffer.php index 7089cdf2a70..09e22711d40 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyAggregateOffer.php +++ b/tests/Fixtures/TestBundle/Entity/DummyAggregateOffer.php @@ -28,8 +28,8 @@ * @author Antoine Bluchet */ #[ApiResource] -#[ApiResource(uriTemplate: '/dummy_products/{id}/offers{._format}', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product')], status: 200, operations: [new GetCollection()])] -#[ApiResource(uriTemplate: '/dummy_products/{id}/related_products/{relatedProducts}/offers{._format}', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id']), 'relatedProducts' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product')], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/dummy_products/{id}/offers{._format}', shortName: 'DummyAggregateOfferByProduct', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product')], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/dummy_products/{id}/related_products/{relatedProducts}/offers{._format}', shortName: 'DummyAggregateOfferByRelatedProduct', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id']), 'relatedProducts' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product')], status: 200, operations: [new GetCollection()])] #[ORM\Entity] class DummyAggregateOffer { diff --git a/tests/Fixtures/TestBundle/Entity/DummyOffer.php b/tests/Fixtures/TestBundle/Entity/DummyOffer.php index 47c9c42142d..2988a075348 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyOffer.php +++ b/tests/Fixtures/TestBundle/Entity/DummyOffer.php @@ -26,9 +26,9 @@ * @author Antoine Bluchet */ #[ApiResource] -#[ApiResource(uriTemplate: '/dummy_aggregate_offers/{id}/offers{._format}', uriVariables: ['id' => new Link(fromClass: DummyAggregateOffer::class, identifiers: ['id'], toProperty: 'aggregate')], status: 200, operations: [new GetCollection()])] -#[ApiResource(uriTemplate: '/dummy_products/{id}/offers/{offers}/offers{._format}', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product'), 'offers' => new Link(fromClass: DummyAggregateOffer::class, identifiers: ['id'], toProperty: 'aggregate')], status: 200, operations: [new GetCollection()])] -#[ApiResource(uriTemplate: '/dummy_products/{id}/related_products/{relatedProducts}/offers/{offers}/offers{._format}', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id']), 'relatedProducts' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product'), 'offers' => new Link(fromClass: DummyAggregateOffer::class, identifiers: ['id'], toProperty: 'aggregate')], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/dummy_aggregate_offers/{id}/offers{._format}', shortName: 'DummyOfferByAggregate', uriVariables: ['id' => new Link(fromClass: DummyAggregateOffer::class, identifiers: ['id'], toProperty: 'aggregate')], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/dummy_products/{id}/offers/{offers}/offers{._format}', shortName: 'DummyOfferByProductOffer', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product'), 'offers' => new Link(fromClass: DummyAggregateOffer::class, identifiers: ['id'], toProperty: 'aggregate')], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/dummy_products/{id}/related_products/{relatedProducts}/offers/{offers}/offers{._format}', shortName: 'DummyOfferByRelatedProductOffer', uriVariables: ['id' => new Link(fromClass: DummyProduct::class, identifiers: ['id']), 'relatedProducts' => new Link(fromClass: DummyProduct::class, identifiers: ['id'], toProperty: 'product'), 'offers' => new Link(fromClass: DummyAggregateOffer::class, identifiers: ['id'], toProperty: 'aggregate')], status: 200, operations: [new GetCollection()])] #[ORM\Entity] class DummyOffer { diff --git a/tests/Fixtures/TestBundle/Entity/DummyProduct.php b/tests/Fixtures/TestBundle/Entity/DummyProduct.php index d6e428f8a02..2e83f1b06a9 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyProduct.php +++ b/tests/Fixtures/TestBundle/Entity/DummyProduct.php @@ -28,7 +28,7 @@ * @author Antoine Bluchet */ #[ApiResource] -#[ApiResource(uriTemplate: '/dummy_products/{id}/related_products{._format}', uriVariables: ['id' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/dummy_products/{id}/related_products{._format}', shortName: 'DummyProductRelatedProducts', uriVariables: ['id' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, operations: [new GetCollection()])] #[ORM\Entity] class DummyProduct { diff --git a/tests/Fixtures/TestBundle/Entity/DummyResourceWithComplexConstructor.php b/tests/Fixtures/TestBundle/Entity/DummyResourceWithComplexConstructor.php index 224206c15bf..2a850595ec3 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyResourceWithComplexConstructor.php +++ b/tests/Fixtures/TestBundle/Entity/DummyResourceWithComplexConstructor.php @@ -19,6 +19,7 @@ #[Post] #[ApiResource( + shortName: 'DummyResourceWithComplexConstructorByCompany', uriTemplate: '/companies/{companyId}/employees/{id}', uriVariables: [ 'companyId' => ['from_class' => Company::class, 'to_property' => 'company'], diff --git a/tests/Fixtures/TestBundle/Entity/Greeting.php b/tests/Fixtures/TestBundle/Entity/Greeting.php index d74e8217b55..7cad16e25a7 100644 --- a/tests/Fixtures/TestBundle/Entity/Greeting.php +++ b/tests/Fixtures/TestBundle/Entity/Greeting.php @@ -19,7 +19,7 @@ use Doctrine\ORM\Mapping as ORM; #[ApiResource] -#[ApiResource(uriTemplate: '/people/{id}/sent_greetings{._format}', uriVariables: ['id' => new Link(fromClass: Person::class, identifiers: ['id'], toProperty: 'sender')], status: 200, operations: [new GetCollection()])] +#[ApiResource(uriTemplate: '/people/{id}/sent_greetings{._format}', shortName: 'GreetingBySender', uriVariables: ['id' => new Link(fromClass: Person::class, identifiers: ['id'], toProperty: 'sender')], status: 200, operations: [new GetCollection()])] #[ORM\Entity] class Greeting { diff --git a/tests/Fixtures/TestBundle/Entity/NetworkPathDummy.php b/tests/Fixtures/TestBundle/Entity/NetworkPathDummy.php index 098750dc0dc..1ac8ec4d55b 100644 --- a/tests/Fixtures/TestBundle/Entity/NetworkPathDummy.php +++ b/tests/Fixtures/TestBundle/Entity/NetworkPathDummy.php @@ -20,7 +20,7 @@ use Doctrine\ORM\Mapping as ORM; #[ApiResource(urlGenerationStrategy: UrlGeneratorInterface::NET_PATH)] -#[ApiResource(uriTemplate: '/network_path_relation_dummies/{id}/network_path_dummies{._format}', uriVariables: ['id' => new Link(fromClass: NetworkPathRelationDummy::class, identifiers: ['id'], toProperty: 'networkPathRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, operations: [new GetCollection()])] +#[ApiResource(shortName: 'NetworkPathDummySubresource', uriTemplate: '/network_path_relation_dummies/{id}/network_path_dummies{._format}', uriVariables: ['id' => new Link(fromClass: NetworkPathRelationDummy::class, identifiers: ['id'], toProperty: 'networkPathRelationDummy')], status: 200, urlGenerationStrategy: UrlGeneratorInterface::NET_PATH, operations: [new GetCollection()])] #[ORM\Entity] class NetworkPathDummy { diff --git a/tests/Fixtures/TestBundle/MessengerHandler/Document/RPCHandler.php b/tests/Fixtures/TestBundle/MessengerHandler/Document/RPCHandler.php new file mode 100644 index 00000000000..d6edb96561b --- /dev/null +++ b/tests/Fixtures/TestBundle/MessengerHandler/Document/RPCHandler.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\Tests\Fixtures\TestBundle\MessengerHandler\Document; + +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RPC; +use Symfony\Component\Messenger\Attribute\AsMessageHandler; + +#[AsMessageHandler] +class RPCHandler +{ + public function __invoke(RPC $data): void + { + } +} diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index e26c8bc3eb7..6cb1bb86951 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -18,7 +18,6 @@ use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; use ApiPlatform\Symfony\Bundle\ApiPlatformBundle; -use ApiPlatform\Tests\Behat\DoctrineContext; use ApiPlatform\Tests\Fixtures\TestBundle\Document\User as UserDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\User; use ApiPlatform\Tests\Fixtures\TestBundle\TestBundle; @@ -27,7 +26,6 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Doctrine\Bundle\MongoDBBundle\Command\TailCursorDoctrineODMCommand; use Doctrine\Bundle\MongoDBBundle\DoctrineMongoDBBundle; -use FriendsOfBehat\SymfonyExtension\Bundle\FriendsOfBehatSymfonyExtensionBundle; use Symfony\AI\McpBundle\McpBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; @@ -63,7 +61,6 @@ public function __construct(string $environment, bool $debug, ?bool $genIdDefaul { parent::__construct($environment, $debug); - // patch for behat/symfony2-extension not supporting %env(APP_ENV)% $this->environment = $_SERVER['APP_ENV'] ?? $environment; $this->genIdDefault = $genIdDefault ?? $_SERVER['GEN_ID_DEFAULT'] ?? null; } @@ -81,10 +78,6 @@ public function registerBundles(): array new MakerBundle(), ]; - if (null === ($_ENV['APP_PHPUNIT'] ?? null) && class_exists(FriendsOfBehatSymfonyExtensionBundle::class)) { - $bundles[] = new FriendsOfBehatSymfonyExtensionBundle(); - } - if (extension_loaded('mongodb') && class_exists(DoctrineMongoDBBundle::class)) { $bundles[] = new DoctrineMongoDBBundle(); } @@ -120,11 +113,6 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load $loader->load(__DIR__."/config/config_{$this->getEnvironment()}.yml"); - if (interface_exists(Behat\Behat\Context\Context::class) && class_exists(DoctrineContext::class)) { - $loader->load(__DIR__.('mongodb' === $this->getEnvironment() ? '/config/config_behat_mongodb.yml' : '/config/config_behat_orm.yml')); - $c->getDefinition(DoctrineContext::class)->setArgument('$passwordHasher', class_exists(NativePasswordHasher::class) ? 'security.user_password_encoder' : 'security.user_password_hasher'); - } - $messengerConfig = [ 'default_bus' => 'messenger.bus.default', 'buses' => [ diff --git a/tests/Fixtures/app/bootstrap.php b/tests/Fixtures/app/bootstrap.php index 10db0977595..d268c9f75e7 100644 --- a/tests/Fixtures/app/bootstrap.php +++ b/tests/Fixtures/app/bootstrap.php @@ -23,4 +23,11 @@ require __DIR__.'/AppKernel.php'; require __DIR__.'/DefaultParametersAppKernel.php'; +if (!is_file($resourcesFile = __DIR__.'/var/resources.php')) { + if (!is_dir(dirname($resourcesFile))) { + mkdir(dirname($resourcesFile), 0777, true); + } + file_put_contents($resourcesFile, ' + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedBoolean as ConvertedBooleanDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddableDummy as EmbeddableDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddedDummy as EmbeddedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedBoolean; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\TestWith; + +final class BooleanFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + EmbeddedDummy::class, + RelatedDummy::class, + ConvertedBoolean::class, + ]; + } + + #[TestWith(['true', 15, ['/dummies/1', '/dummies/2', '/dummies/3']])] + #[TestWith(['1', 15, ['/dummies/1', '/dummies/2', '/dummies/3']])] + #[TestWith(['false', 10, ['/dummies/16', '/dummies/17', '/dummies/18']])] + #[TestWith(['0', 10, ['/dummies/16', '/dummies/17', '/dummies/18']])] + public function testFilterDummiesByBoolean(string $value, int $expectedTotal, array $expectedIds): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 15, true); + $this->createDummies($resource, 10, false); + + $response = self::createClient()->request('GET', '/dummies?dummyBoolean='.$value, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('/contexts/Dummy', $data['@context']); + $this->assertSame('/dummies', $data['@id']); + $this->assertSame('hydra:Collection', $data['@type']); + $this->assertSame($expectedTotal, $data['hydra:totalItems']); + $this->assertSame($expectedIds, array_map(static fn (array $i): string => $i['@id'], $data['hydra:member'])); + $this->assertSame('hydra:PartialCollectionView', $data['hydra:view']['@type']); + $this->assertStringContainsString('dummyBoolean='.$value, $data['hydra:view']['@id']); + } + + #[TestWith(['true', 15, ['/embedded_dummies/1', '/embedded_dummies/2', '/embedded_dummies/3']])] + #[TestWith(['1', 15, ['/embedded_dummies/1', '/embedded_dummies/2', '/embedded_dummies/3']])] + #[TestWith(['false', 10, ['/embedded_dummies/16', '/embedded_dummies/17', '/embedded_dummies/18']])] + #[TestWith(['0', 10, ['/embedded_dummies/16', '/embedded_dummies/17', '/embedded_dummies/18']])] + public function testFilterEmbeddedDummiesByEmbeddedBoolean(string $value, int $expectedTotal, array $expectedIds): void + { + $embeddedDummyClass = $this->embeddedDummyClass(); + $embeddableDummyClass = $this->embeddableDummyClass(); + $this->recreateSchema([$embeddedDummyClass]); + $this->createEmbeddedDummies($embeddedDummyClass, $embeddableDummyClass, 15, true); + $this->createEmbeddedDummies($embeddedDummyClass, $embeddableDummyClass, 10, false); + + $response = self::createClient()->request('GET', '/embedded_dummies?embeddedDummy.dummyBoolean='.$value, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('/contexts/EmbeddedDummy', $data['@context']); + $this->assertSame('/embedded_dummies', $data['@id']); + $this->assertSame($expectedTotal, $data['hydra:totalItems']); + $this->assertSame($expectedIds, array_map(static fn (array $i): string => $i['@id'], $data['hydra:member'])); + } + + public function testFilterEmbeddedDummiesByRelatedDummyEmbeddedBoolean(): void + { + $embeddedDummyClass = $this->embeddedDummyClass(); + $embeddableDummyClass = $this->embeddableDummyClass(); + $relatedDummyClass = $this->relatedDummyClass(); + $this->recreateSchema([$embeddedDummyClass, $relatedDummyClass]); + $this->createEmbeddedDummiesWithRelatedDummy($embeddedDummyClass, $embeddableDummyClass, $relatedDummyClass, 15, true); + $this->createEmbeddedDummiesWithRelatedDummy($embeddedDummyClass, $embeddableDummyClass, $relatedDummyClass, 10, false); + + $response = self::createClient()->request('GET', '/embedded_dummies?relatedDummy.embeddedDummy.dummyBoolean=true', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(15, $data['hydra:totalItems']); + $this->assertSame( + ['/embedded_dummies/1', '/embedded_dummies/2', '/embedded_dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + #[TestWith(['0'])] + #[TestWith(['1'])] + public function testCollectionIgnoresUnknownBooleanFilter(string $value): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 15, true); + $this->createDummies($resource, 10, false); + + $response = self::createClient()->request('GET', '/dummies?unknown='.$value, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(25, $response->toArray()['hydra:totalItems']); + } + + public function testFilterCollectionUsingNameConverter(): void + { + $resource = $this->isMongoDB() ? ConvertedBooleanDocument::class : ConvertedBoolean::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 5; ++$i) { + $entity = new $resource(); + $entity->nameConverted = (bool) ($i % 2); + $manager->persist($entity); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_booleans?name_converted=false', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/converted_booleans/2', '/converted_booleans/4'], $ids); + foreach ($data['hydra:member'] as $member) { + $this->assertSame('ConvertedBoolean', $member['@type']); + $this->assertFalse($member['name_converted']); + } + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @return class-string + */ + private function embeddedDummyClass(): string + { + return $this->isMongoDB() ? EmbeddedDummyDocument::class : EmbeddedDummy::class; + } + + /** + * @return class-string + */ + private function embeddableDummyClass(): string + { + return $this->isMongoDB() ? EmbeddableDummyDocument::class : EmbeddableDummy::class; + } + + /** + * @return class-string + */ + private function relatedDummyClass(): string + { + return $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + } + + /** + * @param class-string $resource + */ + private function createDummies(string $resource, int $nb, bool $bool): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->setDummyBoolean($bool); + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $embeddedClass + * @param class-string $embeddableClass + */ + private function createEmbeddedDummies(string $embeddedClass, string $embeddableClass, int $nb, bool $bool): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $embeddedClass(); + $dummy->setName('Embedded Dummy #'.$i); + $embeddable = new $embeddableClass(); + $embeddable->setDummyName('Embedded Dummy #'.$i); + $embeddable->setDummyBoolean($bool); + $dummy->setEmbeddedDummy($embeddable); + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $embeddedClass + * @param class-string $embeddableClass + * @param class-string $relatedClass + */ + private function createEmbeddedDummiesWithRelatedDummy(string $embeddedClass, string $embeddableClass, string $relatedClass, int $nb, bool $bool): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $embeddedClass(); + $dummy->setName('Embedded Dummy #'.$i); + $embeddable = new $embeddableClass(); + $embeddable->setDummyName('Embedded Dummy #'.$i); + $embeddable->setDummyBoolean($bool); + + $related = new $relatedClass(); + $related->setEmbeddedDummy($embeddable); + + $dummy->setRelatedDummy($related); + + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/DateFilterTest.php b/tests/Functional/Doctrine/DateFilterTest.php new file mode 100644 index 00000000000..eaceb789db6 --- /dev/null +++ b/tests/Functional/Doctrine/DateFilterTest.php @@ -0,0 +1,385 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedDate as ConvertedDateDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDate as DummyDateDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyImmutableDate as DummyImmutableDateDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddableDummy as EmbeddableDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddedDummy as EmbeddedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedDate; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDate; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyImmutableDate; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\TestWith; + +final class DateFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + EmbeddedDummy::class, + DummyDate::class, + DummyImmutableDate::class, + ConvertedDate::class, + ]; + } + + #[TestWith(['dummyDate[after]=2015-04-28', 2])] + #[TestWith(['dummyDate[before]=2015-04-05', 5])] + #[TestWith(['dummyDate[after]=2015-04-28T00:00:00%2B00:00', 2])] + #[TestWith(['dummyDate[before]=2015-04-05Z', 5])] + #[TestWith(['dummyDate[before]=2015-04-05&dummyDate[after]=2015-04-05', 1])] + #[TestWith(['dummyDate[after]=2015-04-05&dummyDate[before]=2015-04-05', 1])] + #[TestWith(['dummyDate[after]=2015-04-06&dummyDate[before]=2015-04-04', 0])] + public function testDummyDateFilter(string $query, int $expectedTotal): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithDate($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?'.$query, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame($expectedTotal, $response->toArray()['hydra:totalItems']); + } + + #[TestWith(['relatedDummy.dummyDate[after]=2015-04-28', 3])] + #[TestWith(['relatedDummy.dummyDate[after]=2015-04-28&relatedDummy_dummyDate[after]=2015-04-28', 3])] + #[TestWith(['relatedDummy.dummyDate[after]=2015-04-28T00:00:00%2B00:00', 3])] + public function testAssociationDateFilter(string $query, int $expectedTotal): void + { + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource]); + $this->createDummiesWithDateAndRelatedDummy($resource, $relatedResource, 30); + + $response = self::createClient()->request('GET', '/dummies?'.$query, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame($expectedTotal, $response->toArray()['hydra:totalItems']); + } + + public function testAssociationDateFilterWithEmptyResultSet(): void + { + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource]); + $this->createDummiesWithDateAndRelatedDummy($resource, $relatedResource, 2); + + $response = self::createClient()->request('GET', '/dummies?relatedDummy.dummyDate[after]=2015-04-28', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(0, $response->toArray()['hydra:totalItems']); + } + + public function testCollectionFilteredByDateThatIsNotDatetime(): void + { + $resource = $this->dummyDateClass(); + $this->recreateSchema([$resource]); + $this->createDummyDates($resource, 30); + + $response = self::createClient()->request('GET', '/dummy_dates?dummyDate[after]=2015-04-28', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(3, $response->toArray()['hydra:totalItems']); + } + + public function testCollectionFilteredByDateIncludeNullAfter(): void + { + $resource = $this->dummyDateClass(); + $this->recreateSchema([$resource]); + $this->createDummyDates($resource, 3, 'dateIncludeNullAfter'); + + $response = self::createClient()->request('GET', '/dummy_dates?dateIncludeNullAfter[after]=2015-04-02', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertSame('2015-04-02T00:00:00+00:00', $data['hydra:member'][0]['dateIncludeNullAfter']); + $this->assertNull($data['hydra:member'][1]['dateIncludeNullAfter']); + + $response = self::createClient()->request('GET', '/dummy_dates?dateIncludeNullAfter[before]=2015-04-02', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertSame('2015-04-01T00:00:00+00:00', $data['hydra:member'][0]['dateIncludeNullAfter']); + $this->assertSame('2015-04-02T00:00:00+00:00', $data['hydra:member'][1]['dateIncludeNullAfter']); + } + + public function testCollectionFilteredByDateIncludeNullBefore(): void + { + $resource = $this->dummyDateClass(); + $this->recreateSchema([$resource]); + $this->createDummyDates($resource, 3, 'dateIncludeNullBefore'); + + $response = self::createClient()->request('GET', '/dummy_dates?dateIncludeNullBefore[before]=2015-04-01', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertSame('2015-04-01T00:00:00+00:00', $data['hydra:member'][0]['dateIncludeNullBefore']); + $this->assertNull($data['hydra:member'][1]['dateIncludeNullBefore']); + + $response = self::createClient()->request('GET', '/dummy_dates?dateIncludeNullBefore[after]=2015-04-01', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertSame('2015-04-01T00:00:00+00:00', $data['hydra:member'][0]['dateIncludeNullBefore']); + $this->assertSame('2015-04-02T00:00:00+00:00', $data['hydra:member'][1]['dateIncludeNullBefore']); + } + + public function testCollectionFilteredByDateIncludeNullBeforeAndAfter(): void + { + $resource = $this->dummyDateClass(); + $this->recreateSchema([$resource]); + $this->createDummyDates($resource, 3, 'dateIncludeNullBeforeAndAfter'); + + $response = self::createClient()->request('GET', '/dummy_dates?dateIncludeNullBeforeAndAfter[before]=2015-04-01', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertSame('2015-04-01T00:00:00+00:00', $data['hydra:member'][0]['dateIncludeNullBeforeAndAfter']); + $this->assertNull($data['hydra:member'][1]['dateIncludeNullBeforeAndAfter']); + + $response = self::createClient()->request('GET', '/dummy_dates?dateIncludeNullBeforeAndAfter[after]=2015-04-02', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $this->assertSame('2015-04-02T00:00:00+00:00', $data['hydra:member'][0]['dateIncludeNullBeforeAndAfter']); + $this->assertNull($data['hydra:member'][1]['dateIncludeNullBeforeAndAfter']); + } + + public function testCollectionFilteredByImmutableDate(): void + { + $resource = $this->isMongoDB() ? DummyImmutableDateDocument::class : DummyImmutableDate::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 30; ++$i) { + $dummy = new $resource(); + $dummy->dummyDate = new \DateTimeImmutable(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); + $manager->persist($dummy); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/dummy_immutable_dates?dummyDate[after]=2015-04-28', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(3, $response->toArray()['hydra:totalItems']); + } + + public function testCollectionFilteredByEmbeddedDate(): void + { + $embeddedClass = $this->isMongoDB() ? EmbeddedDummyDocument::class : EmbeddedDummy::class; + $embeddableClass = $this->isMongoDB() ? EmbeddableDummyDocument::class : EmbeddableDummy::class; + $this->recreateSchema([$embeddedClass]); + + $manager = $this->getManager(); + for ($i = 1; $i <= 29; ++$i) { + $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); + $embeddable = new $embeddableClass(); + $embeddable->setDummyName('Embeddable #'.$i); + $embeddable->setDummyDate($date); + + $dummy = new $embeddedClass(); + $dummy->setName('Dummy #'.$i); + $dummy->setEmbeddedDummy($embeddable); + if (29 !== $i) { + $dummy->setDummyDate($date); + } + $manager->persist($dummy); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/embedded_dummies?embeddedDummy.dummyDate[after]=2015-04-28', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertCount(2, $data['hydra:member']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/embedded_dummies/28', '/embedded_dummies/29'], $ids); + } + + public function testCollectionFilteredUsingNameConverter(): void + { + $resource = $this->isMongoDB() ? ConvertedDateDocument::class : ConvertedDate::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 30; ++$i) { + $entity = new $resource(); + $entity->nameConverted = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); + $manager->persist($entity); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_dates?name_converted[strictly_after]=2015-04-28', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/converted_dates/29', '/converted_dates/30'], $ids); + foreach ($data['hydra:member'] as $member) { + $this->assertSame('ConvertedDate', $member['@type']); + $this->assertIsString($member['name_converted']); + } + + $this->assertSame('hydra:IriTemplate', $data['hydra:search']['@type']); + $this->assertSame('BasicRepresentation', $data['hydra:search']['hydra:variableRepresentation']); + $variables = array_map(static fn (array $m): string => $m['variable'], $data['hydra:search']['hydra:mapping']); + sort($variables); + $this->assertSame([ + 'name_converted[after]', + 'name_converted[before]', + 'name_converted[strictly_after]', + 'name_converted[strictly_before]', + ], $variables); + foreach ($data['hydra:search']['hydra:mapping'] as $mapping) { + $this->assertSame('IriTemplateMapping', $mapping['@type']); + $this->assertSame('name_converted', $mapping['property']); + } + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @return class-string + */ + private function relatedDummyClass(): string + { + return $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + } + + /** + * @return class-string + */ + private function dummyDateClass(): string + { + return $this->isMongoDB() ? DummyDateDocument::class : DummyDate::class; + } + + /** + * @param class-string $resource + */ + private function createDummiesWithDate(string $resource, int $nb): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDescription($descriptions[($i - 1) % 2]); + if ($nb !== $i) { + $dummy->setDummyDate(new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC'))); + } + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $resource + * @param class-string $relatedResource + */ + private function createDummiesWithDateAndRelatedDummy(string $resource, string $relatedResource, int $nb): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); + $relatedDummy = new $relatedResource(); + $relatedDummy->setName('RelatedDummy #'.$i); + $relatedDummy->setDummyDate($date); + + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setRelatedDummy($relatedDummy); + if ($nb !== $i) { + $dummy->setDummyDate($date); + } + $manager->persist($relatedDummy); + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $resource + */ + private function createDummyDates(string $resource, int $nb, ?string $nullableProperty = null): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $date = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); + $dummy = new $resource(); + $dummy->dummyDate = $date; + if ($nullableProperty) { + $dummy->{$nullableProperty} = 0 === $i % 3 ? null : $date; + } + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/EagerLoadingTest.php b/tests/Functional/Doctrine/EagerLoadingTest.php new file mode 100644 index 00000000000..d6f7168b8a0 --- /dev/null +++ b/tests/Functional/Doctrine/EagerLoadingTest.php @@ -0,0 +1,300 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Doctrine\Orm\EntityManager; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyPassenger; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTravel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FourthLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class EagerLoadingTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + DummyFriend::class, + RelatedToDummyFriend::class, + DummyTravel::class, + DummyCar::class, + DummyPassenger::class, + ThirdLevel::class, + FourthLevel::class, + ]; + } + + protected function setUp(): void + { + parent::setUp(); + + if ($this->isMongoDB()) { + $this->markTestSkipped('Eager loading is ORM only.'); + } + } + + public function testEagerLoadingForARelation(): void + { + $this->recreateSchema([ + Dummy::class, RelatedDummy::class, DummyFriend::class, RelatedToDummyFriend::class, + ThirdLevel::class, FourthLevel::class, + ]); + $this->createRelatedDummyWithFriends(2); + + self::createClient()->request('GET', '/related_dummies/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertDqlEquals(<<<'DQL' +SELECT o, thirdLevel_a1, relatedToDummyFriend_a3, fourthLevel_a2, dummyFriend_a4 +FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o + LEFT JOIN o.thirdLevel thirdLevel_a1 + LEFT JOIN thirdLevel_a1.fourthLevel fourthLevel_a2 + LEFT JOIN o.relatedToDummyFriend relatedToDummyFriend_a3 + LEFT JOIN relatedToDummyFriend_a3.dummyFriend dummyFriend_a4 +WHERE o.id = :id_p1 +DQL); + } + + public function testEagerLoadingForTheSearchFilter(): void + { + $this->recreateSchema([ + Dummy::class, RelatedDummy::class, ThirdLevel::class, FourthLevel::class, + ]); + $this->createDummyWithFourthLevelRelation(); + + self::createClient()->request('GET', '/dummies?relatedDummy.thirdLevel.level=3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertDqlEquals(<<<'DQL' +SELECT o +FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy o + INNER JOIN o.relatedDummy relatedDummy_a1 + INNER JOIN relatedDummy_a1.thirdLevel thirdLevel_a2 +WHERE o IN( + SELECT o_a3 + FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy o_a3 + INNER JOIN o_a3.relatedDummy relatedDummy_a4 + INNER JOIN relatedDummy_a4.thirdLevel thirdLevel_a5 + WHERE thirdLevel_a5.level = :level_p1 + ) +ORDER BY o.id ASC +DQL); + } + + public function testEagerLoadingForARelationAndSearchFilter(): void + { + $this->recreateSchema([ + Dummy::class, RelatedDummy::class, DummyFriend::class, RelatedToDummyFriend::class, + ThirdLevel::class, FourthLevel::class, + ]); + $this->createRelatedDummyWithFriends(2); + + self::createClient()->request('GET', '/related_dummies?relatedToDummyFriend.dummyFriend=2', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertDqlEquals(<<<'DQL' +SELECT o, thirdLevel_a4, relatedToDummyFriend_a1, fourthLevel_a5, dummyFriend_a6 +FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o + INNER JOIN o.relatedToDummyFriend relatedToDummyFriend_a1 + LEFT JOIN o.thirdLevel thirdLevel_a4 + LEFT JOIN thirdLevel_a4.fourthLevel fourthLevel_a5 + INNER JOIN relatedToDummyFriend_a1.dummyFriend dummyFriend_a6 +WHERE o IN( + SELECT o_a2 + FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o_a2 + INNER JOIN o_a2.relatedToDummyFriend relatedToDummyFriend_a3 + WHERE relatedToDummyFriend_a3.dummyFriend = :dummyFriend_p1 + ) +ORDER BY o.id ASC +DQL); + } + + public function testEagerLoadingForARelationAndPropertyFilterWithMultipleRelations(): void + { + $this->recreateSchema([ + DummyTravel::class, DummyCar::class, DummyPassenger::class, + ]); + $this->createDummyTravel(); + + $response = self::createClient()->request( + 'GET', + '/dummy_travels/1?properties[]=confirmed&properties[car][]=brand&properties[passenger][]=nickname', + ['headers' => ['Accept' => 'application/ld+json']] + ); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertTrue($data['confirmed']); + $this->assertSame('DummyBrand', $data['car']['carBrand']); + $this->assertSame('Tom', $data['passenger']['nickname']); + $this->assertDqlEquals(<<<'DQL' +SELECT o, car_a1, passenger_a2 +FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyTravel o + LEFT JOIN o.car car_a1 + LEFT JOIN o.passenger passenger_a2 +WHERE o.id = :id_p1 +DQL); + } + + public function testEagerLoadingForARelationWithComplexSubQueryFilter(): void + { + $this->recreateSchema([ + Dummy::class, RelatedDummy::class, DummyFriend::class, RelatedToDummyFriend::class, + ThirdLevel::class, FourthLevel::class, + ]); + $this->createRelatedDummyWithFriends(2); + + self::createClient()->request('GET', '/related_dummies?complex_sub_query_filter=1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertDqlEquals(<<<'DQL' +SELECT o, thirdLevel_a3, relatedToDummyFriend_a5, fourthLevel_a4, dummyFriend_a6 +FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy o + LEFT JOIN o.thirdLevel thirdLevel_a3 + LEFT JOIN thirdLevel_a3.fourthLevel fourthLevel_a4 + LEFT JOIN o.relatedToDummyFriend relatedToDummyFriend_a5 + LEFT JOIN relatedToDummyFriend_a5.dummyFriend dummyFriend_a6 +WHERE o.id IN ( + SELECT related_dummy_a1.id + FROM ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy related_dummy_a1 + INNER JOIN related_dummy_a1.relatedToDummyFriend related_to_dummy_friend_a2 + WITH related_to_dummy_friend_a2.name = :name_p1 + ) +ORDER BY o.id ASC +DQL); + } + + private function createRelatedDummyWithFriends(int $nb): void + { + $manager = $this->getManager(); + + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName('RelatedDummy with friends'); + $manager->persist($relatedDummy); + $manager->flush(); + + for ($i = 1; $i <= $nb; ++$i) { + $friend = new DummyFriend(); + $friend->setName('Friend-'.$i); + $manager->persist($friend); + $manager->flush(); + + $relation = new RelatedToDummyFriend(); + $relation->setName('Relation-'.$i); + $relation->setDummyFriend($friend); + $relation->setRelatedDummy($relatedDummy); + $relatedDummy->addRelatedToDummyFriend($relation); + + $manager->persist($relation); + } + + $relatedDummy2 = new RelatedDummy(); + $relatedDummy2->setName('RelatedDummy without friends'); + $manager->persist($relatedDummy2); + $manager->flush(); + $manager->clear(); + } + + private function createDummyWithFourthLevelRelation(): void + { + $manager = $this->getManager(); + + $fourthLevel = new FourthLevel(); + $fourthLevel->setLevel(4); + $manager->persist($fourthLevel); + + $thirdLevel = new ThirdLevel(); + $thirdLevel->setLevel(3); + $thirdLevel->setFourthLevel($fourthLevel); + $manager->persist($thirdLevel); + + $namedRelatedDummy = new RelatedDummy(); + $namedRelatedDummy->setName('Hello'); + $namedRelatedDummy->setThirdLevel($thirdLevel); + $manager->persist($namedRelatedDummy); + + $relatedDummy = new RelatedDummy(); + $relatedDummy->setThirdLevel($thirdLevel); + $manager->persist($relatedDummy); + + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + $dummy->setRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($relatedDummy); + $manager->persist($dummy); + + $manager->flush(); + $manager->clear(); + } + + private function createDummyTravel(): void + { + $manager = $this->getManager(); + + $car = new DummyCar(); + $car->setName('model x'); + $car->setCanSell(true); + $car->setAvailableAt(new \DateTime()); + $manager->persist($car); + + $passenger = new DummyPassenger(); + $passenger->nickname = 'Tom'; + $manager->persist($passenger); + + $travel = new DummyTravel(); + $travel->car = $car; + $travel->passenger = $passenger; + $travel->confirmed = true; + $manager->persist($travel); + + $manager->flush(); + $manager->clear(); + } + + private function assertDqlEquals(string $expected): void + { + $actual = EntityManager::$dql; + $expected = preg_replace('/\(\R */', '(', $expected); + $expected = preg_replace('/\R *\)/', ')', $expected); + $expected = preg_replace('/\R */', ' ', $expected); + + $this->assertSame($expected, $actual); + } +} diff --git a/tests/Functional/Doctrine/ExistsFilterTest.php b/tests/Functional/Doctrine/ExistsFilterTest.php new file mode 100644 index 00000000000..04bdb592cb9 --- /dev/null +++ b/tests/Functional/Doctrine/ExistsFilterTest.php @@ -0,0 +1,201 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedString as ConvertedStringDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedString; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ExistsFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + ConvertedString::class, + ]; + } + + public function testCollectionWhereScalarPropertyDoesNotExist(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithBoolean($resource, 15, true); + + $response = self::createClient()->request('GET', '/dummies?exists[dummyBoolean]=0', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame(0, $data['hydra:totalItems']); + $this->assertSame([], $data['hydra:member']); + } + + public function testCollectionWhereScalarPropertyDoesExist(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithBoolean($resource, 15, true); + + $response = self::createClient()->request('GET', '/dummies?exists[dummyBoolean]=1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(15, $data['hydra:totalItems']); + $this->assertCount(3, $data['hydra:member']); + foreach ($data['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('#^/dummies/(1|2|3)$#', $member['@id']); + } + } + + public function testCollectionWithEmptyRelationCollection(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource, $this->relatedDummyClass()]); + $this->createDummiesWithRelated($resource, 3, 0); + $this->createDummiesWithRelated($resource, 2, 3); + + $response = self::createClient()->request('GET', '/dummies?exists[relatedDummies]=0', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(3, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/dummies/1', '/dummies/2', '/dummies/3'], $ids); + } + + public function testCollectionWithNonEmptyRelationCollection(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource, $this->relatedDummyClass()]); + $this->createDummiesWithRelated($resource, 3, 0); + $this->createDummiesWithRelated($resource, 2, 3); + + $response = self::createClient()->request('GET', '/dummies?exists[relatedDummies]=1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/dummies/4', '/dummies/5'], $ids); + } + + public function testCollectionFilteredUsingNameConverter(): void + { + $resource = $this->isMongoDB() ? ConvertedStringDocument::class : ConvertedString::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 4; ++$i) { + $entity = new $resource(); + $entity->nameConverted = ($i % 2) ? "name#$i" : null; + $manager->persist($entity); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_strings?exists[name_converted]=true', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/converted_strings/1', '/converted_strings/3'], $ids); + foreach ($data['hydra:member'] as $member) { + $this->assertSame('ConvertedString', $member['@type']); + $this->assertMatchesRegularExpression('/^name#(1|3)$/', $member['name_converted']); + } + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @return class-string + */ + private function relatedDummyClass(): string + { + return $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + } + + /** + * @param class-string $resource + */ + private function createDummiesWithBoolean(string $resource, int $nb, bool $bool): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDummyBoolean($bool); + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $resource + */ + private function createDummiesWithRelated(string $resource, int $nb, int $nbRelated): void + { + $manager = $this->getManager(); + $relatedDummyClass = $this->relatedDummyClass(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + for ($j = 1; $j <= $nbRelated; ++$j) { + $relatedDummy = new $relatedDummyClass(); + $relatedDummy->setName('RelatedDummy'.$j.$i); + $relatedDummy->setAge((int) ($j.$i)); + $manager->persist($relatedDummy); + $dummy->addRelatedDummy($relatedDummy); + } + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/LinkHandlerTest.php b/tests/Functional/Doctrine/LinkHandlerTest.php new file mode 100644 index 00000000000..8f52ba454da --- /dev/null +++ b/tests/Functional/Doctrine/LinkHandlerTest.php @@ -0,0 +1,74 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\LinkHandledDummy as LinkHandledDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\LinkHandledDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class LinkHandlerTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [LinkHandledDummy::class]; + } + + public function testGetCollectionFiltersBySlugViaLinksHandler(): void + { + $resource = $this->isMongoDB() ? LinkHandledDummyDocument::class : LinkHandledDummy::class; + $this->recreateSchema([$resource]); + + $manager = $this->getManager(); + foreach (['foo', 'bar', 'baz', 'foz'] as $slug) { + $manager->persist(new $resource($slug)); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/link_handled_dummies', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(1, $response->toArray()['hydra:totalItems']); + } + + public function testGetItemReturnsSlug(): void + { + $resource = $this->isMongoDB() ? LinkHandledDummyDocument::class : LinkHandledDummy::class; + $this->recreateSchema([$resource]); + + $manager = $this->getManager(); + foreach (['foo', 'bar', 'baz', 'foz'] as $slug) { + $manager->persist(new $resource($slug)); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/link_handled_dummies/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('foo', $response->toArray()['slug']); + } +} diff --git a/tests/Functional/Doctrine/MappedSuperclassPutTest.php b/tests/Functional/Doctrine/MappedSuperclassPutTest.php new file mode 100644 index 00000000000..80088c63df6 --- /dev/null +++ b/tests/Functional/Doctrine/MappedSuperclassPutTest.php @@ -0,0 +1,62 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyMappedSubclass; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class MappedSuperclassPutTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyMappedSubclass::class]; + } + + public function testStandardPutOnEntityInheritedFromMappedSuperclass(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Not tested with mongodb.'); + } + + $this->recreateSchema([DummyMappedSubclass::class]); + + $manager = $this->getManager(); + $manager->persist(new DummyMappedSubclass()); + $manager->flush(); + + $response = self::createClient()->request('PUT', '/dummy_mapped_subclasses/1', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'updated value'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertJsonContains([ + '@context' => '/contexts/DummyMappedSubclass', + '@id' => '/dummy_mapped_subclasses/1', + '@type' => 'DummyMappedSubclass', + 'id' => 1, + 'foo' => 'updated value', + ]); + } +} diff --git a/tests/Functional/Doctrine/MultipleFilterTest.php b/tests/Functional/Doctrine/MultipleFilterTest.php new file mode 100644 index 00000000000..787615cebf3 --- /dev/null +++ b/tests/Functional/Doctrine/MultipleFilterTest.php @@ -0,0 +1,89 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class MultipleFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Dummy::class]; + } + + public function testCollectionFilteredByDateAndBoolean(): void + { + $resource = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30, true); + $this->createDummies($resource, 20, false); + + $response = self::createClient()->request('GET', '/dummies?dummyDate[after]=2015-04-28&dummyBoolean=1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + + $data = $response->toArray(); + $this->assertSame('/contexts/Dummy', $data['@context']); + $this->assertSame('/dummies', $data['@id']); + $this->assertSame('hydra:Collection', $data['@type']); + $this->assertCount(2, $data['hydra:member']); + + $ids = array_map(static fn (array $item): string => $item['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/dummies/28', '/dummies/29'], $ids); + + $this->assertSame('hydra:PartialCollectionView', $data['hydra:view']['@type']); + $this->assertSame('/dummies?dummyBoolean=1&dummyDate%5Bafter%5D=2015-04-28', $data['hydra:view']['@id']); + } + + /** + * @param class-string $resource + */ + private function createDummies(string $resource, int $nb, bool $bool): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $manager = $this->getManager(); + + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->setDummyBoolean($bool); + + if ($nb !== $i) { + $dummy->setDummyDate(new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC'))); + } + + $manager->persist($dummy); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/NumericFilterTest.php b/tests/Functional/Doctrine/NumericFilterTest.php new file mode 100644 index 00000000000..b120b28ea68 --- /dev/null +++ b/tests/Functional/Doctrine/NumericFilterTest.php @@ -0,0 +1,146 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedInteger as ConvertedIntegerDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedInteger; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class NumericFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Dummy::class, ConvertedInteger::class]; + } + + public function testCollectionByDummyPrice(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithPrice($resource, 10); + + $response = self::createClient()->request('GET', '/dummies?dummyPrice=9.99', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(3, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/dummies/1', '/dummies/5', '/dummies/9'], $ids); + } + + public function testCollectionByMultipleDummyPrice(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithPrice($resource, 10); + + $response = self::createClient()->request('GET', '/dummies?dummyPrice[]=9.99&dummyPrice[]=12.99', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(6, $data['hydra:totalItems']); + $this->assertCount(3, $data['hydra:member']); + foreach ($data['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('#^/dummies/(1|2|5|6|9|10)$#', $member['@id']); + } + } + + public function testCollectionByNonNumericDummyPriceIsIgnored(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithPrice($resource, 10); + $this->createDummiesWithPrice($resource, 10); + + $response = self::createClient()->request('GET', '/dummies?dummyPrice=marty', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(20, $data['hydra:totalItems']); + } + + public function testCollectionFilteredUsingNameConverter(): void + { + $resource = $this->isMongoDB() ? ConvertedIntegerDocument::class : ConvertedInteger::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 5; ++$i) { + $entity = new $resource(); + $entity->nameConverted = $i; + $manager->persist($entity); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_integers?name_converted[]=2&name_converted[]=3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/converted_integers/2', '/converted_integers/3'], $ids); + foreach ($data['hydra:member'] as $member) { + $this->assertSame('ConvertedInteger', $member['@type']); + $this->assertIsInt($member['name_converted']); + } + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @param class-string $resource + */ + private function createDummiesWithPrice(string $resource, int $nb): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $prices = ['9.99', '12.99', '15.99', '19.99']; + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->setDummyPrice($prices[($i - 1) % 4]); + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/OrderFilterTest.php b/tests/Functional/Doctrine/OrderFilterTest.php new file mode 100644 index 00000000000..a4f6c2473c8 --- /dev/null +++ b/tests/Functional/Doctrine/OrderFilterTest.php @@ -0,0 +1,314 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedInteger as ConvertedIntegerDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddableDummy as EmbeddableDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddedDummy as EmbeddedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedInteger; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\TestWith; + +final class OrderFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + EmbeddedDummy::class, + ConvertedInteger::class, + ]; + } + + #[TestWith(['order[id]=asc', ['/dummies/1', '/dummies/2', '/dummies/3']])] + #[TestWith(['order[id]=desc', ['/dummies/30', '/dummies/29', '/dummies/28']])] + #[TestWith(['order[name]=asc', ['/dummies/1', '/dummies/10', '/dummies/11']])] + #[TestWith(['order[name]=desc', ['/dummies/9', '/dummies/8', '/dummies/7']])] + public function testOrderDummies(string $query, array $expectedIds): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?'.$query, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame($expectedIds, array_map(static fn (array $i): string => $i['@id'], $data['hydra:member'])); + } + + public function testOrderByMultipleProperties(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?order[name]=desc&order[id]=desc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/39', '/dummies/9', '/dummies/38'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testOrderByAssociation(): void + { + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource]); + $this->createDummiesWithRelatedDummy($resource, $relatedResource, 30); + + $response = self::createClient()->request('GET', '/dummies?order[relatedDummy]=asc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/1', '/dummies/2', '/dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testOrderByEmbedded(): void + { + $embeddedClass = $this->isMongoDB() ? EmbeddedDummyDocument::class : EmbeddedDummy::class; + $embeddableClass = $this->isMongoDB() ? EmbeddableDummyDocument::class : EmbeddableDummy::class; + $this->recreateSchema([$embeddedClass]); + + $manager = $this->getManager(); + for ($i = 1; $i <= 30; ++$i) { + $embeddable = new $embeddableClass(); + $embeddable->setDummyName('EmbeddedDummy #'.$i); + $dummy = new $embeddedClass(); + $dummy->setName('Dummy #'.$i); + $dummy->setEmbeddedDummy($embeddable); + $manager->persist($dummy); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/embedded_dummies?order[embeddedDummy]=asc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/embedded_dummies/1', '/embedded_dummies/2', '/embedded_dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testOrderByEmbeddedStringWithoutValueReturns422(): void + { + $resource = $this->isMongoDB() ? EmbeddedDummyDocument::class : EmbeddedDummy::class; + $this->recreateSchema([$resource]); + + self::createClient()->request('GET', '/embedded_dummies?order[embeddedDummy.dummyName]', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(422); + } + + #[TestWith(['order[alias]=asc'])] + #[TestWith(['order[alias]=desc'])] + #[TestWith(['order[unknown]=asc'])] + #[TestWith(['order[unknown]=desc'])] + public function testOrderByUnsupportedProperty(string $query): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?'.$query, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/1', '/dummies/2', '/dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testOrderByRelatedProperty(): void + { + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource]); + $this->createDummiesWithRelatedDummy($resource, $relatedResource, 2); + + $response = self::createClient()->request('GET', '/dummies?order[relatedDummy.name]=desc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/2', '/dummies/1'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testOrderUsingNameConverter(): void + { + $resource = $this->isMongoDB() ? ConvertedIntegerDocument::class : ConvertedInteger::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 3; ++$i) { + $entity = new $resource(); + $entity->nameConverted = $i; + $manager->persist($entity); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_integers?order[name_converted]=desc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/converted_integers/3', '/converted_integers/2', '/converted_integers/1'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + foreach ($data['hydra:member'] as $member) { + $this->assertSame('ConvertedInteger', $member['@type']); + $this->assertIsInt($member['name_converted']); + } + + $this->assertSame('hydra:IriTemplate', $data['hydra:search']['@type']); + $this->assertSame('BasicRepresentation', $data['hydra:search']['hydra:variableRepresentation']); + $this->assertStringMatchesFormat('/converted_integers{?%a}', $data['hydra:search']['hydra:template']); + $variables = array_map(static fn (array $m): string => $m['variable'], $data['hydra:search']['hydra:mapping']); + sort($variables); + $this->assertSame([ + 'name_converted', + 'name_converted[]', + 'name_converted[between]', + 'name_converted[gt]', + 'name_converted[gte]', + 'name_converted[lt]', + 'name_converted[lte]', + 'order[name_converted]', + ], $variables); + foreach ($data['hydra:search']['hydra:mapping'] as $mapping) { + $this->assertSame('IriTemplateMapping', $mapping['@type']); + $this->assertSame('name_converted', $mapping['property']); + } + } + + public function testOrderListSyntaxIsAccepted(): void + { + $resource = $this->isMongoDB() ? ConvertedIntegerDocument::class : ConvertedInteger::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 3; ++$i) { + $entity = new $resource(); + $entity->nameConverted = $i; + $manager->persist($entity); + } + $manager->flush(); + + self::createClient()->request('GET', '/converted_integers?order[]=desc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @return class-string + */ + private function relatedDummyClass(): string + { + return $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + } + + /** + * @param class-string $resource + */ + private function createDummies(string $resource, int $nb): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDummy('SomeDummyTest'.$i); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->nameConverted = 'Converted '.$i; + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $resource + * @param class-string $relatedResource + */ + private function createDummiesWithRelatedDummy(string $resource, string $relatedResource, int $nb): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $relatedDummy = new $relatedResource(); + $relatedDummy->setName('RelatedDummy #'.$i); + + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->nameConverted = "Converted $i"; + $dummy->setRelatedDummy($relatedDummy); + + $manager->persist($relatedDummy); + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/RangeFilterTest.php b/tests/Functional/Doctrine/RangeFilterTest.php new file mode 100644 index 00000000000..d8da6fda3ee --- /dev/null +++ b/tests/Functional/Doctrine/RangeFilterTest.php @@ -0,0 +1,123 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedInteger as ConvertedIntegerDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedInteger; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\TestWith; + +final class RangeFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Dummy::class, ConvertedInteger::class]; + } + + protected function setUp(): void + { + parent::setUp(); + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummiesWithPrice($resource, 30); + } + + #[TestWith(['dummyPrice[between]=12.99..15.99', 15])] + #[TestWith(['dummyPrice[between]=12.99..12.99', 8])] + #[TestWith(['dummyPrice[between]=9.99..12.99..15.99', 30])] + #[TestWith(['dummyPrice[lt]=12.99', 8])] + #[TestWith(['dummyPrice[lte]=12.99', 16])] + #[TestWith(['dummyPrice[gt]=15.99', 7])] + #[TestWith(['dummyPrice[gte]=15.99', 14])] + #[TestWith(['dummyPrice[gt]=12.99&dummyPrice[lt]=19.99', 7])] + #[TestWith(['dummyPrice[gt]=19.99', 0])] + public function testRangeFilter(string $query, int $expectedTotal): void + { + $response = self::createClient()->request('GET', '/dummies?'.$query, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame($expectedTotal, $data['hydra:totalItems']); + } + + public function testCollectionFilteredUsingNameConverter(): void + { + $resource = $this->isMongoDB() ? ConvertedIntegerDocument::class : ConvertedInteger::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 5; ++$i) { + $entity = new $resource(); + $entity->nameConverted = $i; + $manager->persist($entity); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_integers?name_converted[lte]=2', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(2, $data['hydra:totalItems']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids); + $this->assertSame(['/converted_integers/1', '/converted_integers/2'], $ids); + foreach ($data['hydra:member'] as $member) { + $this->assertSame('ConvertedInteger', $member['@type']); + $this->assertIsInt($member['name_converted']); + } + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @param class-string $resource + */ + private function createDummiesWithPrice(string $resource, int $nb): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $prices = ['9.99', '12.99', '15.99', '19.99']; + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->setDummyPrice($prices[($i - 1) % 4]); + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/SearchFilterTest.php b/tests/Functional/Doctrine/SearchFilterTest.php new file mode 100644 index 00000000000..a5a42fc3231 --- /dev/null +++ b/tests/Functional/Doctrine/SearchFilterTest.php @@ -0,0 +1,802 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5605\MainResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5605\SubResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5648\DummyResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedOwner as ConvertedOwnerDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedRelated as ConvertedRelatedDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDate as DummyDateDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddableDummy as EmbeddableDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\EmbeddedDummy as EmbeddedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FourthLevel as FourthLevelDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ThirdLevel as ThirdLevelDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedOwner; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedRelated; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCarColor; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDate; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummySubEntity; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyWithSubEntity; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddableDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\EmbeddedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FourthLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5735\Group; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5735\Issue5735User; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\Common\Collections\ArrayCollection; +use Symfony\Component\Uid\Uuid as SymfonyUuid; + +final class SearchFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + DummyFriend::class, + RelatedToDummyFriend::class, + EmbeddedDummy::class, + ThirdLevel::class, + FourthLevel::class, + DummyCar::class, + DummyCarColor::class, + DummyDate::class, + ConvertedOwner::class, + ConvertedRelated::class, + DummyResource::class, + MainResource::class, + SubResource::class, + DummyWithSubEntity::class, + DummySubEntity::class, + Group::class, + Issue5735User::class, + ]; + } + + public function testManyToManyWithFilterOnJoinTable(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('HAL relation filter requires ORM join table.'); + } + + $this->recreateSchema([ + RelatedDummy::class, DummyFriend::class, RelatedToDummyFriend::class, + ThirdLevel::class, FourthLevel::class, + ]); + $this->createRelatedDummyWithFriends(4); + + $response = self::createClient()->request('GET', '/related_dummies?relatedToDummyFriend.dummyFriend=/dummy_friends/4', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertCount(1, $data['_embedded']['item']); + $this->assertSame(1, $data['_embedded']['item'][0]['id']); + $this->assertCount(4, $data['_embedded']['item'][0]['_links']['relatedToDummyFriend']); + $this->assertCount(4, $data['_embedded']['item'][0]['_embedded']['relatedToDummyFriend']); + } + + public function testSearchManyToManyWithRelatedEntity(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('DummyCar/Color is ORM only in this scenario.'); + } + $this->recreateSchema([DummyCar::class, DummyCarColor::class]); + $this->createDummyCarWithColors(); + + $response = self::createClient()->request('GET', '/dummy_cars?colors.prop=red', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(1, $data['hydra:totalItems']); + $this->assertSame('/dummy_cars/1', $data['hydra:member'][0]['@id']); + $this->assertCount(2, $data['hydra:member'][0]['colors']); + $this->assertSame('red', $data['hydra:member'][0]['colors'][0]['prop']); + $this->assertSame('blue', $data['hydra:member'][0]['colors'][1]['prop']); + } + + public function testSearchByNamePartial(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?name=my', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/1', '/dummies/2', '/dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testSearchEmbeddedByName(): void + { + $embeddedClass = $this->isMongoDB() ? EmbeddedDummyDocument::class : EmbeddedDummy::class; + $embeddableClass = $this->isMongoDB() ? EmbeddableDummyDocument::class : EmbeddableDummy::class; + $this->recreateSchema([$embeddedClass]); + $this->createEmbeddedDummies($embeddedClass, $embeddableClass, 30); + + $response = self::createClient()->request('GET', '/embedded_dummies?embeddedDummy.dummyName=my', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/embedded_dummies/1', '/embedded_dummies/2', '/embedded_dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testSearchByNameMultipleValues(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?name[]=2&name[]=3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids, \SORT_NATURAL); + $this->assertSame(['/dummies/2', '/dummies/3', '/dummies/12'], $ids); + } + + public function testSearchByDummyCaseInsensitive(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?dummy=somedummytest1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + foreach ($response->toArray()['hydra:member'] as $member) { + $this->assertMatchesRegularExpression('/^SomeDummyTest\d{1,2}$/', $member['dummy']); + } + } + + public function testSearchByAliasStart(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?alias=Ali', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertCount(3, $response->toArray()['hydra:member']); + } + + public function testSearchByDescriptionMultipleStart(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?description[]=Sma&description[]=Not', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertCount(3, $response->toArray()['hydra:member']); + } + + public function testSearchByDescriptionWordStartSqlite(): void + { + if (!$this->isSqlite()) { + $this->markTestSkipped('SQLite-specific: case-insensitive default LIKE.'); + } + + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?description=smart', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/1', '/dummies/2', '/dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testSearchByDescriptionWordStartMultipleSqlite(): void + { + if (!$this->isSqlite()) { + $this->markTestSkipped('SQLite-specific: case-insensitive default LIKE.'); + } + + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?description[]=smart&description[]=so', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame( + ['/dummies/1', '/dummies/2', '/dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']) + ); + } + + public function testSearchByDescriptionWordStartPostgres(): void + { + if (!$this->isPostgres()) { + $this->markTestSkipped('Postgres-specific: case-sensitive default LIKE.'); + } + + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?description=smart', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids, \SORT_NATURAL); + $this->assertSame(['/dummies/2', '/dummies/4', '/dummies/6'], $ids); + } + + public function testSearchEmptyResult(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?name=MuYm', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame([], $response->toArray()['hydra:member']); + } + + public function testSearchByExistingCollectionRouteNameSqlite(): void + { + if (!$this->isSqlite()) { + $this->markTestSkipped('SQLite-specific.'); + } + + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?relatedDummies=dummy_cars', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertIsArray($response->toArray()['hydra:member']); + } + + public function testSearchRelatedCollectionByName(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('HAL relation filter requires ORM join table.'); + } + + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource]); + $this->createDummiesEachWithRelatedDummies($resource, $relatedResource, 3, 3); + + $response = self::createClient()->request('GET', '/dummies?relatedDummies.name=RelatedDummy1', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertCount(3, $data['_embedded']['item']); + foreach ($data['_embedded']['item'] as $item) { + $this->assertCount(3, $item['_links']['relatedDummies']); + } + } + + public function testSearchByRelatedCollectionId(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('HAL relation filter requires ORM join table.'); + } + + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource]); + $this->createDummiesEachWithRelatedDummies($resource, $relatedResource, 2, 2); + + $response = self::createClient()->request('GET', '/dummies?relatedDummies=3', [ + 'headers' => ['Accept' => 'application/hal+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(1, $data['totalItems']); + $this->assertCount(1, $data['_links']['item']); + $this->assertSame('/dummies/2', $data['_links']['item'][0]['href']); + } + + public function testCollectionByIdNonInteger(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?id=9.99', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + ['/dummies/1', '/dummies/2', '/dummies/3'], + array_map(static fn (array $i): string => $i['@id'], $response->toArray()['hydra:member']) + ); + } + + public function testCollectionById(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?id=10', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertCount(1, $data['hydra:member']); + $this->assertSame('/dummies/10', $data['hydra:member'][0]['@id']); + } + + public function testCollectionFilteredByUnknownProperty(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?unknown=0', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertCount(3, $response->toArray()['hydra:member']); + + $response = self::createClient()->request('GET', '/dummies?unknown=1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + $this->assertResponseIsSuccessful(); + $this->assertCount(3, $response->toArray()['hydra:member']); + } + + public function testSearchAtThirdLevel(): void + { + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource, $this->thirdLevelClass(), $this->fourthLevelClass()]); + $this->createDummiesEachWithRelatedDummies($resource, $relatedResource, 30, 0); + $this->createDummyWithFourthLevelRelation(); + + $response = self::createClient()->request('GET', '/dummies?relatedDummy.thirdLevel.level=3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(['/dummies/31'], array_map(static fn (array $i): string => $i['@id'], $data['hydra:member'])); + } + + public function testSearchAtFourthLevel(): void + { + $resource = $this->dummyClass(); + $relatedResource = $this->relatedDummyClass(); + $this->recreateSchema([$resource, $relatedResource, $this->thirdLevelClass(), $this->fourthLevelClass()]); + $this->createDummiesEachWithRelatedDummies($resource, $relatedResource, 30, 0); + $this->createDummyWithFourthLevelRelation(); + + $response = self::createClient()->request('GET', '/dummies?relatedDummy.thirdLevel.fourthLevel.level=4', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertSame(['/dummies/31'], array_map(static fn (array $i): string => $i['@id'], $data['hydra:member'])); + } + + public function testSearchUsingNameConverter(): void + { + $resource = $this->dummyClass(); + $this->recreateSchema([$resource]); + $this->createDummies($resource, 30); + + $response = self::createClient()->request('GET', '/dummies?name_converted=Converted 3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertCount(2, $data['hydra:member']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids, \SORT_NATURAL); + $this->assertSame(['/dummies/3', '/dummies/30'], $ids); + } + + public function testSearchUsingNestedNameConverter(): void + { + $ownerClass = $this->isMongoDB() ? ConvertedOwnerDocument::class : ConvertedOwner::class; + $relatedClass = $this->isMongoDB() ? ConvertedRelatedDocument::class : ConvertedRelated::class; + $this->recreateSchema([$ownerClass, $relatedClass]); + + $manager = $this->getManager(); + for ($i = 1; $i <= 30; ++$i) { + $related = new $relatedClass(); + $related->nameConverted = 'Converted '.$i; + $owner = new $ownerClass(); + $owner->nameConverted = $related; + $manager->persist($related); + $manager->persist($owner); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/converted_owners?name_converted.name_converted=Converted 3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertCount(2, $data['hydra:member']); + $ids = array_map(static fn (array $i): string => $i['@id'], $data['hydra:member']); + sort($ids, \SORT_NATURAL); + $this->assertSame(['/converted_owners/3', '/converted_owners/30'], $ids); + } + + public function testSearchByDate(): void + { + $resource = $this->isMongoDB() ? DummyDateDocument::class : DummyDate::class; + $this->recreateSchema([$resource]); + $manager = $this->getManager(); + for ($i = 1; $i <= 3; ++$i) { + $dummy = new $resource(); + $dummy->dummyDate = new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC')); + $manager->persist($dummy); + } + $manager->flush(); + + $response = self::createClient()->request('GET', '/dummy_dates?dummyDate=2015-04-01', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(1, $response->toArray()['hydra:totalItems']); + } + + public function testCustomSearchFilterUsingDoctrineExpressions(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Custom Doctrine expression filter is ORM only.'); + } + + $this->recreateSchema([Dummy::class, RelatedDummy::class, ThirdLevel::class, FourthLevel::class]); + $this->createDummyWithRelatedDummiesAndThirdLevel(3); + + $response = self::createClient()->request('GET', '/dummy_resource_with_custom_filter?custom=3', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(1, $response->toArray()['hydra:totalItems']); + } + + public function testSearchOnSubEntityWithStringIdentifier(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('DummySubEntity is ORM only.'); + } + + $this->recreateSchema([DummyWithSubEntity::class, DummySubEntity::class]); + $manager = $this->getManager(); + $subEntity = new DummySubEntity('stringId', 'someName'); + $mainEntity = new DummyWithSubEntity(); + $mainEntity->setSubEntity($subEntity); + $mainEntity->setName('main'); + $manager->persist($subEntity); + $manager->persist($mainEntity); + $manager->flush(); + + $response = self::createClient()->request('GET', '/dummy_with_subresource?subEntity=/dummy_subresource/stringId', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertSame(1, $response->toArray()['hydra:totalItems']); + } + + public function testFiltersCanUseUuids(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Issue5735 fixture is ORM only.'); + } + + $this->recreateSchema([Group::class, Issue5735User::class]); + $manager = $this->getManager(); + + $group1 = new Group(); + $group1->setUuid(SymfonyUuid::fromString('61817181-0ecc-42fb-a6e7-d97f2ddcb344')); + $manager->persist($group1); + for ($i = 0; $i < 2; ++$i) { + $user = new Issue5735User(); + $user->addGroup($group1); + $manager->persist($user); + } + $manager->persist(new Issue5735User()); + + $group2 = new Group(); + $group2->setUuid(SymfonyUuid::fromString('32510d53-f737-4e70-8d9d-58e292c871f8')); + $manager->persist($group2); + $user = new Issue5735User(); + $user->addGroup($group2); + $manager->persist($user); + $manager->persist(new Issue5735User()); + + $manager->flush(); + + $response = self::createClient()->request( + 'GET', + '/issue5735/issue5735_users?groups[]=/issue5735/groups/61817181-0ecc-42fb-a6e7-d97f2ddcb344&groups[]=/issue5735/groups/32510d53-f737-4e70-8d9d-58e292c871f8', + ['headers' => ['Accept' => 'application/ld+json']] + ); + + $this->assertResponseIsSuccessful(); + $this->assertSame(3, $response->toArray()['hydra:totalItems']); + } + + /** + * @return class-string + */ + private function dummyClass(): string + { + return $this->isMongoDB() ? DummyDocument::class : Dummy::class; + } + + /** + * @return class-string + */ + private function relatedDummyClass(): string + { + return $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + } + + /** + * @return class-string + */ + private function thirdLevelClass(): string + { + return $this->isMongoDB() ? ThirdLevelDocument::class : ThirdLevel::class; + } + + /** + * @return class-string + */ + private function fourthLevelClass(): string + { + return $this->isMongoDB() ? FourthLevelDocument::class : FourthLevel::class; + } + + /** + * @param class-string $resource + */ + private function createDummies(string $resource, int $nb): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + $dummy->setDummy('SomeDummyTest'.$i); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->nameConverted = 'Converted '.$i; + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $embeddedClass + * @param class-string $embeddableClass + */ + private function createEmbeddedDummies(string $embeddedClass, string $embeddableClass, int $nb): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $embeddedClass(); + $dummy->setName('Dummy #'.$i); + $embeddable = new $embeddableClass(); + $embeddable->setDummyName('Dummy #'.$i); + $dummy->setEmbeddedDummy($embeddable); + $manager->persist($dummy); + } + $manager->flush(); + } + + /** + * @param class-string $resource + * @param class-string $relatedResource + */ + private function createDummiesEachWithRelatedDummies(string $resource, string $relatedResource, int $nb, int $nbRelated): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $dummy = new $resource(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($nb - $i)); + for ($j = 1; $j <= $nbRelated; ++$j) { + $relatedDummy = new $relatedResource(); + $relatedDummy->setName('RelatedDummy'.$j.$i); + $relatedDummy->setAge((int) ($j.$i)); + $manager->persist($relatedDummy); + $dummy->addRelatedDummy($relatedDummy); + } + $manager->persist($dummy); + } + $manager->flush(); + } + + private function createRelatedDummyWithFriends(int $nb): void + { + $manager = $this->getManager(); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName('RelatedDummy with friends'); + $manager->persist($relatedDummy); + $manager->flush(); + + for ($i = 1; $i <= $nb; ++$i) { + $friend = new DummyFriend(); + $friend->setName('Friend-'.$i); + $manager->persist($friend); + $manager->flush(); + + $relation = new RelatedToDummyFriend(); + $relation->setName('Relation-'.$i); + $relation->setDummyFriend($friend); + $relation->setRelatedDummy($relatedDummy); + $relatedDummy->addRelatedToDummyFriend($relation); + $manager->persist($relation); + } + $manager->flush(); + $manager->clear(); + } + + private function createDummyCarWithColors(): void + { + $manager = $this->getManager(); + $car = new DummyCar(); + $car->setName('mustli'); + $car->setCanSell(true); + $car->setAvailableAt(new \DateTime()); + $manager->persist($car); + $manager->flush(); + + $color1 = new DummyCarColor(); + $color1->setProp('red'); + $color1->setCar($car); + $manager->persist($color1); + + $color2 = new DummyCarColor(); + $color2->setProp('blue'); + $color2->setCar($car); + $manager->persist($color2); + $manager->flush(); + + $car->setColors(new ArrayCollection([$color1, $color2])); + $manager->persist($car); + $manager->flush(); + } + + private function createDummyWithFourthLevelRelation(): void + { + $manager = $this->getManager(); + + $fourthLevelClass = $this->fourthLevelClass(); + $thirdLevelClass = $this->thirdLevelClass(); + $relatedDummyClass = $this->relatedDummyClass(); + $dummyClass = $this->dummyClass(); + + $fourthLevel = new $fourthLevelClass(); + $fourthLevel->setLevel(4); + $manager->persist($fourthLevel); + + $thirdLevel = new $thirdLevelClass(); + $thirdLevel->setLevel(3); + $thirdLevel->setFourthLevel($fourthLevel); + $manager->persist($thirdLevel); + + $namedRelatedDummy = new $relatedDummyClass(); + $namedRelatedDummy->setName('Hello'); + $namedRelatedDummy->setThirdLevel($thirdLevel); + $manager->persist($namedRelatedDummy); + + $relatedDummy = new $relatedDummyClass(); + $relatedDummy->setThirdLevel($thirdLevel); + $manager->persist($relatedDummy); + + $dummy = new $dummyClass(); + $dummy->setName('Dummy with relations'); + $dummy->setRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($relatedDummy); + $manager->persist($dummy); + + $manager->flush(); + $manager->clear(); + } + + private function createDummyWithRelatedDummiesAndThirdLevel(int $nb): void + { + $manager = $this->getManager(); + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + for ($i = 1; $i <= $nb; ++$i) { + $thirdLevel = new ThirdLevel(); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName('RelatedDummy #'.$i); + $relatedDummy->setThirdLevel($thirdLevel); + $dummy->addRelatedDummy($relatedDummy); + $manager->persist($thirdLevel); + $manager->persist($relatedDummy); + } + $manager->persist($dummy); + $manager->flush(); + } +} diff --git a/tests/Functional/Doctrine/SeparatedResourceTest.php b/tests/Functional/Doctrine/SeparatedResourceTest.php new file mode 100644 index 00000000000..b19cd54d433 --- /dev/null +++ b/tests/Functional/Doctrine/SeparatedResourceTest.php @@ -0,0 +1,146 @@ + + * + * 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\Doctrine; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\EntityClassAndCustomProviderResource; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ResourceWithSeparatedEntity; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResourceOdm\ResourceWithSeparatedDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\SeparatedEntity as SeparatedDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SeparatedEntity; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class SeparatedResourceTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + ResourceWithSeparatedEntity::class, + ResourceWithSeparatedDocument::class, + EntityClassAndCustomProviderResource::class, + ]; + } + + public function testGetCollection(): void + { + $resource = $this->isMongoDB() ? SeparatedDocument::class : SeparatedEntity::class; + $this->recreateSchema([$resource]); + $this->createSeparatedEntities($resource, 5); + + $uri = $this->isMongoDB() ? '/separated_documents' : '/separated_entities'; + $shortName = $this->isMongoDB() ? 'SeparatedDocument' : 'SeparatedEntity'; + + $response = self::createClient()->request('GET', $uri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $data = $response->toArray(); + $this->assertSame('/contexts/'.$shortName, $data['@context']); + $this->assertStringStartsWith($uri, $data['@id']); + $this->assertSame('hydra:Collection', $data['@type']); + $this->assertIsArray($data['hydra:member']); + $this->assertIsInt($data['hydra:totalItems']); + $this->assertArrayHasKey('hydra:view', $data); + } + + public function testGetOrderedCollection(): void + { + $resource = $this->isMongoDB() ? SeparatedDocument::class : SeparatedEntity::class; + $this->recreateSchema([$resource]); + $this->createSeparatedEntities($resource, 5); + + $uri = $this->isMongoDB() ? '/separated_documents' : '/separated_entities'; + + $response = self::createClient()->request('GET', $uri.'?order[value]=desc', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + $this->assertSame('5', $response->toArray()['hydra:member'][0]['value']); + } + + public function testGetItem(): void + { + $resource = $this->isMongoDB() ? SeparatedDocument::class : SeparatedEntity::class; + $this->recreateSchema([$resource]); + $this->createSeparatedEntities($resource, 5); + + $uri = $this->isMongoDB() ? '/separated_documents/1' : '/separated_entities/1'; + + self::createClient()->request('GET', $uri, [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); + } + + public function testGetAllEntityClassAndCustomProviderResources(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('EntityClassAndCustomProviderResource uses ORM stateOptions only.'); + } + + $this->recreateSchema([SeparatedEntity::class]); + $this->createSeparatedEntities(SeparatedEntity::class, 1); + + self::createClient()->request('GET', '/entityClassAndCustomProviderResources', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + } + + public function testGetOneEntityClassAndCustomProviderResource(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('EntityClassAndCustomProviderResource uses ORM stateOptions only.'); + } + + $this->recreateSchema([SeparatedEntity::class]); + $this->createSeparatedEntities(SeparatedEntity::class, 1); + + self::createClient()->request('GET', '/entityClassAndCustomProviderResources/1', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + } + + /** + * @param class-string $resource + */ + private function createSeparatedEntities(string $resource, int $nb): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $entity = new $resource(); + $entity->value = (string) $i; + $manager->persist($entity); + } + $manager->flush(); + } +} diff --git a/tests/Functional/EnumDenormalizationValidationTest.php b/tests/Functional/EnumDenormalizationValidationTest.php index 8d340915433..3fa939623c8 100644 --- a/tests/Functional/EnumDenormalizationValidationTest.php +++ b/tests/Functional/EnumDenormalizationValidationTest.php @@ -17,6 +17,8 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\EnumValidationResource; use ApiPlatform\Tests\SetupClassResourcesTrait; use Composer\InstalledVersions; +use Composer\Semver\VersionParser; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** * @see https://github.com/api-platform/core/issues/8183 @@ -65,8 +67,13 @@ public function testInvalidBackedEnumValueProducesValidationViolation(): void $this->assertNotNull($genderViolation, 'Expected a constraint violation on "gender" property.'); } + #[IgnoreDeprecations] public function testInvalidBackedEnumValueWithCollectDenormalizationErrors(): void { + if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) { + $this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.'); + } + $response = static::createClient()->request('POST', '/enum_validation_resources_collect', [ 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['gender' => 'unknown'], diff --git a/tests/Functional/GraphQl/AuthorizationTest.php b/tests/Functional/GraphQl/AuthorizationTest.php new file mode 100644 index 00000000000..14a238c1ba9 --- /dev/null +++ b/tests/Functional/GraphQl/AuthorizationTest.php @@ -0,0 +1,590 @@ + + * + * 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\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedLinkedDummy as RelatedLinkedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedSecuredDummy as RelatedSecuredDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\SecuredDummy as SecuredDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedLinkedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedSecuredDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SecuredDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class AuthorizationTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + private const ADMIN_AUTH = 'Basic YWRtaW46a2l0dGVu'; + private const DUNGLAS_AUTH = 'Basic ZHVuZ2xhczprZXZpbg=='; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + SecuredDummy::class, + RelatedDummy::class, + RelatedSecuredDummy::class, + RelatedLinkedDummy::class, + ]; + } + + public function testAnonymousCannotReadSecuredItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummies(1); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + title + description + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertNull($data['data']['securedDummy']); + } + + public function testAnonymousCannotReadSecuredCollection(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummies(1); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummies { + edges { node { title description } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertNull($data['data']['securedDummies']); + } + + public function testAdminCanReadSecuredCollection(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummies(1); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummies { + edges { node { title description } } + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertNotNull($response->toArray()['data']['securedDummies']); + } + + public function testUserCannotReadSecuredCollection(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummies(1); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummies { + edges { node { title description } } + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertNull($data['data']['securedDummies']); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + } + + public function testAnonymousCannotCreateSecuredResource(): void + { + $this->recreateAuthSchema(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createSecuredDummy(input: {owner: "me", title: "Hi", description: "Desc", adminOnlyProperty: "secret", clientMutationId: "auth"}) { + securedDummy { + title + owner + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Only admins can create a secured dummy.', $data['errors'][0]['message']); + $this->assertNull($data['data']['createSecuredDummy']); + } + + public function testAdminCanAccessSecuredRelationsOwnedByAdmin(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummiesWithRelations(1, 'admin'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + relatedDummies { edges { node { id } } } + relatedDummy { id } + relatedSecuredDummies { edges { node { id } } } + relatedSecuredDummy { id } + publicRelatedSecuredDummies { edges { node { id } } } + publicRelatedSecuredDummy { id } + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['securedDummy']; + $this->assertCount(1, $data['relatedDummies']['edges']); + $this->assertNotNull($data['relatedDummy']); + $this->assertCount(1, $data['relatedSecuredDummies']['edges']); + $this->assertNotNull($data['relatedSecuredDummy']); + $this->assertCount(1, $data['publicRelatedSecuredDummies']['edges']); + $this->assertNotNull($data['publicRelatedSecuredDummy']); + } + + public function testUserCannotReadSecuredCollectionRelationOnSecuredItemTheyDoNotOwn(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummiesWithRelations(1, 'someone-else'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + relatedDummies { edges { node { id } } } + relatedDummy { id } + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $secured = $response->toArray(false)['data']['securedDummy']; + $this->assertNull($secured['relatedDummies']); + $this->assertNull($secured['relatedDummy']); + } + + public function testUserCannotAccessRelatedSecuredDummyDirectly(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummiesWithRelations(1, 'dunglas'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + relatedSecuredDummy(id: "/related_secured_dummies/1") { + id + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertNull($data['data']['relatedSecuredDummy']); + } + + public function testUserCannotListRelatedSecuredDummies(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummiesWithRelations(1, 'dunglas'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + relatedSecuredDummies { + edges { node { id } } + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertNull($data['data']['relatedSecuredDummies']); + } + + public function testUserCanAccessSecuredRelationsOnOwnedDummy(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummiesWithRelations(1, 'dunglas'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + relatedSecuredDummies { edges { node { id } } } + relatedSecuredDummy { id } + publicRelatedSecuredDummies { edges { node { id } } } + publicRelatedSecuredDummy { id } + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['securedDummy']; + $this->assertCount(1, $data['relatedSecuredDummies']['edges']); + $this->assertNotNull($data['relatedSecuredDummy']); + $this->assertCount(1, $data['publicRelatedSecuredDummies']['edges']); + $this->assertNotNull($data['publicRelatedSecuredDummy']); + } + + public function testAdminCanCreateSecuredResource(): void + { + $this->recreateAuthSchema(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createSecuredDummy(input: {owner: "someone", title: "Hi", description: "Desc", adminOnlyProperty: "secret"}) { + securedDummy { + id + title + owner + } + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('someone', $response->toArray()['data']['createSecuredDummy']['securedDummy']['owner']); + } + + public function testAdminCanCreateOwnerOnlyPropertyWhenAdminIsOwner(): void + { + $this->recreateAuthSchema(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createSecuredDummy(input: {owner: "admin", title: "Hi", description: "Desc", adminOnlyProperty: "secret", ownerOnlyProperty: "it works"}) { + securedDummy { + ownerOnlyProperty + } + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('it works', $response->toArray()['data']['createSecuredDummy']['securedDummy']['ownerOnlyProperty']); + } + + public function testAdminCannotSetOwnerOnlyPropertyWhenNotOwner(): void + { + $this->recreateAuthSchema(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createSecuredDummy(input: {owner: "dunglas", title: "Hi", description: "Desc", adminOnlyProperty: "secret", ownerOnlyProperty: "should not be set"}) { + securedDummy { + ownerOnlyProperty + } + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertNull($response->toArray()['data']['createSecuredDummy']['securedDummy']['ownerOnlyProperty']); + } + + public function testUserCannotReadItemTheyDoNotOwn(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('admin'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + owner + title + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertNull($data['data']['securedDummy']); + } + + public function testUserCanReadItemTheyOwn(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + owner + title + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('dunglas', $response->toArray()['data']['securedDummy']['owner']); + } + + public function testAdminCanReadAdminOnlyPropertyOnOtherUsersItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas', adminProperty: 'admin secret'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + owner + title + adminOnlyProperty + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('admin secret', $response->toArray()['data']['securedDummy']['adminOnlyProperty']); + } + + public function testUserCannotReadAdminOnlyPropertyOnOwnedItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas', adminProperty: 'admin secret'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + owner + title + adminOnlyProperty + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertNull($response->toArray()['data']['securedDummy']['adminOnlyProperty']); + } + + public function testUserCanReadOwnerOnlyPropertyOnOwnedItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas', ownerProperty: 'owner secret'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + ownerOnlyProperty + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('owner secret', $response->toArray()['data']['securedDummy']['ownerOnlyProperty']); + } + + public function testUserCanUpdateOwnerOnlyPropertyOnOwnedItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas', ownerProperty: 'original'); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateSecuredDummy(input: {id: "/secured_dummies/1", ownerOnlyProperty: "updated"}) { + securedDummy { + ownerOnlyProperty + } + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('updated', $response->toArray()['data']['updateSecuredDummy']['securedDummy']['ownerOnlyProperty']); + } + + public function testAdminCannotReadOwnerOnlyPropertyOnOtherUsersItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas', ownerProperty: 'owner secret'); + + $response = $this->executeGraphQl(<<<'QUERY' + { + securedDummy(id: "/secured_dummies/1") { + ownerOnlyProperty + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertNull($response->toArray()['data']['securedDummy']['ownerOnlyProperty']); + } + + public function testUserCannotAssignItemTheyDoNotOwnToThemselves(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('someone'); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateSecuredDummy(input: {id: "/secured_dummies/1", owner: "kitten"}) { + securedDummy { id title owner } + } + } + QUERY, headers: ['Authorization' => self::ADMIN_AUTH]); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertNull($data['data']['updateSecuredDummy']); + } + + public function testUserCanTransferOwnedItem(): void + { + $this->recreateAuthSchema(); + $this->seedSecuredDummyWithOwner('dunglas'); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateSecuredDummy(input: {id: "/secured_dummies/1", owner: "vincent"}) { + securedDummy { id title owner } + } + } + QUERY, headers: ['Authorization' => self::DUNGLAS_AUTH]); + + $this->assertResponseIsSuccessful(); + $this->assertSame('vincent', $response->toArray()['data']['updateSecuredDummy']['securedDummy']['owner']); + } + + private function recreateAuthSchema(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? SecuredDummyDocument::class : SecuredDummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + $this->isMongoDB() ? RelatedSecuredDummyDocument::class : RelatedSecuredDummy::class, + $this->isMongoDB() ? RelatedLinkedDummyDocument::class : RelatedLinkedDummy::class, + ]); + } + + private function newSecuredDummy(): object + { + $class = $this->isMongoDB() ? SecuredDummyDocument::class : SecuredDummy::class; + + return new $class(); + } + + private function newRelatedDummy(): object + { + $class = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + + return new $class(); + } + + private function newRelatedSecuredDummy(): object + { + $class = $this->isMongoDB() ? RelatedSecuredDummyDocument::class : RelatedSecuredDummy::class; + + return new $class(); + } + + private function newRelatedLinkedDummy(): object + { + $class = $this->isMongoDB() ? RelatedLinkedDummyDocument::class : RelatedLinkedDummy::class; + + return new $class(); + } + + private function seedSecuredDummies(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $d = $this->newSecuredDummy(); + $d->setTitle("#$i"); + $d->setDescription("Hello #$i"); + $d->setOwner('notexist'); + $manager->persist($d); + } + $manager->flush(); + } + + private function seedSecuredDummyWithOwner(string $owner, ?string $adminProperty = null, ?string $ownerProperty = null): void + { + $manager = $this->getManager(); + $d = $this->newSecuredDummy(); + $d->setTitle('#1'); + $d->setDescription('Hello #1'); + $d->setOwner($owner); + if (null !== $adminProperty) { + $d->setAdminOnlyProperty($adminProperty); + } + if (null !== $ownerProperty) { + $d->setOwnerOnlyProperty($ownerProperty); + } + $manager->persist($d); + $manager->flush(); + } + + private function seedSecuredDummiesWithRelations(int $count, string $owner): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $secured = $this->newSecuredDummy(); + $secured->setTitle("#$i"); + $secured->setDescription("Hello #$i"); + $secured->setOwner($owner); + + $related = $this->newRelatedDummy(); + $related->setName('RelatedDummy'); + $manager->persist($related); + + $relatedSecured = $this->newRelatedSecuredDummy(); + $manager->persist($relatedSecured); + + $publicRelated = $this->newRelatedSecuredDummy(); + $manager->persist($publicRelated); + + $linked = $this->newRelatedLinkedDummy(); + $manager->persist($linked); + + $secured->addRelatedDummy($related); + $secured->setRelatedDummy($related); + $secured->addRelatedSecuredDummy($relatedSecured); + $secured->setRelatedSecuredDummy($relatedSecured); + $secured->addPublicRelatedSecuredDummy($publicRelated); + $secured->setPublicRelatedSecuredDummy($publicRelated); + $linked->setSecuredDummy($secured); + + $manager->persist($secured); + } + $manager->flush(); + } +} diff --git a/tests/Functional/GraphQl/CollectionTest.php b/tests/Functional/GraphQl/CollectionTest.php new file mode 100644 index 00000000000..e7931490ce1 --- /dev/null +++ b/tests/Functional/GraphQl/CollectionTest.php @@ -0,0 +1,923 @@ + + * + * 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\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCustomQuery as DummyCustomQueryDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDifferentGraphQlSerializationGroup as DummyDifferentGraphQlSerializationGroupDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyGroup as DummyGroupDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Foo as FooDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FooDummy as FooDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\MusicGroup as MusicGroupDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\SoMany as SoManyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ThirdLevel as ThirdLevelDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\VideoGame as VideoGameDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeItem; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeLabel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositePrimitiveItem; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCustomQuery; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDifferentGraphQlSerializationGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FooDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MusicGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SoMany; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VideoGame; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CollectionTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + ThirdLevel::class, + DummyGroup::class, + DummyCustomQuery::class, + DummyDifferentGraphQlSerializationGroup::class, + Foo::class, + FooDummy::class, + SoMany::class, + MusicGroup::class, + VideoGame::class, + CompositeRelation::class, + CompositeItem::class, + CompositeLabel::class, + CompositePrimitiveItem::class, + ]; + } + + public function testRetrieveCollectionWithRelations(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummyAndThirdLevel(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies { + ...dummyFields + } + } + fragment dummyFields on DummyCursorConnection { + edges { + node { + id + name + relatedDummy { + name + thirdLevel { id level } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertSame('Dummy #3', $edges[2]['node']['name']); + $this->assertSame('RelatedDummy #3', $edges[2]['node']['relatedDummy']['name']); + $this->assertSame(3, $edges[2]['node']['relatedDummy']['thirdLevel']['level']); + } + + public function testRetrieveEmptyCollection(): void + { + $this->recreateDummiesAndRelated(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies { + edges { node { name } } + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['dummies']; + $this->assertCount(0, $data['edges']); + $this->assertNull($data['pageInfo']['endCursor']); + $this->assertNull($data['pageInfo']['startCursor']); + $this->assertFalse($data['pageInfo']['hasNextPage']); + $this->assertFalse($data['pageInfo']['hasPreviousPage']); + } + + public function testRetrieveCollectionWithNestedCollection(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesEachWithRelatedDummies(4, 3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies { + edges { + node { + name + relatedDummies { + edges { node { name } } + } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertSame('Dummy #3', $edges[2]['node']['name']); + $this->assertSame('RelatedDummy23', $edges[2]['node']['relatedDummies']['edges'][1]['node']['name']); + } + + public function testRetrieveInverseSideNestedCollection(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? VideoGameDocument::class : VideoGame::class, + $this->isMongoDB() ? MusicGroupDocument::class : MusicGroup::class, + ]); + $this->seedVideoGameWithMusicGroups(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + musicGroups { + edges { + node { + name + videoGames { edges { node { name } } } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['musicGroups']['edges']; + $this->assertSame('Sum 41', $edges[0]['node']['name']); + $this->assertSame('Guitar Hero', $edges[0]['node']['videoGames']['edges'][0]['node']['name']); + $this->assertSame('Franz Ferdinand', $edges[1]['node']['name']); + $this->assertSame('Guitar Hero', $edges[1]['node']['videoGames']['edges'][0]['node']['name']); + } + + public function testRetrieveCollectionAndItemTogether(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + $this->isMongoDB() ? ThirdLevelDocument::class : ThirdLevel::class, + $this->isMongoDB() ? DummyGroupDocument::class : DummyGroup::class, + ]); + $this->seedDummiesWithDate(3); + $this->seedDummyGroups(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies { + edges { node { name dummyDate } } + } + dummyGroup(id: "/dummy_groups/2") { + foo + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + $this->assertSame('Dummy #2', $data['dummies']['edges'][1]['node']['name']); + $this->assertSame('2015-04-02', $data['dummies']['edges'][1]['node']['dummyDate']); + $this->assertSame('Foo #2', $data['dummyGroup']['foo']); + } + + public function testFirstNItems(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(first: 2) { + edges { node { name } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertCount(2, $response->toArray()['data']['dummies']['edges']); + } + + public function testFirstNItemsOnNestedCollection(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesEachWithRelatedDummies(2, 5); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(first: 1) { + edges { + node { + name + relatedDummies(first: 2) { + edges { node { name } } + } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(1, $edges); + $this->assertCount(2, $edges[0]['node']['relatedDummies']['edges']); + } + + public function testPaginationCursorsForward(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(first: 2) { + edges { cursor node { name } } + totalCount + pageInfo { startCursor endCursor hasNextPage hasPreviousPage } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['dummies']; + $this->assertCount(2, $data['edges']); + $this->assertSame(4, $data['totalCount']); + $this->assertSame('MQ==', $data['pageInfo']['endCursor']); + $this->assertTrue($data['pageInfo']['hasNextPage']); + $this->assertFalse($data['pageInfo']['hasPreviousPage']); + $this->assertSame('MQ==', $data['edges'][1]['cursor']); + $this->assertSame('Dummy #2', $data['edges'][1]['node']['name']); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(first: 2, after: "MQ==") { + edges { cursor node { name } } + pageInfo { endCursor hasNextPage } + } + } + QUERY); + + $data = $response->toArray()['data']['dummies']; + $this->assertCount(2, $data['edges']); + $this->assertSame('Dummy #3', $data['edges'][0]['node']['name']); + $this->assertSame('Mg==', $data['edges'][0]['cursor']); + $this->assertFalse($data['pageInfo']['hasNextPage']); + } + + public function testPaginationCursorsBackward(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(last: 2) { + edges { cursor node { name } } + totalCount + pageInfo { startCursor hasPreviousPage hasNextPage } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['dummies']; + $this->assertCount(2, $data['edges']); + $this->assertSame(4, $data['totalCount']); + $this->assertSame('Mg==', $data['pageInfo']['startCursor']); + $this->assertTrue($data['pageInfo']['hasPreviousPage']); + $this->assertSame('Dummy #4', $data['edges'][1]['node']['name']); + $this->assertSame('Mw==', $data['edges'][1]['cursor']); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(last: 2, before: "Mw==") { + edges { cursor node { name } } + pageInfo { startCursor hasPreviousPage } + } + } + QUERY); + + $data = $response->toArray()['data']['dummies']; + $this->assertCount(2, $data['edges']); + $this->assertSame('Dummy #2', $data['edges'][0]['node']['name']); + $this->assertSame('MQ==', $data['edges'][0]['cursor']); + } + + public function testSoManyPartialPagination(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('SoMany scenario @!mongodb'); + } + $this->recreateSchema([SoMany::class]); + $this->seedSoManies(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + soManies(first: 2) { + edges { cursor node { content } } + totalCount + pageInfo { startCursor endCursor hasNextPage hasPreviousPage } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['soManies']; + $this->assertSame('MA==', $data['pageInfo']['startCursor']); + $this->assertSame('MQ==', $data['pageInfo']['endCursor']); + $this->assertFalse($data['pageInfo']['hasNextPage']); + $this->assertFalse($data['pageInfo']['hasPreviousPage']); + $this->assertSame(0, $data['totalCount']); + $this->assertSame('Many #2', $data['edges'][1]['node']['content']); + $this->assertSame('MQ==', $data['edges'][1]['cursor']); + } + + public function testCollectionWithPaginationDisabled(): void + { + $this->recreateSchema([$this->isMongoDB() ? FooDocument::class : Foo::class]); + $this->seedFoosWithFakeNames(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + foos { + id + name + bar + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $foos = $response->toArray()['data']['foos']; + $this->assertSame('/foos/4', $foos[3]['id']); + $this->assertSame('Separativeness', $foos[3]['name']); + $this->assertSame('Sit', $foos[3]['bar']); + } + + public function testCustomCollectionQuery(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + $this->seedDummyCustomQuery(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testCollectionDummyCustomQueries { + edges { node { message } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'testCollectionDummyCustomQueries' => [ + 'edges' => [ + ['node' => ['message' => 'Success!']], + ['node' => ['message' => 'Success!']], + ], + ], + ], + ], $response->toArray()); + } + + public function testCustomCollectionQueryReadAndSerializeFalse(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + $this->seedDummyCustomQuery(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testCollectionNoReadAndSerializeDummyCustomQueries { + edges { node { message } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => ['testCollectionNoReadAndSerializeDummyCustomQueries' => ['edges' => []]], + ], $response->toArray()); + } + + public function testCustomCollectionQueryWithCustomArguments(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + $this->seedDummyCustomQuery(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testCollectionCustomArgumentsDummyCustomQueries(customArgumentString: "A string") { + edges { node { message customArgs } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'testCollectionCustomArgumentsDummyCustomQueries' => [ + 'edges' => [ + ['node' => ['message' => 'Success!', 'customArgs' => ['customArgumentString' => 'A string']]], + ['node' => ['message' => 'Success!', 'customArgs' => ['customArgumentString' => 'A string']]], + ], + ], + ], + ], $response->toArray()); + } + + public function testRetrieveCompositePrimitiveIdentifierItem(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Composite identifiers @!mongodb'); + } + $this->recreateSchema([CompositePrimitiveItem::class]); + $manager = $this->getManager(); + $foo = new CompositePrimitiveItem('Foo', 2016); + $foo->setDescription('This is foo.'); + $manager->persist($foo); + $bar = new CompositePrimitiveItem('Bar', 2017); + $bar->setDescription('This is bar.'); + $manager->persist($bar); + $manager->flush(); + $manager->clear(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + compositePrimitiveItem(id: "/composite_primitive_items/name=Bar;year=2017") { + description + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('This is bar.', $response->toArray()['data']['compositePrimitiveItem']['description']); + } + + public function testRetrieveCompositeIdentifierItem(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Composite identifiers @!mongodb'); + } + $this->recreateSchema([CompositeRelation::class, CompositeItem::class, CompositeLabel::class]); + $this->seedCompositeIdentifierObjects(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + compositeRelation(id: "/composite_relations/compositeItem=1;compositeLabel=1") { + value + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('somefoobardummy', $response->toArray()['data']['compositeRelation']['value']); + } + + public function testCollectionWithNameConverter(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies { + edges { node { name_converted } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + 'Converted 2', + $response->toArray()['data']['dummies']['edges'][1]['node']['name_converted'], + ); + } + + public function testCollectionWithDifferentSerializationGroups(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyDifferentGraphQlSerializationGroupDocument::class : DummyDifferentGraphQlSerializationGroup::class]); + $this->seedDummyDifferentGroups(3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyDifferentGraphQlSerializationGroups { + edges { node { name } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummyDifferentGraphQlSerializationGroups']['edges']; + $this->assertCount(3, $edges); + $this->assertArrayHasKey('name', $edges[0]['node']); + $this->assertArrayNotHasKey('title', $edges[0]['node']); + } + + public function testPageBasedPagination(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('FooDummy + SoMany scenario @!mongodb'); + } + $this->recreateSchema([Dummy::class, FooDummy::class, SoMany::class]); + $this->seedFooDummies(5); + + $response = $this->executeGraphQl(<<<'QUERY' + { + fooDummies(page: 1) { + collection { id name } + paginationInfo { itemsPerPage lastPage totalCount hasNextPage } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['fooDummies']; + $this->assertCount(3, $data['collection']); + $this->assertSame(3, $data['paginationInfo']['itemsPerPage']); + $this->assertSame(2, $data['paginationInfo']['lastPage']); + $this->assertSame(5, $data['paginationInfo']['totalCount']); + $this->assertTrue($data['paginationInfo']['hasNextPage']); + + $response = $this->executeGraphQl(<<<'QUERY' + { fooDummies(page: 2) { collection { id name } } } + QUERY); + $this->assertCount(2, $response->toArray()['data']['fooDummies']['collection']); + + $response = $this->executeGraphQl(<<<'QUERY' + { fooDummies(page: 3) { collection { id name } } } + QUERY); + $this->assertCount(0, $response->toArray()['data']['fooDummies']['collection']); + } + + public function testPageBasedPaginationWithItemsPerPage(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('FooDummy + SoMany scenario @!mongodb'); + } + $this->recreateSchema([Dummy::class, FooDummy::class, SoMany::class]); + $this->seedFooDummies(5); + + $response = $this->executeGraphQl(<<<'QUERY' + { fooDummies(page: 1, itemsPerPage: 2) { collection { id name } } } + QUERY); + $this->assertCount(2, $response->toArray()['data']['fooDummies']['collection']); + + $response = $this->executeGraphQl(<<<'QUERY' + { fooDummies(page: 2, itemsPerPage: 2) { collection { id name } } } + QUERY); + $this->assertCount(2, $response->toArray()['data']['fooDummies']['collection']); + + $response = $this->executeGraphQl(<<<'QUERY' + { fooDummies(page: 3, itemsPerPage: 2) { collection { id name } } } + QUERY); + $this->assertCount(1, $response->toArray()['data']['fooDummies']['collection']); + } + + public function testMixedPagination(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('FooDummy + SoMany scenario @!mongodb'); + } + $this->recreateSchema([Dummy::class, FooDummy::class, SoMany::class]); + $this->seedFooDummies(5); + + $response = $this->executeGraphQl(<<<'QUERY' + { + fooDummies(page: 1) { + collection { + id name + soManies(first: 2) { + edges { cursor node { content } } + pageInfo { startCursor endCursor hasNextPage hasPreviousPage } + } + } + paginationInfo { itemsPerPage lastPage totalCount hasNextPage } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['fooDummies']; + $this->assertCount(3, $data['collection']); + $this->assertCount(2, $data['collection'][2]['soManies']['edges']); + $this->assertSame('So many 1', $data['collection'][2]['soManies']['edges'][1]['node']['content']); + $this->assertSame('MA==', $data['collection'][2]['soManies']['pageInfo']['startCursor']); + $this->assertTrue($data['paginationInfo']['hasNextPage']); + } + + public function testPaginationOnlyHasNextPage(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('FooDummy + SoMany scenario @!mongodb'); + } + $this->recreateSchema([Dummy::class, FooDummy::class, SoMany::class]); + $this->seedFooDummies(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + fooDummies(page: 1, itemsPerPage: 2) { + collection { + id name + soManies(first: 2) { + edges { node { content } cursor } + pageInfo { startCursor endCursor hasNextPage hasPreviousPage } + } + } + paginationInfo { hasNextPage } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['fooDummies']; + $this->assertCount(2, $data['collection']); + $this->assertArrayHasKey('id', $data['collection'][1]); + $this->assertArrayHasKey('name', $data['collection'][1]); + $this->assertCount(2, $data['collection'][1]['soManies']['edges']); + $this->assertSame('So many 1', $data['collection'][1]['soManies']['edges'][1]['node']['content']); + $this->assertSame('MA==', $data['collection'][1]['soManies']['pageInfo']['startCursor']); + $this->assertTrue($data['paginationInfo']['hasNextPage']); + + $response = $this->executeGraphQl(<<<'QUERY' + { fooDummies(page: 2) { paginationInfo { hasNextPage } } } + QUERY); + $this->assertFalse($response->toArray()['data']['fooDummies']['paginationInfo']['hasNextPage']); + } + + private function recreateDummiesAndRelated(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + $this->isMongoDB() ? ThirdLevelDocument::class : ThirdLevel::class, + ]); + } + + private function newDummy(): object + { + $class = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + + return new $class(); + } + + private function newRelated(): object + { + $class = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + + return new $class(); + } + + private function newThirdLevel(): object + { + $class = $this->isMongoDB() ? ThirdLevelDocument::class : ThirdLevel::class; + + return new $class(); + } + + private function seedDummies(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->setDummy('SomeDummyTest'.$i); + $dummy->nameConverted = 'Converted '.$i; + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesWithDate(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + if ($count !== $i) { + $dummy->setDummyDate(new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC'))); + } + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesEachWithRelatedDummies(int $count, int $nbRelated): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + + for ($j = 1; $j <= $nbRelated; ++$j) { + $related = $this->newRelated(); + $related->setName('RelatedDummy'.$j.$i); + $related->setAge((int) ($j.$i)); + $manager->persist($related); + $dummy->addRelatedDummy($related); + } + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesWithRelatedDummyAndThirdLevel(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $third = $this->newThirdLevel(); + + $related = $this->newRelated(); + $related->setName('RelatedDummy #'.$i); + $related->setThirdLevel($third); + + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->setRelatedDummy($related); + + $manager->persist($third); + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummyGroups(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyGroupDocument::class : DummyGroup::class; + for ($i = 1; $i <= $count; ++$i) { + $g = new $class(); + foreach (['foo', 'bar', 'baz', 'qux'] as $p) { + $g->{$p} = ucfirst($p).' #'.$i; + } + $manager->persist($g); + } + $manager->flush(); + } + + private function seedDummyCustomQuery(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class; + for ($i = 1; $i <= $count; ++$i) { + $manager->persist(new $class()); + } + $manager->flush(); + } + + private function seedDummyDifferentGroups(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyDifferentGraphQlSerializationGroupDocument::class : DummyDifferentGraphQlSerializationGroup::class; + for ($i = 1; $i <= $count; ++$i) { + $d = new $class(); + $d->setName('Name #'.$i); + $d->setTitle('Title #'.$i); + $manager->persist($d); + } + $manager->flush(); + } + + private function seedFoosWithFakeNames(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? FooDocument::class : Foo::class; + $names = ['Hawsepipe', 'Sthenelus', 'Ephesian', 'Separativeness', 'Balbo']; + $bars = ['Lorem', 'Ipsum', 'Dolor', 'Sit', 'Amet']; + for ($i = 0; $i < $count; ++$i) { + $foo = new $class(); + $foo->setName($names[$i]); + $foo->setBar($bars[$i]); + $manager->persist($foo); + } + $manager->flush(); + } + + private function seedFooDummies(int $count): void + { + $manager = $this->getManager(); + $fooClass = $this->isMongoDB() ? FooDummyDocument::class : FooDummy::class; + $dummyClass = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + $soManyClass = $this->isMongoDB() ? SoManyDocument::class : SoMany::class; + $names = ['Hawsepipe', 'Ephesian', 'Sthenelus', 'Separativeness', 'Balbo']; + $dummies = ['Lorem', 'Ipsum', 'Dolor', 'Sit', 'Amet']; + + for ($i = 0; $i < $count; ++$i) { + $dummy = new $dummyClass(); + $dummy->setName($dummies[$i]); + + $foo = new $fooClass(); + $foo->setName($names[$i]); + $foo->setDummy($dummy); + for ($j = 0; $j < 3; ++$j) { + $soMany = new $soManyClass(); + $soMany->content = "So many $j"; + $soMany->fooDummy = $foo; + $foo->soManies->add($soMany); + } + $manager->persist($foo); + } + $manager->flush(); + } + + private function seedSoManies(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? SoManyDocument::class : SoMany::class; + for ($i = 1; $i <= $count; ++$i) { + $s = new $class(); + $s->content = 'Many #'.$i; + $manager->persist($s); + } + $manager->flush(); + } + + private function seedVideoGameWithMusicGroups(): void + { + $manager = $this->getManager(); + $musicClass = $this->isMongoDB() ? MusicGroupDocument::class : MusicGroup::class; + $videoClass = $this->isMongoDB() ? VideoGameDocument::class : VideoGame::class; + + $sum41 = new $musicClass(); + $sum41->name = 'Sum 41'; + $manager->persist($sum41); + + $franz = new $musicClass(); + $franz->name = 'Franz Ferdinand'; + $manager->persist($franz); + + $videoGame = new $videoClass(); + $videoGame->name = 'Guitar Hero'; + $videoGame->addMusicGroup($sum41); + $videoGame->addMusicGroup($franz); + $manager->persist($videoGame); + $manager->flush(); + } + + private function seedCompositeIdentifierObjects(): void + { + $manager = $this->getManager(); + $item = new CompositeItem(); + $item->setField1('foobar'); + $manager->persist($item); + $manager->flush(); + + for ($i = 0; $i < 4; ++$i) { + $label = new CompositeLabel(); + $label->setValue('foo-'.$i); + $manager->persist($label); + $manager->flush(); + + $rel = new CompositeRelation(); + $rel->setCompositeLabel($label); + $rel->setCompositeItem($item); + $rel->setValue('somefoobardummy'); + $manager->persist($rel); + } + $manager->flush(); + $manager->clear(); + } +} diff --git a/tests/Functional/GraphQl/CustomTypeTest.php b/tests/Functional/GraphQl/CustomTypeTest.php new file mode 100644 index 00000000000..6fb33832662 --- /dev/null +++ b/tests/Functional/GraphQl/CustomTypeTest.php @@ -0,0 +1,134 @@ + + * + * 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\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class CustomTypeTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Dummy::class]; + } + + protected function setUp(): void + { + $resource = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + $this->recreateSchema([$resource]); + $this->seedDummies($resource); + } + + public function testQueryFieldWithCustomType(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + dummy(id: "/dummies/1") { + dummyDate + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('2015-04-01', $response->toArray()['data']['dummy']['dummyDate']); + } + + public function testMutationInputWithCustomType(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateDummy(input: {id: "/dummies/1", dummyDate: "2019-05-24T00:00:00+00:00"}) { + dummy { + dummyDate + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('2019-05-24', $response->toArray()['data']['updateDummy']['dummy']['dummyDate']); + } + + public function testMutationVariableWithCustomType(): void + { + $response = $this->executeGraphQl( + <<<'QUERY' + mutation UpdateDummyDate($itemId: ID!, $itemDate: DateTime!) { + updateDummy(input: {id: $itemId, dummyDate: $itemDate}) { + dummy { + dummyDate + } + } + } + QUERY, + ['itemId' => '/dummies/1', 'itemDate' => '2017-11-14T00:00:00+00:00'], + ); + + $this->assertResponseIsSuccessful(); + $this->assertSame('2017-11-14', $response->toArray()['data']['updateDummy']['dummy']['dummyDate']); + } + + public function testMutationVariableWithCustomTypeAndBadValue(): void + { + $response = $this->executeGraphQl( + <<<'QUERY' + mutation UpdateDummyDate($itemId: ID!, $itemDate: DateTime!) { + updateDummy(input: {id: $itemId, dummyDate: $itemDate}) { + dummy { + dummyDate + } + } + } + QUERY, + ['itemId' => '/dummies/1', 'itemDate' => 'bad date'], + ); + + $this->assertResponseIsSuccessful(); + $message = $response->toArray(false)['errors'][0]['message'] ?? ''; + $this->assertStringContainsString('Variable "$itemDate" got invalid value "bad date";', $message); + $this->assertStringContainsString('DateTime cannot represent non date value: "bad date"', $message); + } + + private function seedDummies(string $resourceClass): void + { + $manager = $this->getManager(); + $dummy1 = new $resourceClass(); + $dummy1->setName('Dummy #1'); + $dummy1->setAlias('Alias #1'); + $dummy1->setDescription('Smart dummy.'); + $dummy1->setDummyDate(new \DateTime('2015-04-01', new \DateTimeZone('UTC'))); + $manager->persist($dummy1); + + $dummy2 = new $resourceClass(); + $dummy2->setName('Dummy #2'); + $dummy2->setAlias('Alias #0'); + $dummy2->setDescription('Not so smart dummy.'); + $manager->persist($dummy2); + + $manager->flush(); + } +} diff --git a/tests/Functional/GraphQl/DocsTest.php b/tests/Functional/GraphQl/DocsTest.php new file mode 100644 index 00000000000..f2bc0c20407 --- /dev/null +++ b/tests/Functional/GraphQl/DocsTest.php @@ -0,0 +1,29 @@ + + * + * 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\GraphQl; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; + +final class DocsTest extends ApiTestCase +{ + protected static ?bool $alwaysBootKernel = false; + + public function testRetrieveGraphiQlDocumentation(): void + { + self::createClient()->request('GET', '/graphql', ['headers' => ['Accept' => 'text/html']]); + + $this->assertResponseIsSuccessful(); + $this->assertResponseHeaderSame('Content-Type', 'text/html; charset=UTF-8'); + } +} diff --git a/tests/Functional/GraphQl/FilterTest.php b/tests/Functional/GraphQl/FilterTest.php new file mode 100644 index 00000000000..a7d40722d66 --- /dev/null +++ b/tests/Functional/GraphQl/FilterTest.php @@ -0,0 +1,528 @@ + + * + * 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\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedOwner as ConvertedOwnerDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ConvertedRelated as ConvertedRelatedDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCar as DummyCarDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCarColor as DummyCarColorDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedOwner; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedRelated; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCarColor; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\Common\Collections\ArrayCollection; + +final class FilterTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + ConvertedOwner::class, + ConvertedRelated::class, + DummyCar::class, + DummyCarColor::class, + ]; + } + + public function testBooleanFilter(): void + { + $this->recreateDummiesAndRelated(); + $manager = $this->getManager(); + $true = $this->newDummy(); + $true->setName('Dummy #1'); + $true->setDummyBoolean(true); + $manager->persist($true); + + $false = $this->newDummy(); + $false->setName('Dummy #2'); + $false->setDummyBoolean(false); + $manager->persist($false); + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(dummyBoolean: false) { + edges { node { id dummyBoolean } } + } + } + QUERY); + + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(1, $edges); + $this->assertFalse($edges[0]['node']['dummyBoolean']); + } + + public function testExistsFilter(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(3); + $this->seedDummiesWithRelatedDummy(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(exists: [{relatedDummy: true}]) { + edges { + node { + id + relatedDummy { name } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(2, $edges); + $this->assertArrayHasKey('name', $edges[0]['node']['relatedDummy']); + } + + public function testDateFilter(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithDate(3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(dummyDate: [{after: "2015-04-02"}]) { + edges { node { id dummyDate } } + } + } + QUERY); + + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(1, $edges); + $this->assertSame('2015-04-02', $edges[0]['node']['dummyDate']); + } + + public function testSearchFilterOnName(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(10); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(name: "#2") { + edges { node { id name } } + } + } + QUERY); + + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(1, $edges); + $this->assertSame('/dummies/2', $edges[0]['node']['id']); + } + + public function testSearchFilterWithIntOnNestedCollection(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesEachWithRelatedDummies(4, 3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(name: "Dummy #1") { + totalCount + edges { + node { + name + relatedDummies(age: 31) { + totalCount + edges { + node { id name age } + } + } + } + } + } + } + QUERY); + + $data = $response->toArray()['data']['dummies']; + $this->assertSame(1, $data['totalCount']); + $this->assertSame(1, $data['edges'][0]['node']['relatedDummies']['totalCount']); + $this->assertSame('31', (string) $data['edges'][0]['node']['relatedDummies']['edges'][0]['node']['age']); + } + + public function testSearchFilterWithNameConverter(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummies(10); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(name_converted: "Converted 2") { + edges { node { id name name_converted } } + } + } + QUERY); + + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(1, $edges); + $this->assertSame('/dummies/2', $edges[0]['node']['id']); + $this->assertSame('Converted 2', $edges[0]['node']['name_converted']); + } + + public function testSearchFilterWithNameConverterOnNestedProperty(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? ConvertedOwnerDocument::class : ConvertedOwner::class, + $this->isMongoDB() ? ConvertedRelatedDocument::class : ConvertedRelated::class, + ]); + $this->seedConvertedOwners(20); + + $response = $this->executeGraphQl(<<<'QUERY' + { + convertedOwners(name_converted__name_converted: "Converted 2") { + edges { + node { + id + name_converted { name_converted } + } + } + } + } + QUERY); + + $edges = $response->toArray()['data']['convertedOwners']['edges']; + $this->assertCount(2, $edges); + $this->assertSame('/converted_owners/2', $edges[0]['node']['id']); + $this->assertSame('Converted 2', $edges[0]['node']['name_converted']['name_converted']); + $this->assertSame('/converted_owners/20', $edges[1]['node']['id']); + $this->assertSame('Converted 20', $edges[1]['node']['name_converted']['name_converted']); + } + + public function testSearchFilterOnNestedCollection(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesEachWithRelatedDummies(3, 3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies { + edges { + node { + id + relatedDummies(name: "RelatedDummy13") { + edges { node { id name } } + } + } + } + } + } + QUERY); + + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(0, $edges[0]['node']['relatedDummies']['edges']); + $this->assertCount(0, $edges[1]['node']['relatedDummies']['edges']); + $this->assertCount(1, $edges[2]['node']['relatedDummies']['edges']); + $this->assertSame('RelatedDummy13', $edges[2]['node']['relatedDummies']['edges'][0]['node']['name']); + } + + public function testNestedCollectionFilter(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyCarDocument::class : DummyCar::class, + $this->isMongoDB() ? DummyCarColorDocument::class : DummyCarColor::class, + ]); + $this->seedDummyCarWithColors(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyCar(id: "/dummy_cars/1") { + id + colors(prop: "blue") { + edges { node { id prop } } + } + } + } + QUERY); + + $edges = $response->toArray()['data']['dummyCar']['colors']['edges']; + $this->assertCount(1, $edges); + $this->assertSame('blue', $edges[0]['node']['prop']); + } + + public function testRelatedSearchFilter(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesEachWithRelatedDummies(1, 2); + $this->seedDummiesEachWithRelatedDummies(1, 3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(relatedDummies__name: "RelatedDummy31") { + edges { node { id } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertCount(1, $response->toArray()['data']['dummies']['edges']); + } + + public function testOrderByNestedProperty(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(order: [{relatedDummy__name: "DESC"}]) { + edges { + node { + name + relatedDummy { id name } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertSame('Dummy #2', $edges[0]['node']['name']); + $this->assertSame('Dummy #1', $edges[1]['node']['name']); + } + + public function testMultiKeyOrderRespectsArgumentOrder(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithSimilarProperties(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(order: [{description: "ASC"}, {name: "ASC"}]) { + edges { + node { id name description } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertSame('baz', $edges[0]['node']['name']); + $this->assertSame('bar', $edges[0]['node']['description']); + $this->assertSame('foo', $edges[1]['node']['name']); + $this->assertSame('bar', $edges[1]['node']['description']); + } + + public function testRelatedSearchFilterMultiValueExact(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(3); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummies(relatedDummy__name_list: ["RelatedDummy #1", "RelatedDummy #2"]) { + edges { + node { + id + name + relatedDummy { name } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $edges = $response->toArray()['data']['dummies']['edges']; + $this->assertCount(2, $edges); + $this->assertSame('RelatedDummy #1', $edges[0]['node']['relatedDummy']['name']); + $this->assertSame('RelatedDummy #2', $edges[1]['node']['relatedDummy']['name']); + } + + private function recreateDummiesAndRelated(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + } + + private function newDummy(): object + { + $class = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + + return new $class(); + } + + private function newRelated(): object + { + $class = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + + return new $class(); + } + + private function seedDummies(int $count): void + { + $descriptions = ['Smart dummy.', 'Not so smart dummy.']; + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->setDummy('SomeDummyTest'.$i); + $dummy->setDescription($descriptions[($i - 1) % 2]); + $dummy->nameConverted = 'Converted '.$i; + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesWithDate(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + if ($count !== $i) { + $dummy->setDummyDate(new \DateTime(\sprintf('2015-04-%d', $i), new \DateTimeZone('UTC'))); + } + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesWithRelatedDummy(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $related = $this->newRelated(); + $related->setName('RelatedDummy #'.$i); + + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->nameConverted = "Converted $i"; + $dummy->setRelatedDummy($related); + + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesEachWithRelatedDummies(int $count, int $nbRelated): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + + for ($j = 1; $j <= $nbRelated; ++$j) { + $related = $this->newRelated(); + $related->setName('RelatedDummy'.$j.$i); + $related->setAge((int) ($j.$i)); + $manager->persist($related); + + $dummy->addRelatedDummy($related); + } + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesWithSimilarProperties(): void + { + $manager = $this->getManager(); + foreach ([ + ['foo', 'bar'], + ['baz', 'qux'], + ['foo', 'qux'], + ['baz', 'bar'], + ] as [$name, $description]) { + $dummy = $this->newDummy(); + $dummy->setName($name); + $dummy->setDescription($description); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedConvertedOwners(int $count): void + { + $relatedClass = $this->isMongoDB() ? ConvertedRelatedDocument::class : ConvertedRelated::class; + $ownerClass = $this->isMongoDB() ? ConvertedOwnerDocument::class : ConvertedOwner::class; + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $related = new $relatedClass(); + $related->nameConverted = 'Converted '.$i; + + $owner = new $ownerClass(); + $owner->nameConverted = $related; + + $manager->persist($related); + $manager->persist($owner); + } + $manager->flush(); + } + + private function seedDummyCarWithColors(): void + { + $manager = $this->getManager(); + $carClass = $this->isMongoDB() ? DummyCarDocument::class : DummyCar::class; + $colorClass = $this->isMongoDB() ? DummyCarColorDocument::class : DummyCarColor::class; + + $car = new $carClass(); + $car->setName('mustli'); + $car->setCanSell(true); + $car->setAvailableAt(new \DateTime()); + $manager->persist($car); + $manager->flush(); + + if (\is_object($car->getId())) { + $manager->persist($car->getId()); + $manager->flush(); + } + + $red = new $colorClass(); + $red->setProp('red'); + $red->setCar($car); + $manager->persist($red); + $manager->flush(); + + $blue = new $colorClass(); + $blue->setProp('blue'); + $blue->setCar($car); + $manager->persist($blue); + $manager->flush(); + + $car->setColors(new ArrayCollection([$red, $blue])); + $manager->persist($car); + $manager->flush(); + } +} diff --git a/features/files/test.gif b/tests/Functional/GraphQl/Fixtures/test.gif similarity index 100% rename from features/files/test.gif rename to tests/Functional/GraphQl/Fixtures/test.gif diff --git a/tests/Functional/GraphQl/InputOutputTest.php b/tests/Functional/GraphQl/InputOutputTest.php new file mode 100644 index 00000000000..a120f54edda --- /dev/null +++ b/tests/Functional/GraphQl/InputOutputTest.php @@ -0,0 +1,236 @@ + + * + * 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\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoInputOutput as DummyDtoInputOutputDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoNoInput as DummyDtoNoInputDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoNoOutput as DummyDtoNoOutputDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoInputOutput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoNoInput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoNoOutput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MessengerWithInput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class InputOutputTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + DummyDtoInputOutput::class, + DummyDtoNoOutput::class, + DummyDtoNoInput::class, + MessengerWithInput::class, + RelatedDummy::class, + ]; + } + + public function testRetrieveOutputAfterRestCreation(): void + { + $this->recreateSchema($this->resolveResources([ + DummyDtoInputOutput::class => DummyDtoInputOutputDocument::class, + RelatedDummy::class => RelatedDummyDocument::class, + ])); + $this->seedRelatedDummy(); + + $client = self::createClient(); + $client->request('POST', '/dummy_dto_input_outputs', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['foo' => 'test', 'bar' => 1, 'relatedDummies' => ['/related_dummies/1']], + ]); + $this->assertResponseStatusCodeSame(201); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyDtoInputOutput(id: "/dummy_dto_input_outputs/1") { + _id, id, baz, + relatedDummies { + edges { + node { + name + } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'dummyDtoInputOutput' => [ + '_id' => 1, + 'id' => '/dummy_dto_input_outputs/1', + 'baz' => 1, + 'relatedDummies' => [ + 'edges' => [ + ['node' => ['name' => 'RelatedDummy with friends']], + ], + ], + ], + ], + ], $response->toArray()); + } + + public function testCreateItemWithCustomInputAndOutput(): void + { + $this->recreateSchema($this->resolveResources([DummyDtoInputOutput::class => DummyDtoInputOutputDocument::class])); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummyDtoInputOutput(input: {foo: "A foo", bar: 4, clientMutationId: "myId"}) { + dummyDtoInputOutput { + baz, + bat + } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'createDummyDtoInputOutput' => [ + 'dummyDtoInputOutput' => ['baz' => 4, 'bat' => 'A foo'], + 'clientMutationId' => 'myId', + ], + ], + ], $response->toArray()); + } + + public function testCreateItemWithDisabledOutputClassFailsToQueryFields(): void + { + $this->recreateSchema($this->resolveResources([DummyDtoNoOutput::class => DummyDtoNoOutputDocument::class])); + $this->seedDummyDtoNoOutput(2); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummyDtoNoOutput(input: {foo: "A new one", bar: 3, clientMutationId: "myId"}) { + dummyDtoNoOutput { + id + } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame('Cannot query field "id" on type "DummyDtoNoOutput".', $data['errors'][0]['message']); + $this->assertSame(4, $data['errors'][0]['locations'][0]['line']); + $this->assertSame(7, $data['errors'][0]['locations'][0]['column']); + } + + public function testCreateItemWithDisabledInputClassRejectsUndefinedFields(): void + { + $this->recreateSchema($this->resolveResources([DummyDtoNoInput::class => DummyDtoNoInputDocument::class])); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummyDtoNoInput(input: {lorem: "A new one", ipsum: 3, clientMutationId: "myId"}) { + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertMatchesRegularExpression( + '/^Field "lorem" is not defined by type "?createDummyDtoNoInputInput"?\.$/', + $data['errors'][0]['message'], + ); + $this->assertMatchesRegularExpression( + '/^Field "ipsum" is not defined by type "?createDummyDtoNoInputInput"?\.$/', + $data['errors'][1]['message'], + ); + } + + public function testMessengerWithInputReturnsSynchronousResult(): void + { + // MessengerWithInput is not a Doctrine resource — nothing to recreate. + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createMessengerWithInput(input: {var: "test"}) { + messengerWithInput { id, name } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'createMessengerWithInput' => [ + 'messengerWithInput' => [ + 'id' => '/messenger_with_inputs/1', + 'name' => 'test', + ], + ], + ], + ], $response->toArray()); + } + + /** + * @param array $map + * + * @return list + */ + private function resolveResources(array $map): array + { + $resolved = []; + foreach ($map as $entity => $document) { + $resolved[] = $this->isMongoDB() ? $document : $entity; + } + + return $resolved; + } + + private function seedRelatedDummy(): void + { + $resourceClass = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + $manager = $this->getManager(); + $related = new $resourceClass(); + $related->setName('RelatedDummy with friends'); + $manager->persist($related); + $manager->flush(); + $manager->clear(); + } + + private function seedDummyDtoNoOutput(int $count): void + { + $resourceClass = $this->isMongoDB() ? DummyDtoNoOutputDocument::class : DummyDtoNoOutput::class; + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dto = new $resourceClass(); + $dto->lorem = 'DummyDtoNoOutput foo #'.$i; + $dto->ipsum = (string) ($i / 3); + $manager->persist($dto); + } + $manager->flush(); + } +} diff --git a/tests/Functional/GraphQl/IntrospectionTest.php b/tests/Functional/GraphQl/IntrospectionTest.php new file mode 100644 index 00000000000..b7827b5cc7b --- /dev/null +++ b/tests/Functional/GraphQl/IntrospectionTest.php @@ -0,0 +1,487 @@ + + * + * 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\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DeprecatedResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyAggregateOffer; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDifferentGraphQlSerializationGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProduct; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyProperty; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Person; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VideoGame; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VoDummyCar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VoDummyInspection; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class IntrospectionTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + DummyProduct::class, + DummyAggregateOffer::class, + DummyDifferentGraphQlSerializationGroup::class, + DummyGroup::class, + DummyProperty::class, + DeprecatedResource::class, + VoDummyCar::class, + VoDummyInspection::class, + Person::class, + VideoGame::class, + ]; + } + + public function testEmptyQueryReturnsBadRequest(): void + { + $client = self::createClient(); + $client->request('GET', '/graphql'); + + $this->assertResponseStatusCodeSame(200); + $data = $client->getResponse()->toArray(false); + $this->assertSame(400, $data['errors'][0]['extensions']['status']); + $this->assertSame('GraphQL query is not valid.', $data['errors'][0]['message']); + } + + public function testIntrospectSchema(): void + { + $response = $this->introspectSchema(); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertArrayHasKey('types', $data['data']['__schema']); + $this->assertSame('Query', $data['data']['__schema']['queryType']['name']); + $this->assertSame('Mutation', $data['data']['__schema']['mutationType']['name']); + } + + public function testIntrospectTypes(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + type1: __type(name: "DummyProduct") { + description, + fields { name type { name kind ofType { name kind } } } + } + type2: __type(name: "DummyAggregateOfferCursorConnection") { + description, + fields { name type { name kind ofType { name kind } } } + } + type3: __type(name: "DummyAggregateOfferEdge") { + description, + fields { name type { name kind ofType { name kind } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + + $this->assertSame('Dummy Product.', $data['type1']['description']); + $this->assertContainsEquals( + ['name' => 'offers', 'type' => ['name' => 'DummyAggregateOfferCursorConnection', 'kind' => 'OBJECT', 'ofType' => null]], + $data['type1']['fields'], + ); + $this->assertContainsEquals( + ['name' => 'edges', 'type' => ['name' => null, 'kind' => 'LIST', 'ofType' => ['name' => 'DummyAggregateOfferEdge', 'kind' => 'OBJECT']]], + $data['type2']['fields'], + ); + $this->assertContainsEquals( + ['name' => 'node', 'type' => ['name' => 'DummyAggregateOffer', 'kind' => 'OBJECT', 'ofType' => null]], + $data['type3']['fields'], + ); + $this->assertContainsEquals( + ['name' => 'cursor', 'type' => ['name' => null, 'kind' => 'NON_NULL', 'ofType' => ['name' => 'String', 'kind' => 'SCALAR']]], + $data['type3']['fields'], + ); + } + + public function testIntrospectTypesWithDifferentSerializationGroups(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + type1: __type(name: "DummyDifferentGraphQlSerializationGroupCollection") { + description, + fields { name type { name kind ofType { name kind } } } + } + type2: __type(name: "DummyDifferentGraphQlSerializationGroupItem") { + description, + fields { name type { name kind ofType { name kind } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + + $this->assertSame( + 'Dummy with different serialization groups for item_query and collection_query.', + $data['type1']['description'], + ); + $this->assertCount(3, $data['type1']['fields']); + $this->assertSame('title', $data['type2']['fields'][3]['name']); + } + + public function testIntrospectDeprecatedQueries(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __type (name: "Query") { + name + fields(includeDeprecated: true) { + name + isDeprecated + deprecationReason + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertGraphQlFieldDeprecated($data, 'deprecatedResource', 'This resource is deprecated'); + $this->assertGraphQlFieldDeprecated($data, 'deprecatedResources', 'This resource is deprecated'); + } + + public function testIntrospectDeprecatedMutations(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __type (name: "Mutation") { + name + fields(includeDeprecated: true) { + name + isDeprecated + deprecationReason + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertGraphQlFieldDeprecated($data, 'deleteDeprecatedResource', 'This resource is deprecated'); + $this->assertGraphQlFieldDeprecated($data, 'updateDeprecatedResource', 'This resource is deprecated'); + $this->assertGraphQlFieldDeprecated($data, 'createDeprecatedResource', 'This resource is deprecated'); + } + + public function testIntrospectDeprecatedField(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __type(name: "DeprecatedResource") { + fields(includeDeprecated: true) { + name + isDeprecated + deprecationReason + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertGraphQlFieldDeprecated($response->toArray(), 'deprecatedField', 'This field is deprecated'); + } + + public function testRetrieveRelayNodeInterface(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __type(name: "Node") { + name + kind + fields { + name + type { + kind + ofType { name kind } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + '__type' => [ + 'name' => 'Node', + 'kind' => 'INTERFACE', + 'fields' => [ + [ + 'name' => 'id', + 'type' => ['kind' => 'NON_NULL', 'ofType' => ['name' => 'ID', 'kind' => 'SCALAR']], + ], + ], + ], + ], + ], $response->toArray()); + } + + public function testRetrieveRelayNodeField(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __schema { + queryType { + fields { + name + type { name kind } + args { name type { kind ofType { name kind } } } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $fields = $response->toArray()['data']['__schema']['queryType']['fields']; + $this->assertSame('node', $fields[0]['name']); + $this->assertSame('Node', $fields[0]['type']['name']); + $this->assertSame('INTERFACE', $fields[0]['type']['kind']); + $this->assertSame('id', $fields[0]['args'][0]['name']); + $this->assertSame('NON_NULL', $fields[0]['args'][0]['type']['kind']); + $this->assertSame('ID', $fields[0]['args'][0]['type']['ofType']['name']); + $this->assertSame('SCALAR', $fields[0]['args'][0]['type']['ofType']['kind']); + } + + public function testIntrospectIterableFieldOnDummy(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __type(name: "Dummy") { + description, + fields { name type { name kind ofType { name kind } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertContainsEquals( + ['name' => 'jsonData', 'type' => ['name' => 'Iterable', 'kind' => 'SCALAR', 'ofType' => null]], + $response->toArray()['data']['__type']['fields'], + ); + } + + public function testRetrieveDummyGroupFieldsAndMutationInputs(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + typeQuery: __type(name: "DummyGroup") { + fields { name type { name kind ofType { name kind } } } + } + typeCreateInput: __type(name: "createDummyGroupInput") { + inputFields { name type { name kind ofType { name kind } } } + } + typeCreatePayload: __type(name: "createDummyGroupPayload") { + fields { name type { name kind ofType { name kind } } } + } + typeCreatePayloadData: __type(name: "createDummyGroupPayloadData") { + fields { name type { name kind ofType { name kind } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + + $this->assertCount(2, $data['typeQuery']['fields']); + $this->assertSame('id', $data['typeQuery']['fields'][0]['name']); + $this->assertSame('foo', $data['typeQuery']['fields'][1]['name']); + + $this->assertCount(3, $data['typeCreateInput']['inputFields']); + $this->assertSame('bar', $data['typeCreateInput']['inputFields'][0]['name']); + $this->assertSame('baz', $data['typeCreateInput']['inputFields'][1]['name']); + $this->assertSame('clientMutationId', $data['typeCreateInput']['inputFields'][2]['name']); + + $this->assertCount(2, $data['typeCreatePayload']['fields']); + $this->assertSame('dummyGroup', $data['typeCreatePayload']['fields'][0]['name']); + $this->assertSame('createDummyGroupPayloadData', $data['typeCreatePayload']['fields'][0]['type']['name']); + $this->assertSame('clientMutationId', $data['typeCreatePayload']['fields'][1]['name']); + + $this->assertCount(2, $data['typeCreatePayloadData']['fields']); + $this->assertSame('id', $data['typeCreatePayloadData']['fields'][0]['name']); + $this->assertSame('bar', $data['typeCreatePayloadData']['fields'][1]['name']); + } + + public function testRetrieveNestedMutationPayloadData(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + typeCreatePayload: __type(name: "createDummyPropertyPayload") { + fields { name type { name kind ofType { name kind } } } + } + typeCreatePayloadData: __type(name: "createDummyPropertyPayloadData") { + fields { name type { name kind ofType { name kind } } } + } + typeCreateNestedPayload: __type(name: "createDummyGroupNestedPayload") { + fields { name type { name kind ofType { name kind } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + + $this->assertSame([ + ['name' => 'dummyProperty', 'type' => ['name' => 'createDummyPropertyPayloadData', 'kind' => 'OBJECT', 'ofType' => null]], + ['name' => 'clientMutationId', 'type' => ['name' => 'String', 'kind' => 'SCALAR', 'ofType' => null]], + ], $data['typeCreatePayload']['fields']); + + $this->assertContainsEquals( + ['name' => 'group', 'type' => ['name' => 'createDummyGroupNestedPayload', 'kind' => 'OBJECT', 'ofType' => null]], + $data['typeCreatePayloadData']['fields'], + ); + + $this->assertContainsEquals( + ['name' => 'id', 'type' => ['name' => null, 'kind' => 'NON_NULL', 'ofType' => ['name' => 'ID', 'kind' => 'SCALAR']]], + $data['typeCreateNestedPayload']['fields'], + ); + } + + public function testRetrieveTypenameViaGraphQlQuery(): void + { + $resources = [ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]; + $this->recreateSchema($resources); + $this->seedDummiesWithRelatedDummy(4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummy: dummy(id: "/dummies/3") { + name + relatedDummy { + id + name + __typename + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $dummy = $response->toArray()['data']['dummy']; + $this->assertSame('Dummy #3', $dummy['name']); + $this->assertSame('RelatedDummy #3', $dummy['relatedDummy']['name']); + $this->assertSame('RelatedDummy', $dummy['relatedDummy']['__typename']); + } + + public function testIntrospectTypeAvailableOnlyThroughRelations(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + typeNotAvailable: __type(name: "VoDummyInspectionCursorConnection") { + description + } + typeOwner: __type(name: "VoDummyCar") { + description, + fields { name type { name } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + $this->assertNull($data['typeNotAvailable']); + $this->assertSame('VoDummyInspectionCursorConnection', $data['typeOwner']['fields'][1]['type']['name']); + } + + public function testIntrospectEnum(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + person: __type(name: "Person") { + name + fields { + name + type { + name + description + enumValues { name description } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $field = $response->toArray()['data']['person']['fields'][1]; + $this->assertSame('GenderTypeEnum', $field['type']['name']); + $this->assertSame('MALE', $field['type']['enumValues'][0]['name']); + $this->assertSame('FEMALE', $field['type']['enumValues'][1]['name']); + $this->assertSame('The female gender.', $field['type']['enumValues'][1]['description']); + } + + public function testIntrospectEnumResource(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + videoGame: __type(name: "VideoGame") { + name + fields { + name + type { name kind ofType { name kind } } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + 'GamePlayMode', + $response->toArray()['data']['videoGame']['fields'][3]['type']['ofType']['name'], + ); + } + + private function seedDummiesWithRelatedDummy(int $count): void + { + $dummyClass = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + $relatedClass = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + $manager = $this->getManager(); + + for ($i = 1; $i <= $count; ++$i) { + $related = new $relatedClass(); + $related->setName('RelatedDummy #'.$i); + + $dummy = new $dummyClass(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->nameConverted = "Converted $i"; + $dummy->setRelatedDummy($related); + + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/GraphQl/MutationTest.php b/tests/Functional/GraphQl/MutationTest.php new file mode 100644 index 00000000000..ac8bf520b09 --- /dev/null +++ b/tests/Functional/GraphQl/MutationTest.php @@ -0,0 +1,955 @@ + + * + * 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\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6354\ActivityLog; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCustomMutation as DummyCustomMutationDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyGroup as DummyGroupDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Foo as FooDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FooDummy as FooDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\FooEmbeddable as FooEmbeddableDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Person as PersonDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\WritableId as WritableIdDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeItem; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeLabel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\CompositeRelation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCustomMutation; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FooDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FooEmbeddable; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FourthLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Person; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\VideoGame; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\WritableId; +use ApiPlatform\Tests\Fixtures\TestBundle\Enum\GamePlayMode; +use ApiPlatform\Tests\Fixtures\TestBundle\Model\MediaObject; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\HttpFoundation\File\UploadedFile; + +final class MutationTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + private const FIXTURES_DIR = __DIR__.'/Fixtures'; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Foo::class, + Dummy::class, + RelatedDummy::class, + Person::class, + FooDummy::class, + FooEmbeddable::class, + CompositeRelation::class, + CompositeItem::class, + CompositeLabel::class, + WritableId::class, + DummyGroup::class, + DummyCustomMutation::class, + ActivityLog::class, + GamePlayMode::class, + VideoGame::class, + ThirdLevel::class, + FourthLevel::class, + DummyFriend::class, + RelatedToDummyFriend::class, + MediaObject::class, + ]; + } + + public function testCreateItem(): void + { + $this->recreateSchema([$this->isMongoDB() ? FooDocument::class : Foo::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createFoo(input: {name: "A new one", bar: "new", clientMutationId: "myId"}) { + foo { id _id __typename name bar } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['createFoo']; + $this->assertSame('/foos/1', $data['foo']['id']); + $this->assertSame(1, $data['foo']['_id']); + $this->assertSame('Foo', $data['foo']['__typename']); + $this->assertSame('A new one', $data['foo']['name']); + $this->assertSame('new', $data['foo']['bar']); + $this->assertSame('myId', $data['clientMutationId']); + } + + public function testCreateItemWithoutClientMutationId(): void + { + $this->recreateSchema([$this->isMongoDB() ? FooDocument::class : Foo::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createFoo(input: {name: "Created without mutation id", bar: "works"}) { + foo { id name bar } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['createFoo']['foo']; + $this->assertSame('/foos/1', $data['id']); + $this->assertSame('Created without mutation id', $data['name']); + $this->assertSame('works', $data['bar']); + } + + public function testCreateItemWithRelationToExisting(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + $this->seedDummiesWithRelatedDummy(1); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummy(input: {name: "A dummy", foo: [], relatedDummy: "/related_dummies/1", name_converted: "Converted" clientMutationId: "myId"}) { + dummy { + id + name + foo + relatedDummy { name __typename } + name_converted + } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['createDummy']; + $this->assertSame('/dummies/2', $d['dummy']['id']); + $this->assertSame('A dummy', $d['dummy']['name']); + $this->assertCount(0, $d['dummy']['foo']); + $this->assertSame('RelatedDummy #1', $d['dummy']['relatedDummy']['name']); + $this->assertSame('RelatedDummy', $d['dummy']['relatedDummy']['__typename']); + $this->assertSame('Converted', $d['dummy']['name_converted']); + $this->assertSame('myId', $d['clientMutationId']); + } + + public function testCreateItemWithIterableField(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummy(input: {name: "A dummy", foo: [], jsonData: {bar:{baz:3,qux:[7.6,false,null]}}, arrayData: ["bar", "baz"], clientMutationId: "myId"}) { + dummy { + id name foo jsonData arrayData + } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['createDummy']; + $this->assertSame('/dummies/1', $d['dummy']['id']); + $this->assertSame('A dummy', $d['dummy']['name']); + $this->assertSame(3, $d['dummy']['jsonData']['bar']['baz']); + $this->assertSame(7.6, $d['dummy']['jsonData']['bar']['qux'][0]); + $this->assertFalse($d['dummy']['jsonData']['bar']['qux'][1]); + $this->assertNull($d['dummy']['jsonData']['bar']['qux'][2]); + $this->assertSame('baz', $d['dummy']['arrayData'][1]); + } + + public function testCreateItemWithEnum(): void + { + $this->recreateSchema([$this->isMongoDB() ? PersonDocument::class : Person::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createPerson(input: {name: "Mob", genderType: FEMALE}) { + person { id name genderType } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $p = $response->toArray()['data']['createPerson']['person']; + $this->assertSame('/people/1', $p['id']); + $this->assertSame('Mob', $p['name']); + $this->assertSame('FEMALE', $p['genderType']); + } + + public function testCreateItemWithEnumCollection(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Enum collection scenario @!mongodb'); + } + $this->recreateSchema([Person::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createPerson(input: {name: "Harry", academicGrades: [BACHELOR, MASTER]}) { + person { id name genderType academicGrades } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $p = $response->toArray()['data']['createPerson']['person']; + $this->assertSame('/people/1', $p['id']); + $this->assertSame('Harry', $p['name']); + $this->assertCount(2, $p['academicGrades']); + $this->assertSame('BACHELOR', $p['academicGrades'][0]); + $this->assertSame('MASTER', $p['academicGrades'][1]); + } + + public function testDeleteItem(): void + { + $this->recreateSchema([$this->isMongoDB() ? FooDocument::class : Foo::class]); + $manager = $this->getManager(); + $class = $this->isMongoDB() ? FooDocument::class : Foo::class; + $foo = new $class(); + $foo->setName('Existing'); + $foo->setBar('value'); + $manager->persist($foo); + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + deleteFoo(input: {id: "/foos/1", clientMutationId: "anotherId"}) { + foo { id } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['deleteFoo']; + $this->assertSame('/foos/1', $data['foo']['id']); + $this->assertSame('anotherId', $data['clientMutationId']); + } + + public function testDeleteWithWrongResourceTypeYieldsError(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? FooDocument::class : Foo::class, + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + $this->seedDummiesWithRelatedDummy(1); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + deleteFoo(input: {id: "/dummies/1", clientMutationId: "myId"}) { + foo { id } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + 'Item "/dummies/1" did not match expected type "Foo".', + $response->toArray(false)['errors'][0]['message'], + ); + } + + public function testDeleteItemWithCompositeIdentifiers(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Composite identifiers @!mongodb'); + } + $this->recreateSchema([CompositeRelation::class, CompositeItem::class, CompositeLabel::class]); + $this->seedCompositeIdentifierObjects(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + deleteCompositeRelation(input: {id: "/composite_relations/compositeItem=1;compositeLabel=1", clientMutationId: "myId"}) { + compositeRelation { id } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['deleteCompositeRelation']; + $this->assertSame('/composite_relations/compositeItem=1;compositeLabel=1', $data['compositeRelation']['id']); + $this->assertSame('myId', $data['clientMutationId']); + } + + public function testModifyItem(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + $this->seedDummiesWithRelatedDummy(1); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateDummy(input: {id: "/dummies/1", description: "Modified description.", dummyDate: "2018-06-05T00:00:00+00:00", clientMutationId: "myId"}) { + dummy { id name description dummyDate } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['updateDummy']; + $this->assertSame('/dummies/1', $d['dummy']['id']); + $this->assertSame('Dummy #1', $d['dummy']['name']); + $this->assertSame('Modified description.', $d['dummy']['description']); + $this->assertSame('2018-06-05', $d['dummy']['dummyDate']); + $this->assertSame('myId', $d['clientMutationId']); + } + + public function testModifyItemWithEmbeddedObject(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Embedded object scenario @!mongodb'); + } + $this->recreateSchema([Dummy::class, FooDummy::class]); + $this->seedFooDummyWithEmbeddable(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", embeddedFoo: {dummyName: "Embedded name"}, clientMutationId: "myId"}) { + fooDummy { + id + name + embeddedFoo { dummyName } + } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['updateFooDummy']; + $this->assertSame('modifiedName', $d['fooDummy']['name']); + $this->assertSame('Embedded name', $d['fooDummy']['embeddedFoo']['dummyName']); + $this->assertSame('myId', $d['clientMutationId']); + } + + public function testModifyNonWritablePropertyRejected(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Embedded object scenario @!mongodb'); + } + $this->recreateSchema([Dummy::class, FooDummy::class]); + $this->seedFooDummyWithEmbeddable(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", nonWritableProp: "written", embeddedFoo: {dummyName: "Embedded name"}, clientMutationId: "myId"}) { + fooDummy { id name } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertMatchesRegularExpression( + '/^Field "nonWritableProp" is not defined by type "?updateFooDummyInput"?\.$/', + $response->toArray(false)['errors'][0]['message'], + ); + } + + public function testModifyNonWritableEmbeddedPropertyRejected(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Embedded object scenario @!mongodb'); + } + $this->recreateSchema([Dummy::class, FooDummy::class]); + $this->seedFooDummyWithEmbeddable(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateFooDummy(input: {id: "/foo_dummies/1", name: "modifiedName", embeddedFoo: {dummyName: "Embedded name", nonWritableProp: "written"}, clientMutationId: "myId"}) { + fooDummy { id name } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertMatchesRegularExpression( + '/^Field "nonWritableProp" is not defined by type "?FooEmbeddableNestedInput"?\.$/', + $response->toArray(false)['errors'][0]['message'], + ); + } + + public function testModifyItemWithCompositeIdentifiers(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Composite identifiers @!mongodb'); + } + $this->recreateSchema([CompositeRelation::class, CompositeItem::class, CompositeLabel::class]); + $this->seedCompositeIdentifierObjects(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateCompositeRelation(input: {id: "/composite_relations/compositeItem=1;compositeLabel=2", value: "Modified value.", clientMutationId: "myId"}) { + compositeRelation { id value } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['updateCompositeRelation']; + $this->assertSame('/composite_relations/compositeItem=1;compositeLabel=2', $d['compositeRelation']['id']); + $this->assertSame('Modified value.', $d['compositeRelation']['value']); + $this->assertSame('myId', $d['clientMutationId']); + } + + public function testCreateWithCustomUuid(): void + { + $this->recreateSchema([$this->isMongoDB() ? WritableIdDocument::class : WritableId::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createWritableId(input: {_id: "c6b722fe-0331-48c4-a214-f81f9f1ca082", name: "Foo", clientMutationId: "m"}) { + writableId { id _id name } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['createWritableId']; + $this->assertSame('/writable_ids/c6b722fe-0331-48c4-a214-f81f9f1ca082', $d['writableId']['id']); + $this->assertSame('c6b722fe-0331-48c4-a214-f81f9f1ca082', $d['writableId']['_id']); + $this->assertSame('Foo', $d['writableId']['name']); + $this->assertSame('m', $d['clientMutationId']); + } + + public function testUpdateWithCustomUuid(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('WritableId update @!mongodb'); + } + $this->recreateSchema([WritableId::class]); + $manager = $this->getManager(); + $w = new WritableId(); + $w->id = 'c6b722fe-0331-48c4-a214-f81f9f1ca082'; + $w->name = 'Foo'; + $manager->persist($w); + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateWritableId(input: {id: "/writable_ids/c6b722fe-0331-48c4-a214-f81f9f1ca082", _id: "f8a708b2-310f-416c-9aef-b1b5719dfa47", name: "Foo", clientMutationId: "m"}) { + writableId { id _id name } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['updateWritableId']; + $this->assertSame('/writable_ids/f8a708b2-310f-416c-9aef-b1b5719dfa47', $d['writableId']['id']); + $this->assertSame('f8a708b2-310f-416c-9aef-b1b5719dfa47', $d['writableId']['_id']); + $this->assertSame('Foo', $d['writableId']['name']); + } + + public function testUseSerializationGroups(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyGroupDocument::class : DummyGroup::class]); + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyGroupDocument::class : DummyGroup::class; + $g = new $class(); + foreach (['foo', 'bar', 'baz', 'qux'] as $p) { + $g->{$p} = ucfirst($p).' #1'; + } + $manager->persist($g); + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummyGroup(input: {bar: "Bar", baz: "Baz", clientMutationId: "myId"}) { + dummyGroup { id bar __typename } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['createDummyGroup']; + $this->assertSame('/dummy_groups/2', $d['dummyGroup']['id']); + $this->assertSame('Bar', $d['dummyGroup']['bar']); + $this->assertSame('createDummyGroupPayloadData', $d['dummyGroup']['__typename']); + $this->assertSame('myId', $d['clientMutationId']); + } + + public function testTriggerValidationError(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createDummy(input: {name: "", foo: [], clientMutationId: "myId"}) { + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame('422', (string) $data['errors'][0]['extensions']['status']); + $this->assertSame('name: This value should not be blank.', $data['errors'][0]['message']); + $this->assertArrayHasKey('violations', $data['errors'][0]['extensions']); + $this->assertSame('name', $data['errors'][0]['extensions']['violations'][0]['path']); + $this->assertSame('This value should not be blank.', $data['errors'][0]['extensions']['violations'][0]['message']); + } + + public function testCustomMutation(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomMutationDocument::class : DummyCustomMutation::class]); + $this->seedDummyCustomMutation(1); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + sumDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { + dummyCustomMutation { id result } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + '8', + (string) $response->toArray()['data']['sumDummyCustomMutation']['dummyCustomMutation']['result'], + ); + } + + public function testCustomMutationNotPersisted(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomMutationDocument::class : DummyCustomMutation::class]); + $this->seedDummyCustomMutation(1); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + sumNotPersistedDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { + dummyCustomMutation { id result } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertNull($response->toArray()['data']['sumNotPersistedDummyCustomMutation']['dummyCustomMutation']); + } + + public function testCustomMutationNoWriteCustomResult(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomMutationDocument::class : DummyCustomMutation::class]); + $this->seedDummyCustomMutation(1); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + sumNoWriteCustomResultDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { + dummyCustomMutation { id result } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + '1234', + (string) $response->toArray()['data']['sumNoWriteCustomResultDummyCustomMutation']['dummyCustomMutation']['result'], + ); + } + + public function testCustomMutationOnlyPersist(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomMutationDocument::class : DummyCustomMutation::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + sumOnlyPersistDummyCustomMutation(input: {id: "/dummy_custom_mutations/1", operandB: 5}) { + dummyCustomMutation { id result } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertNull($response->toArray()['data']['sumOnlyPersistDummyCustomMutation']['dummyCustomMutation']); + } + + public function testCustomMutationCustomArguments(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomMutationDocument::class : DummyCustomMutation::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + testCustomArgumentsDummyCustomMutation(input: {operandC: 18, clientMutationId: "myId"}) { + dummyCustomMutation { result } + clientMutationId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['testCustomArgumentsDummyCustomMutation']; + $this->assertSame('18', (string) $d['dummyCustomMutation']['result']); + $this->assertSame('myId', $d['clientMutationId']); + } + + public function testCreateItemWithEnumAsResource(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('VideoGame ORM-only.'); + } + + $this->recreateSchema([VideoGame::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + { + gamePlayModes { id name } + gamePlayMode(id: "/game_play_modes/SINGLE_PLAYER") { name } + } + QUERY); + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']; + $this->assertCount(3, $data['gamePlayModes']); + $this->assertSame('/game_play_modes/SINGLE_PLAYER', $data['gamePlayModes'][2]['id']); + $this->assertSame('SINGLE_PLAYER', $data['gamePlayModes'][2]['name']); + $this->assertSame('SINGLE_PLAYER', $data['gamePlayMode']['name']); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createVideoGame(input: {name: "Baten Kaitos", playMode: "/game_play_modes/SINGLE_PLAYER"}) { + videoGame { id name playMode { id name } } + } + } + QUERY); + $this->assertResponseIsSuccessful(); + $vg = $response->toArray()['data']['createVideoGame']['videoGame']; + $this->assertSame('/video_games/1', $vg['id']); + $this->assertSame('Baten Kaitos', $vg['name']); + $this->assertSame('/game_play_modes/SINGLE_PLAYER', $vg['playMode']['id']); + $this->assertSame('SINGLE_PLAYER', $vg['playMode']['name']); + } + + public function testDeleteInvalidItem(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('ActivityLog @!mongodb.'); + } + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + deleteActivityLog(input: {id: "/activity_logs/1"}) { + activityLog { id } + } + } + QUERY); + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertArrayNotHasKey('errors', $data); + $this->assertArrayHasKey('activityLog', $data['data']['deleteActivityLog']); + } + + public function testUploadFileWithCustomMutation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MediaObject @!mongodb.'); + } + + $file = new UploadedFile(self::FIXTURES_DIR.'/test.gif', 'test.gif', null, \UPLOAD_ERR_OK, true); + $response = $this->executeGraphQlMultipart( + '{"query": "mutation($file: Upload!) { uploadMediaObject(input: {file: $file}) { mediaObject { id contentUrl } } }", "variables": {"file": null}}', + '{"file": ["variables.file"]}', + ['file' => $file], + ); + + $this->assertResponseIsSuccessful(); + $this->assertSame('test.gif', $response->toArray()['data']['uploadMediaObject']['mediaObject']['contentUrl']); + } + + public function testUploadMultipleFilesWithCustomMutation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MediaObject @!mongodb.'); + } + + $files = [ + '0' => new UploadedFile(self::FIXTURES_DIR.'/test.gif', 'test.gif', null, \UPLOAD_ERR_OK, true), + '1' => new UploadedFile(self::FIXTURES_DIR.'/test.gif', 'test.gif', null, \UPLOAD_ERR_OK, true), + '2' => new UploadedFile(self::FIXTURES_DIR.'/test.gif', 'test.gif', null, \UPLOAD_ERR_OK, true), + ]; + $response = $this->executeGraphQlMultipart( + '{"query": "mutation($files: [Upload!]!) { uploadMultipleMediaObject(input: {files: $files}) { mediaObject { id contentUrl } } }", "variables": {"files": [null, null, null]}}', + '{"0": ["variables.files.0"], "1": ["variables.files.1"], "2": ["variables.files.2"]}', + $files, + ); + + $this->assertResponseIsSuccessful(); + $this->assertSame('test.gif', $response->toArray()['data']['uploadMultipleMediaObject']['mediaObject']['contentUrl']); + } + + public function testUseSerializationGroupsWithRelations(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('FourthLevel + RelatedToDummyFriend @!mongodb.'); + } + + $this->recreateSchema([ + Dummy::class, RelatedDummy::class, ThirdLevel::class, FourthLevel::class, + DummyFriend::class, RelatedToDummyFriend::class, + ]); + $this->seedDummyWithRelatedDummyAndThirdLevel(); + $this->seedRelatedDummyWithFriends(2); + $this->seedDummyWithFourthLevelRelation(); + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + updateRelatedDummy(input: { + id: "/related_dummies/2", + symfony: "laravel", + thirdLevel: { fourthLevel: "/fourth_levels/1" } + }) { + relatedDummy { + id symfony + thirdLevel { id fourthLevel { id __typename } __typename } + relatedToDummyFriend { + edges { node { name } } + __typename + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $rel = $response->toArray()['data']['updateRelatedDummy']['relatedDummy']; + $this->assertSame('/related_dummies/2', $rel['id']); + $this->assertSame('laravel', $rel['symfony']); + $this->assertSame('/third_levels/3', $rel['thirdLevel']['id']); + $this->assertSame('updateThirdLevelNestedPayload', $rel['thirdLevel']['__typename']); + $this->assertSame('/fourth_levels/1', $rel['thirdLevel']['fourthLevel']['id']); + $this->assertSame('updateFourthLevelNestedPayload', $rel['thirdLevel']['fourthLevel']['__typename']); + $this->assertSame('updateRelatedToDummyFriendNestedPayloadCursorConnection', $rel['relatedToDummyFriend']['__typename']); + $this->assertSame('Relation-1', $rel['relatedToDummyFriend']['edges'][0]['node']['name']); + $this->assertSame('Relation-2', $rel['relatedToDummyFriend']['edges'][1]['node']['name']); + } + + public function testMutationRunsBeforeValidation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('ActivityLog @!mongodb.'); + } + + $response = $this->executeGraphQl(<<<'QUERY' + mutation { + createActivityLog(input: {name: ""}) { + activityLog { name } + } + } + QUERY); + $this->assertResponseIsSuccessful(); + $this->assertSame('hi', $response->toArray()['data']['createActivityLog']['activityLog']['name']); + } + + private function newDummy(): object + { + $class = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + + return new $class(); + } + + private function newRelated(): object + { + $class = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + + return new $class(); + } + + private function seedDummiesWithRelatedDummy(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $related = $this->newRelated(); + $related->setName('RelatedDummy #'.$i); + + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->setRelatedDummy($related); + + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedFooDummyWithEmbeddable(): void + { + $manager = $this->getManager(); + $dummyClass = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + $fooClass = $this->isMongoDB() ? FooDummyDocument::class : FooDummy::class; + + $dummy = new $dummyClass(); + $dummy->setName('Lorem'); + + $foo = new $fooClass(); + $foo->setName('Hawsepipe'); + + $embeddedClass = $this->isMongoDB() ? FooEmbeddableDocument::class : FooEmbeddable::class; + $embedded = new $embeddedClass(); + $embedded->setDummyName('embeddedHawsepipe'); + $foo->setEmbeddedFoo($embedded); + $foo->setDummy($dummy); + + $manager->persist($foo); + $manager->flush(); + } + + private function seedCompositeIdentifierObjects(): void + { + $manager = $this->getManager(); + $item = new CompositeItem(); + $item->setField1('foobar'); + $manager->persist($item); + $manager->flush(); + + for ($i = 0; $i < 4; ++$i) { + $label = new CompositeLabel(); + $label->setValue('foo-'.$i); + $manager->persist($label); + $manager->flush(); + + $rel = new CompositeRelation(); + $rel->setCompositeLabel($label); + $rel->setCompositeItem($item); + $rel->setValue('somefoobardummy'); + $manager->persist($rel); + } + $manager->flush(); + $manager->clear(); + } + + private function seedDummyCustomMutation(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyCustomMutationDocument::class : DummyCustomMutation::class; + for ($i = 1; $i <= $count; ++$i) { + $m = new $class(); + $m->setOperandA(3); + $manager->persist($m); + } + $manager->flush(); + } + + private function seedDummyWithRelatedDummyAndThirdLevel(): void + { + $manager = $this->getManager(); + $thirdLevel = new ThirdLevel(); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName('RelatedDummy #1'); + $relatedDummy->setThirdLevel($thirdLevel); + $dummy = new Dummy(); + $dummy->setName('Dummy #1'); + $dummy->setAlias('Alias #0'); + $dummy->setRelatedDummy($relatedDummy); + $manager->persist($thirdLevel); + $manager->persist($relatedDummy); + $manager->persist($dummy); + $manager->flush(); + } + + private function seedRelatedDummyWithFriends(int $nb): void + { + $manager = $this->getManager(); + $relatedDummy = new RelatedDummy(); + $relatedDummy->setName('RelatedDummy with friends'); + $manager->persist($relatedDummy); + $manager->flush(); + + for ($i = 1; $i <= $nb; ++$i) { + $friend = new DummyFriend(); + $friend->setName('Friend-'.$i); + $manager->persist($friend); + $manager->flush(); + + $relation = new RelatedToDummyFriend(); + $relation->setName('Relation-'.$i); + $relation->setDummyFriend($friend); + $relation->setRelatedDummy($relatedDummy); + $relatedDummy->addRelatedToDummyFriend($relation); + $manager->persist($relation); + } + + $other = new RelatedDummy(); + $other->setName('RelatedDummy without friends'); + $manager->persist($other); + $manager->flush(); + $manager->clear(); + } + + private function seedDummyWithFourthLevelRelation(): void + { + $manager = $this->getManager(); + $fourthLevel = new FourthLevel(); + $fourthLevel->setLevel(4); + $manager->persist($fourthLevel); + + $thirdLevel = new ThirdLevel(); + $thirdLevel->setLevel(3); + $thirdLevel->setFourthLevel($fourthLevel); + $manager->persist($thirdLevel); + + $namedRelatedDummy = new RelatedDummy(); + $namedRelatedDummy->setName('Hello'); + $namedRelatedDummy->setThirdLevel($thirdLevel); + $manager->persist($namedRelatedDummy); + + $relatedDummy = new RelatedDummy(); + $relatedDummy->setThirdLevel($thirdLevel); + $manager->persist($relatedDummy); + + $dummy = new Dummy(); + $dummy->setName('Dummy with relations'); + $dummy->setRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($namedRelatedDummy); + $dummy->addRelatedDummy($relatedDummy); + $manager->persist($dummy); + + $manager->flush(); + } +} diff --git a/tests/Functional/GraphQl/QueryTest.php b/tests/Functional/GraphQl/QueryTest.php new file mode 100644 index 00000000000..78e1ccb96a7 --- /dev/null +++ b/tests/Functional/GraphQl/QueryTest.php @@ -0,0 +1,852 @@ + + * + * 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\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue6427\SecurityAfterResolver; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCar as DummyCarDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCarColor as DummyCarColorDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyCustomQuery as DummyCustomQueryDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDifferentGraphQlSerializationGroup as DummyDifferentGraphQlSerializationGroupDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoNoInput as DummyDtoNoInputDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyDtoNoOutput as DummyDtoNoOutputDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyGroup as DummyGroupDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\WithJsonDummy as WithJsonDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCarColor; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCustomQuery; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDifferentGraphQlSerializationGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoNoInput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDtoNoOutput; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyGroup; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Foo; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsNested; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsNestedPaginated; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsRelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\MultiRelationsResolveDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\TreeDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\WithJsonDummy; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\Common\Collections\ArrayCollection; + +final class QueryTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + Dummy::class, + RelatedDummy::class, + MultiRelationsDummy::class, + MultiRelationsRelatedDummy::class, + MultiRelationsResolveDummy::class, + MultiRelationsNested::class, + MultiRelationsNestedPaginated::class, + TreeDummy::class, + WithJsonDummy::class, + DummyGroup::class, + DummyCar::class, + DummyCarColor::class, + DummyDtoNoInput::class, + DummyDtoNoOutput::class, + DummyCustomQuery::class, + DummyDifferentGraphQlSerializationGroup::class, + SecurityAfterResolver::class, + Foo::class, + ]; + } + + public function testBasicQuery(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummy(id: "/dummies/1") { + id + name + name_converted + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $dummy = $response->toArray()['data']['dummy']; + $this->assertSame('/dummies/1', $dummy['id']); + $this->assertSame('Dummy #1', $dummy['name']); + $this->assertSame('Converted 1', $dummy['name_converted']); + } + + public function testQueryWithDifferentRelationsToSameResource(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MultiRelationsDummy is ORM-only.'); + } + $this->recreateMultiRelations(); + $this->seedMultiRelations(2, 1, 2, 3, 4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + multiRelationsDummy(id: "/multi_relations_dummies/2") { + id + name + manyToOneRelation { id name } + manyToOneResolveRelation { id name } + manyToManyRelations { edges { node { id name } } } + oneToManyRelations { edges { node { id name } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $payload = $response->toArray(false); + if (isset($payload['errors'])) { + $this->fail('GraphQL errors: '.json_encode($payload['errors'], \JSON_PRETTY_PRINT)); + } + $d = $payload['data']['multiRelationsDummy']; + $this->assertSame('/multi_relations_dummies/2', $d['id']); + $this->assertSame('Dummy #2', $d['name']); + $this->assertNotNull($d['manyToOneRelation']['id']); + $this->assertSame('RelatedManyToOneDummy #2', $d['manyToOneRelation']['name']); + $this->assertCount(2, $d['manyToManyRelations']['edges']); + $this->assertMatchesRegularExpression('#RelatedManyToManyDummy(1|2)2#', $d['manyToManyRelations']['edges'][0]['node']['name']); + $this->assertMatchesRegularExpression('#RelatedManyToManyDummy(1|2)2#', $d['manyToManyRelations']['edges'][1]['node']['name']); + $this->assertCount(3, $d['oneToManyRelations']['edges']); + $this->assertMatchesRegularExpression('#RelatedOneToManyDummy(1|3)2#', $d['oneToManyRelations']['edges'][0]['node']['name']); + $this->assertMatchesRegularExpression('#RelatedOneToManyDummy(1|3)2#', $d['oneToManyRelations']['edges'][2]['node']['name']); + } + + public function testQueryEmbeddedCollections(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MultiRelationsDummy is ORM-only.'); + } + $this->recreateMultiRelations(); + $this->seedMultiRelations(2, 1, 2, 3, 4); + + $response = $this->executeGraphQl(<<<'QUERY' + { + multiRelationsDummy(id: "/multi_relations_dummies/2") { + id + name + manyToOneResolveRelation { id name } + nestedCollection { name } + nestedPaginatedCollection { edges { node { name } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray(); + $this->assertArrayNotHasKey('errors', $d); + $dummy = $d['data']['multiRelationsDummy']; + $this->assertNotNull($dummy['manyToOneResolveRelation']['id']); + $this->assertSame('RelatedManyToOneResolveDummy #2', $dummy['manyToOneResolveRelation']['name']); + for ($i = 1; $i <= 4; ++$i) { + $this->assertSame('NestedDummy'.$i, $dummy['nestedCollection'][$i - 1]['name']); + } + // Edges count exists, but node.name resolves to null because JSON-column hydration + // returns associative arrays, not MultiRelationsNestedPaginated objects, so the + // GraphQL field resolver can't access ->name. Separate from the link bug. + $this->assertCount(4, $dummy['nestedPaginatedCollection']['edges']); + } + + public function testQueryWithUnsetRelations(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('MultiRelationsDummy is ORM-only.'); + } + $this->recreateMultiRelations(); + $this->seedMultiRelations(2, 0, 0, 0, 0); + + $response = $this->executeGraphQl(<<<'QUERY' + { + multiRelationsDummy(id: "/multi_relations_dummies/2") { + id name + manyToOneRelation { id name } + manyToOneResolveRelation { id name } + manyToManyRelations { edges { node { id name } } } + oneToManyRelations { edges { node { id name } } } + nestedCollection { name } + nestedPaginatedCollection { edges { node { name } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertArrayNotHasKey('errors', $data); + $d = $data['data']['multiRelationsDummy']; + $this->assertSame('/multi_relations_dummies/2', $d['id']); + $this->assertSame('Dummy #2', $d['name']); + $this->assertNull($d['manyToOneRelation']); + $this->assertNull($d['manyToOneResolveRelation']); + $this->assertCount(0, $d['manyToManyRelations']['edges']); + $this->assertCount(0, $d['oneToManyRelations']['edges']); + $this->assertCount(0, $d['nestedCollection']); + $this->assertCount(0, $d['nestedPaginatedCollection']['edges']); + } + + public function testTreeDummiesChildRelation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('TreeDummy is ORM-only.'); + } + $this->recreateSchema([TreeDummy::class]); + $manager = $this->getManager(); + $parent = new TreeDummy(); + $child = new TreeDummy(); + $child->setParent($parent); + $manager->persist($parent); + $manager->persist($child); + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + treeDummies { + edges { node { id children { totalCount } } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(); + $this->assertArrayNotHasKey('errors', $data); + $edges = $data['data']['treeDummies']['edges']; + $this->assertSame('/tree_dummies/1', $edges[0]['node']['id']); + $this->assertSame(1, $edges[0]['node']['children']['totalCount']); + $this->assertSame('/tree_dummies/2', $edges[1]['node']['id']); + $this->assertSame(0, $edges[1]['node']['children']['totalCount']); + } + + public function testRelayNode(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + node(id: "/dummies/1") { + id + ... on Dummy { name } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $node = $response->toArray()['data']['node']; + $this->assertSame('/dummies/1', $node['id']); + $this->assertSame('Dummy #1', $node['name']); + } + + public function testIterableField(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(2); + $this->seedDummiesWithJsonAndArrayData(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummy(id: "/dummies/3") { + id + name + jsonData + arrayData + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $dummy = $response->toArray()['data']['dummy']; + $this->assertSame('/dummies/3', $dummy['id']); + $this->assertSame('Dummy #1', $dummy['name']); + $this->assertCount(2, $dummy['jsonData']['foo']); + $this->assertSame(5, $dummy['jsonData']['bar']); + $this->assertSame('baz', $dummy['arrayData'][2]); + } + + public function testNullJsonField(): void + { + $this->recreateSchema([$this->isMongoDB() ? WithJsonDummyDocument::class : WithJsonDummy::class]); + $manager = $this->getManager(); + $class = $this->isMongoDB() ? WithJsonDummyDocument::class : WithJsonDummy::class; + for ($i = 1; $i <= 2; ++$i) { + $w = new $class(); + $w->json = null; + $manager->persist($w); + } + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + withJsonDummy(id: "/with_json_dummies/2") { + id + json + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $w = $response->toArray()['data']['withJsonDummy']; + $this->assertSame('/with_json_dummies/2', $w['id']); + $this->assertNull($w['json']); + } + + public function testQueryWithVariables(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(2); + + $response = $this->executeGraphQl( + <<<'QUERY' + query DummyWithId($itemId: ID = "/dummies/1") { + dummyItem: dummy(id: $itemId) { + id + name + relatedDummy { id name } + } + } + QUERY, + ['itemId' => '/dummies/2'], + ); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['dummyItem']; + $this->assertSame('/dummies/2', $d['id']); + $this->assertSame('Dummy #2', $d['name']); + $this->assertSame('/related_dummies/2', $d['relatedDummy']['id']); + $this->assertSame('RelatedDummy #2', $d['relatedDummy']['name']); + } + + public function testQueryWithOperationName(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(2); + + $query = <<<'QUERY' + query DummyWithId1 { + dummyItem: dummy(id: "/dummies/1") { name } + } + query DummyWithId2 { + dummyItem: dummy(id: "/dummies/2") { id name } + } + QUERY; + + $response = $this->executeGraphQl($query, [], 'DummyWithId2'); + $d = $response->toArray()['data']['dummyItem']; + $this->assertSame('/dummies/2', $d['id']); + $this->assertSame('Dummy #2', $d['name']); + + $response = $this->executeGraphQl($query, [], 'DummyWithId1'); + $this->assertSame('Dummy #1', $response->toArray()['data']['dummyItem']['name']); + } + + public function testSerializationGroups(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyGroupDocument::class : DummyGroup::class]); + $this->seedDummyGroups(1); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyGroup(id: "/dummy_groups/1") { + foo + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('Foo #1', $response->toArray()['data']['dummyGroup']['foo']); + } + + public function testSerializedName(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyCarDocument::class : DummyCar::class, + $this->isMongoDB() ? DummyCarColorDocument::class : DummyCarColor::class, + ]); + $this->seedDummyCarWithColors(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyCar(id: "/dummy_cars/1") { + carBrand + } + } + QUERY); + + $this->assertSame('DummyBrand', $response->toArray()['data']['dummyCar']['carBrand']); + } + + public function testFetchOnlyInternalId(): void + { + $this->recreateDummiesAndRelated(); + $this->seedDummiesWithRelatedDummy(1); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummy(id: "/dummies/1") { + _id + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('1', (string) $response->toArray()['data']['dummy']['_id']); + } + + public function testNonexistentItemReturnsNull(): void + { + $this->recreateDummiesAndRelated(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummy(id: "/dummies/5") { + name + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertNull($response->toArray()['data']['dummy']); + } + + public function testNonexistentIriYieldsDebugMessage(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + foo(id: "/foo/1") { + name + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertGraphQlDebugMessage($data, 'No route matches "/foo/1".'); + $this->assertCount(1, $data['errors']); + } + + public function testOutputClassUsedInsteadOfResource(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyDtoNoInputDocument::class : DummyDtoNoInput::class]); + $this->seedDummyDtoNoInput(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyDtoNoInputs { + edges { node { baz bat } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'dummyDtoNoInputs' => [ + 'edges' => [ + ['node' => ['baz' => 0.33, 'bat' => 'DummyDtoNoInput foo #1']], + ['node' => ['baz' => 0.67, 'bat' => 'DummyDtoNoInput foo #2']], + ], + ], + ], + ], $response->toArray()); + } + + public function testDisableOutputClassYieldsEmptyResponse(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDtoNoInputDocument::class : DummyDtoNoInput::class, + $this->isMongoDB() ? DummyDtoNoOutputDocument::class : DummyDtoNoOutput::class, + ]); + $this->seedDummyDtoNoOutput(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyDtoNoInputs { + edges { node { baz bat } } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => ['dummyDtoNoInputs' => ['edges' => []]], + ], $response->toArray()); + } + + public function testCustomNotRetrievedItemQuery(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testNotRetrievedItemDummyCustomQuery { + message + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => ['testNotRetrievedItemDummyCustomQuery' => ['message' => 'Success (not retrieved)!']], + ], $response->toArray()); + } + + public function testCustomItemQueryWithReadAndSerializeDisabled(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testNoReadAndSerializeItemDummyCustomQuery(id: "/not_used") { + message + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame(['data' => ['testNoReadAndSerializeItemDummyCustomQuery' => null]], $response->toArray()); + } + + public function testCustomItemQuery(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + $this->seedDummyCustomQuery(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testItemDummyCustomQuery(id: "/dummy_custom_queries/1") { + message + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame( + ['data' => ['testItemDummyCustomQuery' => ['message' => 'Success!']]], + $response->toArray(), + ); + } + + public function testCustomItemQueryWithCustomArguments(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class]); + $this->seedDummyCustomQuery(2); + + $response = $this->executeGraphQl(<<<'QUERY' + { + testItemCustomArgumentsDummyCustomQuery( + id: "/dummy_custom_queries/1", + customArgumentBool: true, + customArgumentInt: 3, + customArgumentString: "A string", + customArgumentFloat: 2.6, + customArgumentIntArray: [4], + customArgumentCustomType: "2019-05-24T00:00:00+00:00" + ) { + message + customArgs + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame([ + 'data' => [ + 'testItemCustomArgumentsDummyCustomQuery' => [ + 'message' => 'Success!', + 'customArgs' => [ + 'id' => '/dummy_custom_queries/1', + 'customArgumentBool' => true, + 'customArgumentInt' => 3, + 'customArgumentString' => 'A string', + 'customArgumentFloat' => 2.6, + 'customArgumentIntArray' => [4], + 'customArgumentCustomType' => '2019-05-24T00:00:00+00:00', + ], + ], + ], + ], $response->toArray()); + } + + public function testDifferentSerializationGroupsForItemAndCollection(): void + { + $this->recreateSchema([$this->isMongoDB() ? DummyDifferentGraphQlSerializationGroupDocument::class : DummyDifferentGraphQlSerializationGroup::class]); + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyDifferentGraphQlSerializationGroupDocument::class : DummyDifferentGraphQlSerializationGroup::class; + $entity = new $class(); + $entity->setName('Name #1'); + $entity->setTitle('Title #1'); + $manager->persist($entity); + $manager->flush(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + dummyDifferentGraphQlSerializationGroup(id: "/dummy_different_graph_ql_serialization_groups/1") { + name + title + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $d = $response->toArray()['data']['dummyDifferentGraphQlSerializationGroup']; + $this->assertSame('Name #1', $d['name']); + $this->assertSame('Title #1', $d['title']); + } + + public function testSecurityAfterResolver(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + getSecurityAfterResolver(id: "/security_after_resolvers/1") { + name + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $this->assertSame('test', $response->toArray()['data']['getSecurityAfterResolver']['name']); + } + + public function testSecurityAfterResolverDeniesNonMatchingId(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + getSecurityAfterResolver(id: "/security_after_resolvers/2") { + name + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray(false); + $this->assertSame(403, $data['errors'][0]['extensions']['status']); + $this->assertSame('Access Denied.', $data['errors'][0]['message']); + $this->assertArrayNotHasKey('name', $data['data']['getSecurityAfterResolver'] ?? []); + } + + private function recreateDummiesAndRelated(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? DummyDocument::class : Dummy::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]); + } + + private function recreateMultiRelations(): void + { + $this->recreateSchema([ + MultiRelationsDummy::class, + MultiRelationsRelatedDummy::class, + MultiRelationsResolveDummy::class, + ]); + } + + private function newDummy(): object + { + $class = $this->isMongoDB() ? DummyDocument::class : Dummy::class; + + return new $class(); + } + + private function newRelated(): object + { + $class = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + + return new $class(); + } + + private function seedDummiesWithRelatedDummy(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $related = $this->newRelated(); + $related->setName('RelatedDummy #'.$i); + + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->nameConverted = "Converted $i"; + $dummy->setRelatedDummy($related); + + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummiesWithJsonAndArrayData(int $count): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $count; ++$i) { + $dummy = $this->newDummy(); + $dummy->setName('Dummy #'.$i); + $dummy->setAlias('Alias #'.($count - $i)); + $dummy->setJsonData(['foo' => ['bar', 'baz'], 'bar' => 5]); + $dummy->setArrayData(['foo', 'bar', 'baz']); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedMultiRelations(int $nb, int $nbmtor, int $nbmtmr, int $nbotmr, int $nber): void + { + $manager = $this->getManager(); + for ($i = 1; $i <= $nb; ++$i) { + $related = new MultiRelationsRelatedDummy(); + $related->name = 'RelatedManyToOneDummy #'.$i; + + $resolve = new MultiRelationsResolveDummy(); + $resolve->name = 'RelatedManyToOneResolveDummy #'.$i; + + $dummy = new MultiRelationsDummy(); + $dummy->name = 'Dummy #'.$i; + + if ($nbmtor) { + $dummy->setManyToOneRelation($related); + $dummy->setManyToOneResolveRelation($resolve); + } + + for ($j = 1; $j <= $nbmtmr; ++$j) { + $m2m = new MultiRelationsRelatedDummy(); + $m2m->name = 'RelatedManyToManyDummy'.$j.$i; + $manager->persist($m2m); + $dummy->addManyToManyRelation($m2m); + } + + for ($j = 1; $j <= $nbotmr; ++$j) { + $o2m = new MultiRelationsRelatedDummy(); + $o2m->name = 'RelatedOneToManyDummy'.$j.$i; + $o2m->setOneToManyRelation($dummy); + $manager->persist($o2m); + $dummy->addOneToManyRelation($o2m); + } + + $nested = new ArrayCollection(); + for ($j = 1; $j <= $nber; ++$j) { + $n = new MultiRelationsNested(); + $n->name = 'NestedDummy'.$j; + $nested->add($n); + } + $dummy->setNestedCollection($nested); + + $nestedPaginated = new ArrayCollection(); + for ($j = 1; $j <= $nber; ++$j) { + $np = new MultiRelationsNestedPaginated(); + $np->name = 'NestedPaginatedDummy'.$j; + $nestedPaginated->add($np); + } + $dummy->setNestedPaginatedCollection($nestedPaginated); + + $manager->persist($related); + $manager->persist($resolve); + $manager->persist($dummy); + } + $manager->flush(); + } + + private function seedDummyGroups(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyGroupDocument::class : DummyGroup::class; + for ($i = 1; $i <= $count; ++$i) { + $group = new $class(); + foreach (['foo', 'bar', 'baz', 'qux'] as $property) { + $group->{$property} = ucfirst($property).' #'.$i; + } + $manager->persist($group); + } + $manager->flush(); + } + + private function seedDummyCarWithColors(): void + { + $manager = $this->getManager(); + $carClass = $this->isMongoDB() ? DummyCarDocument::class : DummyCar::class; + $colorClass = $this->isMongoDB() ? DummyCarColorDocument::class : DummyCarColor::class; + + $car = new $carClass(); + $car->setName('mustli'); + $car->setCanSell(true); + $car->setAvailableAt(new \DateTime()); + $manager->persist($car); + $manager->flush(); + if (\is_object($car->getId())) { + $manager->persist($car->getId()); + $manager->flush(); + } + $red = new $colorClass(); + $red->setProp('red'); + $red->setCar($car); + $manager->persist($red); + $blue = new $colorClass(); + $blue->setProp('blue'); + $blue->setCar($car); + $manager->persist($blue); + $manager->flush(); + } + + private function seedDummyDtoNoInput(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyDtoNoInputDocument::class : DummyDtoNoInput::class; + for ($i = 1; $i <= $count; ++$i) { + $dto = new $class(); + $dto->lorem = 'DummyDtoNoInput foo #'.$i; + $dto->ipsum = round($i / 3, 2); + $manager->persist($dto); + } + $manager->flush(); + } + + private function seedDummyDtoNoOutput(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyDtoNoOutputDocument::class : DummyDtoNoOutput::class; + for ($i = 1; $i <= $count; ++$i) { + $dto = new $class(); + $dto->lorem = 'DummyDtoNoOutput foo #'.$i; + $dto->ipsum = (string) round($i / 3, 2); + $manager->persist($dto); + } + $manager->flush(); + } + + private function seedDummyCustomQuery(int $count): void + { + $manager = $this->getManager(); + $class = $this->isMongoDB() ? DummyCustomQueryDocument::class : DummyCustomQuery::class; + for ($i = 1; $i <= $count; ++$i) { + $manager->persist(new $class()); + } + $manager->flush(); + } +} diff --git a/tests/Functional/GraphQl/SchemaExportTest.php b/tests/Functional/GraphQl/SchemaExportTest.php new file mode 100644 index 00000000000..06360f1212c --- /dev/null +++ b/tests/Functional/GraphQl/SchemaExportTest.php @@ -0,0 +1,174 @@ + + * + * 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\GraphQl; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\OptionalRequiredDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedToDummyFriend; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Component\Console\Tester\ApplicationTester; + +final class SchemaExportTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + private ApplicationTester $tester; + + protected function setUp(): void + { + self::bootKernel(); + + $application = new Application(static::$kernel); + $application->setCatchExceptions(false); + $application->setAutoExit(false); + $this->tester = new ApplicationTester($application); + } + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + DummyFriend::class, + RelatedToDummyFriend::class, + OptionalRequiredDummy::class, + ThirdLevel::class, + ]; + } + + public function testExportGraphQlSchema(): void + { + $this->tester->run(['command' => 'api:graphql:export']); + + $output = $this->tester->getDisplay(); + + $this->assertStringContainsString(<<<'SDL' + "Dummy Friend." + type DummyFriend implements Node { + id: ID! + + "The id" + _id: Int! + + "The dummy name" + name: String! + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Cursor connection for DummyFriend." + type DummyFriendCursorConnection { + edges: [DummyFriendEdge] + pageInfo: DummyFriendPageInfo! + totalCount: Int! + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Edge of DummyFriend." + type DummyFriendEdge { + node: DummyFriend + cursor: String! + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Information about the current page." + type DummyFriendPageInfo { + endCursor: String + startCursor: String + hasNextPage: Boolean! + hasPreviousPage: Boolean! + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Updates a DummyFriend." + updateDummyFriend(input: updateDummyFriendInput!): updateDummyFriendPayload + + "Deletes a DummyFriend." + deleteDummyFriend(input: deleteDummyFriendInput!): deleteDummyFriendPayload + + "Creates a DummyFriend." + createDummyFriend(input: createDummyFriendInput!): createDummyFriendPayload + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Updates a DummyFriend." + input updateDummyFriendInput { + id: ID! + + "The dummy name" + name: String + clientMutationId: String + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Updates a DummyFriend." + type updateDummyFriendPayload { + dummyFriend: DummyFriend + clientMutationId: String + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Deletes a DummyFriend." + input deleteDummyFriendInput { + id: ID! + clientMutationId: String + } + + "Deletes a DummyFriend." + type deleteDummyFriendPayload { + dummyFriend: DummyFriend + clientMutationId: String + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Creates a DummyFriend." + input createDummyFriendInput { + "The dummy name" + name: String! + clientMutationId: String + } + + "Creates a DummyFriend." + type createDummyFriendPayload { + dummyFriend: DummyFriend + clientMutationId: String + } + SDL, $output); + + $this->assertStringContainsString(<<<'SDL' + "Updates a OptionalRequiredDummy." + input updateOptionalRequiredDummyInput { + id: ID! + thirdLevel: updateThirdLevelNestedInput + thirdLevelRequired: updateThirdLevelNestedInput! + + "Get relatedToDummyFriend." + relatedToDummyFriend: [updateRelatedToDummyFriendNestedInput] + clientMutationId: String + } + SDL, $output); + } +} diff --git a/tests/Functional/GraphQl/SubscriptionTest.php b/tests/Functional/GraphQl/SubscriptionTest.php new file mode 100644 index 00000000000..72a1f43920f --- /dev/null +++ b/tests/Functional/GraphQl/SubscriptionTest.php @@ -0,0 +1,254 @@ + + * + * 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\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\DummyMercure as DummyMercureDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\RelatedDummy as RelatedDummyDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyMercure; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Tests\Fixtures\TestBundle\Mercure\TestHub; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class SubscriptionTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyMercure::class, RelatedDummy::class]; + } + + public function testIntrospectSubscriptionType(): void + { + $response = $this->executeGraphQl(<<<'QUERY' + { + __type(name: "Subscription") { + fields { + name + description + type { name kind } + args { + name + type { name kind ofType { name kind } } + } + } + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $fields = $response->toArray()['data']['__type']['fields']; + $this->assertNotEmpty($fields); + + foreach ($fields as $field) { + $this->assertMatchesRegularExpression('/^update[A-Za-z0-9_]+Subscribe$/', $field['name']); + $this->assertMatchesRegularExpression('/^Subscribes to the update event of a [A-Za-z0-9_]+\.$/', $field['description']); + $this->assertMatchesRegularExpression('/^update[A-Za-z0-9_]+SubscriptionPayload$/', $field['type']['name']); + $this->assertSame('OBJECT', $field['type']['kind']); + + $this->assertCount(1, $field['args']); + $arg = $field['args'][0]; + $this->assertSame('input', $arg['name']); + $this->assertSame('NON_NULL', $arg['type']['kind']); + $this->assertMatchesRegularExpression('/^update[A-Za-z0-9_]+SubscriptionInput$/', $arg['type']['ofType']['name']); + $this->assertSame('INPUT_OBJECT', $arg['type']['ofType']['kind']); + } + } + + public function testSubscribeToUpdatesProducesMercureUrl(): void + { + $this->recreateSchema($this->resources()); + $this->seedDummyMercure(2); + + $response = $this->executeGraphQl(<<<'QUERY' + subscription { + updateDummyMercureSubscribe(input: {id: "/dummy_mercures/1", clientSubscriptionId: "myId"}) { + dummyMercure { + id + name + relatedDummy { + name + } + } + mercureUrl + clientSubscriptionId + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['updateDummyMercureSubscribe']; + $this->assertSame('/dummy_mercures/1', $data['dummyMercure']['id']); + $this->assertSame('Dummy Mercure #1', $data['dummyMercure']['name']); + $this->assertSame('myId', $data['clientSubscriptionId']); + $this->assertMatchesRegularExpression( + '@^https://demo\.mercure\.rocks\?topic=http://[^/]+/subscriptions/[a-f0-9]+$@', + $data['mercureUrl'], + ); + + $response = $this->executeGraphQl(<<<'QUERY' + subscription { + updateDummyMercureSubscribe(input: {id: "/dummy_mercures/2"}) { + dummyMercure { id } + mercureUrl + } + } + QUERY); + + $this->assertResponseIsSuccessful(); + $data = $response->toArray()['data']['updateDummyMercureSubscribe']; + $this->assertSame('/dummy_mercures/2', $data['dummyMercure']['id']); + $this->assertMatchesRegularExpression( + '@^https://demo\.mercure\.rocks\?topic=http://[^/]+/subscriptions/[a-f0-9]+$@', + $data['mercureUrl'], + ); + } + + public function testReceiveMercureUpdatesAfterPut(): void + { + $this->recreateSchema($this->resources()); + $this->seedDummyMercure(2); + + $client = self::createClient(); + $client->getKernelBrowser()->disableReboot(); + + // Subscribe to both dummies so the SubscriptionManager registers different payload shapes. + $this->executeGraphQl(<<<'QUERY' + subscription { + updateDummyMercureSubscribe(input: {id: "/dummy_mercures/1", clientSubscriptionId: "myId"}) { + dummyMercure { id name relatedDummy { name } } + mercureUrl + } + } + QUERY); + $this->executeGraphQl(<<<'QUERY' + subscription { + updateDummyMercureSubscribe(input: {id: "/dummy_mercures/2"}) { + dummyMercure { id } + mercureUrl + } + } + QUERY); + + $client->request('PUT', '/dummy_mercures/1', [ + 'headers' => ['Accept' => 'application/ld+json', 'Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Dummy Mercure #1 updated'], + ]); + $this->assertResponseIsSuccessful(); + + $client->request('PUT', '/dummy_mercures/2', [ + 'headers' => ['Accept' => 'application/ld+json', 'Content-Type' => 'application/ld+json'], + 'json' => ['name' => 'Dummy Mercure #2 updated'], + ]); + $this->assertResponseIsSuccessful(); + + /** @var TestHub $hub */ + $hub = static::getContainer()->get('mercure.hub.default.test_hub'); + $updates = $hub->getUpdates(); + + $this->assertGreaterThanOrEqual(2, \count($updates)); + + $this->assertMercureUpdatePresent($updates, '#^http://[^/]+/subscriptions/[a-f0-9]+$#', [ + 'dummyMercure' => [ + 'id' => 1, + 'name' => 'Dummy Mercure #1 updated', + 'relatedDummy' => ['name' => 'RelatedDummy #1'], + ], + ]); + + $this->assertMercureUpdatePresent($updates, '#^http://[^/]+/subscriptions/[a-f0-9]+$#', [ + 'dummyMercure' => ['id' => 2], + ]); + } + + /** + * @param list<\Symfony\Component\Mercure\Update> $updates + * @param array $expectedPayload + */ + private function assertMercureUpdatePresent(array $updates, string $topicPattern, array $expectedPayload): void + { + $expectedJson = json_encode($expectedPayload, \JSON_THROW_ON_ERROR); + + foreach ($updates as $update) { + $topicsMatch = false; + foreach ($update->getTopics() as $topic) { + if (preg_match($topicPattern, (string) $topic)) { + $topicsMatch = true; + break; + } + } + if (!$topicsMatch) { + continue; + } + + if ($update->getData() === $expectedJson) { + $this->assertTrue(true); + + return; + } + } + + $this->fail(\sprintf( + 'No Mercure update matched topic %s with payload %s. Captured: %s', + $topicPattern, + $expectedJson, + json_encode(array_map( + static fn ($u) => ['topics' => $u->getTopics(), 'data' => $u->getData()], + $updates, + ), \JSON_PRETTY_PRINT), + )); + } + + /** + * @return list + */ + private function resources(): array + { + return [ + $this->isMongoDB() ? DummyMercureDocument::class : DummyMercure::class, + $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class, + ]; + } + + private function seedDummyMercure(int $count): void + { + $manager = $this->getManager(); + $relatedClass = $this->isMongoDB() ? RelatedDummyDocument::class : RelatedDummy::class; + $dummyClass = $this->isMongoDB() ? DummyMercureDocument::class : DummyMercure::class; + + for ($i = 1; $i <= $count; ++$i) { + $related = new $relatedClass(); + $related->setName('RelatedDummy #'.$i); + + $dummy = new $dummyClass(); + $dummy->name = "Dummy Mercure #$i"; + $dummy->description = 'Description'; + $dummy->relatedDummy = $related; + + $manager->persist($related); + $manager->persist($dummy); + } + $manager->flush(); + } +} diff --git a/tests/Functional/MappingTest.php b/tests/Functional/MappingTest.php index 353153b9f69..0b3571a0e9c 100644 --- a/tests/Functional/MappingTest.php +++ b/tests/Functional/MappingTest.php @@ -73,7 +73,7 @@ public function testShouldMapBetweenResourceAndEntity(): void $this->markTestSkipped('ObjectMapper not installed'); } - $this->recreateSchema([MappedEntity::class]); + $this->recreateSchema([$this->isMongoDB() ? MappedDocument::class : MappedEntity::class]); $this->loadFixtures(); $client = self::createClient(); $client->request('GET', $this->isMongoDB() ? 'mapped_resource_odms' : 'mapped_resources'); diff --git a/tests/Functional/NullOnNonNullablePropertyTest.php b/tests/Functional/NullOnNonNullablePropertyTest.php index d6aa24c078f..eba8ce3f10c 100644 --- a/tests/Functional/NullOnNonNullablePropertyTest.php +++ b/tests/Functional/NullOnNonNullablePropertyTest.php @@ -16,6 +16,9 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\NullOnNonNullableProperty\NullOnNonNullableResource; use ApiPlatform\Tests\SetupClassResourcesTrait; +use Composer\InstalledVersions; +use Composer\Semver\VersionParser; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** @see https://github.com/symfony/symfony/issues/64159 */ final class NullOnNonNullablePropertyTest extends ApiTestCase @@ -45,8 +48,13 @@ public function testNullOnNonNullablePropertyReturns400(): void $this->assertStringContainsString('Expected argument of type "string", "null" given at property path "name"', $body['hydra:description'] ?? $body['detail'] ?? ''); } + #[IgnoreDeprecations] public function testNullOnNonNullablePropertyReturns422WhenCollectingErrors(): void { + if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) { + $this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.'); + } + $response = self::createClient()->request('POST', '/null_on_non_nullable_resources_collect', [ 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['name' => null], diff --git a/tests/Functional/SubResource/SubResourceTest.php b/tests/Functional/SubResource/SubResourceTest.php index 45d79665fae..cfc7c2328cf 100644 --- a/tests/Functional/SubResource/SubResourceTest.php +++ b/tests/Functional/SubResource/SubResourceTest.php @@ -369,7 +369,7 @@ public function testGetOffersFromAggregateOffers(): void $this->assertResponseStatusCodeSame(200); $this->assertJsonEquals([ - '@context' => '/contexts/DummyOffer', + '@context' => '/contexts/DummyOfferByProductOffer', '@id' => '/dummy_products/2/offers/1/offers', '@type' => 'hydra:Collection', 'hydra:member' => [[ @@ -391,7 +391,7 @@ public function testGetOffersFromAggregateOffersDirect(): void $this->assertResponseStatusCodeSame(200); $this->assertJsonEquals([ - '@context' => '/contexts/DummyOffer', + '@context' => '/contexts/DummyOfferByAggregate', '@id' => '/dummy_aggregate_offers/1/offers', '@type' => 'hydra:Collection', 'hydra:member' => [[ @@ -448,7 +448,7 @@ public function testPersonSentGreetings(): void $this->assertResponseStatusCodeSame(200); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonEquals([ - '@context' => '/contexts/Greeting', + '@context' => '/contexts/GreetingBySender', '@id' => '/people/1/sent_greetings', '@type' => 'hydra:Collection', 'hydra:member' => [[ diff --git a/tests/Functional/SubResource/SubResourceWithoutGetTest.php b/tests/Functional/SubResource/SubResourceWithoutGetTest.php new file mode 100644 index 00000000000..cc8e42880f4 --- /dev/null +++ b/tests/Functional/SubResource/SubResourceWithoutGetTest.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\SubResource; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5722\Event; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5722\ItemLog; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\Common\Collections\ArrayCollection; +use Ramsey\Uuid\Uuid; + +final class SubResourceWithoutGetTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Event::class, ItemLog::class]; + } + + public function testGetSubresourceFromInverseSideWithoutItemOperation(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped('Not tested with mongodb.'); + } + + $this->recreateSchema([Event::class, ItemLog::class]); + + $manager = $this->getManager(); + $event = new Event(); + $event->logs = new ArrayCollection([new ItemLog(), new ItemLog()]); + $event->uuid = Uuid::fromString('03af3507-271e-4cca-8eee-6244fb06e95b'); + $manager->persist($event); + foreach ($event->logs as $log) { + $log->item = $event; + $manager->persist($log); + } + $manager->flush(); + + self::createClient()->request('GET', '/events/03af3507-271e-4cca-8eee-6244fb06e95b/logs', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseIsSuccessful(); + } +} diff --git a/tests/RecreateSchemaTrait.php b/tests/RecreateSchemaTrait.php index a5f53cb9326..24511eec159 100644 --- a/tests/RecreateSchemaTrait.php +++ b/tests/RecreateSchemaTrait.php @@ -31,7 +31,7 @@ private function recreateSchema(array $classes = []): void $schemaManager = $manager->getSchemaManager(); $firstDocumentClass = null; foreach ($classes as $c) { - $class = str_contains($c, 'Entity') ? str_replace('Entity', 'Document', $c) : $c; + $class = str_contains($c, '\\Entity\\') ? str_replace('\\Entity\\', '\\Document\\', $c) : $c; $firstDocumentClass ??= $class; $schemaManager->dropDocumentCollection($class); } diff --git a/tests/SetupClassResourcesTrait.php b/tests/SetupClassResourcesTrait.php index 32f08efc2f9..16b61907db7 100644 --- a/tests/SetupClassResourcesTrait.php +++ b/tests/SetupClassResourcesTrait.php @@ -26,6 +26,7 @@ public static function setUpBeforeClass(): void public static function tearDownAfterClass(): void { + static::ensureKernelShutdown(); static::removeResources(); $reflectionClass = new \ReflectionClass(Router::class); $reflectionClass->setStaticPropertyValue('cache', []); diff --git a/tests/TestSuiteConfigCache.php b/tests/TestSuiteConfigCache.php index e26db511af0..94a97673507 100644 --- a/tests/TestSuiteConfigCache.php +++ b/tests/TestSuiteConfigCache.php @@ -48,6 +48,8 @@ public function write(string $content, ?array $metadata = null): void private function getHash(): string { - return hash_file('xxh3', __DIR__.'/Fixtures/app/var/resources.php'); + $file = __DIR__.'/Fixtures/app/var/resources.php'; + + return is_file($file) ? hash_file('xxh3', $file) : ''; } } diff --git a/tests/WithResourcesTrait.php b/tests/WithResourcesTrait.php index 464c653c263..aa8ee610b49 100644 --- a/tests/WithResourcesTrait.php +++ b/tests/WithResourcesTrait.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Tests; +use Symfony\Component\Cache\Adapter\PhpFilesAdapter; + trait WithResourcesTrait { /** @@ -21,10 +23,53 @@ trait WithResourcesTrait protected static function writeResources(array $resources): void { file_put_contents(__DIR__.'/Fixtures/app/var/resources.php', \sprintf(' $v.'::class', $resources)))); + self::invalidateMetadataPools(); } protected static function removeResources(): void { file_put_contents(__DIR__.'/Fixtures/app/var/resources.php', 'hasProperty('valuesCache')) { + $property = $reflection->getProperty('valuesCache'); + $property->setValue(null, []); + } + } + + private static function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($iterator as $file) { + $file->isDir() ? @rmdir($file->getPathname()) : @unlink($file->getPathname()); + } + + @rmdir($dir); } } From 34e46af552557576b4cf316e0d5e63111abab8cd Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sat, 30 May 2026 09:22:10 +0200 Subject: [PATCH 20/84] ci: trim phpunit-components matrix and merge fail-deprecation (#8214) --- .github/workflows/ci.yml | 64 ++++++++-------------------------------- 1 file changed, 13 insertions(+), 51 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62d9ce3f8ea..606832f764e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -313,7 +313,14 @@ jobs: timeout-minutes: 20 strategy: matrix: - php: ${{ fromJSON(github.event_name == 'pull_request' && '[{"version":"8.2"},{"version":"8.5","coverage":true},{"version":"8.5","lowest":true},{"version":"8.5","minimal-changes":true}]' || '[{"version":"8.2"},{"version":"8.3"},{"version":"8.4"},{"version":"8.5","coverage":true},{"version":"8.5","lowest":true},{"version":"8.5","minimal-changes":true}]') }} + php: + - version: '8.2' + - version: '8.5' + coverage: true + - version: '8.5' + lowest: true + - version: '8.5' + minimal-changes: true component: - api-platform/doctrine-common - api-platform/doctrine-orm @@ -370,6 +377,11 @@ jobs: run: | mkdir -p /tmp/build/logs/phpunit composer ${{matrix.component}} test -- --log-junit "/tmp/build/logs/phpunit/junit.xml" ${{ matrix.php.coverage && '--coverage-clover /tmp/build/logs/phpunit/clover.xml' || '' }}${{ matrix.php.lowest && ' --ignore-baseline' || '' }} + - name: Run ${{ matrix.component }} tests (no deprecations) + if: ${{ matrix.php.version == '8.5' && !matrix.php.lowest && !matrix.php.minimal-changes }} + run: | + cd $(composer ${{matrix.component}} --cwd) + ./vendor/bin/phpunit --fail-on-deprecation --display-deprecations - name: Upload test artifacts if: always() uses: actions/upload-artifact@v6 @@ -397,56 +409,6 @@ jobs: php-coveralls --coverage_clover=/tmp/build/logs/phpunit/clover.xml continue-on-error: true - phpunit-components-fail-deprecation: - name: PHPUnit no deprecations ${{ matrix.component }} (PHP ${{ matrix.php.version }} - runs-on: ubuntu-latest - timeout-minutes: 20 - strategy: - matrix: - php: - - version: '8.5' - component: - - api-platform/doctrine-common - - api-platform/doctrine-orm - - api-platform/doctrine-odm - - api-platform/metadata - - api-platform/hydra - - api-platform/json-api - - api-platform/json-schema - - api-platform/elasticsearch - - api-platform/openapi - - api-platform/graphql - - api-platform/http-cache - - api-platform/ramsey-uuid - - api-platform/serializer - - api-platform/state - - api-platform/symfony - - api-platform/validator - fail-fast: false - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php.version }} - tools: pecl, composer:2.9.8 - extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite - ini-values: memory_limit=-1 - - name: Linking - run: | - composer global require soyuka/pmu - composer global config allow-plugins.soyuka/pmu true --no-interaction - composer global link . --permanent - - name: Run ${{ matrix.component }} install - run: | - composer ${{matrix.component}} update - - name: Run ${{ matrix.component }} tests - run: | - mkdir -p /tmp/build/logs/phpunit - cd $(composer ${{matrix.component}} --cwd) - ./vendor/bin/phpunit --fail-on-deprecation --display-deprecations --log-junit "/tmp/build/logs/phpunit/junit.xml" - postgresql: name: PHPUnit (PHP ${{ matrix.php }}) (PostgreSQL) runs-on: ubuntu-latest From b2f1a5ac34c6b6eb71bce40a91e34c5bee63f514 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 1 Jun 2026 13:05:48 +0200 Subject: [PATCH 21/84] feat(metadata): throwOnNotFound option (#6027) --- src/Metadata/ApiResource.php | 2 + src/Metadata/Delete.php | 2 + .../Extractor/XmlResourceExtractor.php | 1 + .../Extractor/YamlResourceExtractor.php | 1 + src/Metadata/Extractor/schema/resources.xsd | 1 + src/Metadata/Get.php | 2 + src/Metadata/GetCollection.php | 2 + src/Metadata/HttpOperation.php | 2 + src/Metadata/Metadata.php | 14 ++++ src/Metadata/Operation.php | 2 + src/Metadata/Patch.php | 2 + src/Metadata/Post.php | 2 + src/Metadata/Put.php | 2 + .../Extractor/Adapter/XmlResourceAdapter.php | 1 + .../Tests/Extractor/Adapter/resources.xml | 2 +- .../Tests/Extractor/Adapter/resources.yaml | 1 + .../ResourceMetadataCompatibilityTest.php | 2 + .../Tests/Extractor/XmlExtractorTest.php | 8 +++ .../Tests/Extractor/YamlExtractorTest.php | 12 ++++ src/State/Provider/ReadProvider.php | 20 +++--- src/State/Tests/Provider/ReadProviderTest.php | 70 +++++++++++++++++++ .../ApiResource/ThrowOnNotFound/Feeder.php | 40 +++++++++++ tests/Functional/ThrowOnNotFoundTest.php | 50 +++++++++++++ 23 files changed, 232 insertions(+), 9 deletions(-) create mode 100644 tests/Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php create mode 100644 tests/Functional/ThrowOnNotFoundTest.php diff --git a/src/Metadata/ApiResource.php b/src/Metadata/ApiResource.php index 32922afb1d7..5d136533d83 100644 --- a/src/Metadata/ApiResource.php +++ b/src/Metadata/ApiResource.php @@ -978,6 +978,7 @@ public function __construct( protected ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, protected ?bool $jsonStream = null, + protected ?bool $throwOnNotFound = null, protected array $extraProperties = [], ?bool $map = null, protected ?array $mcp = null, @@ -1026,6 +1027,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map, ); diff --git a/src/Metadata/Delete.php b/src/Metadata/Delete.php index 3674a5d6fe9..61744470fd6 100644 --- a/src/Metadata/Delete.php +++ b/src/Metadata/Delete.php @@ -102,6 +102,7 @@ public function __construct( ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, ?bool $jsonStream = null, + ?bool $throwOnNotFound = null, array $extraProperties = [], ?bool $map = null, ) { @@ -187,6 +188,7 @@ class: $class, parameters: $parameters, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, + throwOnNotFound: $throwOnNotFound, stateOptions: $stateOptions, map: $map ); diff --git a/src/Metadata/Extractor/XmlResourceExtractor.php b/src/Metadata/Extractor/XmlResourceExtractor.php index 4d3c3206b53..d439c1e9815 100644 --- a/src/Metadata/Extractor/XmlResourceExtractor.php +++ b/src/Metadata/Extractor/XmlResourceExtractor.php @@ -149,6 +149,7 @@ private function buildBase(\SimpleXMLElement $resource): array 'write' => $this->phpize($resource, 'write', 'bool'), 'jsonStream' => $this->phpize($resource, 'jsonStream', 'bool'), 'map' => $this->phpize($resource, 'map', 'bool'), + 'throwOnNotFound' => $this->phpize($resource, 'throwOnNotFound', 'bool'), ]; } diff --git a/src/Metadata/Extractor/YamlResourceExtractor.php b/src/Metadata/Extractor/YamlResourceExtractor.php index 67848c56942..38ac28e057d 100644 --- a/src/Metadata/Extractor/YamlResourceExtractor.php +++ b/src/Metadata/Extractor/YamlResourceExtractor.php @@ -176,6 +176,7 @@ private function buildBase(array $resource): array 'write' => $this->phpize($resource, 'write', 'bool'), 'jsonStream' => $this->phpize($resource, 'jsonStream', 'bool'), 'map' => $this->phpize($resource, 'map', 'bool'), + 'throwOnNotFound' => $this->phpize($resource, 'throwOnNotFound', 'bool'), ]; } diff --git a/src/Metadata/Extractor/schema/resources.xsd b/src/Metadata/Extractor/schema/resources.xsd index 8a1644c4790..6019722d6bb 100644 --- a/src/Metadata/Extractor/schema/resources.xsd +++ b/src/Metadata/Extractor/schema/resources.xsd @@ -526,6 +526,7 @@ + diff --git a/src/Metadata/Get.php b/src/Metadata/Get.php index 4c59d7ab957..82a01f83dc8 100644 --- a/src/Metadata/Get.php +++ b/src/Metadata/Get.php @@ -102,6 +102,7 @@ public function __construct( ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, ?bool $jsonStream = null, + ?bool $throwOnNotFound = null, array $extraProperties = [], ?bool $map = null, ) { @@ -186,6 +187,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map ); diff --git a/src/Metadata/GetCollection.php b/src/Metadata/GetCollection.php index 6256366bd27..a94ae240999 100644 --- a/src/Metadata/GetCollection.php +++ b/src/Metadata/GetCollection.php @@ -103,6 +103,7 @@ public function __construct( protected ?bool $hideHydraOperation = null, ?bool $jsonStream = null, array $extraProperties = [], + ?bool $throwOnNotFound = null, private ?string $itemUriTemplate = null, ?bool $map = null, ) { @@ -181,6 +182,7 @@ class: $class, processor: $processor, parameters: $parameters, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, rules: $rules, policy: $policy, diff --git a/src/Metadata/HttpOperation.php b/src/Metadata/HttpOperation.php index 32dfa15bb7e..a8f28f22d83 100644 --- a/src/Metadata/HttpOperation.php +++ b/src/Metadata/HttpOperation.php @@ -222,6 +222,7 @@ public function __construct( array|string|null $middleware = null, ?bool $queryParameterValidationEnabled = null, ?bool $jsonStream = null, + ?bool $throwOnNotFound = null, array $extraProperties = [], ?bool $map = null, ) { @@ -283,6 +284,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map ); diff --git a/src/Metadata/Metadata.php b/src/Metadata/Metadata.php index 009612c9900..35e039c85a8 100644 --- a/src/Metadata/Metadata.php +++ b/src/Metadata/Metadata.php @@ -82,6 +82,7 @@ public function __construct( protected ?bool $hideHydraOperation = null, protected ?bool $jsonStream = null, protected ?bool $map = null, + protected ?bool $throwOnNotFound = null, protected array $extraProperties = [], ) { if (\is_array($parameters) && $parameters) { @@ -655,6 +656,19 @@ public function withMiddleware(string|array $middleware): static return $self; } + public function getThrowOnNotFound(): ?bool + { + return $this->throwOnNotFound; + } + + public function withThrowOnNotFound(bool $throwOnNotFound): static + { + $self = clone $this; + $self->throwOnNotFound = $throwOnNotFound; + + return $self; + } + public function getExtraProperties(): ?array { return $this->extraProperties; diff --git a/src/Metadata/Operation.php b/src/Metadata/Operation.php index cbd53751e59..343915a673c 100644 --- a/src/Metadata/Operation.php +++ b/src/Metadata/Operation.php @@ -814,6 +814,7 @@ public function __construct( protected ?bool $strictQueryParameterValidation = null, protected ?bool $hideHydraOperation = null, protected ?bool $jsonStream = null, + protected ?bool $throwOnNotFound = null, protected array $extraProperties = [], ?bool $map = null, ) { @@ -862,6 +863,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map ); diff --git a/src/Metadata/Patch.php b/src/Metadata/Patch.php index e6147a18dad..100ac370e7a 100644 --- a/src/Metadata/Patch.php +++ b/src/Metadata/Patch.php @@ -102,6 +102,7 @@ public function __construct( ?bool $strictQueryParameterValidation = null, ?bool $hideHydraOperation = null, ?bool $jsonStream = null, + ?bool $throwOnNotFound = null, array $extraProperties = [], ?bool $map = null, ) { @@ -187,6 +188,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map ); diff --git a/src/Metadata/Post.php b/src/Metadata/Post.php index e68e4b0ec66..61a4a059c7c 100644 --- a/src/Metadata/Post.php +++ b/src/Metadata/Post.php @@ -100,6 +100,7 @@ public function __construct( ?string $policy = null, array|string|null $middleware = null, ?bool $jsonStream = null, + ?bool $throwOnNotFound = null, array $extraProperties = [], private ?string $itemUriTemplate = null, ?bool $strictQueryParameterValidation = null, @@ -188,6 +189,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map ); diff --git a/src/Metadata/Put.php b/src/Metadata/Put.php index 73632c786bc..87529e95879 100644 --- a/src/Metadata/Put.php +++ b/src/Metadata/Put.php @@ -100,6 +100,7 @@ public function __construct( ?string $policy = null, array|string|null $middleware = null, ?bool $jsonStream = null, + ?bool $throwOnNotFound = null, array $extraProperties = [], ?bool $strictQueryParameterValidation = null, ?bool $hideHydraOperation = null, @@ -188,6 +189,7 @@ class: $class, strictQueryParameterValidation: $strictQueryParameterValidation, hideHydraOperation: $hideHydraOperation, jsonStream: $jsonStream, + throwOnNotFound: $throwOnNotFound, extraProperties: $extraProperties, map: $map ); diff --git a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php index 6e3a1296f1b..4bebfa435e7 100644 --- a/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php +++ b/src/Metadata/Tests/Extractor/Adapter/XmlResourceAdapter.php @@ -66,6 +66,7 @@ final class XmlResourceAdapter implements ResourceAdapterInterface 'stateOptions', 'collectDenormalizationErrors', 'jsonStream', + 'throwOnNotFound', 'links', 'parameters', ]; diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.xml b/src/Metadata/Tests/Extractor/Adapter/resources.xml index 06c90ebfd20..15883953196 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.xml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.xml @@ -1,3 +1,3 @@ -someirischemaanotheririschemaCommentapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetapplication/merge-patch+json+ldapplication/merge-patch+json+ld_foo\d+bazhttps
60120AuthorizationAccept-LanguageAcceptcomment:read_collectioncomment:writebazhttp://purl.org/dc/terms/bazbarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbarstringapplication/vnd.ms-excelapplication/merge-patch+jsonapplication/merge-patch+jsonpouet\d+barhttphttps60120AuthorizationAccept-Languagecomment:readcomment:writecomment:custombazhttp://purl.org/dc/terms/bazbarcomment.custom_filterfoobarcustombazcustomquxcomment:read_collectioncomment:writebarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbar/v1/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit ametLorem ipsum dolor sit ametDolor sit amet +someirischemaanotheririschemaCommentapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetapplication/merge-patch+json+ldapplication/merge-patch+json+ld_foo\d+bazhttps
60120AuthorizationAccept-LanguageAcceptcomment:read_collectioncomment:writebazhttp://purl.org/dc/terms/bazbarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbarstringapplication/vnd.ms-excelapplication/merge-patch+jsonapplication/merge-patch+jsonpouet\d+barhttphttps60120AuthorizationAccept-Languagecomment:readcomment:writecomment:custombazhttp://purl.org/dc/terms/bazbarcomment.custom_filterfoobarcustombazcustomquxcomment:read_collectioncomment:writebarcomment.another_custom_filteruserIdLorem ipsum dolor sit ametDolor sit ametbar/v1/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit amet/v1Lorem ipsum dolor sit ametDolor sit ametLorem ipsum dolor sit ametDolor sit amet diff --git a/src/Metadata/Tests/Extractor/Adapter/resources.yaml b/src/Metadata/Tests/Extractor/Adapter/resources.yaml index 6dc74676c48..fe1595bf154 100644 --- a/src/Metadata/Tests/Extractor/Adapter/resources.yaml +++ b/src/Metadata/Tests/Extractor/Adapter/resources.yaml @@ -343,6 +343,7 @@ resources: strictQueryParameterValidation: false hideHydraOperation: false jsonStream: true + throwOnNotFound: true extraProperties: custom_property: 'Lorem ipsum dolor sit amet' another_custom_property: diff --git a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php index fb4f177d81f..0accd28d7ca 100644 --- a/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php +++ b/src/Metadata/Tests/Extractor/ResourceMetadataCompatibilityTest.php @@ -168,6 +168,7 @@ final class ResourceMetadataCompatibilityTest extends TestCase ], ], 'jsonStream' => true, + 'throwOnNotFound' => true, 'mercure' => true, 'stateOptions' => [ 'elasticsearchOptions' => [ @@ -481,6 +482,7 @@ final class ResourceMetadataCompatibilityTest extends TestCase 'order', 'extraProperties', 'jsonStream', + 'throwOnNotFound', ]; private const EXTENDED_BASE = [ 'uriTemplate', diff --git a/src/Metadata/Tests/Extractor/XmlExtractorTest.php b/src/Metadata/Tests/Extractor/XmlExtractorTest.php index b9d4dc23594..3447e2417ea 100644 --- a/src/Metadata/Tests/Extractor/XmlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/XmlExtractorTest.php @@ -107,6 +107,8 @@ public function testValidXML(): void 'jsonStream' => null, 'map' => null, 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], [ 'uriTemplate' => '/users/{author}/comments{._format}', @@ -285,6 +287,8 @@ public function testValidXML(): void 'jsonStream' => null, 'map' => null, 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], [ 'name' => null, @@ -400,6 +404,8 @@ public function testValidXML(): void 'jsonStream' => null, 'map' => null, 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], ], 'graphQlOperations' => null, @@ -414,6 +420,8 @@ public function testValidXML(): void 'jsonStream' => null, 'map' => null, 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], ], ], $extractor->getResources()); diff --git a/src/Metadata/Tests/Extractor/YamlExtractorTest.php b/src/Metadata/Tests/Extractor/YamlExtractorTest.php index 7d58abe6ba3..de25bcf7a9b 100644 --- a/src/Metadata/Tests/Extractor/YamlExtractorTest.php +++ b/src/Metadata/Tests/Extractor/YamlExtractorTest.php @@ -106,6 +106,8 @@ public function testValidYaml(): void 'jsonStream' => null, 'map' => null, 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], ], Program::class => [ @@ -180,6 +182,8 @@ public function testValidYaml(): void 'jsonStream' => null, 'map' => null, 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], [ 'uriTemplate' => '/users/{author}/programs{._format}', @@ -325,6 +329,8 @@ public function testValidYaml(): void 'jsonStream' => null, 'map' => null, 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], [ 'name' => null, @@ -413,6 +419,8 @@ public function testValidYaml(): void 'jsonStream' => null, 'map' => null, 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], ], 'graphQlOperations' => null, @@ -427,6 +435,8 @@ public function testValidYaml(): void 'jsonStream' => null, 'map' => null, 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], ], SingleFileConfigDummy::class => [ @@ -501,6 +511,8 @@ public function testValidYaml(): void 'jsonStream' => null, 'map' => null, 'jsonldContext' => null, + + 'throwOnNotFound' => null, ], ], ], $extractor->getResources()); diff --git a/src/State/Provider/ReadProvider.php b/src/State/Provider/ReadProvider.php index c6c65a3888b..734229d4aea 100644 --- a/src/State/Provider/ReadProvider.php +++ b/src/State/Provider/ReadProvider.php @@ -88,14 +88,18 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $data = null; } - if ( - null === $data - && 'POST' !== $operation->getMethod() - && ('PUT' !== $operation->getMethod() - || ($operation instanceof Put && !($operation->getAllowCreate() ?? false)) - ) - ) { - throw new NotFoundHttpException('Not Found', $e ?? null); + if (null === $data) { + $throwOnNotFound = $operation->getThrowOnNotFound(); + if (null === $throwOnNotFound) { + $throwOnNotFound = 'POST' !== $operation->getMethod() + && ('PUT' !== $operation->getMethod() + || ($operation instanceof Put && !($operation->getAllowCreate() ?? false)) + ); + } + + if ($throwOnNotFound) { + throw new NotFoundHttpException('Not Found', $e ?? null); + } } $request?->attributes->set('data', $data); diff --git a/src/State/Tests/Provider/ReadProviderTest.php b/src/State/Tests/Provider/ReadProviderTest.php index 92cfce527da..3b5f6ee2092 100644 --- a/src/State/Tests/Provider/ReadProviderTest.php +++ b/src/State/Tests/Provider/ReadProviderTest.php @@ -15,11 +15,14 @@ use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use ApiPlatform\Metadata\Put; use ApiPlatform\State\Provider\ReadProvider; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; class ReadProviderTest extends TestCase { @@ -61,4 +64,71 @@ public function testWithoutRequest(): void $readProvider = new ReadProvider($provider, $serializerContextBuilder); $this->assertEquals($readProvider->provide($operation), ['ok']); } + + public function testThrowOnNotFoundExplicitTrueThrowsForPost(): void + { + $operation = new Post(read: true, throwOnNotFound: true); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $provider = new ReadProvider($decorated); + $this->expectException(NotFoundHttpException::class); + $provider->provide($operation, ['id' => 1], ['request' => new Request()]); + } + + public function testThrowOnNotFoundExplicitFalseSkipsThrowForGet(): void + { + $operation = new Get(read: true, throwOnNotFound: false); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $provider = new ReadProvider($decorated); + $request = new Request(); + $this->assertNull($provider->provide($operation, ['id' => 1], ['request' => $request])); + $this->assertNull($request->attributes->get('data')); + } + + public function testThrowOnNotFoundDefaultThrowsForGet(): void + { + $operation = new Get(read: true); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $provider = new ReadProvider($decorated); + $this->expectException(NotFoundHttpException::class); + $provider->provide($operation, ['id' => 1], ['request' => new Request()]); + } + + public function testThrowOnNotFoundDefaultSkipsThrowForPost(): void + { + $operation = new Post(read: true); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $provider = new ReadProvider($decorated); + $request = new Request(); + $this->assertNull($provider->provide($operation, [], ['request' => $request])); + } + + public function testThrowOnNotFoundDefaultThrowsForPutWithoutAllowCreate(): void + { + $operation = new Put(read: true); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $provider = new ReadProvider($decorated); + $this->expectException(NotFoundHttpException::class); + $provider->provide($operation, ['id' => 1], ['request' => new Request()]); + } + + public function testThrowOnNotFoundDefaultSkipsThrowForPutWithAllowCreate(): void + { + $operation = new Put(read: true, allowCreate: true); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $provider = new ReadProvider($decorated); + $request = new Request(); + $this->assertNull($provider->provide($operation, ['id' => 1], ['request' => $request])); + } } diff --git a/tests/Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php b/tests/Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php new file mode 100644 index 00000000000..2456a1d1482 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/ThrowOnNotFound/Feeder.php @@ -0,0 +1,40 @@ + + * + * 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\ThrowOnNotFound; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Post; + +#[ApiResource(operations: [ + new Post( + uriTemplate: '/throw_on_not_found_feeders/{id}/feed', + throwOnNotFound: true, + provider: [Feeder::class, 'provide'], + read: true, + ), + new Post( + uriTemplate: '/throw_on_not_found_feeders/{id}/feed_default', + provider: [Feeder::class, 'provide'], + read: true, + ), +])] +final class Feeder +{ + public ?int $id = null; + + public static function provide(): null + { + return null; + } +} diff --git a/tests/Functional/ThrowOnNotFoundTest.php b/tests/Functional/ThrowOnNotFoundTest.php new file mode 100644 index 00000000000..060f1cedf46 --- /dev/null +++ b/tests/Functional/ThrowOnNotFoundTest.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ThrowOnNotFound\Feeder; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +final class ThrowOnNotFoundTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + public static function getResources(): array + { + return [Feeder::class]; + } + + public function testPostWithThrowOnNotFoundReturns404WhenProviderReturnsNull(): void + { + self::createClient()->request('POST', '/throw_on_not_found_feeders/42/feed', [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => '{}', + ]); + + $this->assertResponseStatusCodeSame(404); + } + + public function testPostDefaultDoesNotReturn404WhenProviderReturnsNull(): void + { + self::createClient()->request('POST', '/throw_on_not_found_feeders/42/feed_default', [ + 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], + 'body' => '{}', + ]); + + $this->assertNotSame(404, self::getClient()->getResponse()->getStatusCode()); + } +} From 3c658d1c71def5a1b3ae94ae64f270458b7f26e8 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 2 Jun 2026 11:06:49 +0200 Subject: [PATCH 22/84] feat!(state): constraint-aware 422 for denormalization errors (#8211) --- phpstan.neon.dist | 1 + src/Laravel/ApiPlatformProvider.php | 14 +- .../State/DenormalizationViolationFactory.php | 216 ++++++++++++++++ .../Tests/DenormalizationValidationTest.php | 79 ++++++ src/Laravel/phpstan.neon.dist | 1 + ...normalizationViolationFactoryInterface.php | 52 ++++ src/State/Provider/DeserializeProvider.php | 103 +------- .../Provider/DeserializeProviderTest.php | 131 ++++------ .../Resources/config/state/provider.php | 13 +- .../Resources/config/symfony/events.php | 13 +- .../DenormalizationViolationFactory.php | 241 ++++++++++++++++++ .../DenormalizationViolationFactoryTest.php | 194 ++++++++++++++ src/Validator/composer.json | 1 + .../DenormalizationValidationResource.php | 53 ++++ .../DenormalizationValidationTest.php | 131 ++++++++++ .../EnumDenormalizationValidationTest.php | 7 - .../NullOnNonNullablePropertyTest.php | 8 - .../Security/SecurityHeadersTest.php | 2 +- .../Functional/Security/StrongTypingTest.php | 16 +- 19 files changed, 1077 insertions(+), 199 deletions(-) create mode 100644 src/Laravel/State/DenormalizationViolationFactory.php create mode 100644 src/Laravel/Tests/DenormalizationValidationTest.php create mode 100644 src/State/DenormalizationViolationFactoryInterface.php create mode 100644 src/Validator/DenormalizationViolationFactory.php create mode 100644 src/Validator/Tests/DenormalizationViolationFactoryTest.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/DenormalizationValidationResource.php create mode 100644 tests/Functional/DenormalizationValidationTest.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 0fd1288f02d..b1cf2e3823f 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -92,6 +92,7 @@ parameters: - "#Call to function method_exists\\(\\) with 'Symfony\\\\\\\\Component\\\\\\\\Serializer\\\\\\\\Serializer' and 'getSupportedTypes' will always evaluate to true\\.#" - "#Call to function method_exists\\(\\) with Symfony\\\\Component\\\\Serializer\\\\Normalizer\\\\NormalizerInterface and 'getSupportedTypes' will always evaluate to true\\.#" - "#Call to function method_exists\\(\\) with Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\ClassMetadata\\|Doctrine\\\\ORM\\\\Mapping\\\\ClassMetadata and 'isChangeTrackingDef…' will always evaluate to true\\.#" + - "#Call to function method_exists\\(\\) with Symfony\\\\Component\\\\Serializer\\\\Exception\\\\PartialDenormalizationException and 'getNotNormalizableV…' will always evaluate to true\\.#" # See https://github.com/phpstan/phpstan-symfony/issues/27 - diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 9477d5c8a15..2e6be65777e 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -107,6 +107,7 @@ use ApiPlatform\Laravel\Security\ResourceAccessChecker; use ApiPlatform\Laravel\Serializer\EloquentOperationResourceClassResolver; use ApiPlatform\Laravel\State\AccessCheckerProvider; +use ApiPlatform\Laravel\State\DenormalizationViolationFactory as LaravelDenormalizationViolationFactory; use ApiPlatform\Laravel\State\SwaggerUiProcessor; use ApiPlatform\Laravel\State\SwaggerUiProvider; use ApiPlatform\Laravel\State\ValidateProvider; @@ -158,6 +159,7 @@ use ApiPlatform\Serializer\SerializerContextBuilder; use ApiPlatform\State\CallableProcessor; use ApiPlatform\State\CallableProvider; +use ApiPlatform\State\DenormalizationViolationFactoryInterface; use ApiPlatform\State\ErrorProvider; use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\Pagination\PaginationOptions; @@ -422,8 +424,18 @@ public function register(): void ); }); + $this->app->singleton(DenormalizationViolationFactoryInterface::class, static function () { + return new LaravelDenormalizationViolationFactory(); + }); + $this->app->singleton(DeserializeProvider::class, static function (Application $app) { - return new DeserializeProvider($app->make(SwaggerUiProvider::class), $app->make(SerializerInterface::class), $app->make(SerializerContextBuilderInterface::class)); + return new DeserializeProvider( + $app->make(SwaggerUiProvider::class), + $app->make(SerializerInterface::class), + $app->make(SerializerContextBuilderInterface::class), + null, + $app->make(DenormalizationViolationFactoryInterface::class), + ); }); $this->app->singleton(ValidateProvider::class, static function (Application $app) { diff --git a/src/Laravel/State/DenormalizationViolationFactory.php b/src/Laravel/State/DenormalizationViolationFactory.php new file mode 100644 index 00000000000..c09c821a70a --- /dev/null +++ b/src/Laravel/State/DenormalizationViolationFactory.php @@ -0,0 +1,216 @@ + + * + * 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\Laravel\State; + +use ApiPlatform\Laravel\ApiResource\ValidationError; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\DenormalizationViolationFactoryInterface; +use Illuminate\Contracts\Validation\Rule as LaravelRule; +use Illuminate\Contracts\Validation\ValidationRule; +use Illuminate\Foundation\Http\FormRequest; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; + +/** + * Laravel-flavored denormalization violation factory — translates Symfony serializer + * type errors into a 422 {@see ValidationError} when the Operation's Laravel rules + * describe the property. + * + * Reads rules declared on the operation (string|array form, e.g. `'required|string'` + * or `['required', 'string']`). FormRequest-class rules and pure-callable rule sets + * are intentionally skipped in v1: a FormRequest-based contract typically runs in the + * validation phase against the raw request, not the denormalized body. + * + * Mapping: + * + * | Exception "current type" | Matching Laravel rule | Emitted code | + * |--------------------------|---------------------------------------------|----------------| + * | null | required, filled | blank | + * | null | present | null | + * | any wrong type | string, integer, int, numeric, boolean, | invalid_type | + * | | bool, array, date, json | | + * | any wrong type | any other rule (no `nullable`) | invalid_type | + * | null | nullable (no required/present/filled) | (no match) | + * | any | (no rule) | (no match) | + * + * In collect mode, unconstrained errors still emit a generic `invalid_type` entry so + * the response surface stays consistent with prior behavior. + * + * Codes are plain semantic strings — the Laravel package does not depend on Symfony + * Validator. + * + * @author Antoine Bluchet + */ +final class DenormalizationViolationFactory implements DenormalizationViolationFactoryInterface +{ + public const CODE_BLANK = 'blank'; + public const CODE_NULL = 'null'; + public const CODE_INVALID_TYPE = 'invalid_type'; + + private const REQUIRED_RULES = ['required' => true, 'filled' => true]; + private const PRESENT_RULES = ['present' => true]; + + public function handle(NotNormalizableValueException|PartialDenormalizationException $exception, Operation $operation): void + { + if ($exception instanceof NotNormalizableValueException) { + $violation = $this->buildViolation($exception, $operation); + if (null === $violation) { + return; + } + + throw new ValidationError($violation['message'], $this->makeId([$violation['propertyPath']]), $exception, [$violation]); + } + + $violations = []; + $errors = method_exists($exception, 'getNotNormalizableValueErrors') ? $exception->getNotNormalizableValueErrors() : $exception->getErrors(); + foreach ($errors as $error) { + if (!$error instanceof NotNormalizableValueException) { + continue; + } + $violations[] = $this->buildViolation($error, $operation) ?? $this->buildGenericViolation($error); + } + + if (!$violations) { + return; + } + + $paths = array_filter(array_map(static fn (array $v): string => $v['propertyPath'], $violations)); + $message = implode('; ', array_map(static fn (array $v): string => $v['propertyPath'].': '.$v['message'], $violations)); + + throw new ValidationError($message, $this->makeId($paths), $exception, $violations); + } + + /** + * @return array{propertyPath: string, message: string, code: string}|null + */ + private function buildViolation(NotNormalizableValueException $exception, Operation $operation): ?array + { + $rules = $operation->getRules(); + if (\is_callable($rules)) { + $rules = $rules(); + } + + if (\is_string($rules) && is_a($rules, FormRequest::class, true)) { + return null; + } + + if (!\is_array($rules)) { + return null; + } + + $path = $exception->getPath(); + if (null === $path || '' === $path || !\array_key_exists($path, $rules)) { + return null; + } + + $propertyRules = $this->extractRuleTokens($rules[$path]); + if (!$propertyRules) { + return null; + } + + $isNull = 'null' === strtolower((string) $exception->getCurrentType()); + + if ($isNull) { + $hasRequired = (bool) array_intersect_key(self::REQUIRED_RULES, $propertyRules); + $hasPresent = (bool) array_intersect_key(self::PRESENT_RULES, $propertyRules); + + // `nullable` explicitly permits null when no required/present/filled is set. + if (isset($propertyRules['nullable']) && !$hasRequired && !$hasPresent) { + return null; + } + + if ($hasRequired) { + return $this->violation($path, 'This value should not be blank.', self::CODE_BLANK); + } + if ($hasPresent) { + return $this->violation($path, 'This value should not be null.', self::CODE_NULL); + } + } + + return $this->violation($path, $this->typeMessage($exception), self::CODE_INVALID_TYPE); + } + + /** + * @return array rule tokens as a keyed map for O(1) lookup + */ + private function extractRuleTokens(mixed $raw): array + { + if (\is_string($raw)) { + $items = explode('|', $raw); + } elseif (\is_array($raw)) { + $items = $raw; + } else { + return []; + } + + $tokens = []; + foreach ($items as $item) { + if ($item instanceof LaravelRule || $item instanceof ValidationRule || \is_object($item)) { + continue; + } + if (!\is_string($item)) { + continue; + } + $name = strtolower(strstr($item, ':', true) ?: $item); + if ('' === $name) { + continue; + } + $tokens[$name] = true; + } + + return $tokens; + } + + /** + * @return array{propertyPath: string, message: string, code: string} + */ + private function violation(string $path, string $message, string $code): array + { + return [ + 'propertyPath' => $path, + 'message' => $message, + 'code' => $code, + ]; + } + + /** + * @return array{propertyPath: string, message: string, code: string} + */ + private function buildGenericViolation(NotNormalizableValueException $exception): array + { + return $this->violation( + (string) $exception->getPath(), + $exception->canUseMessageForUser() ? $exception->getMessage() : $this->typeMessage($exception), + self::CODE_INVALID_TYPE, + ); + } + + private function typeMessage(NotNormalizableValueException $exception): string + { + $expectedTypes = $exception->getExpectedTypes() ?? []; + if (!$expectedTypes) { + return 'This value should be of the right type.'; + } + + return \sprintf('This value should be of type %s.', implode('|', $expectedTypes)); + } + + /** + * @param string[] $paths + */ + private function makeId(array $paths): string + { + return hash('xxh3', implode(',', $paths) ?: 'denormalization'); + } +} diff --git a/src/Laravel/Tests/DenormalizationValidationTest.php b/src/Laravel/Tests/DenormalizationValidationTest.php new file mode 100644 index 00000000000..a3375374b07 --- /dev/null +++ b/src/Laravel/Tests/DenormalizationValidationTest.php @@ -0,0 +1,79 @@ + + * + * 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\Laravel\Tests; + +use ApiPlatform\Laravel\Test\ApiTestAssertionsTrait; +use Illuminate\Contracts\Config\Repository; +use Illuminate\Foundation\Testing\RefreshDatabase; +use Orchestra\Testbench\Concerns\WithWorkbench; +use Orchestra\Testbench\TestCase; + +/** + * @see https://github.com/api-platform/core/issues/7981 + */ +class DenormalizationValidationTest extends TestCase +{ + use ApiTestAssertionsTrait; + use RefreshDatabase; + use WithWorkbench; + + protected function defineEnvironment($app): void + { + tap($app['config'], static function (Repository $config): void { + $config->set('api-platform.formats', ['jsonld' => ['application/ld+json']]); + $config->set('api-platform.docs_formats', ['jsonld' => ['application/ld+json']]); + }); + } + + public function testWrongTypeOnTypedDtoWithRuleProduces422(): void + { + $response = $this->postJson( + '/api/issue6745/rule_validations', + ['prop' => 'abc'], + ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json'] + ); + + $response->assertStatus(422); + $body = json_decode((string) $response->getContent(), true); + $this->assertSame('ValidationError', $body['@type'] ?? null); + $this->assertNotEmpty($body['violations'] ?? []); + $this->assertSame('prop', $body['violations'][0]['propertyPath']); + } + + public function testWrongTypeWithoutRuleRethrows(): void + { + // `max` rule is `lt:2` (no required, no type rule) — but per the rule table, ANY rule + // on the property triggers a generic Type @ 422 (consistent with Symfony's + // "any wrong type | any other constraint" branch). + $response = $this->postJson( + '/api/issue6745/rule_validations', + ['max' => 'abc'], + ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json'] + ); + + $response->assertStatus(422); + } + + public function testEloquentNullOnRequiredFieldStillReturns422(): void + { + // Eloquent dynamic attrs → no denormalization error. Validation layer catches null + required. + $response = $this->postJson( + '/api/issue_6932', + ['sur_name' => null], + ['accept' => 'application/ld+json', 'content-type' => 'application/ld+json'] + ); + + $response->assertStatus(422); + } +} diff --git a/src/Laravel/phpstan.neon.dist b/src/Laravel/phpstan.neon.dist index 0e2effea271..3ef80c407f8 100644 --- a/src/Laravel/phpstan.neon.dist +++ b/src/Laravel/phpstan.neon.dist @@ -18,3 +18,4 @@ parameters: - Tests ignoreErrors: - '#Cannot call method expectsQuestion#' + - "#Call to function method_exists\\(\\) with Symfony\\\\Component\\\\Serializer\\\\Exception\\\\PartialDenormalizationException and 'getNotNormalizableV…' will always evaluate to true\\.#" diff --git a/src/State/DenormalizationViolationFactoryInterface.php b/src/State/DenormalizationViolationFactoryInterface.php new file mode 100644 index 00000000000..bc414423fab --- /dev/null +++ b/src/State/DenormalizationViolationFactoryInterface.php @@ -0,0 +1,52 @@ + + * + * 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; + +use ApiPlatform\Metadata\Operation; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; + +/** + * Promotes Symfony serializer denormalization errors (raw type mismatches that would + * otherwise produce a 400) into HTTP-level validation violations (422) when the target + * {@see Operation} declares a matching validation contract. + * + * Each framework integration provides its own implementation: the Symfony bundle reads + * Symfony Validator metadata and throws {@see \ApiPlatform\Validator\Exception\ValidationException}; + * the Laravel package reads Illuminate validation rules and throws Laravel's native + * {@see \ApiPlatform\Laravel\ApiResource\ValidationError}. Implementations must NOT + * depend on a sibling framework's validation stack. + * + * Contract: throw an HTTP exception (typically 422) when at least one error has a + * matching validation contract; return void when nothing matches so the caller can + * rethrow the original denormalization exception for an honest 400. + * + * @author Antoine Bluchet + * + * @see https://github.com/api-platform/core/issues/7981 + */ +interface DenormalizationViolationFactoryInterface +{ + /** + * Builds and throws a validation violation from a denormalization error. + * + * Accepts either a single {@see NotNormalizableValueException} (raised when the + * serializer fails on the first type mismatch) or a {@see PartialDenormalizationException} + * (raised when `collect_denormalization_errors=true` collects every type mismatch in + * a batch). Implementations dispatch on the concrete type. + * + * @throws \Throwable when at least one error has a matching validation contract + */ + public function handle(NotNormalizableValueException|PartialDenormalizationException $exception, Operation $operation): void; +} diff --git a/src/State/Provider/DeserializeProvider.php b/src/State/Provider/DeserializeProvider.php index 338c8371418..02572ac9b1a 100644 --- a/src/State/Provider/DeserializeProvider.php +++ b/src/State/Provider/DeserializeProvider.php @@ -15,22 +15,17 @@ use ApiPlatform\Metadata\HttpOperation; use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\DenormalizationViolationFactoryInterface; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; use ApiPlatform\State\StopwatchAwareInterface; use ApiPlatform\State\StopwatchAwareTrait; -use ApiPlatform\Validator\Exception\ValidationException; use Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\PartialDenormalizationException; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\SerializerInterface; -use Symfony\Component\Validator\Constraints\Type; -use Symfony\Component\Validator\ConstraintViolation; -use Symfony\Component\Validator\ConstraintViolationList; -use Symfony\Contracts\Translation\LocaleAwareInterface; use Symfony\Contracts\Translation\TranslatorInterface; -use Symfony\Contracts\Translation\TranslatorTrait; final class DeserializeProvider implements ProviderInterface, StopwatchAwareInterface { @@ -40,13 +35,11 @@ public function __construct( private readonly ?ProviderInterface $decorated, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder, - private ?TranslatorInterface $translator = null, + ?TranslatorInterface $translator = null, + private readonly ?DenormalizationViolationFactoryInterface $violationFactory = null, ) { - if (null === $this->translator) { - $this->translator = new class implements TranslatorInterface, LocaleAwareInterface { - use TranslatorTrait; - }; - $this->translator->setLocale('en'); + if (null !== $translator) { + trigger_deprecation('api-platform/core', '4.4', 'Passing a "%s" to "%s" is deprecated and will be removed in 5.0. Translation is now handled by "%s".', TranslatorInterface::class, self::class, DenormalizationViolationFactoryInterface::class); } } @@ -101,31 +94,10 @@ public function provide(Operation $operation, array $uriVariables = [], array $c try { $data = $this->serializer->deserialize((string) $request->getContent(), $serializerContext['deserializer_type'] ?? $operation->getClass(), $format, $serializerContext); - } catch (PartialDenormalizationException $e) { - if (!class_exists(ConstraintViolationList::class)) { - throw $e; - } - - $violations = new ConstraintViolationList(); - foreach ($e->getErrors() as $exception) { - if (!$exception instanceof NotNormalizableValueException) { - continue; - } - $violations->add($this->createViolationFromException($exception)); - } - if (0 !== \count($violations)) { - throw new ValidationException($violations); - } - } catch (NotNormalizableValueException $e) { - // BackedEnum denormalization errors should surface as validation violations (422) - // rather than denormalization errors (400). See https://github.com/api-platform/core/issues/8183. - if (!class_exists(ConstraintViolationList::class) || !$this->isBackedEnumException($e)) { - throw $e; - } + } catch (PartialDenormalizationException|NotNormalizableValueException $e) { + $this->violationFactory?->handle($e, $operation); - $violations = new ConstraintViolationList(); - $violations->add($this->createViolationFromException($e)); - throw new ValidationException($violations); + throw $e; } $this->stopwatch?->stop('api_platform.provider.deserialize'); @@ -134,63 +106,4 @@ public function provide(Operation $operation, array $uriVariables = [], array $c return $data; } - - private function normalizeExpectedTypes(?array $expectedTypes = null): array - { - $normalizedTypes = []; - - foreach ($expectedTypes ?? [] as $expectedType) { - $normalizedType = $expectedType; - - if (class_exists($expectedType) || interface_exists($expectedType)) { - $classReflection = new \ReflectionClass($expectedType); - $normalizedType = $classReflection->getShortName(); - } - - $normalizedTypes[] = $normalizedType; - } - - return $normalizedTypes; - } - - private function createViolationFromException(NotNormalizableValueException $exception): ConstraintViolation - { - $expectedTypes = $this->normalizeExpectedTypes($exception->getExpectedTypes()); - $parameters = []; - if ($exception->canUseMessageForUser()) { - $parameters['hint'] = $exception->getMessage(); - } - - if (!$expectedTypes && $exception->canUseMessageForUser()) { - $violationMessage = $exception->getMessage(); - - return new ConstraintViolation($violationMessage, $violationMessage, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR); - } - - $message = (new Type($expectedTypes))->message; - - return new ConstraintViolation($this->translator->trans($message, ['{{ type }}' => implode('|', $expectedTypes)], 'validators'), $message, $parameters, null, $exception->getPath(), null, null, (string) Type::INVALID_TYPE_ERROR); - } - - private function isBackedEnumException(NotNormalizableValueException $exception): bool - { - foreach ($exception->getExpectedTypes() ?? [] as $expectedType) { - if (\is_string($expectedType) && (class_exists($expectedType) || interface_exists($expectedType)) && is_subclass_of($expectedType, \BackedEnum::class)) { - return true; - } - } - - for ($previous = $exception->getPrevious(); $previous instanceof \Throwable; $previous = $previous->getPrevious()) { - if (!$previous instanceof NotNormalizableValueException) { - continue; - } - foreach ($previous->getExpectedTypes() ?? [] as $expectedType) { - if (\is_string($expectedType) && (class_exists($expectedType) || interface_exists($expectedType)) && is_subclass_of($expectedType, \BackedEnum::class)) { - return true; - } - } - } - - return false; - } } diff --git a/src/State/Tests/Provider/DeserializeProviderTest.php b/src/State/Tests/Provider/DeserializeProviderTest.php index 1fced0c05eb..608df3bf09c 100644 --- a/src/State/Tests/Provider/DeserializeProviderTest.php +++ b/src/State/Tests/Provider/DeserializeProviderTest.php @@ -18,10 +18,10 @@ use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use ApiPlatform\State\DenormalizationViolationFactoryInterface; use ApiPlatform\State\Provider\DeserializeProvider; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; -use ApiPlatform\Validator\Exception\ValidationException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; @@ -31,7 +31,6 @@ use Symfony\Component\Serializer\Exception\PartialDenormalizationException; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\SerializerInterface; -use Symfony\Component\Validator\Constraints\Type; class DeserializeProviderTest extends TestCase { @@ -208,70 +207,41 @@ public function testDeserializeSetsObjectToPopulateWhenContextIsTrue(): void } #[IgnoreDeprecations] - public function testDeserializeKeepsTypeMessageWhenExpectedTypesAreSet(): void + public function testDeserializeDelegatesSingleErrorToHandler(): void { $operation = new Post(deserialize: true, class: \stdClass::class); $decorated = $this->createStub(ProviderInterface::class); $decorated->method('provide')->willReturn(null); - $exception = NotNormalizableValueException::createForUnexpectedDataType( - 'The data must belong to a backed enumeration of type Suit.', - 'invalid', - ['string'], - 'status', - true, - ); - $partialException = new PartialDenormalizationException('Denormalization failed.', [$exception]); + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 'invalid', ['string'], 'status', true); $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); $serializerContextBuilder->method('createFromRequest')->willReturn([]); $serializer = $this->createMock(SerializerInterface::class); - $serializer->method('deserialize')->willThrowException($partialException); + $serializer->method('deserialize')->willThrowException($exception); - $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); + $handler = $this->createMock(DenormalizationViolationFactoryInterface::class); + $handler->expects($this->once())->method('handle')->with($exception, $operation) + ->willThrowException(new \LogicException('handler-threw')); + + $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder, null, $handler); $request = new Request(content: '{"status":"invalid"}'); $request->headers->set('CONTENT_TYPE', 'application/json'); $request->attributes->set('input_format', 'json'); - try { - $provider->provide($operation, [], ['request' => $request]); - $this->fail('Expected ValidationException'); - } catch (ValidationException $e) { - $violations = $e->getConstraintViolationList(); - $this->assertCount(1, $violations); - $this->assertSame('This value should be of type string.', $violations[0]->getMessage()); - $this->assertSame('status', $violations[0]->getPropertyPath()); - $this->assertSame((string) Type::INVALID_TYPE_ERROR, $violations[0]->getCode()); - $this->assertSame('The data must belong to a backed enumeration of type Suit.', $violations[0]->getParameters()['hint'] ?? null); - } + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('handler-threw'); + $provider->provide($operation, [], ['request' => $request]); } - /** - * Simulates Symfony 8.1 BackedEnumNormalizer behavior (symfony/serializer PR #62574): - * when a value has the right type but is not a valid enum case, the exception - * is created with expectedTypes=null and a user-friendly message listing valid values. - */ #[IgnoreDeprecations] - public function testDeserializeUsesExceptionMessageWhenExpectedTypesIsNull(): void + public function testDeserializeDelegatesPartialErrorToHandler(): void { $operation = new Post(deserialize: true, class: \stdClass::class); $decorated = $this->createStub(ProviderInterface::class); $decorated->method('provide')->willReturn(null); - $ctor = new \ReflectionMethod(NotNormalizableValueException::class, '__construct'); - if ($ctor->getNumberOfParameters() <= 3) { - $this->markTestSkipped('NotNormalizableValueException does not support extended constructor parameters.'); - } - - $exception = new NotNormalizableValueException( - "The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'", - 0, - null, - null, - null, - 'suit', - true, - ); + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 'invalid', ['string'], 'status', true); $partialException = new PartialDenormalizationException('Denormalization failed.', [$exception]); $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); @@ -279,38 +249,51 @@ public function testDeserializeUsesExceptionMessageWhenExpectedTypesIsNull(): vo $serializer = $this->createMock(SerializerInterface::class); $serializer->method('deserialize')->willThrowException($partialException); + $handler = $this->createMock(DenormalizationViolationFactoryInterface::class); + $handler->expects($this->once())->method('handle')->with($partialException, $operation) + ->willThrowException(new \LogicException('handler-threw-partial')); + + $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder, null, $handler); + $request = new Request(content: '{"status":"invalid"}'); + $request->headers->set('CONTENT_TYPE', 'application/json'); + $request->attributes->set('input_format', 'json'); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('handler-threw-partial'); + $provider->provide($operation, [], ['request' => $request]); + } + + #[IgnoreDeprecations] + public function testDeserializeRethrowsSingleErrorWhenNoHandler(): void + { + $operation = new Post(deserialize: true, class: \stdClass::class); + $decorated = $this->createStub(ProviderInterface::class); + $decorated->method('provide')->willReturn(null); + + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 'invalid', ['string'], 'status', true); + + $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $serializerContextBuilder->method('createFromRequest')->willReturn([]); + $serializer = $this->createMock(SerializerInterface::class); + $serializer->method('deserialize')->willThrowException($exception); + $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); - $request = new Request(content: '{"suit":"invalid"}'); + $request = new Request(content: '{"status":"invalid"}'); $request->headers->set('CONTENT_TYPE', 'application/json'); $request->attributes->set('input_format', 'json'); - try { - $provider->provide($operation, [], ['request' => $request]); - $this->fail('Expected ValidationException'); - } catch (ValidationException $e) { - $violations = $e->getConstraintViolationList(); - $this->assertCount(1, $violations); - $this->assertSame("The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'", $violations[0]->getMessage()); - $this->assertSame("The data must be one of the following values: 'hearts', 'diamonds', 'clubs', 'spades'", $violations[0]->getMessageTemplate()); - $this->assertSame('suit', $violations[0]->getPropertyPath()); - $this->assertSame((string) Type::INVALID_TYPE_ERROR, $violations[0]->getCode()); - } + $this->expectException(NotNormalizableValueException::class); + $provider->provide($operation, [], ['request' => $request]); } #[IgnoreDeprecations] - public function testDeserializeUsesTypeMessageWhenCannotUseMessageForUser(): void + public function testDeserializeRethrowsPartialErrorWhenHandlerReturnsVoid(): void { $operation = new Post(deserialize: true, class: \stdClass::class); $decorated = $this->createStub(ProviderInterface::class); $decorated->method('provide')->willReturn(null); - $exception = NotNormalizableValueException::createForUnexpectedDataType( - 'Internal error detail', - 42, - ['string'], - 'name', - false, - ); + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 'invalid', ['string'], 'status', true); $partialException = new PartialDenormalizationException('Denormalization failed.', [$exception]); $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); @@ -318,22 +301,16 @@ public function testDeserializeUsesTypeMessageWhenCannotUseMessageForUser(): voi $serializer = $this->createMock(SerializerInterface::class); $serializer->method('deserialize')->willThrowException($partialException); - $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); - $request = new Request(content: '{"name":42}'); + $handler = $this->createMock(DenormalizationViolationFactoryInterface::class); + $handler->expects($this->once())->method('handle'); + + $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder, null, $handler); + $request = new Request(content: '{"status":"invalid"}'); $request->headers->set('CONTENT_TYPE', 'application/json'); $request->attributes->set('input_format', 'json'); - try { - $provider->provide($operation, [], ['request' => $request]); - $this->fail('Expected ValidationException'); - } catch (ValidationException $e) { - $violations = $e->getConstraintViolationList(); - $this->assertCount(1, $violations); - $this->assertStringContainsString('string', $violations[0]->getMessage()); - $this->assertSame('name', $violations[0]->getPropertyPath()); - $this->assertSame((string) Type::INVALID_TYPE_ERROR, $violations[0]->getCode()); - $this->assertArrayNotHasKey('hint', $violations[0]->getParameters()); - } + $this->expectException(PartialDenormalizationException::class); + $provider->provide($operation, [], ['request' => $request]); } public function testDeserializeDoesNotSetObjectToPopulateWhenContextIsFalse(): void diff --git a/src/Symfony/Bundle/Resources/config/state/provider.php b/src/Symfony/Bundle/Resources/config/state/provider.php index f31fc2bc7c1..e57c02f59a5 100644 --- a/src/Symfony/Bundle/Resources/config/state/provider.php +++ b/src/Symfony/Bundle/Resources/config/state/provider.php @@ -13,11 +13,13 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use ApiPlatform\State\DenormalizationViolationFactoryInterface; use ApiPlatform\State\Provider\ContentNegotiationProvider; use ApiPlatform\State\Provider\DeserializeProvider; use ApiPlatform\State\Provider\ParameterProvider; use ApiPlatform\State\Provider\ReadProvider; use ApiPlatform\Symfony\EventListener\ErrorListener; +use ApiPlatform\Validator\DenormalizationViolationFactory; return static function (ContainerConfigurator $container) { $services = $container->services(); @@ -40,13 +42,22 @@ service('api_platform.serializer.context_builder'), ]); + $services->set('api_platform.state.denormalization_violation_factory', DenormalizationViolationFactory::class) + ->args([ + service('validator'), + service('translator')->nullOnInvalid(), + ]); + + $services->alias(DenormalizationViolationFactoryInterface::class, 'api_platform.state.denormalization_violation_factory'); + $services->set('api_platform.state_provider.deserialize', DeserializeProvider::class) ->decorate('api_platform.state_provider.main', null, 300) ->args([ service('api_platform.state_provider.deserialize.inner'), service('api_platform.serializer'), service('api_platform.serializer.context_builder'), - service('translator')->nullOnInvalid(), + null, + service('api_platform.state.denormalization_violation_factory')->nullOnInvalid(), ]); $services->set('api_platform.error_listener', ErrorListener::class) diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.php b/src/Symfony/Bundle/Resources/config/symfony/events.php index c8c2c833e70..23105235373 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.php +++ b/src/Symfony/Bundle/Resources/config/symfony/events.php @@ -13,6 +13,7 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use ApiPlatform\State\DenormalizationViolationFactoryInterface; use ApiPlatform\State\Processor\AddLinkHeaderProcessor; use ApiPlatform\State\Processor\RespondProcessor; use ApiPlatform\State\Processor\SerializeProcessor; @@ -31,6 +32,7 @@ use ApiPlatform\Symfony\EventListener\RespondListener; use ApiPlatform\Symfony\EventListener\SerializeListener; use ApiPlatform\Symfony\EventListener\WriteListener; +use ApiPlatform\Validator\DenormalizationViolationFactory; return static function (ContainerConfigurator $container) { $services = $container->services(); @@ -70,12 +72,21 @@ ]) ->tag('kernel.event_listener', ['event' => 'kernel.request', 'method' => 'onKernelRequest', 'priority' => 4]); + $services->set('api_platform.state.denormalization_violation_factory', DenormalizationViolationFactory::class) + ->args([ + service('validator'), + service('translator')->nullOnInvalid(), + ]); + + $services->alias(DenormalizationViolationFactoryInterface::class, 'api_platform.state.denormalization_violation_factory'); + $services->set('api_platform.state_provider.deserialize', DeserializeProvider::class) ->args([ null, service('api_platform.serializer'), service('api_platform.serializer.context_builder'), - service('translator')->nullOnInvalid(), + null, + service('api_platform.state.denormalization_violation_factory')->nullOnInvalid(), ]); $services->set('api_platform.listener.request.deserialize', DeserializeListener::class) diff --git a/src/Validator/DenormalizationViolationFactory.php b/src/Validator/DenormalizationViolationFactory.php new file mode 100644 index 00000000000..76b38e69cb2 --- /dev/null +++ b/src/Validator/DenormalizationViolationFactory.php @@ -0,0 +1,241 @@ + + * + * 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\Validator; + +use ApiPlatform\Metadata\Operation; +use ApiPlatform\State\DenormalizationViolationFactoryInterface; +use ApiPlatform\Validator\Exception\ValidationException; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Constraints\Type; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationInterface; +use Symfony\Component\Validator\ConstraintViolationList; +use Symfony\Component\Validator\Exception\NoSuchMetadataException; +use Symfony\Component\Validator\Mapping\ClassMetadataInterface; +use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface; +use Symfony\Contracts\Translation\LocaleAwareInterface; +use Symfony\Contracts\Translation\TranslatorInterface; +use Symfony\Contracts\Translation\TranslatorTrait; + +/** + * Constraint-aware denormalization violation factory — Symfony Validator flavor. + * + * Rule table (see issue #7981): + * + * | Exception "current type" | Matching constraint | Emitted violation | + * |--------------------------|----------------------|---------------------------------------------------| + * | null | NotBlank | NotBlank::IS_BLANK_ERROR + constraint message | + * | null | NotNull | NotNull::IS_NULL_ERROR + constraint message | + * | any wrong type | Type | Type::INVALID_TYPE_ERROR + constraint message | + * | any wrong type | any other constraint | generic Type violation @ 422 | + * | any wrong type | (no constraint) | none — single-error path rethrows → 400 | + * + * In collect mode (PartialDenormalizationException), unconstrained errors still emit + * a generic Type violation so the response stays consistent with prior behavior. + * + * @author Antoine Bluchet + */ +final class DenormalizationViolationFactory implements DenormalizationViolationFactoryInterface +{ + private TranslatorInterface $translator; + + public function __construct( + private readonly MetadataFactoryInterface $metadataFactory, + ?TranslatorInterface $translator = null, + ) { + if (null === $translator) { + $translator = new class implements TranslatorInterface, LocaleAwareInterface { + use TranslatorTrait; + }; + $translator->setLocale('en'); + } + + $this->translator = $translator; + } + + public function handle(NotNormalizableValueException|PartialDenormalizationException $exception, Operation $operation): void + { + if ($exception instanceof NotNormalizableValueException) { + $violation = $this->buildViolation($exception, $operation); + if (null === $violation) { + return; + } + + throw new ValidationException(new ConstraintViolationList([$violation])); + } + + $violations = new ConstraintViolationList(); + $errors = method_exists($exception, 'getNotNormalizableValueErrors') ? $exception->getNotNormalizableValueErrors() : $exception->getErrors(); + foreach ($errors as $error) { + if (!$error instanceof NotNormalizableValueException) { + continue; + } + $violations->add($this->buildViolation($error, $operation) ?? $this->buildViolation($error, $operation, true)); + } + + if (\count($violations) > 0) { + throw new ValidationException($violations); + } + } + + /** + * Returns a violation for the given error. + * + * When `$generic` is true, emits a Type-based fallback regardless of property metadata + * (used in collect mode to keep one violation per error). When false, returns null if + * no matching constraint is declared on the property — caller rethrows. + */ + private function buildViolation(NotNormalizableValueException $exception, Operation $operation, bool $generic = false): ?ConstraintViolationInterface + { + $path = $exception->getPath(); + if (null === $path || '' === $path) { + return $generic ? $this->emitViolation($exception, null, (string) Type::INVALID_TYPE_ERROR) : null; + } + + if ($generic) { + return $this->emitViolation($exception, null, (string) Type::INVALID_TYPE_ERROR); + } + + $class = $operation->getClass(); + if (null === $class || (!class_exists($class) && !interface_exists($class))) { + return null; + } + + try { + $classMetadata = $this->metadataFactory->getMetadataFor($class); + } catch (NoSuchMetadataException) { + return null; + } + + if (!$classMetadata instanceof ClassMetadataInterface || !$classMetadata->hasPropertyMetadata($path)) { + return null; + } + + $validationGroups = ($operation->getValidationContext() ?? [])['groups'] ?? null; + $constraints = $this->collectConstraints($classMetadata, $path, $validationGroups); + if (!$constraints) { + return null; + } + + $isNull = 'null' === strtolower((string) $exception->getCurrentType()); + + if ($isNull) { + if (isset($constraints[NotBlank::class])) { + return $this->emitViolation($exception, $constraints[NotBlank::class], (string) NotBlank::IS_BLANK_ERROR); + } + if (isset($constraints[NotNull::class])) { + return $this->emitViolation($exception, $constraints[NotNull::class], (string) NotNull::IS_NULL_ERROR); + } + } + + if (isset($constraints[Type::class])) { + return $this->emitViolation($exception, $constraints[Type::class], (string) Type::INVALID_TYPE_ERROR); + } + + // Property has constraints but none match by class → still 422 with a generic Type message. + return $this->emitViolation($exception, new Type([]), (string) Type::INVALID_TYPE_ERROR); + } + + /** + * @param array|null $validationGroups + * + * @return array, Constraint> indexed by constraint class; later entries overwrite earlier + */ + private function collectConstraints(ClassMetadataInterface $classMetadata, string $property, ?array $validationGroups): array + { + $groups = $validationGroups ?: [Constraint::DEFAULT_GROUP]; + $constraints = []; + + foreach ($classMetadata->getPropertyMetadata($property) as $propertyMetadata) { + foreach ($groups as $group) { + foreach ($propertyMetadata->findConstraints($group) as $constraint) { + $constraints[$constraint::class] = $constraint; + } + } + } + + return $constraints; + } + + private function emitViolation(NotNormalizableValueException $exception, ?Constraint $constraint, string $code): ConstraintViolation + { + $parameters = []; + if ($exception->canUseMessageForUser()) { + $parameters['hint'] = $exception->getMessage(); + } + + $expectedTypes = $this->normalizeExpectedTypes($exception->getExpectedTypes()); + + // No constraint + no expected types + user-friendly message → use the exception message verbatim. + if (null === $constraint && !$expectedTypes && $exception->canUseMessageForUser()) { + $message = $exception->getMessage(); + + return new ConstraintViolation($message, $message, $parameters, null, $exception->getPath(), null, null, $code); + } + + $message = $this->resolveMessage($constraint, $expectedTypes); + $translationParameters = []; + if ($expectedTypes && str_contains($message, '{{ type }}')) { + $translationParameters['{{ type }}'] = implode('|', $expectedTypes); + } + + return new ConstraintViolation( + $this->translator->trans($message, $translationParameters, 'validators'), + $message, + $parameters, + null, + $exception->getPath(), + null, + null, + $code, + $constraint, + ); + } + + /** + * @param string[] $expectedTypes + */ + private function resolveMessage(?Constraint $constraint, array $expectedTypes): string + { + if ($constraint instanceof NotBlank || $constraint instanceof NotNull || $constraint instanceof Type) { + return $constraint->message; + } + + return (new Type($expectedTypes))->message; + } + + /** + * @param string[]|null $expectedTypes + * + * @return string[] + */ + private function normalizeExpectedTypes(?array $expectedTypes): array + { + $normalized = []; + foreach ($expectedTypes ?? [] as $expectedType) { + if (\is_string($expectedType) && (class_exists($expectedType) || interface_exists($expectedType))) { + $pos = strrpos($expectedType, '\\'); + $normalized[] = false === $pos ? $expectedType : substr($expectedType, $pos + 1); + continue; + } + $normalized[] = $expectedType; + } + + return $normalized; + } +} diff --git a/src/Validator/Tests/DenormalizationViolationFactoryTest.php b/src/Validator/Tests/DenormalizationViolationFactoryTest.php new file mode 100644 index 00000000000..5befc8cd93b --- /dev/null +++ b/src/Validator/Tests/DenormalizationViolationFactoryTest.php @@ -0,0 +1,194 @@ + + * + * 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\Validator\Tests; + +use ApiPlatform\Metadata\Post; +use ApiPlatform\Validator\DenormalizationViolationFactory; +use ApiPlatform\Validator\Exception\ValidationException; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\Exception\NotNormalizableValueException; +use Symfony\Component\Serializer\Exception\PartialDenormalizationException; +use Symfony\Component\Validator\Constraints as Assert; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Constraints\Type; +use Symfony\Component\Validator\Mapping\Factory\LazyLoadingMetadataFactory; +use Symfony\Component\Validator\Mapping\Loader\AttributeLoader; + +final class DenormalizationViolationFactoryTest extends TestCase +{ + private DenormalizationViolationFactory $factory; + + protected function setUp(): void + { + $this->factory = new DenormalizationViolationFactory( + new LazyLoadingMetadataFactory(new AttributeLoader()), + ); + } + + public function testNullCurrentTypeWithNotBlankThrowsValidationException(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'name'); + + try { + $this->factory->handle($exception, $this->operation()); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $violation = $e->getConstraintViolationList()[0]; + $this->assertSame((string) NotBlank::IS_BLANK_ERROR, $violation->getCode()); + $this->assertSame('name', $violation->getPropertyPath()); + } + } + + public function testNullCurrentTypeWithNotNullThrowsValidationException(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'description'); + + try { + $this->factory->handle($exception, $this->operation()); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $this->assertSame((string) NotNull::IS_NULL_ERROR, $e->getConstraintViolationList()[0]->getCode()); + } + } + + public function testWrongTypeWithTypeConstraintThrowsValidationException(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 'abc', ['float'], 'score'); + + try { + $this->factory->handle($exception, $this->operation()); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $this->assertSame((string) Type::INVALID_TYPE_ERROR, $e->getConstraintViolationList()[0]->getCode()); + } + } + + public function testWrongTypeWithOtherConstraintThrowsGenericTypeViolation(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 123, ['string'], 'choice'); + + try { + $this->factory->handle($exception, $this->operation()); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $this->assertSame((string) Type::INVALID_TYPE_ERROR, $e->getConstraintViolationList()[0]->getCode()); + } + } + + public function testWrongTypeWithoutConstraintReturnsVoid(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', 'abc', ['float'], 'rawFloat'); + + // Returns without throwing → caller rethrows for 400. + $this->factory->handle($exception, $this->operation()); + $this->expectNotToPerformAssertions(); + } + + public function testUnknownClassReturnsVoid(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'name'); + + $this->factory->handle($exception, $this->operation('NotAClass')); + $this->expectNotToPerformAssertions(); + } + + public function testUnknownPropertyReturnsVoid(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'missingProperty'); + + $this->factory->handle($exception, $this->operation()); + $this->expectNotToPerformAssertions(); + } + + public function testNestedPathReturnsVoid(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'address.street'); + + $this->factory->handle($exception, $this->operation()); + $this->expectNotToPerformAssertions(); + } + + public function testGroupFilteringExcludesConstraintsOutsideActiveGroups(): void + { + $exception = NotNormalizableValueException::createForUnexpectedDataType('Type error.', null, ['string'], 'adminOnly'); + + // Default group → constraint scoped to "admin" excluded → returns void. + $this->factory->handle($exception, $this->operation()); + + // Active "admin" group → matches. + try { + $this->factory->handle($exception, $this->operation(DenormHandlerFixture::class, ['admin'])); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $this->assertSame((string) NotBlank::IS_BLANK_ERROR, $e->getConstraintViolationList()[0]->getCode()); + } + } + + public function testHandlePartialAggregatesAllErrors(): void + { + $errors = [ + NotNormalizableValueException::createForUnexpectedDataType('msg', null, ['string'], 'name'), + NotNormalizableValueException::createForUnexpectedDataType('msg', 'abc', ['float'], 'rawFloat'), + ]; + $partial = new PartialDenormalizationException(null, $errors); + + try { + $this->factory->handle($partial, $this->operation()); + $this->fail('Expected ValidationException'); + } catch (ValidationException $e) { + $this->assertCount(2, $e->getConstraintViolationList()); + $codes = []; + foreach ($e->getConstraintViolationList() as $violation) { + $codes[$violation->getPropertyPath()] = $violation->getCode(); + } + $this->assertSame((string) NotBlank::IS_BLANK_ERROR, $codes['name']); + // Unconstrained → generic Type fallback @ INVALID_TYPE_ERROR + $this->assertSame((string) Type::INVALID_TYPE_ERROR, $codes['rawFloat']); + } + } + + /** + * @param array|null $groups + */ + private function operation(string $class = DenormHandlerFixture::class, ?array $groups = null): Post + { + $operation = new Post(class: $class); + if (null !== $groups) { + $operation = $operation->withValidationContext(['groups' => $groups]); + } + + return $operation; + } +} + +class DenormHandlerFixture +{ + #[NotBlank] + public string $name = ''; + + #[NotNull] + public string $description = ''; + + #[Type('numeric')] + public float $score = 0.0; + + #[Assert\Choice(choices: ['a', 'b'])] + public string $choice = 'a'; + + public float $rawFloat = 0.0; + + #[NotBlank(groups: ['admin'])] + public string $adminOnly = ''; +} diff --git a/src/Validator/composer.json b/src/Validator/composer.json index 392e1f5a8bd..298f6527eed 100644 --- a/src/Validator/composer.json +++ b/src/Validator/composer.json @@ -24,6 +24,7 @@ "require": { "php": ">=8.2", "api-platform/metadata": "^4.3", + "api-platform/state": "^4.3", "symfony/type-info": "^7.3 || ^8.0", "symfony/http-kernel": "^6.4.13 || ^7.1 || ^8.0", "symfony/serializer": "^6.4 || ^7.1 || ^8.0", diff --git a/tests/Fixtures/TestBundle/ApiResource/DenormalizationValidationResource.php b/tests/Fixtures/TestBundle/ApiResource/DenormalizationValidationResource.php new file mode 100644 index 00000000000..43e253d86b2 --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/DenormalizationValidationResource.php @@ -0,0 +1,53 @@ + + * + * 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; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\Post; +use Symfony\Component\Validator\Constraints as Assert; + +#[ApiResource( + operations: [ + new Post( + uriTemplate: '/denormalization_validation_resources', + processor: self::class.'::process', + ), + new Post( + uriTemplate: '/denormalization_validation_resources_collect', + processor: self::class.'::process', + collectDenormalizationErrors: true, + ), + ], +)] +class DenormalizationValidationResource +{ + public int $id = 1; + + #[Assert\NotBlank] + public string $name = ''; + + #[Assert\NotNull] + public string $description = ''; + + #[Assert\Type('numeric')] + public float $score = 0.0; + + public float $rawFloat = 0.0; + + public static function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + return $data; + } +} diff --git a/tests/Functional/DenormalizationValidationTest.php b/tests/Functional/DenormalizationValidationTest.php new file mode 100644 index 00000000000..e0c8311cea9 --- /dev/null +++ b/tests/Functional/DenormalizationValidationTest.php @@ -0,0 +1,131 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\DenormalizationValidationResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\Validator\Constraints\NotBlank; +use Symfony\Component\Validator\Constraints\NotNull; +use Symfony\Component\Validator\Constraints\Type; + +/** + * @see https://github.com/api-platform/core/issues/7981 + */ +final class DenormalizationValidationTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DenormalizationValidationResource::class]; + } + + public function testNullOnNotBlankPropertyProduces422WithNotBlankViolation(): void + { + $response = static::createClient()->request('POST', '/denormalization_validation_resources', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['name' => null], + ]); + + $this->assertResponseStatusCodeSame(422); + $content = $response->toArray(false); + $violation = $this->findViolation($content['violations'] ?? [], 'name'); + $this->assertNotNull($violation, 'Expected a violation on "name".'); + $this->assertSame((string) NotBlank::IS_BLANK_ERROR, $violation['code'] ?? null); + } + + public function testNullOnNotNullPropertyProduces422WithNotNullViolation(): void + { + $response = static::createClient()->request('POST', '/denormalization_validation_resources', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['description' => null], + ]); + + $this->assertResponseStatusCodeSame(422); + $content = $response->toArray(false); + $violation = $this->findViolation($content['violations'] ?? [], 'description'); + $this->assertNotNull($violation, 'Expected a violation on "description".'); + $this->assertSame((string) NotNull::IS_NULL_ERROR, $violation['code'] ?? null); + } + + public function testWrongTypeOnTypeConstrainedPropertyProduces422WithTypeViolation(): void + { + $response = static::createClient()->request('POST', '/denormalization_validation_resources', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['score' => 'abc'], + ]); + + $this->assertResponseStatusCodeSame(422); + $content = $response->toArray(false); + $violation = $this->findViolation($content['violations'] ?? [], 'score'); + $this->assertNotNull($violation, 'Expected a violation on "score".'); + $this->assertSame((string) Type::INVALID_TYPE_ERROR, $violation['code'] ?? null); + } + + public function testWrongTypeWithoutConstraintProduces400(): void + { + $response = static::createClient()->request('POST', '/denormalization_validation_resources', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['rawFloat' => 'abc'], + ]); + + $this->assertSame(400, $response->getStatusCode()); + } + + public function testCollectMixedConstrainedAndUnconstrainedProduces422WithSpecificCodes(): void + { + $response = static::createClient()->request('POST', '/denormalization_validation_resources_collect', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => [ + 'name' => null, + 'score' => 'abc', + 'rawFloat' => 'abc', + ], + ]); + + $this->assertResponseStatusCodeSame(422); + $content = $response->toArray(false); + $violations = $content['violations'] ?? []; + + $nameViolation = $this->findViolation($violations, 'name'); + $this->assertNotNull($nameViolation); + $this->assertSame((string) NotBlank::IS_BLANK_ERROR, $nameViolation['code'] ?? null); + + $scoreViolation = $this->findViolation($violations, 'score'); + $this->assertNotNull($scoreViolation); + $this->assertSame((string) Type::INVALID_TYPE_ERROR, $scoreViolation['code'] ?? null); + + // Unconstrained property still translates to a generic Type violation in collect mode + // (consistent with prior behavior — collect mode never re-throws single errors). + $rawFloatViolation = $this->findViolation($violations, 'rawFloat'); + $this->assertNotNull($rawFloatViolation); + } + + private function findViolation(array $violations, string $propertyPath): ?array + { + foreach ($violations as $violation) { + if (($violation['propertyPath'] ?? null) === $propertyPath) { + return $violation; + } + } + + return null; + } +} diff --git a/tests/Functional/EnumDenormalizationValidationTest.php b/tests/Functional/EnumDenormalizationValidationTest.php index 3fa939623c8..8d340915433 100644 --- a/tests/Functional/EnumDenormalizationValidationTest.php +++ b/tests/Functional/EnumDenormalizationValidationTest.php @@ -17,8 +17,6 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\EnumValidationResource; use ApiPlatform\Tests\SetupClassResourcesTrait; use Composer\InstalledVersions; -use Composer\Semver\VersionParser; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** * @see https://github.com/api-platform/core/issues/8183 @@ -67,13 +65,8 @@ public function testInvalidBackedEnumValueProducesValidationViolation(): void $this->assertNotNull($genderViolation, 'Expected a constraint violation on "gender" property.'); } - #[IgnoreDeprecations] public function testInvalidBackedEnumValueWithCollectDenormalizationErrors(): void { - if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) { - $this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.'); - } - $response = static::createClient()->request('POST', '/enum_validation_resources_collect', [ 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['gender' => 'unknown'], diff --git a/tests/Functional/NullOnNonNullablePropertyTest.php b/tests/Functional/NullOnNonNullablePropertyTest.php index eba8ce3f10c..d6aa24c078f 100644 --- a/tests/Functional/NullOnNonNullablePropertyTest.php +++ b/tests/Functional/NullOnNonNullablePropertyTest.php @@ -16,9 +16,6 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\NullOnNonNullableProperty\NullOnNonNullableResource; use ApiPlatform\Tests\SetupClassResourcesTrait; -use Composer\InstalledVersions; -use Composer\Semver\VersionParser; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** @see https://github.com/symfony/symfony/issues/64159 */ final class NullOnNonNullablePropertyTest extends ApiTestCase @@ -48,13 +45,8 @@ public function testNullOnNonNullablePropertyReturns400(): void $this->assertStringContainsString('Expected argument of type "string", "null" given at property path "name"', $body['hydra:description'] ?? $body['detail'] ?? ''); } - #[IgnoreDeprecations] public function testNullOnNonNullablePropertyReturns422WhenCollectingErrors(): void { - if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) { - $this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.'); - } - $response = self::createClient()->request('POST', '/null_on_non_nullable_resources_collect', [ 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['name' => null], diff --git a/tests/Functional/Security/SecurityHeadersTest.php b/tests/Functional/Security/SecurityHeadersTest.php index cacbf430537..30793614cd3 100644 --- a/tests/Functional/Security/SecurityHeadersTest.php +++ b/tests/Functional/Security/SecurityHeadersTest.php @@ -55,7 +55,7 @@ public function testDeserializationErrorResponseIncludesSecurityHeaders(): void ], ); - $this->assertResponseStatusCodeSame(400); + $this->assertResponseStatusCodeSame(422); $this->assertResponseHeaderSame('x-content-type-options', 'nosniff'); $this->assertResponseHeaderSame('x-frame-options', 'deny'); } diff --git a/tests/Functional/Security/StrongTypingTest.php b/tests/Functional/Security/StrongTypingTest.php index d42b6a8aab9..78a906b94dc 100644 --- a/tests/Functional/Security/StrongTypingTest.php +++ b/tests/Functional/Security/StrongTypingTest.php @@ -86,12 +86,12 @@ public function testNullValueForRequiredStringTriggersTypeError(): void ], ); - $this->assertResponseStatusCodeSame(400); + $this->assertResponseStatusCodeSame(422); $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); $this->assertJsonContains([ - '@context' => '/contexts/Error', - '@type' => 'hydra:Error', - 'detail' => 'The type of the "name" attribute must be "string", "NULL" given.', + '@context' => '/contexts/ConstraintViolation', + '@type' => 'ConstraintViolation', + 'detail' => 'name: This value should not be blank.', ]); } @@ -198,12 +198,12 @@ public function testIntegerInsteadOfStringScalarTriggersTypeError(): void ], ); - $this->assertResponseStatusCodeSame(400); + $this->assertResponseStatusCodeSame(422); $this->assertResponseHeaderSame('content-type', 'application/problem+json; charset=utf-8'); $this->assertJsonContains([ - '@context' => '/contexts/Error', - '@type' => 'hydra:Error', - 'detail' => 'The type of the "name" attribute must be "string", "integer" given.', + '@context' => '/contexts/ConstraintViolation', + '@type' => 'ConstraintViolation', + 'detail' => 'name: This value should be of type string.', ]); } From 9179b366710e50085b796430e390a6a158f40e24 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 8 Jun 2026 09:35:45 +0200 Subject: [PATCH 23/84] feat(doctrine): per-property filter map in FreeTextQueryFilter (#8257) --- .../Odm/Filter/FreeTextQueryFilter.php | 45 +++++++++++++++---- .../Orm/Filter/FreeTextQueryFilter.php | 45 +++++++++++++++---- .../Fixtures/TestBundle/Document/Chicken.php | 4 ++ tests/Fixtures/TestBundle/Entity/Chicken.php | 4 ++ .../Parameters/FreeTextQueryFilterTest.php | 17 +++++++ 5 files changed, 97 insertions(+), 18 deletions(-) diff --git a/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php b/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php index b4f545e8f83..4262dca5e45 100644 --- a/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php +++ b/src/Doctrine/Odm/Filter/FreeTextQueryFilter.php @@ -28,24 +28,51 @@ final class FreeTextQueryFilter implements FilterInterface, ManagerRegistryAware use ManagerRegistryAwareTrait; /** - * @param list $properties an array of properties, defaults to `parameter->getProperties()` + * @param FilterInterface|array $filter a filter applied to every property, + * or a map of `property => filter` to use a + * dedicated filter per property + * @param list|null $properties an array of properties, defaults to + * the map keys when `$filter` is a map, + * otherwise to `parameter->getProperties()` */ - public function __construct(private readonly FilterInterface $filter, private readonly ?array $properties = null) + public function __construct(private readonly FilterInterface|array $filter, private readonly ?array $properties = null) { } public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { - if ($this->filter instanceof ManagerRegistryAwareInterface) { - $this->filter->setManagerRegistry($this->getManagerRegistry()); - } + $filterMap = \is_array($this->filter) ? $this->filter : null; + + if (null === $filterMap) { + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } - if ($this->filter instanceof LoggerAwareInterface) { - $this->filter->setLogger($this->getLogger()); + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } } $parameter = $context['parameter']; - foreach ($this->properties ?? $parameter->getProperties() ?? [] as $property) { + $properties = $this->properties ?? (null !== $filterMap ? array_keys($filterMap) : $parameter->getProperties()) ?? []; + + foreach ($properties as $property) { + $filter = null !== $filterMap ? ($filterMap[$property] ?? null) : $this->filter; + + if (null === $filter) { + continue; + } + + if (null !== $filterMap) { + if ($filter instanceof ManagerRegistryAwareInterface) { + $filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($filter instanceof LoggerAwareInterface) { + $filter->setLogger($this->getLogger()); + } + } + $subParameter = $parameter->withProperty($property); $nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? []; @@ -57,7 +84,7 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera ]); $newContext = ['parameter' => $subParameter, 'match' => $context['match'] ?? $aggregationBuilder->match()->expr()] + $context; - $this->filter->apply( + $filter->apply( $aggregationBuilder, $resourceClass, $operation, diff --git a/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php b/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php index a269ac41137..5f4b76e96a4 100644 --- a/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php +++ b/src/Doctrine/Orm/Filter/FreeTextQueryFilter.php @@ -31,27 +31,54 @@ final class FreeTextQueryFilter implements FilterInterface, ManagerRegistryAware use ManagerRegistryAwareTrait; /** - * @param list $properties an array of properties, defaults to `parameter->getProperties()` + * @param FilterInterface|array $filter a filter applied to every property, + * or a map of `property => filter` to use a + * dedicated filter per property + * @param list|null $properties an array of properties, defaults to + * the map keys when `$filter` is a map, + * otherwise to `parameter->getProperties()` */ - public function __construct(private readonly FilterInterface $filter, private readonly ?array $properties = null) + public function __construct(private readonly FilterInterface|array $filter, private readonly ?array $properties = null) { } public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { - if ($this->filter instanceof ManagerRegistryAwareInterface) { - $this->filter->setManagerRegistry($this->getManagerRegistry()); - } + $filterMap = \is_array($this->filter) ? $this->filter : null; + + if (null === $filterMap) { + if ($this->filter instanceof ManagerRegistryAwareInterface) { + $this->filter->setManagerRegistry($this->getManagerRegistry()); + } - if ($this->filter instanceof LoggerAwareInterface) { - $this->filter->setLogger($this->getLogger()); + if ($this->filter instanceof LoggerAwareInterface) { + $this->filter->setLogger($this->getLogger()); + } } $parameter = $context['parameter']; $qb = clone $queryBuilder; $qb->resetDQLPart('where'); $qb->setParameters(new ArrayCollection()); - foreach ($this->properties ?? $parameter->getProperties() ?? [] as $property) { + $properties = $this->properties ?? (null !== $filterMap ? array_keys($filterMap) : $parameter->getProperties()) ?? []; + + foreach ($properties as $property) { + $filter = null !== $filterMap ? ($filterMap[$property] ?? null) : $this->filter; + + if (null === $filter) { + continue; + } + + if (null !== $filterMap) { + if ($filter instanceof ManagerRegistryAwareInterface) { + $filter->setManagerRegistry($this->getManagerRegistry()); + } + + if ($filter instanceof LoggerAwareInterface) { + $filter->setLogger($this->getLogger()); + } + } + $subParameter = $parameter->withProperty($property); $nestedPropertiesInfo = $parameter->getExtraProperties()['nested_properties_info'] ?? []; @@ -62,7 +89,7 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q : [], ]); - $this->filter->apply( + $filter->apply( $qb, $queryNameGenerator, $resourceClass, diff --git a/tests/Fixtures/TestBundle/Document/Chicken.php b/tests/Fixtures/TestBundle/Document/Chicken.php index c9385f1f41c..55fd33676c1 100644 --- a/tests/Fixtures/TestBundle/Document/Chicken.php +++ b/tests/Fixtures/TestBundle/Document/Chicken.php @@ -47,6 +47,10 @@ ), 'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']), 'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']), + 'qmixed' => new QueryParameter(filter: new FreeTextQueryFilter([ + 'name' => new OrFilter(new PartialSearchFilter()), + 'ean' => new OrFilter(new ExactFilter()), + ]), description: 'Partial name match or exact ean match'), 'ownerNamePartial' => new QueryParameter( filter: new PartialSearchFilter(), property: 'owner.name', diff --git a/tests/Fixtures/TestBundle/Entity/Chicken.php b/tests/Fixtures/TestBundle/Entity/Chicken.php index 3f785481b44..95f8e8f9598 100644 --- a/tests/Fixtures/TestBundle/Entity/Chicken.php +++ b/tests/Fixtures/TestBundle/Entity/Chicken.php @@ -47,6 +47,10 @@ ), 'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']), 'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']), + 'qmixed' => new QueryParameter(filter: new FreeTextQueryFilter([ + 'name' => new OrFilter(new PartialSearchFilter()), + 'ean' => new OrFilter(new ExactFilter()), + ]), description: 'Partial name match or exact ean match'), 'ownerNamePartial' => new QueryParameter( filter: new PartialSearchFilter(), property: 'owner.name', diff --git a/tests/Functional/Parameters/FreeTextQueryFilterTest.php b/tests/Functional/Parameters/FreeTextQueryFilterTest.php index 31e051b51f6..b09f8c6ef47 100644 --- a/tests/Functional/Parameters/FreeTextQueryFilterTest.php +++ b/tests/Functional/Parameters/FreeTextQueryFilterTest.php @@ -100,6 +100,23 @@ public function testFreeTextQueryFilterWithTwoLevelTraversalPartial(): void $this->assertCount(2, $response['member']); } + public function testFreeTextQueryFilterWithPerPropertyFilterMap(): void + { + $client = $this->createClient(); + + $response = $client->request('GET', '/chickens?qmixed=Henri')->toArray(); + $this->assertJsonContains(['totalItems' => 1]); + $this->assertSame('Henriette', $response['member'][0]['name']); + + $response = $client->request('GET', '/chickens?qmixed=978020137963')->toArray(); + $this->assertJsonContains(['totalItems' => 1]); + $this->assertSame('978020137963', $response['member'][0]['ean']); + + $response = $client->request('GET', '/chickens?qmixed=97802')->toArray(); + $this->assertJsonContains(['totalItems' => 1]); + $this->assertSame('978020137962', $response['member'][0]['name']); + } + public function testFreeTextQueryFilterWithTwoLevelTraversalPartialWithPropertyPlaceholder(): void { $client = $this->createClient(); From e62e42fc7491dfa3439b4dfa54f4a3a6bda28a6e Mon Sep 17 00:00:00 2001 From: Alexis Lefebvre <2071331+alexisLefebvre@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:51:17 +0200 Subject: [PATCH 24/84] chore: fix php-cs-fixer (#8269) --- src/JsonApi/Serializer/ItemNormalizerTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/JsonApi/Serializer/ItemNormalizerTrait.php b/src/JsonApi/Serializer/ItemNormalizerTrait.php index b307caa54aa..e2279ca8868 100644 --- a/src/JsonApi/Serializer/ItemNormalizerTrait.php +++ b/src/JsonApi/Serializer/ItemNormalizerTrait.php @@ -62,7 +62,7 @@ public function denormalize(mixed $data, string $type, ?string $format = null, a if (!$allowClientGeneratedId) { throw new NotNormalizableValueException(\sprintf('Client-generated IDs are not allowed on this operation. Set the "%s" denormalization context flag (or the bundle "allow_client_generated_id" configuration) to enable it.', ItemNormalizer::ALLOW_CLIENT_GENERATED_ID)); } - // Fall through: client id is merged into the denormalized payload below. + // Fall through: client id is merged into the denormalized payload below. } elseif (true !== ($context['api_allow_update'] ?? true)) { throw new NotNormalizableValueException('Update is not allowed for this operation.'); } else { From 573df71e32a219febf2c9bdb0478247c6bc2674f Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sat, 13 Jun 2026 06:47:55 +0200 Subject: [PATCH 25/84] test: drop stale serializer 8.1 getErrors() deprecation expectation (#8287) --- tests/Functional/EnumDenormalizationValidationTest.php | 7 ------- tests/Functional/NullOnNonNullablePropertyTest.php | 8 -------- 2 files changed, 15 deletions(-) diff --git a/tests/Functional/EnumDenormalizationValidationTest.php b/tests/Functional/EnumDenormalizationValidationTest.php index 3fa939623c8..8d340915433 100644 --- a/tests/Functional/EnumDenormalizationValidationTest.php +++ b/tests/Functional/EnumDenormalizationValidationTest.php @@ -17,8 +17,6 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\EnumValidationResource; use ApiPlatform\Tests\SetupClassResourcesTrait; use Composer\InstalledVersions; -use Composer\Semver\VersionParser; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** * @see https://github.com/api-platform/core/issues/8183 @@ -67,13 +65,8 @@ public function testInvalidBackedEnumValueProducesValidationViolation(): void $this->assertNotNull($genderViolation, 'Expected a constraint violation on "gender" property.'); } - #[IgnoreDeprecations] public function testInvalidBackedEnumValueWithCollectDenormalizationErrors(): void { - if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) { - $this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.'); - } - $response = static::createClient()->request('POST', '/enum_validation_resources_collect', [ 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['gender' => 'unknown'], diff --git a/tests/Functional/NullOnNonNullablePropertyTest.php b/tests/Functional/NullOnNonNullablePropertyTest.php index eba8ce3f10c..d6aa24c078f 100644 --- a/tests/Functional/NullOnNonNullablePropertyTest.php +++ b/tests/Functional/NullOnNonNullablePropertyTest.php @@ -16,9 +16,6 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\NullOnNonNullableProperty\NullOnNonNullableResource; use ApiPlatform\Tests\SetupClassResourcesTrait; -use Composer\InstalledVersions; -use Composer\Semver\VersionParser; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; /** @see https://github.com/symfony/symfony/issues/64159 */ final class NullOnNonNullablePropertyTest extends ApiTestCase @@ -48,13 +45,8 @@ public function testNullOnNonNullablePropertyReturns400(): void $this->assertStringContainsString('Expected argument of type "string", "null" given at property path "name"', $body['hydra:description'] ?? $body['detail'] ?? ''); } - #[IgnoreDeprecations] public function testNullOnNonNullablePropertyReturns422WhenCollectingErrors(): void { - if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) { - $this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.'); - } - $response = self::createClient()->request('POST', '/null_on_non_nullable_resources_collect', [ 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['name' => null], From b0f6dbd63b1314efadedfd3a0b35474f6f1b8cbf Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 17 Jun 2026 12:10:16 +0200 Subject: [PATCH 26/84] feat(doctrine): add EndSearchFilter primary for ORM and ODM (#8319) --- src/Doctrine/Odm/Filter/EndSearchFilter.php | 77 +++++++ .../Odm/Tests/Filter/EndSearchFilterTest.php | 134 +++++++++++ src/Doctrine/Orm/Filter/EndSearchFilter.php | 83 +++++++ .../Fixtures/TestBundle/Document/Chicken.php | 10 + tests/Fixtures/TestBundle/Entity/Chicken.php | 10 + .../Parameters/EndSearchFilterTest.php | 215 ++++++++++++++++++ 6 files changed, 529 insertions(+) create mode 100644 src/Doctrine/Odm/Filter/EndSearchFilter.php create mode 100644 src/Doctrine/Odm/Tests/Filter/EndSearchFilterTest.php create mode 100644 src/Doctrine/Orm/Filter/EndSearchFilter.php create mode 100644 tests/Functional/Parameters/EndSearchFilterTest.php diff --git a/src/Doctrine/Odm/Filter/EndSearchFilter.php b/src/Doctrine/Odm/Filter/EndSearchFilter.php new file mode 100644 index 00000000000..f798dd636bf --- /dev/null +++ b/src/Doctrine/Odm/Filter/EndSearchFilter.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\Doctrine\Odm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Odm\NestedPropertyHelperTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use MongoDB\BSON\Regex; + +/** + * Filters the collection by the end of a string property, using a regular expression anchored at the end. + */ +final class EndSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; + use OpenApiFilterTrait; + + public function __construct(private readonly bool $caseSensitive = true) + { + } + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + $parameter = $context['parameter']; + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + + $property = $parameter->getProperty(); + $values = $parameter->getValue(); + $match = $context['match'] = $context['match'] ?? + $aggregationBuilder + ->matchExpr(); + $operator = $context['operator'] ?? 'addAnd'; + + $matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter, false, $context); + + if (!is_iterable($values)) { + $escapedValue = preg_quote($values, '/'); + $match->{$operator}( + $aggregationBuilder->matchExpr()->field($matchField)->equals(new Regex($escapedValue.'$', $this->caseSensitive ? '' : 'i')) + ); + + return; + } + + $or = $aggregationBuilder->matchExpr(); + foreach ($values as $value) { + $escapedValue = preg_quote($value, '/'); + + $or->addOr( + $aggregationBuilder->matchExpr() + ->field($matchField) + ->equals(new Regex($escapedValue.'$', $this->caseSensitive ? '' : 'i')) + ); + } + + $match->{$operator}($or); + } +} diff --git a/src/Doctrine/Odm/Tests/Filter/EndSearchFilterTest.php b/src/Doctrine/Odm/Tests/Filter/EndSearchFilterTest.php new file mode 100644 index 00000000000..88b7eab45d2 --- /dev/null +++ b/src/Doctrine/Odm/Tests/Filter/EndSearchFilterTest.php @@ -0,0 +1,134 @@ + + * + * 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\Doctrine\Odm\Tests\Filter; + +use ApiPlatform\Doctrine\Odm\Filter\EndSearchFilter; +use ApiPlatform\Doctrine\Odm\Tests\DoctrineMongoDbOdmTestCase; +use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\Dummy; +use ApiPlatform\Doctrine\Odm\Tests\Fixtures\Document\RelatedDummy; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Doctrine\ODM\MongoDB\DocumentManager; +use MongoDB\BSON\Regex; +use PHPUnit\Framework\TestCase; + +class EndSearchFilterTest extends TestCase +{ + private DocumentManager $manager; + + protected function setUp(): void + { + $this->manager = DoctrineMongoDbOdmTestCase::createTestDocumentManager(); + } + + public function testEndSearchSimpleProperty(): void + { + $filter = new EndSearchFilter(); + + $parameter = new QueryParameter(property: 'name', key: 'name'); + $parameter->setValue('foo'); + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => ['name' => 'foo'], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + + // The filter populates $context['match'] with the match expression (no pipeline stage added) + $this->assertArrayHasKey('match', $context); + $this->assertEquals( + ['$and' => [['name' => new Regex('foo$', '')]]], + $context['match']->getQuery() + ); + $this->assertNoPipelineStages($aggregationBuilder); + } + + public function testEndSearchCaseInsensitive(): void + { + $filter = new EndSearchFilter(caseSensitive: false); + + $parameter = new QueryParameter(property: 'name', key: 'name'); + $parameter->setValue('foo'); + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => ['name' => 'foo'], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + + $this->assertEquals( + ['$and' => [['name' => new Regex('foo$', 'i')]]], + $context['match']->getQuery() + ); + } + + public function testEndSearchNestedProperty(): void + { + $filter = new EndSearchFilter(); + + $parameter = new QueryParameter( + property: 'relatedDummy.name', + key: 'relatedDummy.name', + extraProperties: [ + 'nested_properties_info' => ['relatedDummy.name' => [ + 'relation_segments' => ['relatedDummy'], + 'relation_classes' => [Dummy::class], + 'leaf_property' => 'name', + 'leaf_class' => RelatedDummy::class, + 'odm_segments' => [ + [ + 'type' => 'reference', + 'target_document' => RelatedDummy::class, + 'is_owning_side' => true, + 'mapped_by' => null, + ], + ], + ]], + ], + ); + $parameter->setValue('bar'); + + $aggregationBuilder = $this->manager->getRepository(Dummy::class)->createAggregationBuilder(); + + $context = [ + 'parameter' => $parameter, + 'filters' => ['relatedDummy.name' => 'bar'], + ]; + + $filter->apply($aggregationBuilder, Dummy::class, null, $context); + $pipeline = $aggregationBuilder->getPipeline(); + + // Nested property adds $lookup + $unwind stages + $this->assertCount(2, $pipeline); + $this->assertArrayHasKey('$lookup', $pipeline[0]); + $this->assertArrayHasKey('$unwind', $pipeline[1]); + + // The match expression is populated for the parameter extension to commit + $this->assertArrayHasKey('match', $context); + } + + private function assertNoPipelineStages(Builder $aggregationBuilder): void + { + try { + $pipeline = $aggregationBuilder->getPipeline(); + $this->assertEmpty($pipeline); + } catch (\OutOfRangeException) { + // No stages added — expected for simple property filters + } + } +} diff --git a/src/Doctrine/Orm/Filter/EndSearchFilter.php b/src/Doctrine/Orm/Filter/EndSearchFilter.php new file mode 100644 index 00000000000..242e79152df --- /dev/null +++ b/src/Doctrine/Orm/Filter/EndSearchFilter.php @@ -0,0 +1,83 @@ + + * + * 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\Doctrine\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +/** + * Filters the collection by the end of a string property, using a `LIKE '%value'` clause. + */ +final class EndSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; + use OpenApiFilterTrait; + + public function __construct(private readonly bool $caseSensitive = false) + { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter']; + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + [$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + $field = $alias.'.'.$property; + $values = $parameter->getValue(); + + if (!is_iterable($values)) { + $parameterName = $queryNameGenerator->generateParameterName($property); + $queryBuilder->setParameter($parameterName, $this->formatLikeValue($values)); + + $likeExpression = $this->caseSensitive + ? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\'' + : 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}($likeExpression); + + return; + } + + $likeExpressions = []; + foreach ($values as $val) { + $parameterName = $queryNameGenerator->generateParameterName($property); + $likeExpressions[] = $this->caseSensitive + ? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\'' + : 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + + $queryBuilder->setParameter($parameterName, $this->formatLikeValue($val)); + } + + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( + $queryBuilder->expr()->orX(...$likeExpressions) + ); + } + + private function formatLikeValue(string $value): string + { + return '%'.addcslashes($value, '\\%_'); + } +} diff --git a/tests/Fixtures/TestBundle/Document/Chicken.php b/tests/Fixtures/TestBundle/Document/Chicken.php index 55fd33676c1..af8fe04296e 100644 --- a/tests/Fixtures/TestBundle/Document/Chicken.php +++ b/tests/Fixtures/TestBundle/Document/Chicken.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; use ApiPlatform\Doctrine\Odm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Odm\Filter\EndSearchFilter; use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; use ApiPlatform\Doctrine\Odm\Filter\FreeTextQueryFilter; use ApiPlatform\Doctrine\Odm\Filter\IriFilter; @@ -45,6 +46,15 @@ filter: new PartialSearchFilter(true), property: 'name', ), + 'nameEnd' => new QueryParameter( + filter: new EndSearchFilter(false), + property: 'name', + ), + 'nameEndNoProperty' => new QueryParameter(filter: new EndSearchFilter()), + 'nameEndSensitive' => new QueryParameter( + filter: new EndSearchFilter(true), + property: 'name', + ), 'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']), 'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']), 'qmixed' => new QueryParameter(filter: new FreeTextQueryFilter([ diff --git a/tests/Fixtures/TestBundle/Entity/Chicken.php b/tests/Fixtures/TestBundle/Entity/Chicken.php index 95f8e8f9598..386a04c5852 100644 --- a/tests/Fixtures/TestBundle/Entity/Chicken.php +++ b/tests/Fixtures/TestBundle/Entity/Chicken.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Orm\Filter\EndSearchFilter; use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Doctrine\Orm\Filter\FreeTextQueryFilter; use ApiPlatform\Doctrine\Orm\Filter\IriFilter; @@ -45,6 +46,15 @@ filter: new PartialSearchFilter(true), property: 'name', ), + 'nameEnd' => new QueryParameter( + filter: new EndSearchFilter(), + property: 'name', + ), + 'nameEndNoProperty' => new QueryParameter(filter: new EndSearchFilter()), + 'nameEndSensitive' => new QueryParameter( + filter: new EndSearchFilter(true), + property: 'name', + ), 'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']), 'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']), 'qmixed' => new QueryParameter(filter: new FreeTextQueryFilter([ diff --git a/tests/Functional/Parameters/EndSearchFilterTest.php b/tests/Functional/Parameters/EndSearchFilterTest.php new file mode 100644 index 00000000000..a6e45d1e576 --- /dev/null +++ b/tests/Functional/Parameters/EndSearchFilterTest.php @@ -0,0 +1,215 @@ + + * + * 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\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Chicken as DocumentChicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ChickenCoop as DocumentChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Owner as DocumentOwner; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Owner; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; + +final class EndSearchFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Chicken::class, ChickenCoop::class, Owner::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entities = $this->isMongoDB() + ? [DocumentChicken::class, DocumentChickenCoop::class, DocumentOwner::class] + : [Chicken::class, ChickenCoop::class, Owner::class]; + + $this->recreateSchema($entities); + $this->loadFixtures(); + } + + #[DataProvider('endSearchFilterProvider')] + public function testEndSearchFilter(string $url, int $expectedCount, array $expectedNames): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + + $names = array_map(static fn ($chicken) => $chicken['name'], $filteredItems); + sort($names); + sort($expectedNames); + + $this->assertSame($expectedNames, $names, 'The returned names do not match the expected values.'); + } + + public static function endSearchFilterProvider(): \Generator + { + yield 'filter by ending "rude"' => [ + '/chickens?nameEnd=rude', + 1, + ['Gertrude'], + ]; + + yield 'filter by ending "tte"' => [ + '/chickens?nameEnd=tte', + 1, + ['Henriette'], + ]; + + yield 'filter by ending "e" (should match both)' => [ + '/chickens?nameEnd=e', + 2, + ['Gertrude', 'Henriette'], + ]; + + yield 'filter by ending "rud" (must not match — not a suffix)' => [ + '/chickens?nameEnd=rud', + 0, + [], + ]; + + yield 'filter by ending with no matching entities' => [ + '/chickens?nameEnd=Zebra', + 0, + [], + ]; + + yield 'filter by ending "xx"' => [ + '/chickens?nameEnd=xx', + 1, + ['xx_%_\\_%_xx'], + ]; + + yield 'filter with multiple endings "rude" OR "tte"' => [ + '/chickens?nameEnd[]=rude&nameEnd[]=tte', + 2, + ['Gertrude', 'Henriette'], + ]; + + yield 'filter with multiple endings, one matching "rude", the other not matching "Zebra"' => [ + '/chickens?nameEnd[]=rude&nameEnd[]=Zebra', + 1, + ['Gertrude'], + ]; + } + + public function testEndSearchFilterThrowsExceptionWhenPropertyIsMissing(): void + { + $response = self::createClient()->request('GET', '/chickens?nameEndNoProperty=rude'); + $this->assertResponseStatusCodeSame(400); + + $responseData = $response->toArray(false); + + $this->assertStringContainsString( + 'The filter parameter with key "nameEndNoProperty" must specify a property', + $responseData['detail'] + ); + } + + #[DataProvider('endSearchFilterCaseSensitiveProvider')] + public function testEndSearchCaseSensitiveFilter(string $url, int $expectedCount, array $expectedNames): void + { + if ($this->isMysql() || $this->isSqlite()) { + $this->markTestSkipped('Mysql and sqlite use case insensitive LIKE.'); + } + + $this->testEndSearchFilter($url, $expectedCount, $expectedNames); + } + + public static function endSearchFilterCaseSensitiveProvider(): \Generator + { + yield 'case insensitive ending "rude"' => [ + '/chickens?nameEnd=RUDE', + 1, + ['Gertrude'], + ]; + + yield 'case sensitive ending "rude"' => [ + '/chickens?nameEndSensitive=rude', + 1, + ['Gertrude'], + ]; + + yield 'case sensitive ending "RUDE"' => [ + '/chickens?nameEndSensitive=RUDE', + 0, + [], + ]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(): void + { + $manager = $this->getManager(); + + $chickenClass = $this->isMongoDB() ? DocumentChicken::class : Chicken::class; + $coopClass = $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class; + $ownerClass = $this->isMongoDB() ? DocumentOwner::class : Owner::class; + + $owner1 = new $ownerClass(); + $owner1->setName('Alice'); + + $manager->persist($owner1); + $manager->flush(); + + $chickenCoop1 = new $coopClass(); + + $chicken1 = new $chickenClass(); + $chicken1->setName('Gertrude'); + $chicken1->setChickenCoop($chickenCoop1); + $chicken1->setOwner($owner1); + + $chicken2 = new $chickenClass(); + $chicken2->setName('Henriette'); + $chicken2->setChickenCoop($chickenCoop1); + $chicken2->setOwner($owner1); + + $chicken3 = new $chickenClass(); + $chicken3->setName('xx_%_\\_%_xx'); + $chicken3->setChickenCoop($chickenCoop1); + $chicken3->setOwner($owner1); + + $chickenCoop1->addChicken($chicken1); + $chickenCoop1->addChicken($chicken2); + $chickenCoop1->addChicken($chicken3); + + $manager->persist($chickenCoop1); + $manager->persist($chicken1); + $manager->persist($chicken2); + $manager->persist($chicken3); + + $manager->flush(); + } +} From af0a0ab6c286b5e30160dce9f4bcc23836125dab Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 17 Jun 2026 15:17:04 +0200 Subject: [PATCH 27/84] feat(doctrine): promote ComparisonFilter out of @experimental (#8323) --- src/Doctrine/Odm/Filter/ComparisonFilter.php | 2 -- src/Doctrine/Orm/Filter/ComparisonFilter.php | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/Doctrine/Odm/Filter/ComparisonFilter.php b/src/Doctrine/Odm/Filter/ComparisonFilter.php index 1593fc473ed..53488ed6443 100644 --- a/src/Doctrine/Odm/Filter/ComparisonFilter.php +++ b/src/Doctrine/Odm/Filter/ComparisonFilter.php @@ -29,8 +29,6 @@ /** * Decorates an equality filter (ExactFilter) to add comparison operators (gt, gte, lt, lte). - * - * @experimental */ final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface { diff --git a/src/Doctrine/Orm/Filter/ComparisonFilter.php b/src/Doctrine/Orm/Filter/ComparisonFilter.php index 474973d72fd..c1362320b82 100644 --- a/src/Doctrine/Orm/Filter/ComparisonFilter.php +++ b/src/Doctrine/Orm/Filter/ComparisonFilter.php @@ -30,8 +30,6 @@ /** * Decorates an equality filter (ExactFilter, UuidFilter) to add comparison operators (gt, gte, lt, lte). - * - * @experimental */ final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface { From 6942dc0a1bc708c0f86c454a8553c25d7e9e7fff Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 17 Jun 2026 15:17:20 +0200 Subject: [PATCH 28/84] feat(doctrine): promote OrFilter out of @experimental (#8324) --- src/Doctrine/Orm/Filter/OrFilter.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Doctrine/Orm/Filter/OrFilter.php b/src/Doctrine/Orm/Filter/OrFilter.php index d8e020221a7..792eeb5e541 100644 --- a/src/Doctrine/Orm/Filter/OrFilter.php +++ b/src/Doctrine/Orm/Filter/OrFilter.php @@ -26,8 +26,6 @@ /** * @author Vincent Amstoutz - * - * @experimental */ final class OrFilter implements FilterInterface, OpenApiParameterFilterInterface, ManagerRegistryAwareInterface, LoggerAwareInterface { From 48bc56e9ab34532d87aafb9abd2e6c6c98768571 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 17 Jun 2026 16:30:26 +0200 Subject: [PATCH 29/84] feat(metadata): document BackwardCompatibleFilterDescriptionTrait as public API (#8326) --- ...ckwardCompatibleFilterDescriptionTrait.php | 8 +++-- ...rdCompatibleFilterDescriptionTraitTest.php | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) create mode 100644 src/Metadata/Tests/BackwardCompatibleFilterDescriptionTraitTest.php diff --git a/src/Metadata/BackwardCompatibleFilterDescriptionTrait.php b/src/Metadata/BackwardCompatibleFilterDescriptionTrait.php index ace2d185f39..965fa9d3643 100644 --- a/src/Metadata/BackwardCompatibleFilterDescriptionTrait.php +++ b/src/Metadata/BackwardCompatibleFilterDescriptionTrait.php @@ -14,9 +14,13 @@ namespace ApiPlatform\Metadata; /** - * @author Vincent Amstoutz + * Lets a filter satisfy the legacy FilterInterface::getDescription() requirement without implementing it by hand. + * + * Use this trait in a filter that does not need to describe itself through the deprecated getDescription() mechanism: + * it returns an empty array, which is the expected value now that filters are described through QueryParameter metadata. + * The trait will be removed in 6.0 together with FilterInterface::getDescription(). * - * @internal + * @author Vincent Amstoutz */ trait BackwardCompatibleFilterDescriptionTrait { diff --git a/src/Metadata/Tests/BackwardCompatibleFilterDescriptionTraitTest.php b/src/Metadata/Tests/BackwardCompatibleFilterDescriptionTraitTest.php new file mode 100644 index 00000000000..dc02b9072b4 --- /dev/null +++ b/src/Metadata/Tests/BackwardCompatibleFilterDescriptionTraitTest.php @@ -0,0 +1,29 @@ + + * + * 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\Tests; + +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use PHPUnit\Framework\TestCase; + +final class BackwardCompatibleFilterDescriptionTraitTest extends TestCase +{ + public function testGetDescriptionReturnsEmptyArray(): void + { + $filter = new class { + use BackwardCompatibleFilterDescriptionTrait; + }; + + $this->assertSame([], $filter->getDescription('Foo')); + } +} From 4bf850fc8957616f94c3faf5a1abc799826c6379 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 17 Jun 2026 20:20:36 +0200 Subject: [PATCH 30/84] feat(doctrine): add StartSearchFilter and WordStartSearchFilter (ORM + ODM) (#8328) --- src/Doctrine/Odm/Filter/StartSearchFilter.php | 77 +++++++ .../Odm/Filter/WordStartSearchFilter.php | 82 +++++++ src/Doctrine/Orm/Filter/StartSearchFilter.php | 83 +++++++ .../Orm/Filter/WordStartSearchFilter.php | 92 ++++++++ .../Fixtures/TestBundle/Document/Chicken.php | 16 ++ tests/Fixtures/TestBundle/Entity/Chicken.php | 16 ++ .../Parameters/StartSearchFilterTest.php | 203 ++++++++++++++++++ .../Parameters/WordStartSearchFilterTest.php | 185 ++++++++++++++++ 8 files changed, 754 insertions(+) create mode 100644 src/Doctrine/Odm/Filter/StartSearchFilter.php create mode 100644 src/Doctrine/Odm/Filter/WordStartSearchFilter.php create mode 100644 src/Doctrine/Orm/Filter/StartSearchFilter.php create mode 100644 src/Doctrine/Orm/Filter/WordStartSearchFilter.php create mode 100644 tests/Functional/Parameters/StartSearchFilterTest.php create mode 100644 tests/Functional/Parameters/WordStartSearchFilterTest.php diff --git a/src/Doctrine/Odm/Filter/StartSearchFilter.php b/src/Doctrine/Odm/Filter/StartSearchFilter.php new file mode 100644 index 00000000000..bbec450837d --- /dev/null +++ b/src/Doctrine/Odm/Filter/StartSearchFilter.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\Doctrine\Odm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Odm\NestedPropertyHelperTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use MongoDB\BSON\Regex; + +/** + * Filters the collection by the beginning of a string property, using a regular expression anchored at the start. + */ +final class StartSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; + use OpenApiFilterTrait; + + public function __construct(private readonly bool $caseSensitive = true) + { + } + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + $parameter = $context['parameter']; + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + + $property = $parameter->getProperty(); + $values = $parameter->getValue(); + $match = $context['match'] = $context['match'] ?? + $aggregationBuilder + ->matchExpr(); + $operator = $context['operator'] ?? 'addAnd'; + + $matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter, false, $context); + + if (!is_iterable($values)) { + $escapedValue = preg_quote($values, '/'); + $match->{$operator}( + $aggregationBuilder->matchExpr()->field($matchField)->equals(new Regex('^'.$escapedValue, $this->caseSensitive ? '' : 'i')) + ); + + return; + } + + $or = $aggregationBuilder->matchExpr(); + foreach ($values as $value) { + $escapedValue = preg_quote($value, '/'); + + $or->addOr( + $aggregationBuilder->matchExpr() + ->field($matchField) + ->equals(new Regex('^'.$escapedValue, $this->caseSensitive ? '' : 'i')) + ); + } + + $match->{$operator}($or); + } +} diff --git a/src/Doctrine/Odm/Filter/WordStartSearchFilter.php b/src/Doctrine/Odm/Filter/WordStartSearchFilter.php new file mode 100644 index 00000000000..3ece9a2cdee --- /dev/null +++ b/src/Doctrine/Odm/Filter/WordStartSearchFilter.php @@ -0,0 +1,82 @@ + + * + * 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\Doctrine\Odm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Odm\NestedPropertyHelperTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ODM\MongoDB\Aggregation\Builder; +use MongoDB\BSON\Regex; + +/** + * Filters the collection by a word boundary prefix, matching documents that contain a word starting with the value, + * using a regular expression anchored at the start of the string or at a word boundary. + */ +final class WordStartSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; + use OpenApiFilterTrait; + + public function __construct(private readonly bool $caseSensitive = true) + { + } + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + $parameter = $context['parameter']; + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + + $property = $parameter->getProperty(); + $values = $parameter->getValue(); + $match = $context['match'] = $context['match'] ?? + $aggregationBuilder + ->matchExpr(); + $operator = $context['operator'] ?? 'addAnd'; + + $matchField = $this->addNestedParameterLookups($property, $aggregationBuilder, $parameter, false, $context); + + if (!is_iterable($values)) { + $match->{$operator}( + $aggregationBuilder->matchExpr()->field($matchField)->equals($this->createRegex($values)) + ); + + return; + } + + $or = $aggregationBuilder->matchExpr(); + foreach ($values as $value) { + $or->addOr( + $aggregationBuilder->matchExpr() + ->field($matchField) + ->equals($this->createRegex($value)) + ); + } + + $match->{$operator}($or); + } + + private function createRegex(string $value): Regex + { + $escapedValue = preg_quote($value, '/'); + + return new Regex('(^'.$escapedValue.'|\s'.$escapedValue.')', $this->caseSensitive ? '' : 'i'); + } +} diff --git a/src/Doctrine/Orm/Filter/StartSearchFilter.php b/src/Doctrine/Orm/Filter/StartSearchFilter.php new file mode 100644 index 00000000000..4cca0231e01 --- /dev/null +++ b/src/Doctrine/Orm/Filter/StartSearchFilter.php @@ -0,0 +1,83 @@ + + * + * 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\Doctrine\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +/** + * Filters the collection by the beginning of a string property, using a `LIKE 'value%'` clause. + */ +final class StartSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; + use OpenApiFilterTrait; + + public function __construct(private readonly bool $caseSensitive = false) + { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter']; + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + [$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + $field = $alias.'.'.$property; + $values = $parameter->getValue(); + + if (!is_iterable($values)) { + $parameterName = $queryNameGenerator->generateParameterName($property); + $queryBuilder->setParameter($parameterName, $this->formatLikeValue($values)); + + $likeExpression = $this->caseSensitive + ? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\'' + : 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}($likeExpression); + + return; + } + + $likeExpressions = []; + foreach ($values as $val) { + $parameterName = $queryNameGenerator->generateParameterName($property); + $likeExpressions[] = $this->caseSensitive + ? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\'' + : 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + + $queryBuilder->setParameter($parameterName, $this->formatLikeValue($val)); + } + + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( + $queryBuilder->expr()->orX(...$likeExpressions) + ); + } + + private function formatLikeValue(string $value): string + { + return addcslashes($value, '\\%_').'%'; + } +} diff --git a/src/Doctrine/Orm/Filter/WordStartSearchFilter.php b/src/Doctrine/Orm/Filter/WordStartSearchFilter.php new file mode 100644 index 00000000000..e872b2d5204 --- /dev/null +++ b/src/Doctrine/Orm/Filter/WordStartSearchFilter.php @@ -0,0 +1,92 @@ + + * + * 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\Doctrine\Orm\Filter; + +use ApiPlatform\Doctrine\Common\Filter\OpenApiFilterTrait; +use ApiPlatform\Doctrine\Orm\NestedPropertyHelperTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\OpenApiParameterFilterInterface; +use ApiPlatform\Metadata\Operation; +use Doctrine\ORM\QueryBuilder; + +/** + * Filters the collection by a word boundary prefix, matching fields that contain a word starting with the value, + * using a `LIKE 'value%' OR LIKE '% value%'` clause. + */ +final class WordStartSearchFilter implements FilterInterface, OpenApiParameterFilterInterface +{ + use BackwardCompatibleFilterDescriptionTrait; + use NestedPropertyHelperTrait; + use OpenApiFilterTrait; + + public function __construct(private readonly bool $caseSensitive = false) + { + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $parameter = $context['parameter']; + + if (null === $parameter->getProperty()) { + throw new InvalidArgumentException(\sprintf('The filter parameter with key "%s" must specify a property. Please provide the property explicitly.', $parameter->getKey())); + } + + $property = $parameter->getProperty(); + $alias = $queryBuilder->getRootAliases()[0]; + [$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + $field = $alias.'.'.$property; + $values = $parameter->getValue(); + + if (!is_iterable($values)) { + $values = [$values]; + } + + $expressions = []; + foreach ($values as $val) { + $startName = $queryNameGenerator->generateParameterName($property); + $wordName = $queryNameGenerator->generateParameterName($property); + + $expressions[] = $queryBuilder->expr()->orX( + $this->createLikeExpression($field, $startName), + $this->createLikeExpression($field, $wordName), + ); + + $queryBuilder->setParameter($startName, $this->formatStartValue($val)); + $queryBuilder->setParameter($wordName, $this->formatWordValue($val)); + } + + $queryBuilder->{$context['whereClause'] ?? 'andWhere'}( + $queryBuilder->expr()->orX(...$expressions) + ); + } + + private function createLikeExpression(string $field, string $parameterName): string + { + return $this->caseSensitive + ? $field.' LIKE :'.$parameterName.' ESCAPE \'\\\'' + : 'LOWER('.$field.') LIKE LOWER(:'.$parameterName.') ESCAPE \'\\\''; + } + + private function formatStartValue(string $value): string + { + return addcslashes($value, '\\%_').'%'; + } + + private function formatWordValue(string $value): string + { + return '% '.addcslashes($value, '\\%_').'%'; + } +} diff --git a/tests/Fixtures/TestBundle/Document/Chicken.php b/tests/Fixtures/TestBundle/Document/Chicken.php index af8fe04296e..06d5fa16fa2 100644 --- a/tests/Fixtures/TestBundle/Document/Chicken.php +++ b/tests/Fixtures/TestBundle/Document/Chicken.php @@ -20,6 +20,8 @@ use ApiPlatform\Doctrine\Odm\Filter\IriFilter; use ApiPlatform\Doctrine\Odm\Filter\OrFilter; use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; +use ApiPlatform\Doctrine\Odm\Filter\StartSearchFilter; +use ApiPlatform\Doctrine\Odm\Filter\WordStartSearchFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; @@ -55,6 +57,20 @@ filter: new EndSearchFilter(true), property: 'name', ), + 'nameStart' => new QueryParameter( + filter: new StartSearchFilter(false), + property: 'name', + ), + 'nameStartNoProperty' => new QueryParameter(filter: new StartSearchFilter()), + 'nameStartSensitive' => new QueryParameter( + filter: new StartSearchFilter(true), + property: 'name', + ), + 'nameWordStart' => new QueryParameter( + filter: new WordStartSearchFilter(false), + property: 'name', + ), + 'nameWordStartNoProperty' => new QueryParameter(filter: new WordStartSearchFilter()), 'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']), 'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']), 'qmixed' => new QueryParameter(filter: new FreeTextQueryFilter([ diff --git a/tests/Fixtures/TestBundle/Entity/Chicken.php b/tests/Fixtures/TestBundle/Entity/Chicken.php index 386a04c5852..4a5c0d861ae 100644 --- a/tests/Fixtures/TestBundle/Entity/Chicken.php +++ b/tests/Fixtures/TestBundle/Entity/Chicken.php @@ -20,6 +20,8 @@ use ApiPlatform\Doctrine\Orm\Filter\IriFilter; use ApiPlatform\Doctrine\Orm\Filter\OrFilter; use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; +use ApiPlatform\Doctrine\Orm\Filter\StartSearchFilter; +use ApiPlatform\Doctrine\Orm\Filter\WordStartSearchFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; @@ -55,6 +57,20 @@ filter: new EndSearchFilter(true), property: 'name', ), + 'nameStart' => new QueryParameter( + filter: new StartSearchFilter(), + property: 'name', + ), + 'nameStartNoProperty' => new QueryParameter(filter: new StartSearchFilter()), + 'nameStartSensitive' => new QueryParameter( + filter: new StartSearchFilter(true), + property: 'name', + ), + 'nameWordStart' => new QueryParameter( + filter: new WordStartSearchFilter(), + property: 'name', + ), + 'nameWordStartNoProperty' => new QueryParameter(filter: new WordStartSearchFilter()), 'autocomplete' => new QueryParameter(filter: new FreeTextQueryFilter(new OrFilter(new ExactFilter())), properties: ['name', 'ean']), 'q' => new QueryParameter(filter: new FreeTextQueryFilter(new PartialSearchFilter()), properties: ['name', 'ean']), 'qmixed' => new QueryParameter(filter: new FreeTextQueryFilter([ diff --git a/tests/Functional/Parameters/StartSearchFilterTest.php b/tests/Functional/Parameters/StartSearchFilterTest.php new file mode 100644 index 00000000000..fda860ec295 --- /dev/null +++ b/tests/Functional/Parameters/StartSearchFilterTest.php @@ -0,0 +1,203 @@ + + * + * 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\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Chicken as DocumentChicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ChickenCoop as DocumentChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Owner as DocumentOwner; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Owner; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; + +final class StartSearchFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Chicken::class, ChickenCoop::class, Owner::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entities = $this->isMongoDB() + ? [DocumentChicken::class, DocumentChickenCoop::class, DocumentOwner::class] + : [Chicken::class, ChickenCoop::class, Owner::class]; + + $this->recreateSchema($entities); + $this->loadFixtures(); + } + + #[DataProvider('startSearchFilterProvider')] + public function testStartSearchFilter(string $url, int $expectedCount, array $expectedNames): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + + $names = array_map(static fn ($chicken) => $chicken['name'], $filteredItems); + sort($names); + sort($expectedNames); + + $this->assertSame($expectedNames, $names, 'The returned names do not match the expected values.'); + } + + public static function startSearchFilterProvider(): \Generator + { + yield 'filter by prefix "Gert"' => [ + '/chickens?nameStart=Gert', + 1, + ['Gertrude'], + ]; + + yield 'filter by prefix "Hen"' => [ + '/chickens?nameStart=Hen', + 1, + ['Henriette'], + ]; + + yield 'prefix in the middle does not match (start anchored)' => [ + '/chickens?nameStart=rude', + 0, + [], + ]; + + yield 'filter by prefix with no matching entities' => [ + '/chickens?nameStart=Zebra', + 0, + [], + ]; + + yield 'filter with multiple prefixes "Gert" OR "Hen"' => [ + '/chickens?nameStart[]=Gert&nameStart[]=Hen', + 2, + ['Gertrude', 'Henriette'], + ]; + + yield 'filter by prefix "xx_"' => [ + '/chickens?nameStart=xx_', + 1, + ['xx_%_\\_%_xx'], + ]; + } + + public function testStartSearchFilterThrowsExceptionWhenPropertyIsMissing(): void + { + $response = self::createClient()->request('GET', '/chickens?nameStartNoProperty=Gert'); + $this->assertResponseStatusCodeSame(400); + + $responseData = $response->toArray(false); + + $this->assertStringContainsString( + 'The filter parameter with key "nameStartNoProperty" must specify a property', + $responseData['detail'] + ); + } + + #[DataProvider('startSearchFilterCaseSensitiveProvider')] + public function testStartSearchCaseSensitiveFilter(string $url, int $expectedCount, array $expectedNames): void + { + if ($this->isMysql() || $this->isSqlite()) { + $this->markTestSkipped('Mysql and sqlite use case insensitive LIKE.'); + } + + $this->testStartSearchFilter($url, $expectedCount, $expectedNames); + } + + public static function startSearchFilterCaseSensitiveProvider(): \Generator + { + yield 'filter by prefix "gert"' => [ + '/chickens?nameStart=gert', + 1, + ['Gertrude'], + ]; + + yield 'filter by case sensitive prefix "Gert"' => [ + '/chickens?nameStartSensitive=Gert', + 1, + ['Gertrude'], + ]; + + yield 'filter by case sensitive prefix "gert"' => [ + '/chickens?nameStartSensitive=gert', + 0, + [], + ]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(): void + { + $manager = $this->getManager(); + + $chickenClass = $this->isMongoDB() ? DocumentChicken::class : Chicken::class; + $coopClass = $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class; + $ownerClass = $this->isMongoDB() ? DocumentOwner::class : Owner::class; + + $owner1 = new $ownerClass(); + $owner1->setName('Alice'); + + $manager->persist($owner1); + $manager->flush(); + + $chickenCoop1 = new $coopClass(); + + $chicken1 = new $chickenClass(); + $chicken1->setName('Gertrude'); + $chicken1->setChickenCoop($chickenCoop1); + $chicken1->setOwner($owner1); + + $chicken2 = new $chickenClass(); + $chicken2->setName('Henriette'); + $chicken2->setChickenCoop($chickenCoop1); + $chicken2->setOwner($owner1); + + $chicken3 = new $chickenClass(); + $chicken3->setName('xx_%_\\_%_xx'); + $chicken3->setChickenCoop($chickenCoop1); + $chicken3->setOwner($owner1); + + $chickenCoop1->addChicken($chicken1); + $chickenCoop1->addChicken($chicken2); + $chickenCoop1->addChicken($chicken3); + + $manager->persist($chickenCoop1); + $manager->persist($chicken1); + $manager->persist($chicken2); + $manager->persist($chicken3); + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/WordStartSearchFilterTest.php b/tests/Functional/Parameters/WordStartSearchFilterTest.php new file mode 100644 index 00000000000..00edaf0ecad --- /dev/null +++ b/tests/Functional/Parameters/WordStartSearchFilterTest.php @@ -0,0 +1,185 @@ + + * + * 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\Parameters; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Chicken as DocumentChicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\ChickenCoop as DocumentChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Owner as DocumentOwner; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Chicken; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ChickenCoop; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Owner; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; + +final class WordStartSearchFilterTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [Chicken::class, ChickenCoop::class, Owner::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entities = $this->isMongoDB() + ? [DocumentChicken::class, DocumentChickenCoop::class, DocumentOwner::class] + : [Chicken::class, ChickenCoop::class, Owner::class]; + + $this->recreateSchema($entities); + $this->loadFixtures(); + } + + #[DataProvider('wordStartSearchFilterProvider')] + public function testWordStartSearchFilter(string $url, int $expectedCount, array $expectedNames): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + + $names = array_map(static fn ($chicken) => $chicken['name'], $filteredItems); + sort($names); + sort($expectedNames); + + $this->assertSame($expectedNames, $names, 'The returned names do not match the expected values.'); + } + + public static function wordStartSearchFilterProvider(): \Generator + { + // Fixtures: "Gertrude the Hen", "Henriette", "Red Rooster" + yield 'matches word at the very start' => [ + '/chickens?nameWordStart=Gert', + 1, + ['Gertrude the Hen'], + ]; + + yield 'matches a word starting in the middle of the string' => [ + '/chickens?nameWordStart=Hen', + 2, + ['Gertrude the Hen', 'Henriette'], + ]; + + yield 'does not match a substring inside a word' => [ + '/chickens?nameWordStart=ette', + 0, + [], + ]; + + yield 'does not match a substring inside a non-leading word' => [ + '/chickens?nameWordStart=ooster', + 0, + [], + ]; + + yield 'matches the leading word of a multi-word value' => [ + '/chickens?nameWordStart=Red', + 1, + ['Red Rooster'], + ]; + + yield 'matches a trailing word' => [ + '/chickens?nameWordStart=Roo', + 1, + ['Red Rooster'], + ]; + + yield 'no match' => [ + '/chickens?nameWordStart=Zebra', + 0, + [], + ]; + + yield 'multiple values "Gert" OR "Red"' => [ + '/chickens?nameWordStart[]=Gert&nameWordStart[]=Red', + 2, + ['Gertrude the Hen', 'Red Rooster'], + ]; + } + + public function testWordStartSearchFilterThrowsExceptionWhenPropertyIsMissing(): void + { + $response = self::createClient()->request('GET', '/chickens?nameWordStartNoProperty=Gert'); + $this->assertResponseStatusCodeSame(400); + + $responseData = $response->toArray(false); + + $this->assertStringContainsString( + 'The filter parameter with key "nameWordStartNoProperty" must specify a property', + $responseData['detail'] + ); + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(): void + { + $manager = $this->getManager(); + + $chickenClass = $this->isMongoDB() ? DocumentChicken::class : Chicken::class; + $coopClass = $this->isMongoDB() ? DocumentChickenCoop::class : ChickenCoop::class; + $ownerClass = $this->isMongoDB() ? DocumentOwner::class : Owner::class; + + $owner1 = new $ownerClass(); + $owner1->setName('Alice'); + + $manager->persist($owner1); + $manager->flush(); + + $chickenCoop1 = new $coopClass(); + + $chicken1 = new $chickenClass(); + $chicken1->setName('Gertrude the Hen'); + $chicken1->setChickenCoop($chickenCoop1); + $chicken1->setOwner($owner1); + + $chicken2 = new $chickenClass(); + $chicken2->setName('Henriette'); + $chicken2->setChickenCoop($chickenCoop1); + $chicken2->setOwner($owner1); + + $chicken3 = new $chickenClass(); + $chicken3->setName('Red Rooster'); + $chicken3->setChickenCoop($chickenCoop1); + $chicken3->setOwner($owner1); + + $chickenCoop1->addChicken($chicken1); + $chickenCoop1->addChicken($chicken2); + $chickenCoop1->addChicken($chicken3); + + $manager->persist($chickenCoop1); + $manager->persist($chicken1); + $manager->persist($chicken2); + $manager->persist($chicken3); + + $manager->flush(); + } +} From c3fd6dd6b5dedbc96851ccc2fcc6d01ac2fc4e46 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 17 Jun 2026 20:29:35 +0200 Subject: [PATCH 31/84] feat(doctrine): deprecate AbstractFilter base class (#8330) --- src/Doctrine/Odm/Filter/AbstractFilter.php | 4 ++++ src/Doctrine/Orm/Filter/AbstractFilter.php | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/src/Doctrine/Odm/Filter/AbstractFilter.php b/src/Doctrine/Odm/Filter/AbstractFilter.php index 1f897d223c3..42170f36495 100644 --- a/src/Doctrine/Odm/Filter/AbstractFilter.php +++ b/src/Doctrine/Odm/Filter/AbstractFilter.php @@ -18,7 +18,9 @@ use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; use ApiPlatform\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\FilterInterface as MetadataFilterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\Persistence\ManagerRegistry; @@ -32,6 +34,8 @@ * Abstract class for easing the implementation of a filter. * * @author Alan Poulain + * + * @deprecated since API Platform 4.4, implement {@see MetadataFilterInterface} directly together with {@see BackwardCompatibleFilterDescriptionTrait} and the canonical QueryParameter-based filters (ExactFilter, PartialSearchFilter, EndSearchFilter, ComparisonFilter, OrFilter, …) instead; this class is removed in 6.0 */ abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface { diff --git a/src/Doctrine/Orm/Filter/AbstractFilter.php b/src/Doctrine/Orm/Filter/AbstractFilter.php index e07597bea1e..55a31104c21 100644 --- a/src/Doctrine/Orm/Filter/AbstractFilter.php +++ b/src/Doctrine/Orm/Filter/AbstractFilter.php @@ -19,7 +19,9 @@ use ApiPlatform\Doctrine\Common\PropertyHelperTrait; use ApiPlatform\Doctrine\Orm\PropertyHelperTrait as OrmPropertyHelperTrait; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\BackwardCompatibleFilterDescriptionTrait; use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\FilterInterface as MetadataFilterInterface; use ApiPlatform\Metadata\Operation; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; @@ -27,6 +29,9 @@ use Psr\Log\NullLogger; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +/** + * @deprecated since API Platform 4.4, implement {@see MetadataFilterInterface} directly together with {@see BackwardCompatibleFilterDescriptionTrait} and the canonical QueryParameter-based filters (ExactFilter, PartialSearchFilter, EndSearchFilter, ComparisonFilter, OrFilter, …) instead; this class is removed in 6.0 + */ abstract class AbstractFilter implements FilterInterface, PropertyAwareFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface { use OrmPropertyHelperTrait; From c9e5071d973caedeb9b554f885dedbc89743b93d Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 17 Jun 2026 20:30:46 +0200 Subject: [PATCH 32/84] feat(symfony): deprecate Symfony Security AccessDeniedException (#8318) --- .../Exception/AccessDeniedException.php | 12 +++++- .../Security/State/AccessCheckerProvider.php | 2 +- .../Exception/AccessDeniedExceptionTest.php | 41 +++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 src/Symfony/Tests/Security/Exception/AccessDeniedExceptionTest.php diff --git a/src/Symfony/Security/Exception/AccessDeniedException.php b/src/Symfony/Security/Exception/AccessDeniedException.php index e5c594a428b..88349e501f2 100644 --- a/src/Symfony/Security/Exception/AccessDeniedException.php +++ b/src/Symfony/Security/Exception/AccessDeniedException.php @@ -13,14 +13,24 @@ namespace ApiPlatform\Symfony\Security\Exception; +use ApiPlatform\Metadata\Exception\AccessDeniedException as MetadataAccessDeniedException; use ApiPlatform\Metadata\Exception\HttpExceptionInterface; use Symfony\Component\Security\Core\Exception\AccessDeniedException as ExceptionAccessDeniedException; /** - * TODO: deprecate in favor of Metadata. + * @deprecated since API Platform 4.4, use {@see MetadataAccessDeniedException} instead */ final class AccessDeniedException extends ExceptionAccessDeniedException implements HttpExceptionInterface { + public function __construct(string $message = 'Access Denied.', ?\Throwable $previous = null, int $code = 403, bool $triggerDeprecation = true) + { + if ($triggerDeprecation) { + trigger_deprecation('api-platform/core', '4.4', 'The "%s" class is deprecated, use "%s" instead.', self::class, MetadataAccessDeniedException::class); + } + + parent::__construct($message, $previous, $code); + } + public function getStatusCode(): int { return 403; diff --git a/src/Symfony/Security/State/AccessCheckerProvider.php b/src/Symfony/Security/State/AccessCheckerProvider.php index ec14aceff1c..fa3509767b7 100644 --- a/src/Symfony/Security/State/AccessCheckerProvider.php +++ b/src/Symfony/Security/State/AccessCheckerProvider.php @@ -98,7 +98,7 @@ public function provide(Operation $operation, array $uriVariables = [], array $c } if (!$this->resourceAccessChecker->isGranted($operation->getClass(), $isGranted, $resourceAccessCheckerContext)) { - $operation instanceof GraphQlOperation ? throw new AccessDeniedHttpException($message ?? 'Access Denied.') : throw new AccessDeniedException($message ?? 'Access Denied.'); + $operation instanceof GraphQlOperation ? throw new AccessDeniedHttpException($message ?? 'Access Denied.') : throw new AccessDeniedException($message ?? 'Access Denied.', null, 403, false); } return 'pre_read' === $this->event ? $this->decorated->provide($operation, $uriVariables, $context) : $body; diff --git a/src/Symfony/Tests/Security/Exception/AccessDeniedExceptionTest.php b/src/Symfony/Tests/Security/Exception/AccessDeniedExceptionTest.php new file mode 100644 index 00000000000..f10d0fa3c85 --- /dev/null +++ b/src/Symfony/Tests/Security/Exception/AccessDeniedExceptionTest.php @@ -0,0 +1,41 @@ + + * + * 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\Security\Exception; + +use ApiPlatform\Symfony\Security\Exception\AccessDeniedException; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use PHPUnit\Framework\TestCase; + +class AccessDeniedExceptionTest extends TestCase +{ + #[IgnoreDeprecations] + public function testInstantiationTriggersDeprecation(): void + { + $this->expectUserDeprecationMessage('Since api-platform/core 4.4: The "ApiPlatform\Symfony\Security\Exception\AccessDeniedException" class is deprecated, use "ApiPlatform\Metadata\Exception\AccessDeniedException" instead.'); + + new AccessDeniedException(); + } + + #[IgnoreDeprecations] + public function testKeepsBaseExceptionBehavior(): void + { + $previous = new \RuntimeException('previous'); + $exception = new AccessDeniedException('Custom message', $previous, 403); + + $this->assertSame('Custom message', $exception->getMessage()); + $this->assertSame($previous, $exception->getPrevious()); + $this->assertSame(403, $exception->getStatusCode()); + $this->assertSame([], $exception->getHeaders()); + } +} From 8f48b9dbc02e1dd35e02151edcab1bb0138d1ed9 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 22 Jun 2026 11:08:41 +0200 Subject: [PATCH 33/84] feat(symfony): deprecate jsonapi.use_iri_as_id defaulting to true (#8327) --- .../ApiPlatformExtension.php | 10 +- .../DependencyInjection/Configuration.php | 5 +- .../JsonApiUseIriAsIdDeprecationTest.php | 138 ++++++++++++++++++ src/Symfony/composer.json | 1 + tests/Fixtures/app/config/config_common.yml | 2 + .../DependencyInjection/ConfigurationTest.php | 2 +- 6 files changed, 152 insertions(+), 6 deletions(-) create mode 100644 src/Symfony/Tests/Bundle/DependencyInjection/JsonApiUseIriAsIdDeprecationTest.php diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index f7feddc373c..e1ca3a70eed 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -708,13 +708,19 @@ private function registerJsonApiConfiguration(ContainerBuilder $container, array $loader->load('jsonapi.php'); $loader->load('state/jsonapi.php'); + $useIriAsId = $config['jsonapi']['use_iri_as_id']; + if (null === $useIriAsId) { + trigger_deprecation('api-platform/core', '4.4', 'Not setting "api_platform.jsonapi.use_iri_as_id" explicitly is deprecated. Its default value will change from "true" to "false" in API Platform 5.0. Set it to "true" to keep the current behavior or to "false" to use entity identifiers as the "id" field, and silence this deprecation.'); + $useIriAsId = true; + } + $itemNormalizer = $container->getDefinition('api_platform.jsonapi.normalizer.item'); $itemNormalizer->replaceArgument(7, [JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID => $config['jsonapi']['allow_client_generated_id'] ?? false]); - $itemNormalizer->addArgument($config['jsonapi']['use_iri_as_id']); + $itemNormalizer->addArgument($useIriAsId); $itemDenormalizer = $container->getDefinition('api_platform.jsonapi.denormalizer.item'); $itemDenormalizer->replaceArgument(7, [JsonApiItemNormalizer::ALLOW_CLIENT_GENERATED_ID => $config['jsonapi']['allow_client_generated_id'] ?? false]); - $itemDenormalizer->addArgument($config['jsonapi']['use_iri_as_id']); + $itemDenormalizer->addArgument($useIriAsId); } private function registerJsonLdHydraConfiguration(ContainerBuilder $container, array $formats, PhpFileLoader $loader, array $config): void diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 0c9e3c07dec..27f5cfbd2be 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -100,13 +100,12 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() - // TODO 4.4: deprecate use_iri_as_id defaulting to true ->arrayNode('jsonapi') ->addDefaultsIfNotSet() ->children() ->booleanNode('use_iri_as_id') - ->defaultTrue() - ->info('Set to false to use entity identifiers instead of IRIs as the "id" field in JSON:API responses.') + ->defaultNull() + ->info('Set to false to use entity identifiers instead of IRIs as the "id" field in JSON:API responses. Defaults to true; this default will change to false in API Platform 5.0.') ->end() ->booleanNode('allow_client_generated_id') ->defaultFalse() diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/JsonApiUseIriAsIdDeprecationTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/JsonApiUseIriAsIdDeprecationTest.php new file mode 100644 index 00000000000..83f34ce3c49 --- /dev/null +++ b/src/Symfony/Tests/Bundle/DependencyInjection/JsonApiUseIriAsIdDeprecationTest.php @@ -0,0 +1,138 @@ + + * + * 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\Tests\Bundle\DependencyInjection; + +use ApiPlatform\Metadata\Exception\ExceptionInterface; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; +use ApiPlatform\Metadata\UrlGeneratorInterface; +use ApiPlatform\Symfony\Bundle\DependencyInjection\ApiPlatformExtension; +use ApiPlatform\Tests\Fixtures\TestBundle\TestBundle; +use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; +use Doctrine\ORM\OptimisticLockException; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\SecurityBundle\SecurityBundle; +use Symfony\Bundle\TwigBundle\TwigBundle; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; +use Symfony\Component\HttpFoundation\Response; + +final class JsonApiUseIriAsIdDeprecationTest extends TestCase +{ + private ContainerBuilder $container; + + protected function setUp(): void + { + $containerParameterBag = new ParameterBag([ + 'kernel.bundles' => [ + 'DoctrineBundle' => DoctrineBundle::class, + 'SecurityBundle' => SecurityBundle::class, + 'TwigBundle' => TwigBundle::class, + ], + 'kernel.bundles_metadata' => [ + 'TestBundle' => [ + 'parent' => null, + 'path' => realpath(__DIR__.'/../../../Fixtures/TestBundle'), + 'namespace' => TestBundle::class, + ], + ], + 'kernel.project_dir' => __DIR__.'/../../../Fixtures/app', + 'kernel.debug' => false, + 'kernel.environment' => 'test', + ]); + + $this->container = new ContainerBuilder($containerParameterBag); + } + + #[Group('legacy')] + #[IgnoreDeprecations] + public function testNotSettingUseIriAsIdIsDeprecatedAndResolvesToTrue(): void + { + $this->expectUserDeprecationMessage('Since api-platform/core 4.4: Not setting "api_platform.jsonapi.use_iri_as_id" explicitly is deprecated. Its default value will change from "true" to "false" in API Platform 5.0. Set it to "true" to keep the current behavior or to "false" to use entity identifiers as the "id" field, and silence this deprecation.'); + + (new ApiPlatformExtension())->load($this->buildConfig(), $this->container); + + $this->assertTrue($this->container->getDefinition('api_platform.jsonapi.normalizer.item')->getArgument(13)); + $this->assertTrue($this->container->getDefinition('api_platform.jsonapi.denormalizer.item')->getArgument(12)); + } + + public function testSettingUseIriAsIdToFalseDoesNotDeprecateAndResolvesToFalse(): void + { + (new ApiPlatformExtension())->load($this->buildConfig(['use_iri_as_id' => false]), $this->container); + + $this->assertFalse($this->container->getDefinition('api_platform.jsonapi.normalizer.item')->getArgument(13)); + $this->assertFalse($this->container->getDefinition('api_platform.jsonapi.denormalizer.item')->getArgument(12)); + } + + public function testSettingUseIriAsIdToTrueDoesNotDeprecateAndResolvesToTrue(): void + { + (new ApiPlatformExtension())->load($this->buildConfig(['use_iri_as_id' => true]), $this->container); + + $this->assertTrue($this->container->getDefinition('api_platform.jsonapi.normalizer.item')->getArgument(13)); + $this->assertTrue($this->container->getDefinition('api_platform.jsonapi.denormalizer.item')->getArgument(12)); + } + + private function buildConfig(?array $jsonapi = null): array + { + $config = ['api_platform' => [ + 'title' => 'title', + 'description' => 'description', + 'version' => 'version', + 'enable_json_streamer' => false, + 'serializer' => ['hydra_prefix' => true], + 'formats' => [ + 'json' => ['mime_types' => ['json']], + 'jsonld' => ['mime_types' => ['application/ld+json']], + 'jsonapi' => ['mime_types' => ['application/vnd.api+json']], + ], + 'doctrine_mongodb_odm' => [ + 'enabled' => true, + ], + 'defaults' => [ + 'extra_properties' => [], + 'url_generation_strategy' => UrlGeneratorInterface::ABS_URL, + ], + 'error_formats' => [ + 'jsonproblem' => ['application/problem+json'], + 'jsonld' => ['application/ld+json'], + ], + 'patch_formats' => [], + 'exception_to_status' => [ + ExceptionInterface::class => Response::HTTP_BAD_REQUEST, + InvalidArgumentException::class => Response::HTTP_BAD_REQUEST, + OptimisticLockException::class => Response::HTTP_CONFLICT, + ], + 'show_webby' => true, + 'eager_loading' => [ + 'enabled' => true, + 'max_joins' => 30, + 'force_eager' => true, + 'fetch_partial' => false, + ], + 'asset_package' => null, + 'enable_entrypoint' => true, + 'enable_docs' => true, + 'enable_swagger' => true, + 'enable_swagger_ui' => true, + 'use_symfony_listeners' => false, + ]]; + + if (null !== $jsonapi) { + $config['api_platform']['jsonapi'] = $jsonapi; + } + + return $config; + } +} diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index 914d4bc77e7..c139336e1b9 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -55,6 +55,7 @@ "api-platform/elasticsearch": "^4.3", "api-platform/graphql": "^4.3", "api-platform/hal": "^4.3", + "api-platform/json-api": "^4.3", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index d03cbd48b35..b046bc20e2f 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -61,6 +61,8 @@ api_platform: jsonapi: ['application/vnd.api+json'] html: ['text/html'] xml: ['application/xml', 'text/xml'] + jsonapi: + use_iri_as_id: true graphql: enabled: true nesting_separator: __ diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index ed8e05043a1..a31121d8a19 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -251,7 +251,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'format' => 'jsonld', ], 'jsonapi' => [ - 'use_iri_as_id' => true, + 'use_iri_as_id' => null, 'allow_client_generated_id' => false, ], 'enable_scalar' => true, From 373b56b98c01ee09583714b79ab0aa0bf3232508 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 22 Jun 2026 12:03:29 +0200 Subject: [PATCH 34/84] feat(doctrine): deprecate the extends-AbstractFilter form of Date/Range/Exists filters (#8340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DateFilter, RangeFilter and ExistsFilter (ORM + ODM) still extend the AbstractFilter base deprecated in 4.4. Unlike the doomed legacy filters, these three survive: in 5.0 they are rewritten standalone (Date/Range as overlays over ComparisonFilter, Exists reading its value from the QueryParameter) — same class name, same URL syntax, drop-in. Add an @deprecated note on the legacy extends-AbstractFilter form so users migrate their declarations to QueryParameter ahead of the 5.0 rewrite. The classes themselves are not removed. PHPDoc-only, no runtime trigger. --- src/Doctrine/Odm/Filter/DateFilter.php | 2 ++ src/Doctrine/Odm/Filter/ExistsFilter.php | 2 ++ src/Doctrine/Odm/Filter/RangeFilter.php | 2 ++ src/Doctrine/Orm/Filter/DateFilter.php | 2 ++ src/Doctrine/Orm/Filter/ExistsFilter.php | 2 ++ src/Doctrine/Orm/Filter/RangeFilter.php | 2 ++ 6 files changed, 12 insertions(+) diff --git a/src/Doctrine/Odm/Filter/DateFilter.php b/src/Doctrine/Odm/Filter/DateFilter.php index 7bc5451d172..e65b38a3cff 100644 --- a/src/Doctrine/Odm/Filter/DateFilter.php +++ b/src/Doctrine/Odm/Filter/DateFilter.php @@ -120,6 +120,8 @@ * @author Kévin Dunglas * @author Théo FIDRY * @author Alan Poulain + * + * @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone overlay over {@see ComparisonFilter} (translating the `[before]`/`[strictly_before]`/`[after]`/`[strictly_after]` syntax) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate. */ final class DateFilter extends AbstractFilter implements DateFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { diff --git a/src/Doctrine/Odm/Filter/ExistsFilter.php b/src/Doctrine/Odm/Filter/ExistsFilter.php index 452df1e9d85..bc59a3e99e6 100644 --- a/src/Doctrine/Odm/Filter/ExistsFilter.php +++ b/src/Doctrine/Odm/Filter/ExistsFilter.php @@ -110,6 +110,8 @@ * * @author Teoh Han Hui * @author Alan Poulain + * + * @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone filter (reading its value from the QueryParameter instead of the legacy `context['filters']` lookup) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate. */ final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { diff --git a/src/Doctrine/Odm/Filter/RangeFilter.php b/src/Doctrine/Odm/Filter/RangeFilter.php index 0356b29b6fa..f72c148536e 100644 --- a/src/Doctrine/Odm/Filter/RangeFilter.php +++ b/src/Doctrine/Odm/Filter/RangeFilter.php @@ -107,6 +107,8 @@ * * @author Lee Siong Chan * @author Alan Poulain + * + * @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone overlay over {@see ComparisonFilter} (translating `[between]=X..Y` to `[gte]=X` + `[lte]=Y`, passing through `[gt]`/`[gte]`/`[lt]`/`[lte]`) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate. */ final class RangeFilter extends AbstractFilter implements RangeFilterInterface, OpenApiParameterFilterInterface { diff --git a/src/Doctrine/Orm/Filter/DateFilter.php b/src/Doctrine/Orm/Filter/DateFilter.php index b7f7af569b8..33301e41e7b 100644 --- a/src/Doctrine/Orm/Filter/DateFilter.php +++ b/src/Doctrine/Orm/Filter/DateFilter.php @@ -124,6 +124,8 @@ * * @author Kévin Dunglas * @author Théo FIDRY + * + * @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone overlay over {@see ComparisonFilter} (translating the `[before]`/`[strictly_before]`/`[after]`/`[strictly_after]` syntax) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate. */ final class DateFilter extends AbstractFilter implements DateFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { diff --git a/src/Doctrine/Orm/Filter/ExistsFilter.php b/src/Doctrine/Orm/Filter/ExistsFilter.php index b9f23857fb3..057e731e17f 100644 --- a/src/Doctrine/Orm/Filter/ExistsFilter.php +++ b/src/Doctrine/Orm/Filter/ExistsFilter.php @@ -116,6 +116,8 @@ * Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?exists[comment]=true`. * * @author Teoh Han Hui + * + * @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone filter (reading its value from the QueryParameter instead of the legacy `context['filters']` lookup) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate. */ final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { diff --git a/src/Doctrine/Orm/Filter/RangeFilter.php b/src/Doctrine/Orm/Filter/RangeFilter.php index 240010077e3..fbfa3ad3bf5 100644 --- a/src/Doctrine/Orm/Filter/RangeFilter.php +++ b/src/Doctrine/Orm/Filter/RangeFilter.php @@ -108,6 +108,8 @@ * Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?price[between]=12.99..15.99`. * * @author Lee Siong Chan + * + * @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone overlay over {@see ComparisonFilter} (translating `[between]=X..Y` to `[gte]=X` + `[lte]=Y`, passing through `[gt]`/`[gte]`/`[lt]`/`[lte]`) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate. */ final class RangeFilter extends AbstractFilter implements RangeFilterInterface, OpenApiParameterFilterInterface { From 9b1a58fd533839a94f7ead264645410745f104fa Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 22 Jun 2026 12:04:09 +0200 Subject: [PATCH 35/84] feat(doctrine): deprecate the legacy SearchFilter/Boolean/Numeric/BackedEnum/OrderFilter (#8341) --- src/Doctrine/Odm/Filter/BooleanFilter.php | 2 ++ src/Doctrine/Odm/Filter/NumericFilter.php | 2 ++ src/Doctrine/Odm/Filter/OrderFilter.php | 2 ++ src/Doctrine/Odm/Filter/SearchFilter.php | 2 ++ src/Doctrine/Orm/Filter/BackedEnumFilter.php | 2 ++ src/Doctrine/Orm/Filter/BooleanFilter.php | 2 ++ src/Doctrine/Orm/Filter/NumericFilter.php | 2 ++ src/Doctrine/Orm/Filter/OrderFilter.php | 2 ++ src/Doctrine/Orm/Filter/SearchFilter.php | 2 ++ 9 files changed, 18 insertions(+) diff --git a/src/Doctrine/Odm/Filter/BooleanFilter.php b/src/Doctrine/Odm/Filter/BooleanFilter.php index 855b593e0ea..0b10583de01 100644 --- a/src/Doctrine/Odm/Filter/BooleanFilter.php +++ b/src/Doctrine/Odm/Filter/BooleanFilter.php @@ -105,6 +105,8 @@ * @author Amrouche Hamza * @author Teoh Han Hui * @author Alan Poulain + * + * @deprecated since API Platform 4.4: use {@see ExactFilter} declared with a boolean `nativeType` instead. Removed in 6.0. */ final class BooleanFilter extends AbstractFilter implements JsonSchemaFilterInterface { diff --git a/src/Doctrine/Odm/Filter/NumericFilter.php b/src/Doctrine/Odm/Filter/NumericFilter.php index c6122e4705e..f5411a0bc4d 100644 --- a/src/Doctrine/Odm/Filter/NumericFilter.php +++ b/src/Doctrine/Odm/Filter/NumericFilter.php @@ -105,6 +105,8 @@ * @author Amrouche Hamza * @author Teoh Han Hui * @author Alan Poulain + * + * @deprecated since API Platform 4.4: use {@see ExactFilter} declared with a numeric `nativeType` (int/float) instead. Removed in 6.0. */ final class NumericFilter extends AbstractFilter implements JsonSchemaFilterInterface { diff --git a/src/Doctrine/Odm/Filter/OrderFilter.php b/src/Doctrine/Odm/Filter/OrderFilter.php index c518cf6ca4d..0ba287de25f 100644 --- a/src/Doctrine/Odm/Filter/OrderFilter.php +++ b/src/Doctrine/Odm/Filter/OrderFilter.php @@ -199,6 +199,8 @@ * @author Kévin Dunglas * @author Théo FIDRY * @author Alan Poulain + * + * @deprecated since API Platform 4.4: use {@see SortFilter} instead. Removed in 6.0. */ final class OrderFilter extends AbstractFilter implements OrderFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { diff --git a/src/Doctrine/Odm/Filter/SearchFilter.php b/src/Doctrine/Odm/Filter/SearchFilter.php index 8d597836fa3..ec070e33fd5 100644 --- a/src/Doctrine/Odm/Filter/SearchFilter.php +++ b/src/Doctrine/Odm/Filter/SearchFilter.php @@ -133,6 +133,8 @@ * * @author Kévin Dunglas * @author Alan Poulain + * + * @deprecated since API Platform 4.4: use the per-strategy QueryParameter-based filters instead — {@see ExactFilter} (`exact`), {@see PartialSearchFilter} (`partial`), {@see StartSearchFilter} (`start`), {@see EndSearchFilter} (`end`); for relation properties matched by IRI use {@see IriFilter}. Removed in 6.0. */ final class SearchFilter extends AbstractFilter implements SearchFilterInterface { diff --git a/src/Doctrine/Orm/Filter/BackedEnumFilter.php b/src/Doctrine/Orm/Filter/BackedEnumFilter.php index ab39bd0d405..29e30c60580 100644 --- a/src/Doctrine/Orm/Filter/BackedEnumFilter.php +++ b/src/Doctrine/Orm/Filter/BackedEnumFilter.php @@ -107,6 +107,8 @@ * Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?status=published`. * * @author Rémi Marseille + * + * @deprecated since API Platform 4.4: use {@see ExactFilter} declared with a backed-enum `nativeType` instead. Removed in 6.0. */ final class BackedEnumFilter extends AbstractFilter { diff --git a/src/Doctrine/Orm/Filter/BooleanFilter.php b/src/Doctrine/Orm/Filter/BooleanFilter.php index 9fda1f507d8..785bb4e76b1 100644 --- a/src/Doctrine/Orm/Filter/BooleanFilter.php +++ b/src/Doctrine/Orm/Filter/BooleanFilter.php @@ -107,6 +107,8 @@ * * @author Amrouche Hamza * @author Teoh Han Hui + * + * @deprecated since API Platform 4.4: use {@see ExactFilter} declared with a boolean `nativeType` instead. Removed in 6.0. */ final class BooleanFilter extends AbstractFilter implements JsonSchemaFilterInterface { diff --git a/src/Doctrine/Orm/Filter/NumericFilter.php b/src/Doctrine/Orm/Filter/NumericFilter.php index 661e96a5a9d..a02b6c64ebf 100644 --- a/src/Doctrine/Orm/Filter/NumericFilter.php +++ b/src/Doctrine/Orm/Filter/NumericFilter.php @@ -107,6 +107,8 @@ * * @author Amrouche Hamza * @author Teoh Han Hui + * + * @deprecated since API Platform 4.4: use {@see ExactFilter} declared with a numeric `nativeType` (int/float) instead. Removed in 6.0. */ final class NumericFilter extends AbstractFilter implements JsonSchemaFilterInterface { diff --git a/src/Doctrine/Orm/Filter/OrderFilter.php b/src/Doctrine/Orm/Filter/OrderFilter.php index 54de60267cd..bdf59d6b283 100644 --- a/src/Doctrine/Orm/Filter/OrderFilter.php +++ b/src/Doctrine/Orm/Filter/OrderFilter.php @@ -198,6 +198,8 @@ * * @author Kévin Dunglas * @author Théo FIDRY + * + * @deprecated since API Platform 4.4: use {@see SortFilter} instead. Removed in 6.0. */ final class OrderFilter extends AbstractFilter implements OrderFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface { diff --git a/src/Doctrine/Orm/Filter/SearchFilter.php b/src/Doctrine/Orm/Filter/SearchFilter.php index a93a8c197c9..398d76c82db 100644 --- a/src/Doctrine/Orm/Filter/SearchFilter.php +++ b/src/Doctrine/Orm/Filter/SearchFilter.php @@ -132,6 +132,8 @@ * * * @author Kévin Dunglas + * + * @deprecated since API Platform 4.4: use the per-strategy QueryParameter-based filters instead — {@see ExactFilter} (`exact`), {@see PartialSearchFilter} (`partial`), {@see StartSearchFilter} (`start`), {@see EndSearchFilter} (`end`); for relation properties matched by IRI use {@see IriFilter}. Removed in 6.0. */ final class SearchFilter extends AbstractFilter implements SearchFilterInterface { From 9b7ace54fdef376d249243f1220d7df638f19b34 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 23 Jun 2026 16:10:25 +0200 Subject: [PATCH 36/84] fix(graphql): build filter args from parameters (#8347) --- src/Doctrine/Odm/Filter/SortFilter.php | 3 +- src/Doctrine/Orm/Filter/SortFilter.php | 3 +- src/GraphQl/Type/FieldsBuilder.php | 502 +++++++++++------- src/Laravel/Eloquent/Filter/OrderFilter.php | 3 +- src/Metadata/SortFilterInterface.php | 26 + .../Document/GraphQlFilteredResource.php | 106 ++++ .../Document/GraphQlFilteredResourceColor.php | 95 ++++ .../Entity/GraphQlFilteredResource.php | 110 ++++ .../Entity/GraphQlFilteredResourceColor.php | 100 ++++ .../GraphQl/ParameterFilterParityTest.php | 178 +++++++ 10 files changed, 932 insertions(+), 194 deletions(-) create mode 100644 src/Metadata/SortFilterInterface.php create mode 100644 tests/Fixtures/TestBundle/Document/GraphQlFilteredResource.php create mode 100644 tests/Fixtures/TestBundle/Document/GraphQlFilteredResourceColor.php create mode 100644 tests/Fixtures/TestBundle/Entity/GraphQlFilteredResource.php create mode 100644 tests/Fixtures/TestBundle/Entity/GraphQlFilteredResourceColor.php create mode 100644 tests/Functional/GraphQl/ParameterFilterParityTest.php diff --git a/src/Doctrine/Odm/Filter/SortFilter.php b/src/Doctrine/Odm/Filter/SortFilter.php index abadd4926cc..4acad603409 100644 --- a/src/Doctrine/Odm/Filter/SortFilter.php +++ b/src/Doctrine/Odm/Filter/SortFilter.php @@ -21,6 +21,7 @@ use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\SortFilterInterface; use Doctrine\ODM\MongoDB\Aggregation\Builder; /** @@ -33,7 +34,7 @@ * * @author Antoine Bluchet */ -final class SortFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface +final class SortFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface, SortFilterInterface { use BackwardCompatibleFilterDescriptionTrait; use NestedPropertyHelperTrait; diff --git a/src/Doctrine/Orm/Filter/SortFilter.php b/src/Doctrine/Orm/Filter/SortFilter.php index c1bf315bdfe..d77da9131e2 100644 --- a/src/Doctrine/Orm/Filter/SortFilter.php +++ b/src/Doctrine/Orm/Filter/SortFilter.php @@ -22,6 +22,7 @@ use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\SortFilterInterface; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; @@ -35,7 +36,7 @@ * * @author Antoine Bluchet */ -final class SortFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface +final class SortFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface, SortFilterInterface { use BackwardCompatibleFilterDescriptionTrait; use NestedPropertyHelperTrait; diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 7f63a2d1ba5..93e86b95ad6 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -22,10 +22,13 @@ use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\GraphQl\Subscription; use ApiPlatform\Metadata\InflectorInterface; +use ApiPlatform\Metadata\JsonSchemaFilterInterface; +use ApiPlatform\Metadata\Parameter; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\SortFilterInterface; use ApiPlatform\Metadata\Util\Inflector; use ApiPlatform\Metadata\Util\PropertyInfoToTypeInfoHelper; use ApiPlatform\Metadata\Util\TypeHelper; @@ -310,90 +313,6 @@ public function resolveResourceArgs(array $args, Operation $operation): array return $args; } - /** - * Transform the result of a parse_str to a GraphQL object type. - * We should consider merging getFilterArgs and this, `getFilterArgs` uses `convertType` whereas we assume that parameters have only scalar types. - * Note that this method has a lower complexity then the `getFilterArgs` one. - * TODO: Is there a use case with an argument being a complex type (eg: a Resource, Enum etc.)? - * - * @param array $flattenFields - */ - private function parameterToObjectType(array $flattenFields, string $name): InputObjectType - { - $fields = []; - foreach ($flattenFields as $field) { - $key = $field['name']; - $type = \in_array($field['type'], TypeIdentifier::values(), true) ? Type::builtin($field['type']) : Type::object($field['type']); - if (!$field['required']) { - $type = Type::nullable($type); - } - - $type = $this->getParameterType($type); - if (\is_array($l = $field['leafs'])) { - if (0 === key($l)) { - $key = $key; - $type = GraphQLType::listOf($type); - } else { - $n = []; - foreach ($field['leafs'] as $l => $value) { - $n[] = ['required' => null, 'name' => $l, 'leafs' => $value, 'type' => 'string', 'description' => null]; - } - - $type = $this->parameterToObjectType($n, $key); - if (isset($fields[$key]) && ($t = $fields[$key]['type']) instanceof InputObjectType) { - $t = $fields[$key]['type']; - $t->config['fields'] = array_merge($t->config['fields'], $type->config['fields']); - $type = $t; - } - } - } - - if ($field['required']) { - $type = GraphQLType::nonNull($type); - } - - if (isset($fields[$key])) { - if ($type instanceof ListOfType) { - $key .= '_list'; - } elseif ($fields[$key]['type'] instanceof InputObjectType && !$type instanceof InputObjectType) { - continue; - } - } - - $fields[$key] = ['type' => $type, 'name' => $key]; - } - - return new InputObjectType(['name' => $name, 'fields' => $fields]); - } - - /** - * A simplified version of convert type that does not support resources. - */ - private function getParameterType(Type $type): GraphQLType - { - if ($type->isIdentifiedBy(TypeIdentifier::BOOL)) { - return GraphQLType::boolean(); - } - - if ($type->isIdentifiedBy(TypeIdentifier::INT)) { - return GraphQLType::int(); - } - - if ($type->isIdentifiedBy(TypeIdentifier::FLOAT)) { - return GraphQLType::float(); - } - - if ($type->isIdentifiedBy(TypeIdentifier::STRING, TypeIdentifier::OBJECT)) { - return GraphQLType::string(); - } - - if ($type instanceof CollectionType) { - return GraphQLType::listOf($this->getParameterType($type->getCollectionValueType())); - } - - return GraphQLType::string(); - } - /** * Get the field configuration of a resource. * @@ -455,16 +374,7 @@ private function getResourceFieldConfiguration(?string $property, ?string $field $args = $this->getGraphQlPaginationArgs($resourceOperation); } - $args = $this->getFilterArgs($args, $resourceClass, $rootResource, $resourceOperation, $rootOperation, $property, $depth); - - // Also register parameter args in the types container - // Note: This is a workaround, for more information read the comment on the parameterToObjectType function. - foreach ($this->getParameterArgs($rootOperation) as $key => $arg) { - if ($arg instanceof InputObjectType || (\is_array($arg) && isset($arg['name']))) { - $this->typesContainer->set(\is_array($arg) ? $arg['name'] : $arg->name(), $arg); - } - $args[$key] = $arg; - } + $args = $this->getCollectionFilterArgs($args, $resourceClass, $rootResource, $resourceOperation, $rootOperation, $property, $depth); } } @@ -488,71 +398,6 @@ private function getResourceFieldConfiguration(?string $property, ?string $field return null; } - /* - * This function is @experimental, read the comment on the parameterToObjectType function for additional information. - * @experimental - */ - private function getParameterArgs(Operation $operation, array $args = []): array - { - $groups = []; - - foreach ($operation->getParameters() ?? [] as $parameter) { - $key = $parameter->getKey(); - - if (str_contains($key, '[')) { - $key = str_replace('.', $this->nestingSeparator, $key); - parse_str($key, $values); - $rootKey = key($values); - - $leafs = $values[$rootKey]; - $name = key($leafs); - - $filterLeafs = []; - if ($filter = $this->resolveFilter($parameter->getFilter())) { - $property = $parameter->getProperty() ?? $name; - $property = str_replace('.', $this->nestingSeparator, $property); - $description = $filter->getDescription($operation->getClass()); - - foreach ($description as $descKey => $descValue) { - $descKey = str_replace('.', $this->nestingSeparator, $descKey); - parse_str($descKey, $descValues); - if (isset($descValues[$property]) && \is_array($descValues[$property])) { - $filterLeafs = array_merge($filterLeafs, $descValues[$property]); - } - } - } - - if ($filterLeafs) { - $leafs[$name] = $filterLeafs; - } - - $groups[$rootKey][] = [ - 'name' => $name, - 'leafs' => $leafs[$name], - 'required' => $parameter->getRequired(), - 'description' => $parameter->getDescription(), - 'type' => 'string', - ]; - continue; - } - - $args[$key] = ['type' => GraphQLType::string()]; - - if ($parameter->getRequired()) { - $args[$key]['type'] = GraphQLType::nonNull($args[$key]['type']); - } - } - - foreach ($groups as $key => $flattenFields) { - $name = $key.$operation->getShortName().$operation->getName(); - $inputObject = $this->parameterToObjectType($flattenFields, $name); - $this->typesContainer->set($name, $inputObject); - $args[$key] = $inputObject; - } - - return $args; - } - private function getGraphQlPaginationArgs(Operation $queryOperation): array { $paginationType = $this->pagination->getGraphQlPaginationType($queryOperation); @@ -597,12 +442,41 @@ private function getGraphQlPaginationArgs(Operation $queryOperation): array return $args; } - private function getFilterArgs(array $args, ?string $resourceClass, string $rootResource, Operation $resourceOperation, Operation $rootOperation, ?string $property, int $depth): array + /** + * Single entry point for GraphQL collection-field arguments. + * + * Builds one intermediate "arg tree" from BOTH the legacy `Operation::getFilters()` + * descriptions and the canonical `Operation::getParameters()` (#[QueryParameter]), + * then materializes it into GraphQL types once via {@see argTreeToGraphQLType()}. + * + * It is called with the *resource* operation (not the root one): for a nested + * relation field, $resourceOperation is the related resource's collection_query, + * so its own parameters/filters surface as nested arguments on that sub-field. + */ + private function getCollectionFilterArgs(array $args, ?string $resourceClass, string $rootResource, Operation $resourceOperation, Operation $rootOperation, ?string $property, int $depth): array { if (null === $resourceClass) { return $args; } + $tree = []; + $this->buildFilterArgTree($tree, $resourceClass, $rootResource, $resourceOperation, $rootOperation, $property, $depth); + $this->buildParameterArgTree($tree, $resourceOperation); + + return $args + $this->argTreeToGraphQLType($tree); + } + + /** + * Feeds the arg tree from the legacy `Operation::getFilters()` descriptions. + * + * A leaf is a GraphQLType; a nested node is an array carrying a reserved `#name` + * (the generated InputObjectType name). Nested filter nodes are list-wrapped to + * preserve the historical GraphQL filter shape (e.g. `order: [..]`, `availableAt: [..]`). + * + * @param array $tree + */ + private function buildFilterArgTree(array &$tree, string $resourceClass, string $rootResource, Operation $resourceOperation, Operation $rootOperation, ?string $property, int $depth): void + { foreach ($resourceOperation->getFilters() ?? [] as $filterId) { if (!($filter = $this->resolveFilter($filterId))) { continue; @@ -631,70 +505,316 @@ private function getFilterArgs(array $args, ?string $resourceClass, string $root array_walk_recursive($parsed, static function (&$v) use ($graphqlFilterType): void { $v = $graphqlFilterType; }); - $args = $this->mergeFilterArgs($args, $parsed, $resourceOperation, $key); + $this->mergeArgTree($tree, $parsed, $resourceOperation->getShortName(), $key); + } + } + } + + /** + * Feeds the arg tree from the canonical `Operation::getParameters()`. + * + * Each parameter's shape is derived from its JSON Schema (via + * {@see JsonSchemaFilterInterface::getSchema()}, e.g. ComparisonFilter exposing + * gt/gte/lt/lte/ne) and falls back to its `getNativeType()` for plain scalars. + * Bracketed keys (`order[:property]` → `order[name]`) collapse into a single + * list-wrapped input object; dotted keys (`colors.price`) flatten to a nested + * key (`colors__price`) so the runtime `__`→`.` contract is preserved. + * + * @param array $tree + */ + private function buildParameterArgTree(array &$tree, Operation $operation): void + { + foreach ($operation->getParameters() ?? [] as $parameter) { + $key = $parameter->getKey(); + if (null === $key) { + continue; + } + + $filter = $this->resolveFilter($parameter->getFilter()); + $schema = ($filter instanceof JsonSchemaFilterInterface ? $filter->getSchema($parameter) : null) ?? $parameter->getSchema(); + $leafType = $this->parameterLeafType($parameter, $schema); + + if (str_contains($key, '[')) { + // Bracketed key (order[name], order[:property] expanded). The portion + // before the first bracket becomes one input object whose fields are + // the bracketed accessors, list-shaped for :property-template filters. + $rootKey = substr($key, 0, (int) strpos($key, '[')); + preg_match_all('/\[([^\[\]]+)\]/', $key, $matches); + $accessors = $matches[1]; + + $name = $rootKey.$operation->getShortName().$operation->getName(); + $node = $tree[$rootKey] ?? ['#name' => $name, '#list' => $this->isListParameter($filter)]; + if (!\is_array($node)) { + // A scalar leaf (written by a filter) already holds this key; a + // bracketed parameter cannot merge into a non-object argument. + continue; + } + $entityClass = $this->getStateOptionsClass($operation, $operation->getClass() ?? ''); + $cursor = &$node; + foreach ($accessors as $i => $accessor) { + if ($i === \count($accessors) - 1) { + if (!isset($cursor[$accessor])) { + $cursor[$accessor] = $this->bracketLeaf($parameter, $filter, $accessor, $leafType, $name, $entityClass); + } + break; + } + $cursor[$accessor] ??= ['#name' => $name.'_'.$accessor, '#list' => false]; + $cursor = &$cursor[$accessor]; + } + unset($cursor); + $tree[$rootKey] = $node; + continue; + } + + // Dotted key: flatten to the nesting-separator form (colors.price -> colors__price) + // so it matches the legacy runtime contract (ReadProvider converts __ back to .). + $argKey = str_replace('.', $this->nestingSeparator, $key); + + if (\is_array($schema) && 'object' === ($schema['type'] ?? null) && \is_array($schema['properties'] ?? null)) { + // Operator form (e.g. ComparisonFilter gt/gte/lt/lte/ne): a non-list input object. + $name = $operation->getShortName().$operation->getName().'_'.strtr($argKey, ['.' => '__']); + $node = ['#name' => $name, '#list' => false, '#nonNull' => (bool) $parameter->getRequired()]; + foreach ($schema['properties'] as $prop => $propSchema) { + $propSchema = \is_array($propSchema) ? $propSchema : []; + // The operator's inner schema is often a bare {type:string} placeholder + // (ComparisonFilter wraps an untyped equality filter); prefer the + // parameter's native type so an int property yields GraphQL Int. + $node[$prop] = 'string' === ($propSchema['type'] ?? 'string') ? $leafType : $this->jsonSchemaToGraphQLType($propSchema); + } + $tree[$argKey] = $node; + continue; + } + + $type = $leafType; + if ($parameter->getRequired()) { + $type = GraphQLType::nonNull($type); + } + $tree[$argKey] = $type; + } + } + + /** + * Whether a bracketed parameter exposes a list-shaped GraphQL argument + * (e.g. `order: [{name: "DESC"}, {description: "ASC"}]`) instead of a single + * input object. + * + * Only sort filters are sequence-sensitive: GraphQL input-object fields are + * unordered, so multi-key ordering cannot be expressed as one object and must + * be a list. Every other bracketed filter (search, comparison, date, exists) + * is a single input object. Recognized through the backend-agnostic + * {@see SortFilterInterface}, keeping this component free of any persistence + * dependency. + */ + private function isListParameter(?FilterInterface $filter): bool + { + return $filter instanceof SortFilterInterface; + } + + /** + * Computes the leaf for a bracketed-parameter accessor. + * + * A scalar by default, but enriched from the filter's `getDescription()`: when the + * description for the accessor's property exposes sub-keys it becomes either a + * `listOf` (sequential `foo[]` form) or a nested non-list input object (e.g. a date + * filter's `createdAt[before]`/`[after]`), preserving the historical shape. + * + * @return GraphQLType|array + */ + private function bracketLeaf(Parameter $parameter, ?FilterInterface $filter, string $accessor, GraphQLType $leafType, string $parentName, string $entityClass): GraphQLType|array + { + if (!$filter instanceof FilterInterface) { + return $leafType; + } + + $property = $parameter->getProperty() ?? $accessor; + $property = str_replace('.', $this->nestingSeparator, $property); + + $descriptionLeafs = []; + foreach ($filter->getDescription($entityClass) as $descKey => $descValue) { + $descKey = str_replace('.', $this->nestingSeparator, $descKey); + parse_str($descKey, $descValues); + if (isset($descValues[$property]) && \is_array($descValues[$property])) { + $descriptionLeafs = array_merge($descriptionLeafs, $descValues[$property]); } } - return $this->convertFilterArgsToTypes($args); + if (!$descriptionLeafs) { + return $leafType; + } + + // Sequential array (e.g. foo[]) => list of the scalar leaf. + if (0 === key($descriptionLeafs)) { + return GraphQLType::listOf($leafType); + } + + // Associative sub-keys (e.g. before/after) => nested non-list input object. + $node = ['#name' => $parentName.'_'.$accessor, '#list' => false]; + foreach (array_keys($descriptionLeafs) as $subKey) { + $node[$subKey] = GraphQLType::string(); + } + + return $node; } - private function mergeFilterArgs(array $args, array $parsed, ?Operation $operation = null, string $original = ''): array + /** + * Merges a parsed legacy-filter subtree into the shared arg tree, tagging nested + * nodes with the generated `#name` used for InputObjectType dedup. + * + * @param array $tree + * @param array $parsed + */ + private function mergeArgTree(array &$tree, array $parsed, string $shortName, string $original): void { foreach ($parsed as $key => $value) { - // Never override keys that cannot be merged - if (isset($args[$key]) && !\is_array($args[$key])) { + // Never override keys that cannot be merged. + if (isset($tree[$key]) && !\is_array($tree[$key])) { continue; } if (\is_array($value)) { - $value = $this->mergeFilterArgs($args[$key] ?? [], $value); - if (!isset($value['#name'])) { + $sub = $tree[$key] ?? []; + $this->mergeArgTree($sub, $value, $shortName, $original); + if (!isset($sub['#name'])) { $name = (false === $pos = strrpos($original, '[')) ? $original : substr($original, 0, (int) $pos); - $value['#name'] = ($operation ? $operation->getShortName() : '').'Filter_'.strtr($name, ['[' => '_', ']' => '', '.' => '__']); + $sub['#name'] = $shortName.'Filter_'.strtr($name, ['[' => '_', ']' => '', '.' => '__']); + $sub['#list'] = true; } + $tree[$key] = $sub; + continue; } - $args[$key] = $value; + $tree[$key] = $value; + } + } + + /** + * Materializes an arg tree into GraphQL argument definitions. + * + * Leaves are GraphQLType instances. A nested node (array) is converted to an + * `InputObjectType` named by its `#name` marker, list-wrapped when `#list` is + * true. Generated input objects are registered in the TypesContainer and reused + * on name collision (dedup). + * + * @param array $tree + * + * @return array + */ + private function argTreeToGraphQLType(array $tree): array + { + $args = []; + foreach ($tree as $key => $value) { + if ($value instanceof GraphQLType) { + $args[$key] = $value; + continue; + } + + if (\is_array($value) && isset($value['#name'])) { + $args[$key] = $this->buildInputObjectType($value); + } } return $args; } - private function convertFilterArgsToTypes(array $args): array + /** + * @param array $node + */ + private function buildInputObjectType(array $node): GraphQLType { - foreach ($args as $key => $value) { - if (strpos($key, '.')) { - // Declare relations/nested fields in a GraphQL compatible syntax. - $args[str_replace('.', $this->nestingSeparator, $key)] = $value; - unset($args[$key]); - } + $name = $node['#name']; + $list = $node['#list'] ?? true; + $nonNull = $node['#nonNull'] ?? false; + + if ($this->typesContainer->has($name)) { + return $this->typesContainer->get($name); } - foreach ($args as $key => $value) { - if (!\is_array($value) || !isset($value['#name'])) { + unset($node['#name'], $node['#list'], $node['#nonNull']); + + $fields = []; + foreach ($node as $fieldKey => $fieldValue) { + if ($fieldValue instanceof GraphQLType) { + $fields[$fieldKey] = $fieldValue; continue; } - $name = $value['#name']; - - if ($this->typesContainer->has($name)) { - $args[$key] = $this->typesContainer->get($name); - continue; + if (\is_array($fieldValue) && isset($fieldValue['#name'])) { + $fields[$fieldKey] = $this->buildInputObjectType($fieldValue); } + } - unset($value['#name']); + $inputObject = new InputObjectType(['name' => $name, 'fields' => $fields]); + $type = $list ? GraphQLType::listOf($inputObject) : $inputObject; + if ($nonNull) { + $type = GraphQLType::nonNull($type); + } - $filterArgType = GraphQLType::listOf(new InputObjectType([ - 'name' => $name, - 'fields' => $this->convertFilterArgsToTypes($value), - ])); + $this->typesContainer->set($name, $type); - $this->typesContainer->set($name, $filterArgType); + return $type; + } - $args[$key] = $filterArgType; + /** + * Resolves the scalar GraphQL leaf type for a parameter, from its JSON Schema + * scalar type when available, otherwise from its native (PHP) type. + * + * @param array|null $schema + */ + private function parameterLeafType(Parameter $parameter, ?array $schema): GraphQLType + { + if (\is_array($schema) && isset($schema['type']) && \is_string($schema['type']) && 'object' !== $schema['type'] && 'array' !== $schema['type']) { + return $this->jsonSchemaToGraphQLType($schema); } - return $args; + if ($nativeType = $parameter->getNativeType()) { + return $this->nativeTypeToGraphQLType($nativeType); + } + + return GraphQLType::string(); + } + + /** + * @param array $schema + */ + private function jsonSchemaToGraphQLType(array $schema): GraphQLType + { + if ('array' === ($schema['type'] ?? null)) { + $items = \is_array($schema['items'] ?? null) ? $schema['items'] : ['type' => 'string']; + + return GraphQLType::listOf($this->jsonSchemaToGraphQLType($items)); + } + + return match ($schema['type'] ?? 'string') { + 'integer' => GraphQLType::int(), + 'number' => GraphQLType::float(), + 'boolean' => GraphQLType::boolean(), + default => GraphQLType::string(), + }; + } + + private function nativeTypeToGraphQLType(Type $type): GraphQLType + { + if ($type->isIdentifiedBy(TypeIdentifier::BOOL)) { + return GraphQLType::boolean(); + } + + if ($type->isIdentifiedBy(TypeIdentifier::INT)) { + return GraphQLType::int(); + } + + if ($type->isIdentifiedBy(TypeIdentifier::FLOAT)) { + return GraphQLType::float(); + } + + if ($type->isIdentifiedBy(TypeIdentifier::STRING, TypeIdentifier::OBJECT)) { + return GraphQLType::string(); + } + + if ($type instanceof CollectionType) { + return GraphQLType::listOf($this->nativeTypeToGraphQLType($type->getCollectionValueType())); + } + + return GraphQLType::string(); } /** diff --git a/src/Laravel/Eloquent/Filter/OrderFilter.php b/src/Laravel/Eloquent/Filter/OrderFilter.php index 2987fe67837..7e5ade9b529 100644 --- a/src/Laravel/Eloquent/Filter/OrderFilter.php +++ b/src/Laravel/Eloquent/Filter/OrderFilter.php @@ -16,13 +16,14 @@ use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Parameter; +use ApiPlatform\Metadata\SortFilterInterface; use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasOneOrMany; -final class OrderFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface +final class OrderFilter implements FilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface, SortFilterInterface { use QueryPropertyTrait; diff --git a/src/Metadata/SortFilterInterface.php b/src/Metadata/SortFilterInterface.php new file mode 100644 index 00000000000..dd1fee51fab --- /dev/null +++ b/src/Metadata/SortFilterInterface.php @@ -0,0 +1,26 @@ + + * + * 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; + +/** + * Marks a filter that sorts a collection by one or more properties. + * + * Backend-agnostic so consumers can recognize a sort filter without depending + * on a persistence layer: GraphQL, for instance, exposes such a parameter as an + * ordered list of single-property inputs to preserve multi-key ordering, which + * an (unordered) input object cannot express. + */ +interface SortFilterInterface +{ +} diff --git a/tests/Fixtures/TestBundle/Document/GraphQlFilteredResource.php b/tests/Fixtures/TestBundle/Document/GraphQlFilteredResource.php new file mode 100644 index 00000000000..d334256c12b --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/GraphQlFilteredResource.php @@ -0,0 +1,106 @@ + + * + * 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\Document; + +use ApiPlatform\Doctrine\Odm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; +use ApiPlatform\Doctrine\Odm\Filter\SortFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\Serializer\Attribute as Serializer; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * ODM mirror of the QueryParameter-based GraphQL parity fixture. + */ +#[ApiResource( + normalizationContext: ['groups' => ['graphql_filtered']], + graphQlOperations: [ + new Query(), + new QueryCollection( + parameters: [ + 'name' => new QueryParameter(filter: new ExactFilter()), + 'colors.prop' => new QueryParameter(filter: new PartialSearchFilter(), property: 'colors.prop'), + 'colors.price' => new QueryParameter(filter: new ComparisonFilter(new ExactFilter()), property: 'colors.price', nativeType: new BuiltinType(TypeIdentifier::INT)), + 'order[:property]' => new QueryParameter(filter: new SortFilter()), + ], + ), + ], +)] +#[ODM\Document] +class GraphQlFilteredResource +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + #[Serializer\Groups(['graphql_filtered'])] + private ?int $id = null; + + #[ODM\Field(type: 'string')] + #[Serializer\Groups(['graphql_filtered'])] + private string $name = ''; + + /** + * @var Collection + */ + #[ODM\ReferenceMany(targetDocument: GraphQlFilteredResourceColor::class, mappedBy: 'resource')] + #[Serializer\Groups(['graphql_filtered'])] + private Collection $colors; + + public function __construct() + { + $this->colors = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + /** + * @return Collection + */ + public function getColors(): Collection + { + return $this->colors; + } + + public function setColors(Collection $colors): void + { + $this->colors = $colors; + } + + public function addColor(GraphQlFilteredResourceColor $color): void + { + if (!$this->colors->contains($color)) { + $this->colors->add($color); + $color->setResource($this); + } + } +} diff --git a/tests/Fixtures/TestBundle/Document/GraphQlFilteredResourceColor.php b/tests/Fixtures/TestBundle/Document/GraphQlFilteredResourceColor.php new file mode 100644 index 00000000000..f9926d2b788 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/GraphQlFilteredResourceColor.php @@ -0,0 +1,95 @@ + + * + * 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\Document; + +use ApiPlatform\Doctrine\Odm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\Serializer\Attribute as Serializer; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * ODM mirror of the QueryParameter-based "color" collection resource. + */ +#[ApiResource( + normalizationContext: ['groups' => ['graphql_filtered']], + graphQlOperations: [ + new Query(), + new QueryCollection( + parameters: [ + 'prop' => new QueryParameter(filter: new PartialSearchFilter(), property: 'prop'), + 'price' => new QueryParameter(filter: new ComparisonFilter(new ExactFilter()), property: 'price', nativeType: new BuiltinType(TypeIdentifier::INT)), + ], + ), + ], +)] +#[ODM\Document] +class GraphQlFilteredResourceColor +{ + #[ODM\Id(strategy: 'INCREMENT', type: 'int')] + #[Serializer\Groups(['graphql_filtered'])] + private ?int $id = null; + + #[ODM\ReferenceOne(targetDocument: GraphQlFilteredResource::class, inversedBy: 'colors', storeAs: 'id')] + private ?GraphQlFilteredResource $resource = null; + + #[ODM\Field(type: 'string')] + #[Serializer\Groups(['graphql_filtered'])] + private string $prop = ''; + + #[ODM\Field(type: 'int')] + #[Serializer\Groups(['graphql_filtered'])] + private int $price = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getResource(): ?GraphQlFilteredResource + { + return $this->resource; + } + + public function setResource(?GraphQlFilteredResource $resource): void + { + $this->resource = $resource; + } + + public function getProp(): string + { + return $this->prop; + } + + public function setProp(string $prop): void + { + $this->prop = $prop; + } + + public function getPrice(): int + { + return $this->price; + } + + public function setPrice(int $price): void + { + $this->price = $price; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/GraphQlFilteredResource.php b/tests/Fixtures/TestBundle/Entity/GraphQlFilteredResource.php new file mode 100644 index 00000000000..1a68984b8dd --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/GraphQlFilteredResource.php @@ -0,0 +1,110 @@ + + * + * 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\Entity; + +use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; +use ApiPlatform\Doctrine\Orm\Filter\SortFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Attribute as Serializer; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * Parameter-based (QueryParameter) mirror of the DummyCar <-> DummyCarColor relationship, + * used to prove GraphQL filter-argument parity between the canonical + * Operation::getParameters() path and the legacy Operation::getFilters() path. + */ +#[ApiResource( + normalizationContext: ['groups' => ['graphql_filtered']], + graphQlOperations: [ + new Query(), + new QueryCollection( + parameters: [ + 'name' => new QueryParameter(filter: new ExactFilter()), + 'colors.prop' => new QueryParameter(filter: new PartialSearchFilter(), property: 'colors.prop'), + 'colors.price' => new QueryParameter(filter: new ComparisonFilter(new ExactFilter()), property: 'colors.price', nativeType: new BuiltinType(TypeIdentifier::INT)), + 'order[:property]' => new QueryParameter(filter: new SortFilter()), + ], + ), + ], +)] +#[ORM\Entity] +class GraphQlFilteredResource +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + #[Serializer\Groups(['graphql_filtered'])] + private ?int $id = null; + + #[ORM\Column(type: 'string')] + #[Serializer\Groups(['graphql_filtered'])] + private string $name = ''; + + /** + * @var Collection + */ + #[ORM\OneToMany(targetEntity: GraphQlFilteredResourceColor::class, mappedBy: 'resource')] + #[Serializer\Groups(['graphql_filtered'])] + private Collection $colors; + + public function __construct() + { + $this->colors = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): void + { + $this->name = $name; + } + + /** + * @return Collection + */ + public function getColors(): Collection + { + return $this->colors; + } + + public function setColors(Collection $colors): void + { + $this->colors = $colors; + } + + public function addColor(GraphQlFilteredResourceColor $color): void + { + if (!$this->colors->contains($color)) { + $this->colors->add($color); + $color->setResource($this); + } + } +} diff --git a/tests/Fixtures/TestBundle/Entity/GraphQlFilteredResourceColor.php b/tests/Fixtures/TestBundle/Entity/GraphQlFilteredResourceColor.php new file mode 100644 index 00000000000..2ebc2c4eb28 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/GraphQlFilteredResourceColor.php @@ -0,0 +1,100 @@ + + * + * 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\Entity; + +use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GraphQl\Query; +use ApiPlatform\Metadata\GraphQl\QueryCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Attribute as Serializer; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * QueryParameter-based "color" collection resource mirroring DummyCarColor. + * Declares its filters via QueryParameter so the nested `colors(prop: ...)` / + * `colors(price: {gt: ...})` arguments must be derived from getParameters(). + */ +#[ApiResource( + normalizationContext: ['groups' => ['graphql_filtered']], + graphQlOperations: [ + new Query(), + new QueryCollection( + parameters: [ + 'prop' => new QueryParameter(filter: new PartialSearchFilter(), property: 'prop'), + 'price' => new QueryParameter(filter: new ComparisonFilter(new ExactFilter()), property: 'price', nativeType: new BuiltinType(TypeIdentifier::INT)), + ], + ), + ], +)] +#[ORM\Entity] +class GraphQlFilteredResourceColor +{ + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column(type: 'integer')] + #[Serializer\Groups(['graphql_filtered'])] + private ?int $id = null; + + #[ORM\ManyToOne(targetEntity: GraphQlFilteredResource::class, inversedBy: 'colors')] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + private ?GraphQlFilteredResource $resource = null; + + #[ORM\Column(type: 'string')] + #[Serializer\Groups(['graphql_filtered'])] + private string $prop = ''; + + #[ORM\Column(type: 'integer')] + #[Serializer\Groups(['graphql_filtered'])] + private int $price = 0; + + public function getId(): ?int + { + return $this->id; + } + + public function getResource(): ?GraphQlFilteredResource + { + return $this->resource; + } + + public function setResource(?GraphQlFilteredResource $resource): void + { + $this->resource = $resource; + } + + public function getProp(): string + { + return $this->prop; + } + + public function setProp(string $prop): void + { + $this->prop = $prop; + } + + public function getPrice(): int + { + return $this->price; + } + + public function setPrice(int $price): void + { + $this->price = $price; + } +} diff --git a/tests/Functional/GraphQl/ParameterFilterParityTest.php b/tests/Functional/GraphQl/ParameterFilterParityTest.php new file mode 100644 index 00000000000..292f9ccfec4 --- /dev/null +++ b/tests/Functional/GraphQl/ParameterFilterParityTest.php @@ -0,0 +1,178 @@ + + * + * 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\GraphQl; + +use ApiPlatform\GraphQl\Test\GraphQlTestTrait; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\GraphQlFilteredResource as GraphQlFilteredResourceDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\GraphQlFilteredResourceColor as GraphQlFilteredResourceColorDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\GraphQlFilteredResource; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\GraphQlFilteredResourceColor; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\Common\Collections\ArrayCollection; + +/** + * Parity safety-net: the GraphQL filter *arguments* generated from the canonical + * Operation::getParameters() (#[QueryParameter]) path must match what the legacy + * Operation::getFilters() (#[ApiFilter]) path produces on DummyCar/DummyCarColor + * (see FilterTest::testNestedCollectionFilter and the ComparisonFilter operator forms). + * + * Covers the three parity gaps the unified FieldsBuilder arg-tree pipeline closes: + * the nested `colors(prop:)` argument from a dotted parameter key, the + * ComparisonFilter gt/gte/lt/lte/ne operator form, and the `order: [..]` list shape. + */ +final class ParameterFilterParityTest extends ApiTestCase +{ + use GraphQlTestTrait; + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [ + GraphQlFilteredResource::class, + GraphQlFilteredResourceColor::class, + ]; + } + + private function recreate(): void + { + $this->recreateSchema([ + $this->isMongoDB() ? GraphQlFilteredResourceDocument::class : GraphQlFilteredResource::class, + $this->isMongoDB() ? GraphQlFilteredResourceColorDocument::class : GraphQlFilteredResourceColor::class, + ]); + } + + public function testNestedCollectionSearchArgumentFromQueryParameter(): void + { + $this->recreate(); + $this->seedResourceWithColors(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + graphQlFilteredResource(id: "/graph_ql_filtered_resources/1") { + id + colors(prop: "blue") { + edges { node { id prop } } + } + } + } + QUERY); + + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json, json_encode($json['errors'] ?? null)); + + $edges = $json['data']['graphQlFilteredResource']['colors']['edges']; + $this->assertCount(1, $edges); + $this->assertSame('blue', $edges[0]['node']['prop']); + } + + public function testComparisonOperatorArgumentFromQueryParameter(): void + { + $this->recreate(); + $this->seedResourceWithColors(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + graphQlFilteredResource(id: "/graph_ql_filtered_resources/1") { + id + colors(price: {gt: 10}) { + edges { node { id prop price } } + } + } + } + QUERY); + + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json, json_encode($json['errors'] ?? null)); + + $edges = $json['data']['graphQlFilteredResource']['colors']['edges']; + $this->assertCount(1, $edges); + $this->assertSame('blue', $edges[0]['node']['prop']); + } + + public function testRootExactSearchArgumentFromQueryParameter(): void + { + $this->recreate(); + $this->seedResourceWithColors(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + graphQlFilteredResources(name: "mustli") { + edges { node { id name } } + } + } + QUERY); + + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json, json_encode($json['errors'] ?? null)); + + $edges = $json['data']['graphQlFilteredResources']['edges']; + $this->assertCount(1, $edges); + $this->assertSame('mustli', $edges[0]['node']['name']); + } + + public function testOrderArgumentFromQueryParameter(): void + { + $this->recreate(); + $this->seedResourceWithColors(); + + $response = $this->executeGraphQl(<<<'QUERY' + { + graphQlFilteredResources(order: [{name: "DESC"}]) { + edges { node { id name } } + } + } + QUERY); + + $json = $response->toArray(false); + $this->assertArrayNotHasKey('errors', $json, json_encode($json['errors'] ?? null)); + $this->assertResponseIsSuccessful(); + } + + private function seedResourceWithColors(): void + { + $manager = $this->getManager(); + $resourceClass = $this->isMongoDB() ? GraphQlFilteredResourceDocument::class : GraphQlFilteredResource::class; + $colorClass = $this->isMongoDB() ? GraphQlFilteredResourceColorDocument::class : GraphQlFilteredResourceColor::class; + + $resource = new $resourceClass(); + $resource->setName('mustli'); + $manager->persist($resource); + $manager->flush(); + + $red = new $colorClass(); + $red->setProp('red'); + $red->setPrice(5); + $red->setResource($resource); + $manager->persist($red); + + $blue = new $colorClass(); + $blue->setProp('blue'); + $blue->setPrice(20); + $blue->setResource($resource); + $manager->persist($blue); + $manager->flush(); + + $resource->setColors(new ArrayCollection([$red, $blue])); + $manager->persist($resource); + $manager->flush(); + } +} From 75f9056d32d696fdfd729ead9d4dd5e443eb6062 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 25 Jun 2026 09:28:04 +0200 Subject: [PATCH 37/84] feat(openapi): support OpenAPI 3.2.0 (#8350) --- src/OpenApi/Model/Components.php | 16 +++++- src/OpenApi/Model/Encoding.php | 50 ++++++++++++++++++- src/OpenApi/Model/Example.php | 28 ++++++++++- src/OpenApi/Model/MediaType.php | 50 ++++++++++++++++++- src/OpenApi/Model/OAuthFlow.php | 15 +++++- src/OpenApi/Model/OAuthFlows.php | 15 +++++- src/OpenApi/Model/PathItem.php | 34 ++++++++++++- src/OpenApi/Model/Response.php | 15 +++++- src/OpenApi/Model/SecurityScheme.php | 28 ++++++++++- src/OpenApi/Model/Server.php | 15 +++++- src/OpenApi/Model/Tag.php | 41 ++++++++++++++- src/OpenApi/OpenApi.php | 19 ++++++- .../Serializer/LegacyOpenApiNormalizer.php | 2 +- tests/Functional/DocumentationActionTest.php | 4 +- tests/Functional/OpenApiTest.php | 4 +- 15 files changed, 318 insertions(+), 18 deletions(-) diff --git a/src/OpenApi/Model/Components.php b/src/OpenApi/Model/Components.php index 04c6ef14023..2ecec747d6e 100644 --- a/src/OpenApi/Model/Components.php +++ b/src/OpenApi/Model/Components.php @@ -30,8 +30,9 @@ final class Components * @param \ArrayObject|\ArrayObject $links * @param \ArrayObject>|\ArrayObject> $callbacks * @param \ArrayObject|\ArrayObject $pathItems + * @param \ArrayObject|\ArrayObject $mediaTypes */ - public function __construct(?\ArrayObject $schemas = null, private ?\ArrayObject $responses = null, private ?\ArrayObject $parameters = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $requestBodies = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $securitySchemes = null, private ?\ArrayObject $links = null, private ?\ArrayObject $callbacks = null, private ?\ArrayObject $pathItems = null) + public function __construct(?\ArrayObject $schemas = null, private ?\ArrayObject $responses = null, private ?\ArrayObject $parameters = null, private ?\ArrayObject $examples = null, private ?\ArrayObject $requestBodies = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $securitySchemes = null, private ?\ArrayObject $links = null, private ?\ArrayObject $callbacks = null, private ?\ArrayObject $pathItems = null, private ?\ArrayObject $mediaTypes = null) { $schemas?->ksort(); @@ -88,6 +89,11 @@ public function getPathItems(): ?\ArrayObject return $this->pathItems; } + public function getMediaTypes(): ?\ArrayObject + { + return $this->mediaTypes; + } + public function withSchemas(\ArrayObject $schemas): self { $clone = clone $this; @@ -167,4 +173,12 @@ public function withPathItems(\ArrayObject $pathItems): self return $clone; } + + public function withMediaTypes(\ArrayObject $mediaTypes): self + { + $clone = clone $this; + $clone->mediaTypes = $mediaTypes; + + return $clone; + } } diff --git a/src/OpenApi/Model/Encoding.php b/src/OpenApi/Model/Encoding.php index d56ee0e436f..4ec8f7d7e7c 100644 --- a/src/OpenApi/Model/Encoding.php +++ b/src/OpenApi/Model/Encoding.php @@ -17,7 +17,10 @@ final class Encoding { use ExtensionTrait; - public function __construct(private string $contentType = '', private ?\ArrayObject $headers = null, private string $style = '', private bool $explode = false, private bool $allowReserved = false) + /** + * @param array|null $prefixEncoding + */ + public function __construct(private string $contentType = '', private ?\ArrayObject $headers = null, private string $style = '', private bool $explode = false, private bool $allowReserved = false, private ?\ArrayObject $encoding = null, private ?array $prefixEncoding = null, private ?self $itemEncoding = null) { } @@ -56,6 +59,24 @@ public function getAllowReserved(): bool return $this->allowReserved; } + public function getEncoding(): ?\ArrayObject + { + return $this->encoding; + } + + /** + * @return array|null + */ + public function getPrefixEncoding(): ?array + { + return $this->prefixEncoding; + } + + public function getItemEncoding(): ?self + { + return $this->itemEncoding; + } + public function withContentType(string $contentType): self { $clone = clone $this; @@ -95,4 +116,31 @@ public function withAllowReserved(bool $allowReserved): self return $clone; } + + public function withEncoding(?\ArrayObject $encoding): self + { + $clone = clone $this; + $clone->encoding = $encoding; + + return $clone; + } + + /** + * @param array|null $prefixEncoding + */ + public function withPrefixEncoding(?array $prefixEncoding): self + { + $clone = clone $this; + $clone->prefixEncoding = $prefixEncoding; + + return $clone; + } + + public function withItemEncoding(self $itemEncoding): self + { + $clone = clone $this; + $clone->itemEncoding = $itemEncoding; + + return $clone; + } } diff --git a/src/OpenApi/Model/Example.php b/src/OpenApi/Model/Example.php index 4b2f1903c78..583820d407b 100644 --- a/src/OpenApi/Model/Example.php +++ b/src/OpenApi/Model/Example.php @@ -17,7 +17,7 @@ final class Example { use ExtensionTrait; - public function __construct(private ?string $summary = null, private ?string $description = null, private mixed $value = null, private ?string $externalValue = null) + public function __construct(private ?string $summary = null, private ?string $description = null, private mixed $value = null, private ?string $externalValue = null, private mixed $dataValue = null, private ?string $serializedValue = null) { } @@ -72,4 +72,30 @@ public function withExternalValue(string $externalValue): self return $clone; } + + public function getDataValue(): mixed + { + return $this->dataValue; + } + + public function withDataValue(mixed $dataValue): self + { + $clone = clone $this; + $clone->dataValue = $dataValue; + + return $clone; + } + + public function getSerializedValue(): ?string + { + return $this->serializedValue; + } + + public function withSerializedValue(string $serializedValue): self + { + $clone = clone $this; + $clone->serializedValue = $serializedValue; + + return $clone; + } } diff --git a/src/OpenApi/Model/MediaType.php b/src/OpenApi/Model/MediaType.php index ea50465398f..10d9c1d7d4c 100644 --- a/src/OpenApi/Model/MediaType.php +++ b/src/OpenApi/Model/MediaType.php @@ -17,7 +17,10 @@ final class MediaType { use ExtensionTrait; - public function __construct(private ?\ArrayObject $schema = null, private mixed $example = null, private ?\ArrayObject $examples = null, private ?Encoding $encoding = null) + /** + * @param array|null $prefixEncoding + */ + public function __construct(private ?\ArrayObject $schema = null, private mixed $example = null, private ?\ArrayObject $examples = null, private ?Encoding $encoding = null, private ?\ArrayObject $itemSchema = null, private ?array $prefixEncoding = null, private ?Encoding $itemEncoding = null) { } @@ -41,6 +44,24 @@ public function getEncoding(): ?Encoding return $this->encoding; } + public function getItemSchema(): ?\ArrayObject + { + return $this->itemSchema; + } + + /** + * @return array|null + */ + public function getPrefixEncoding(): ?array + { + return $this->prefixEncoding; + } + + public function getItemEncoding(): ?Encoding + { + return $this->itemEncoding; + } + public function withSchema(\ArrayObject $schema): self { $clone = clone $this; @@ -72,4 +93,31 @@ public function withEncoding(Encoding $encoding): self return $clone; } + + public function withItemSchema(\ArrayObject $itemSchema): self + { + $clone = clone $this; + $clone->itemSchema = $itemSchema; + + return $clone; + } + + /** + * @param array|null $prefixEncoding + */ + public function withPrefixEncoding(?array $prefixEncoding): self + { + $clone = clone $this; + $clone->prefixEncoding = $prefixEncoding; + + return $clone; + } + + public function withItemEncoding(Encoding $itemEncoding): self + { + $clone = clone $this; + $clone->itemEncoding = $itemEncoding; + + return $clone; + } } diff --git a/src/OpenApi/Model/OAuthFlow.php b/src/OpenApi/Model/OAuthFlow.php index 2c2e356fbe7..479d1853da8 100644 --- a/src/OpenApi/Model/OAuthFlow.php +++ b/src/OpenApi/Model/OAuthFlow.php @@ -17,7 +17,7 @@ final class OAuthFlow { use ExtensionTrait; - public function __construct(private ?string $authorizationUrl = null, private ?string $tokenUrl = null, private ?string $refreshUrl = null, private ?\ArrayObject $scopes = null) + public function __construct(private ?string $authorizationUrl = null, private ?string $tokenUrl = null, private ?string $refreshUrl = null, private ?\ArrayObject $scopes = null, private ?string $deviceAuthorizationUrl = null) { } @@ -41,6 +41,11 @@ public function getScopes(): \ArrayObject return $this->scopes; } + public function getDeviceAuthorizationUrl(): ?string + { + return $this->deviceAuthorizationUrl; + } + public function withAuthorizationUrl(string $authorizationUrl): self { $clone = clone $this; @@ -72,4 +77,12 @@ public function withScopes(\ArrayObject $scopes): self return $clone; } + + public function withDeviceAuthorizationUrl(string $deviceAuthorizationUrl): self + { + $clone = clone $this; + $clone->deviceAuthorizationUrl = $deviceAuthorizationUrl; + + return $clone; + } } diff --git a/src/OpenApi/Model/OAuthFlows.php b/src/OpenApi/Model/OAuthFlows.php index ad0f9fb7049..d677d105cad 100644 --- a/src/OpenApi/Model/OAuthFlows.php +++ b/src/OpenApi/Model/OAuthFlows.php @@ -17,7 +17,7 @@ final class OAuthFlows { use ExtensionTrait; - public function __construct(private ?OAuthFlow $implicit = null, private ?OAuthFlow $password = null, private ?OAuthFlow $clientCredentials = null, private ?OAuthFlow $authorizationCode = null) + public function __construct(private ?OAuthFlow $implicit = null, private ?OAuthFlow $password = null, private ?OAuthFlow $clientCredentials = null, private ?OAuthFlow $authorizationCode = null, private ?OAuthFlow $deviceAuthorization = null) { } @@ -41,6 +41,11 @@ public function getAuthorizationCode(): ?OAuthFlow return $this->authorizationCode; } + public function getDeviceAuthorization(): ?OAuthFlow + { + return $this->deviceAuthorization; + } + public function withImplicit(OAuthFlow $implicit): self { $clone = clone $this; @@ -72,4 +77,12 @@ public function withAuthorizationCode(OAuthFlow $authorizationCode): self return $clone; } + + public function withDeviceAuthorization(OAuthFlow $deviceAuthorization): self + { + $clone = clone $this; + $clone->deviceAuthorization = $deviceAuthorization; + + return $clone; + } } diff --git a/src/OpenApi/Model/PathItem.php b/src/OpenApi/Model/PathItem.php index e481e7536b8..8ff59cb3ffe 100644 --- a/src/OpenApi/Model/PathItem.php +++ b/src/OpenApi/Model/PathItem.php @@ -19,7 +19,7 @@ final class PathItem public static array $methods = ['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH', 'TRACE']; - public function __construct(private ?string $ref = null, private ?string $summary = null, private ?string $description = null, private ?Operation $get = null, private ?Operation $put = null, private ?Operation $post = null, private ?Operation $delete = null, private ?Operation $options = null, private ?Operation $head = null, private ?Operation $patch = null, private ?Operation $trace = null, private ?array $servers = null, private ?array $parameters = null) + public function __construct(private ?string $ref = null, private ?string $summary = null, private ?string $description = null, private ?Operation $get = null, private ?Operation $put = null, private ?Operation $post = null, private ?Operation $delete = null, private ?Operation $options = null, private ?Operation $head = null, private ?Operation $patch = null, private ?Operation $trace = null, private ?array $servers = null, private ?array $parameters = null, private ?Operation $query = null, private ?array $additionalOperations = null) { } @@ -88,6 +88,19 @@ public function getParameters(): ?array return $this->parameters; } + public function getQuery(): ?Operation + { + return $this->query; + } + + /** + * @return array|null + */ + public function getAdditionalOperations(): ?array + { + return $this->additionalOperations; + } + public function withRef(string $ref): self { $clone = clone $this; @@ -191,4 +204,23 @@ public function withParameters(?array $parameters = null): self return $clone; } + + public function withQuery(?Operation $query): self + { + $clone = clone $this; + $clone->query = $query; + + return $clone; + } + + /** + * @param array|null $additionalOperations + */ + public function withAdditionalOperations(?array $additionalOperations = null): self + { + $clone = clone $this; + $clone->additionalOperations = $additionalOperations; + + return $clone; + } } diff --git a/src/OpenApi/Model/Response.php b/src/OpenApi/Model/Response.php index 187e8be10ec..b417b9d4a91 100644 --- a/src/OpenApi/Model/Response.php +++ b/src/OpenApi/Model/Response.php @@ -17,7 +17,7 @@ final class Response { use ExtensionTrait; - public function __construct(private ?string $description = null, private ?\ArrayObject $content = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $links = null) + public function __construct(private ?string $description = null, private ?\ArrayObject $content = null, private ?\ArrayObject $headers = null, private ?\ArrayObject $links = null, private ?string $summary = null) { } @@ -41,6 +41,11 @@ public function getLinks(): ?\ArrayObject return $this->links; } + public function getSummary(): ?string + { + return $this->summary; + } + public function withDescription(string $description): self { $clone = clone $this; @@ -72,4 +77,12 @@ public function withLinks(\ArrayObject $links): self return $clone; } + + public function withSummary(string $summary): self + { + $clone = clone $this; + $clone->summary = $summary; + + return $clone; + } } diff --git a/src/OpenApi/Model/SecurityScheme.php b/src/OpenApi/Model/SecurityScheme.php index 52ed63fc6fc..b2ab6edcf96 100644 --- a/src/OpenApi/Model/SecurityScheme.php +++ b/src/OpenApi/Model/SecurityScheme.php @@ -17,7 +17,7 @@ final class SecurityScheme { use ExtensionTrait; - public function __construct(private ?string $type = null, private string $description = '', private ?string $name = null, private ?string $in = null, private ?string $scheme = null, private ?string $bearerFormat = null, private ?OAuthFlows $flows = null, private ?string $openIdConnectUrl = null) + public function __construct(private ?string $type = null, private string $description = '', private ?string $name = null, private ?string $in = null, private ?string $scheme = null, private ?string $bearerFormat = null, private ?OAuthFlows $flows = null, private ?string $openIdConnectUrl = null, private ?string $oauth2MetadataUrl = null, private ?bool $deprecated = null) { } @@ -61,6 +61,16 @@ public function getOpenIdConnectUrl(): ?string return $this->openIdConnectUrl; } + public function getOauth2MetadataUrl(): ?string + { + return $this->oauth2MetadataUrl; + } + + public function getDeprecated(): ?bool + { + return $this->deprecated; + } + public function withType(string $type): self { $clone = clone $this; @@ -124,4 +134,20 @@ public function withOpenIdConnectUrl(string $openIdConnectUrl): self return $clone; } + + public function withOauth2MetadataUrl(string $oauth2MetadataUrl): self + { + $clone = clone $this; + $clone->oauth2MetadataUrl = $oauth2MetadataUrl; + + return $clone; + } + + public function withDeprecated(bool $deprecated): self + { + $clone = clone $this; + $clone->deprecated = $deprecated; + + return $clone; + } } diff --git a/src/OpenApi/Model/Server.php b/src/OpenApi/Model/Server.php index e5a50a7e6b5..8b5d9ed78ff 100644 --- a/src/OpenApi/Model/Server.php +++ b/src/OpenApi/Model/Server.php @@ -17,7 +17,7 @@ final class Server { use ExtensionTrait; - public function __construct(private string $url, private string $description = '', private ?\ArrayObject $variables = null) + public function __construct(private string $url, private string $description = '', private ?\ArrayObject $variables = null, private ?string $name = null) { } @@ -36,6 +36,11 @@ public function getVariables(): ?\ArrayObject return $this->variables; } + public function getName(): ?string + { + return $this->name; + } + public function withUrl(string $url): self { $clone = clone $this; @@ -59,4 +64,12 @@ public function withVariables(\ArrayObject $variables): self return $clone; } + + public function withName(string $name): self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } } diff --git a/src/OpenApi/Model/Tag.php b/src/OpenApi/Model/Tag.php index c0793522a15..82e8e700bdf 100644 --- a/src/OpenApi/Model/Tag.php +++ b/src/OpenApi/Model/Tag.php @@ -17,7 +17,7 @@ final class Tag { use ExtensionTrait; - public function __construct(private string $name, private ?string $description = null, private ?string $externalDocs = null) + public function __construct(private string $name, private ?string $description = null, private ?string $externalDocs = null, private ?string $summary = null, private ?string $parent = null, private ?string $kind = null) { } @@ -59,4 +59,43 @@ public function withExternalDocs(string $externalDocs): self return $clone; } + + public function getSummary(): ?string + { + return $this->summary; + } + + public function withSummary(string $summary): self + { + $clone = clone $this; + $clone->summary = $summary; + + return $clone; + } + + public function getParent(): ?string + { + return $this->parent; + } + + public function withParent(string $parent): self + { + $clone = clone $this; + $clone->parent = $parent; + + return $clone; + } + + public function getKind(): ?string + { + return $this->kind; + } + + public function withKind(string $kind): self + { + $clone = clone $this; + $clone->kind = $kind; + + return $clone; + } } diff --git a/src/OpenApi/OpenApi.php b/src/OpenApi/OpenApi.php index 61a43d7d490..ac17632ff9d 100644 --- a/src/OpenApi/OpenApi.php +++ b/src/OpenApi/OpenApi.php @@ -17,12 +17,13 @@ use ApiPlatform\OpenApi\Model\ExtensionTrait; use ApiPlatform\OpenApi\Model\Info; use ApiPlatform\OpenApi\Model\Paths; +use Symfony\Component\Serializer\Attribute\SerializedName; final class OpenApi { use ExtensionTrait; - public const VERSION = '3.1.0'; + public const VERSION = '3.2.0'; private string $openapi = self::VERSION; private Components $components; @@ -30,7 +31,7 @@ final class OpenApi /** * @param array|null $externalDocs */ - public function __construct(private Info $info, private array $servers, private Paths $paths, ?Components $components = null, private array $security = [], private array $tags = [], private $externalDocs = null, private ?string $jsonSchemaDialect = null, private readonly ?\ArrayObject $webhooks = null) + public function __construct(private Info $info, private array $servers, private Paths $paths, ?Components $components = null, private array $security = [], private array $tags = [], private $externalDocs = null, private ?string $jsonSchemaDialect = null, private readonly ?\ArrayObject $webhooks = null, private ?string $self = null) { $this->components = $components ?? new Components(); } @@ -85,6 +86,12 @@ public function getWebhooks(): ?\ArrayObject return $this->webhooks; } + #[SerializedName('$self')] + public function getSelf(): ?string + { + return $this->self; + } + public function withOpenapi(string $openapi): self { $clone = clone $this; @@ -156,4 +163,12 @@ public function withJsonSchemaDialect(?string $jsonSchemaDialect): self return $clone; } + + public function withSelf(?string $self): self + { + $clone = clone $this; + $clone->self = $self; + + return $clone; + } } diff --git a/src/OpenApi/Serializer/LegacyOpenApiNormalizer.php b/src/OpenApi/Serializer/LegacyOpenApiNormalizer.php index 747b2d23752..e9787431bd4 100644 --- a/src/OpenApi/Serializer/LegacyOpenApiNormalizer.php +++ b/src/OpenApi/Serializer/LegacyOpenApiNormalizer.php @@ -24,7 +24,7 @@ final class LegacyOpenApiNormalizer implements NormalizerInterface private const SCHEMA_NESTED_KEYS = ['items', 'additionalProperties', 'not', 'contains', 'propertyNames', 'if', 'then', 'else']; private array $defaultContext = [ - self::SPEC_VERSION => '3.1.0', + self::SPEC_VERSION => '3.2.0', ]; public function __construct(private readonly NormalizerInterface $decorated, array $defaultContext = []) diff --git a/tests/Functional/DocumentationActionTest.php b/tests/Functional/DocumentationActionTest.php index 69d8ba90fde..e587e03228c 100644 --- a/tests/Functional/DocumentationActionTest.php +++ b/tests/Functional/DocumentationActionTest.php @@ -98,7 +98,7 @@ public function testJsonDocumentationIsAccessibleWhenSwaggerUiIsDisabled(): void $client->request('GET', '/docs.jsonopenapi', ['headers' => ['Accept' => 'application/vnd.openapi+json']]); $this->assertResponseIsSuccessful(); - $this->assertJsonContains(['openapi' => '3.1.0']); + $this->assertJsonContains(['openapi' => '3.2.0']); $this->assertJsonContains(['info' => ['title' => 'My Dummy API']]); } @@ -163,7 +163,7 @@ public function testJsonDocumentationIsAccessibleWhenSwaggerUiIsEnabled(): void $client->request('GET', '/docs.jsonopenapi', ['headers' => ['Accept' => 'application/vnd.openapi+json']]); $this->assertResponseIsSuccessful(); - $this->assertJsonContains(['openapi' => '3.1.0']); + $this->assertJsonContains(['openapi' => '3.2.0']); $this->assertJsonContains(['info' => ['title' => 'My Dummy API']]); } diff --git a/tests/Functional/OpenApiTest.php b/tests/Functional/OpenApiTest.php index 8cb8120b63d..3db5fdfd439 100644 --- a/tests/Functional/OpenApiTest.php +++ b/tests/Functional/OpenApiTest.php @@ -332,7 +332,7 @@ public function testRetrieveTheOpenApiDocumentation(): void $json = $response->toArray(); // Context - $this->assertSame('3.1.0', $json['openapi']); + $this->assertSame('3.2.0', $json['openapi']); // Root properties $this->assertSame('My Dummy API', $json['info']['title']); $this->assertStringContainsString('This is a test API.', $json['info']['description']); @@ -565,7 +565,7 @@ public function testRetrieveTheJsonOpenApiDocumentation(): void $json = $response->toArray(); // Context - $this->assertSame('3.1.0', $json['openapi']); + $this->assertSame('3.2.0', $json['openapi']); // Root properties $this->assertSame('My Dummy API', $json['info']['title']); $this->assertStringContainsString('This is a test API.', $json['info']['description']); From 0fb1dc8f6ff5457df773299f0c7eb7071494be69 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Thu, 25 Jun 2026 15:58:22 +0200 Subject: [PATCH 38/84] feat(symfony): api:upgrade-filter codemod + filter fixture migration (#8344) * feat(symfony): add api:upgrade-filter codemod Add an `api:upgrade-filter` command that rewrites legacy `#[ApiFilter]` attributes into parameter-based filters via a php-parser visitor. Includes the mapper/resolver/visitor pipeline, collision/skip/name-conversion handling, DI wiring, service config and unit tests. CI runs the codemod with --force before the functional suite. * test: migrate #[ApiFilter] fixtures to parameter filters + legacy suite Run api:upgrade-filter over the test fixtures to move them onto parameter filters, and relocate the un-migrated #[ApiFilter] fixtures (Boolean, Numeric, Enum, Order, Search, Date/Range/Exists, ExceptionToStatus) into a Legacy/ suite that keeps covering the deprecated path. * feat(metadata): deprecate #[ApiFilter] and Operation::$filters Emit a deprecation for the #[ApiFilter] attribute and for Operation::$filters consumption, surfaced through the metadata factory and OpenAPI factory with de-duplication. Baseline the resulting deprecations. * fix(state): cast empty/null scalar params, reject bad scalars with 400 ValueCaster now casts empty/null boolean params to false and empty/null numeric params consistently, and rejects empty values cast to a scalar native type with a 400 via BadRequestException (wired through the Laravel error renderer). * Apply suggestions from code review Co-authored-by: Antoine Bluchet --- .github/workflows/ci.yml | 44 ++ phpunit.baseline.xml | 64 +++ src/Laravel/Exception/ErrorRenderer.php | 3 +- src/Metadata/ApiFilter.php | 2 + .../Exception/BadRequestException.php | 33 ++ ...ltersResourceMetadataCollectionFactory.php | 7 +- src/Serializer/Filter/PropertyFilter.php | 2 +- src/State/Parameter/ValueCaster.php | 17 +- src/State/Tests/Parameter/ValueCasterTest.php | 87 ++++ .../UpgradeApiFilterCollisionException.php | 29 ++ .../Upgrade/UpgradeApiFilterMapper.php | 87 ++++ .../Upgrade/UpgradeApiFilterMapping.php | 30 ++ ...pgradeApiFilterNameConversionException.php | 29 ++ .../Upgrade/UpgradeApiFilterParameter.php | 46 +++ .../Upgrade/UpgradeApiFilterResolver.php | 218 ++++++++++ .../Upgrade/UpgradeApiFilterSkipException.php | 23 ++ .../Upgrade/UpgradeApiFilterVisitor.php | 268 +++++++++++++ .../Command/UpgradeApiFilterCommand.php | 226 +++++++++++ .../ApiPlatformExtension.php | 4 + .../Compiler/AttributeFilterPass.php | 2 + .../Bundle/Resources/config/upgrade.php | 40 ++ .../Command/UpgradeApiFilterMapperTest.php | 143 +++++++ .../Command/UpgradeApiFilterResolverTest.php | 377 ++++++++++++++++++ .../Command/UpgradeApiFilterVisitorTest.php | 369 +++++++++++++++++ .../JsonLd/NonResourceContainer.php | 21 +- .../PropertyFilter/SparseFieldsetParent.php | 19 +- .../Document/FilteredBooleanParameter.php | 8 +- .../Document/FilteredNumericParameter.php | 16 +- .../Document/FilteredOrderParameter.php | 22 +- .../Legacy/FilteredAttributeParameter.php | 72 ++++ .../Legacy/FilteredBooleanParameter.php | 70 ++++ .../Legacy/FilteredNumericParameter.php | 83 ++++ .../Legacy/FilteredOrderParameter.php | 87 ++++ .../{ => Legacy}/SearchFilterParameter.php | 10 +- tests/Fixtures/TestBundle/Entity/DummyCar.php | 22 +- .../TestBundle/Entity/DummyCarColor.php | 7 +- .../Fixtures/TestBundle/Entity/DummyPhp8.php | 7 +- .../Entity/FilteredBooleanParameter.php | 8 +- .../Entity/FilteredNumericParameter.php | 16 +- .../Entity/FilteredOrderParameter.php | 22 +- .../Entity/Issue5735/Issue5735User.php | 16 +- .../Issue7126/DummyForBackedEnumFilter.php | 9 +- .../Entity/Issue8085/DatedCursorDummy.php | 20 +- .../{ => Legacy}/DummyExceptionToStatus.php | 2 +- .../Legacy/DummyForBackedEnumFilter.php | 70 ++++ .../Legacy/FilteredAttributeParameter.php | 74 ++++ .../Legacy/FilteredBooleanParameter.php | 72 ++++ .../Legacy/FilteredNumericParameter.php | 85 ++++ .../Entity/Legacy/FilteredOrderParameter.php | 89 +++++ .../{ => Legacy}/SearchFilterParameter.php | 10 +- .../TestBundle/Entity/RelatedDummy.php | 22 +- tests/Fixtures/TestBundle/Entity/SoMany.php | 8 +- tests/Functional/ExceptionToStatusTest.php | 2 +- tests/Functional/OpenApiTest.php | 6 +- .../Parameters/BooleanFilterTest.php | 21 +- tests/Functional/Parameters/DoctrineTest.php | 154 ------- .../Legacy/AttributeFilterLegacyTest.php | 99 +++++ .../Legacy/BackedEnumFilterLegacyTest.php | 91 +++++ .../Legacy/BooleanFilterLegacyTest.php | 124 ++++++ .../Legacy/NumericFilterLegacyTest.php | 112 ++++++ .../Legacy/OrderFilterLegacyTest.php | 167 ++++++++ .../SearchFilterParameterLegacyTest.php | 196 +++++++++ .../Parameters/NumericFilterTest.php | 27 +- .../Functional/Parameters/OrderFilterTest.php | 8 - .../Compiler/AttributeFilterPassTest.php | 34 ++ .../Resource/LegacyFilteredResource.php | 23 ++ 66 files changed, 3837 insertions(+), 344 deletions(-) create mode 100644 src/Metadata/Exception/BadRequestException.php create mode 100644 src/State/Tests/Parameter/ValueCasterTest.php create mode 100644 src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterCollisionException.php create mode 100644 src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterMapper.php create mode 100644 src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterMapping.php create mode 100644 src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterNameConversionException.php create mode 100644 src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterParameter.php create mode 100644 src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterResolver.php create mode 100644 src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterSkipException.php create mode 100644 src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterVisitor.php create mode 100644 src/Symfony/Bundle/Command/UpgradeApiFilterCommand.php create mode 100644 src/Symfony/Bundle/Resources/config/upgrade.php create mode 100644 src/Symfony/Tests/Bundle/Command/UpgradeApiFilterMapperTest.php create mode 100644 src/Symfony/Tests/Bundle/Command/UpgradeApiFilterResolverTest.php create mode 100644 src/Symfony/Tests/Bundle/Command/UpgradeApiFilterVisitorTest.php create mode 100644 tests/Fixtures/TestBundle/Document/Legacy/FilteredAttributeParameter.php create mode 100644 tests/Fixtures/TestBundle/Document/Legacy/FilteredBooleanParameter.php create mode 100644 tests/Fixtures/TestBundle/Document/Legacy/FilteredNumericParameter.php create mode 100644 tests/Fixtures/TestBundle/Document/Legacy/FilteredOrderParameter.php rename tests/Fixtures/TestBundle/Document/{ => Legacy}/SearchFilterParameter.php (87%) rename tests/Fixtures/TestBundle/Entity/{ => Legacy}/DummyExceptionToStatus.php (97%) create mode 100644 tests/Fixtures/TestBundle/Entity/Legacy/DummyForBackedEnumFilter.php create mode 100644 tests/Fixtures/TestBundle/Entity/Legacy/FilteredAttributeParameter.php create mode 100644 tests/Fixtures/TestBundle/Entity/Legacy/FilteredBooleanParameter.php create mode 100644 tests/Fixtures/TestBundle/Entity/Legacy/FilteredNumericParameter.php create mode 100644 tests/Fixtures/TestBundle/Entity/Legacy/FilteredOrderParameter.php rename tests/Fixtures/TestBundle/Entity/{ => Legacy}/SearchFilterParameter.php (88%) create mode 100644 tests/Functional/Parameters/Legacy/AttributeFilterLegacyTest.php create mode 100644 tests/Functional/Parameters/Legacy/BackedEnumFilterLegacyTest.php create mode 100644 tests/Functional/Parameters/Legacy/BooleanFilterLegacyTest.php create mode 100644 tests/Functional/Parameters/Legacy/NumericFilterLegacyTest.php create mode 100644 tests/Functional/Parameters/Legacy/OrderFilterLegacyTest.php create mode 100644 tests/Functional/Parameters/Legacy/SearchFilterParameterLegacyTest.php create mode 100644 tests/Symfony/Bundle/DependencyInjection/Compiler/Resource/LegacyFilteredResource.php diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb5e497643d..bc3a74bdf3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,6 +122,50 @@ jobs: - name: Run container lint run: tests/Fixtures/app/console lint:container + upgrade-filter: + name: Upgrade Filter Codemod + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + matrix: + php: + - '8.5' + fail-fast: false + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: intl, bcmath, curl, openssl, mbstring + ini-values: memory_limit=-1 + tools: composer + coverage: none + - name: Get composer cache directory + id: composercache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + - name: Cache dependencies + uses: actions/cache@v5 + with: + path: ${{ steps.composercache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer- + - name: Update project dependencies + run: | + composer global require soyuka/pmu + composer global config allow-plugins.soyuka/pmu true --no-interaction + composer global link . + - name: Codemod unit tests + run: vendor/bin/phpunit src/Symfony/Tests/Bundle/Command + # The #[ApiFilter] fixtures are already migrated in the tree, so --force only re-skips the + # special cases (name conversion, service/#[ApiFilter] key overlap); it must not error. + - name: Codemod force run + run: tests/Fixtures/app/console api:upgrade-filter --force + # Guard both paths: the migrated QueryParameter fixtures and the Legacy/ #[ApiFilter] fixtures. + - name: Functional suite + run: vendor/bin/phpunit tests/Functional + phpstan: name: PHPStan (PHP ${{ matrix.php }}) runs-on: ubuntu-latest diff --git a/phpunit.baseline.xml b/phpunit.baseline.xml index bb10f6ce0f9..559aaf8a570 100644 --- a/phpunit.baseline.xml +++ b/phpunit.baseline.xml @@ -48,4 +48,68 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Laravel/Exception/ErrorRenderer.php b/src/Laravel/Exception/ErrorRenderer.php index ee187f55f53..21ac699043a 100644 --- a/src/Laravel/Exception/ErrorRenderer.php +++ b/src/Laravel/Exception/ErrorRenderer.php @@ -15,6 +15,7 @@ use ApiPlatform\Laravel\ApiResource\Error; use ApiPlatform\Laravel\Controller\ApiPlatformController; +use ApiPlatform\Metadata\Exception\HttpExceptionInterface; use ApiPlatform\Metadata\Exception\InvalidUriVariableException; use ApiPlatform\Metadata\Exception\ProblemExceptionInterface; use ApiPlatform\Metadata\Exception\StatusAwareExceptionInterface; @@ -186,7 +187,7 @@ private function getStatusCode(?HttpOperation $apiOperation, ?HttpOperation $err return 403; } - if ($exception instanceof SymfonyHttpExceptionInterface) { + if ($exception instanceof SymfonyHttpExceptionInterface || $exception instanceof HttpExceptionInterface) { return $exception->getStatusCode(); } diff --git a/src/Metadata/ApiFilter.php b/src/Metadata/ApiFilter.php index 6a96f191b29..14be225afbb 100644 --- a/src/Metadata/ApiFilter.php +++ b/src/Metadata/ApiFilter.php @@ -19,6 +19,8 @@ * Filter attribute. * * @author Antoine Bluchet + * + * @deprecated since API Platform 4.4, use the {@see QueryParameter} attribute instead. Will be removed in 6.0. */ #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_CLASS | \Attribute::IS_REPEATABLE)] final class ApiFilter diff --git a/src/Metadata/Exception/BadRequestException.php b/src/Metadata/Exception/BadRequestException.php new file mode 100644 index 00000000000..414a510b228 --- /dev/null +++ b/src/Metadata/Exception/BadRequestException.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Metadata\Exception; + +/** + * Framework-agnostic 400 Bad Request, mapped by both the Symfony and Laravel error handlers. + */ +class BadRequestException extends \RuntimeException implements ExceptionInterface, HttpExceptionInterface +{ + public function getStatusCode(): int + { + return 400; + } + + /** + * @return array + */ + public function getHeaders(): array + { + return []; + } +} diff --git a/src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php index 00ebfc6bc45..e29a29b2e72 100644 --- a/src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/FiltersResourceMetadataCollectionFactory.php @@ -58,7 +58,12 @@ public function create(string $resourceClass): ResourceMetadataCollection foreach ($resourceMetadataCollection as $i => $resource) { foreach ($operations = $resource->getOperations() ?? [] as $operationName => $operation) { - $operations->add($operationName, $operation->withFilters(array_unique(array_merge($resource->getFilters() ?? [], $operation->getFilters() ?? [], $filters)))); + $operationFilters = array_unique(array_merge($resource->getFilters() ?? [], $operation->getFilters() ?? [], $filters)); + if ($operationFilters) { + trigger_deprecation('api-platform/core', '4.4', \sprintf('Declaring filters on the "%s" operation through "Operation::$filters" is deprecated, use the "parameters" argument instead. It will be removed in 6.0.', $operation->getShortName())); + } + + $operations->add($operationName, $operation->withFilters($operationFilters)); } if ($operations) { diff --git a/src/Serializer/Filter/PropertyFilter.php b/src/Serializer/Filter/PropertyFilter.php index 01f36dfc0b1..2cf6c2594c9 100644 --- a/src/Serializer/Filter/PropertyFilter.php +++ b/src/Serializer/Filter/PropertyFilter.php @@ -280,7 +280,7 @@ public function getSchema(MetadataParameter $parameter): array public function getOpenApiParameters(MetadataParameter $parameter): Parameter { $example = \sprintf( - '%1$s[]={propertyName}&%1$s[]={anotherPropertyName}', + '%1$s[]={propertyName}&%1$s[]={anotherPropertyName}&%1$s[{nestedPropertyParent}][]={nestedProperty}', $parameter->getKey() ); diff --git a/src/State/Parameter/ValueCaster.php b/src/State/Parameter/ValueCaster.php index 7b9366e8d07..8ba295eebf2 100644 --- a/src/State/Parameter/ValueCaster.php +++ b/src/State/Parameter/ValueCaster.php @@ -13,10 +13,12 @@ namespace ApiPlatform\State\Parameter; +use ApiPlatform\Metadata\Exception\BadRequestException; + /** - * Caster returns the default value when a value can not be casted - * This is used by parameters before they get validated by constraints - * Therefore we do not need to throw exceptions, validation will just fail. + * Caster returns the value unchanged when it can not be casted, so constraint validation can + * reject it. An empty string is the exception: it can not represent a scalar native type, so we + * throw a Bad Request (400) rather than letting it reach the filter as a raw, untyped value. * * @internal */ @@ -31,6 +33,7 @@ public static function toBool(mixed $v): mixed return match (strtolower($v)) { '1', 'true' => true, '0', 'false' => false, + '' => throw new BadRequestException('An empty value cannot be cast to a boolean.'), default => $v, }; } @@ -41,6 +44,10 @@ public static function toInt(mixed $v): mixed return $v; } + if ('' === $v) { + throw new BadRequestException('An empty value cannot be cast to an integer.'); + } + $value = filter_var($v, \FILTER_VALIDATE_INT); return false === $value ? $v : $value; @@ -52,6 +59,10 @@ public static function toFloat(mixed $v): mixed return $v; } + if ('' === $v) { + throw new BadRequestException('An empty value cannot be cast to a float.'); + } + $value = filter_var($v, \FILTER_VALIDATE_FLOAT); return false === $value ? $v : $value; diff --git a/src/State/Tests/Parameter/ValueCasterTest.php b/src/State/Tests/Parameter/ValueCasterTest.php new file mode 100644 index 00000000000..481cd30e7e5 --- /dev/null +++ b/src/State/Tests/Parameter/ValueCasterTest.php @@ -0,0 +1,87 @@ + + * + * 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\Tests\Parameter; + +use ApiPlatform\Metadata\Exception\BadRequestException; +use ApiPlatform\State\Parameter\ValueCaster; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +class ValueCasterTest extends TestCase +{ + #[DataProvider('boolProvider')] + public function testToBool(mixed $value, mixed $expected): void + { + $this->assertSame($expected, ValueCaster::toBool($value)); + } + + public static function boolProvider(): \Generator + { + yield 'true string' => ['true', true]; + yield 'numeric 1' => ['1', true]; + yield 'false string' => ['false', false]; + yield 'numeric 0' => ['0', false]; + // Unrecognized values (including "null") are returned untouched so constraint validation + // rejects them. + yield 'invalid string' => ['string', 'string']; + yield 'null string is not cast' => ['null', 'null']; + yield 'non-string passthrough' => [true, true]; + } + + #[DataProvider('intProvider')] + public function testToInt(mixed $value, mixed $expected): void + { + $this->assertSame($expected, ValueCaster::toInt($value)); + } + + public static function intProvider(): \Generator + { + yield 'integer string' => ['10', 10]; + yield 'invalid string' => ['string', 'string']; + yield 'null string is not cast' => ['null', 'null']; + yield 'int passthrough' => [10, 10]; + } + + #[DataProvider('floatProvider')] + public function testToFloat(mixed $value, mixed $expected): void + { + $this->assertSame($expected, ValueCaster::toFloat($value)); + } + + public static function floatProvider(): \Generator + { + yield 'float string' => ['1.5', 1.5]; + yield 'invalid string' => ['string', 'string']; + yield 'null string is not cast' => ['null', 'null']; + yield 'float passthrough' => [1.5, 1.5]; + } + + /** + * An empty string cannot represent a scalar native type, so the caster rejects it with a + * Bad Request rather than leaving a raw value for the filter. + */ + #[DataProvider('emptyCasterProvider')] + public function testEmptyValueThrowsBadRequest(callable $caster): void + { + $this->expectException(BadRequestException::class); + $caster(''); + } + + public static function emptyCasterProvider(): \Generator + { + yield 'toBool' => [ValueCaster::toBool(...)]; + yield 'toInt' => [ValueCaster::toInt(...)]; + yield 'toFloat' => [ValueCaster::toFloat(...)]; + } +} diff --git a/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterCollisionException.php b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterCollisionException.php new file mode 100644 index 00000000000..48749e3dbed --- /dev/null +++ b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterCollisionException.php @@ -0,0 +1,29 @@ + + * + * 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\Command\Upgrade; + +/** + * Thrown when two legacy filters on a resource resolve to the same QueryParameter key + * (e.g. an exact and a range filter on one property), which cannot be expressed as two + * QueryParameters. Such resources are skipped by the command and handled separately. + * + * @internal + */ +final class UpgradeApiFilterCollisionException extends UpgradeApiFilterSkipException +{ + public function __construct(public readonly string $parameterKey) + { + parent::__construct(\sprintf('Cannot auto-migrate: two filters resolve to the same QueryParameter key "%s".', $parameterKey)); + } +} diff --git a/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterMapper.php b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterMapper.php new file mode 100644 index 00000000000..87546f52514 --- /dev/null +++ b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterMapper.php @@ -0,0 +1,87 @@ + + * + * 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\Command\Upgrade; + +/** + * Maps a legacy Doctrine filter to its canonical QueryParameter replacement. + * + * Filters that survive (Date/Range/Exists) and custom/third-party filters are returned + * unchanged — the codemod still wraps them in a `QueryParameter`, it just keeps the class. + * + * @internal + */ +final class UpgradeApiFilterMapper +{ + private const ORM_NAMESPACE = 'ApiPlatform\Doctrine\Orm\Filter'; + private const ODM_NAMESPACE = 'ApiPlatform\Doctrine\Odm\Filter'; + + public function map(string $filterClass, ?string $strategy = null, ?string $propertyNativeType = null, bool $isRelation = false): UpgradeApiFilterMapping + { + $namespace = $this->driverNamespace($filterClass); + + // Custom / third-party filter: keep as-is, just wrap it in a QueryParameter. + if (null === $namespace) { + return new UpgradeApiFilterMapping($filterClass); + } + + $shortName = substr($filterClass, \strlen($namespace) + 1); + $canonical = static fn (string $name): string => $namespace.'\\'.$name; + + return match ($shortName) { + 'BooleanFilter' => new UpgradeApiFilterMapping($canonical('ExactFilter'), castToNativeType: true, nativeType: 'bool'), + 'NumericFilter' => new UpgradeApiFilterMapping($canonical('ExactFilter'), castToNativeType: true, nativeType: $propertyNativeType ?? 'int'), + 'BackedEnumFilter' => new UpgradeApiFilterMapping($canonical('ExactFilter'), castToNativeType: true, nativeType: $propertyNativeType), + 'OrderFilter' => new UpgradeApiFilterMapping($canonical('SortFilter')), + 'SearchFilter' => $this->searchReplacement($canonical, $strategy, $isRelation), + default => new UpgradeApiFilterMapping($filterClass), + }; + } + + /** + * @param callable(string): string $canonical + */ + private function searchReplacement(callable $canonical, ?string $strategy, bool $isRelation): UpgradeApiFilterMapping + { + if ($isRelation) { + return new UpgradeApiFilterMapping($canonical('IriFilter')); + } + + // A leading "i" makes the legacy strategy case-insensitive; the new search filters are + // case-insensitive by default, so a case-sensitive (non-"i") strategy opts back in. + $caseInsensitive = null !== $strategy && str_starts_with($strategy, 'i'); + $base = $caseInsensitive ? substr($strategy, 1) : $strategy; + + $shortName = match ($base) { + 'exact' => 'ExactFilter', + 'start' => 'StartSearchFilter', + 'end' => 'EndSearchFilter', + 'word_start' => 'WordStartSearchFilter', + default => 'PartialSearchFilter', + }; + + // ExactFilter has no case-sensitivity option. + $caseSensitive = 'ExactFilter' !== $shortName && !$caseInsensitive; + + return new UpgradeApiFilterMapping($canonical($shortName), caseSensitive: $caseSensitive); + } + + private function driverNamespace(string $filterClass): ?string + { + return match (true) { + str_starts_with($filterClass, self::ORM_NAMESPACE.'\\') => self::ORM_NAMESPACE, + str_starts_with($filterClass, self::ODM_NAMESPACE.'\\') => self::ODM_NAMESPACE, + default => null, + }; + } +} diff --git a/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterMapping.php b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterMapping.php new file mode 100644 index 00000000000..0a2c118902b --- /dev/null +++ b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterMapping.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\Symfony\Bundle\Command\Upgrade; + +/** + * Canonical replacement for a single legacy filter, resolved by {@see UpgradeApiFilterMapper}. + * + * @internal + */ +final readonly class UpgradeApiFilterMapping +{ + public function __construct( + public string $filterClass, + public bool $castToNativeType = false, + public ?string $nativeType = null, + public bool $caseSensitive = false, + ) { + } +} diff --git a/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterNameConversionException.php b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterNameConversionException.php new file mode 100644 index 00000000000..a6bbc93dc03 --- /dev/null +++ b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterNameConversionException.php @@ -0,0 +1,29 @@ + + * + * 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\Command\Upgrade; + +/** + * Thrown when a filtered property is renamed by a configured name converter. The new overlay filters + * do not denormalize the property (the parameter factory normalizes it), so such a resource cannot be + * auto-migrated faithfully and is reported and skipped. + * + * @internal + */ +final class UpgradeApiFilterNameConversionException extends UpgradeApiFilterSkipException +{ + public function __construct(public readonly string $property) + { + parent::__construct(\sprintf('Cannot auto-migrate: property "%s" is renamed by a name converter, which the target filters do not support. Migrate this resource manually.', $property)); + } +} diff --git a/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterParameter.php b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterParameter.php new file mode 100644 index 00000000000..4128408b013 --- /dev/null +++ b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterParameter.php @@ -0,0 +1,46 @@ + + * + * 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\Command\Upgrade; + +/** + * Resolved target of a single `#[ApiFilter]` declaration: the QueryParameter to emit. + * + * @internal + */ +final readonly class UpgradeApiFilterParameter +{ + /** + * @param string $key the parameter key (query string name) + * @param string $filterClass canonical replacement filter FQCN to instantiate + * @param string|null $property explicit property when it differs from $key + * @param string|null $nativeType scalar native type hint (bool|int|float|string), null to omit + * @param bool $castToNativeType whether the QueryParameter should coerce the raw value + * @param string|null $filterContext filter-specific config carried by the QueryParameter (e.g. the + * DateFilter null-management mode), null to omit + * @param bool $caseSensitive emit `caseSensitive: true` on the search filter (case-sensitive + * strategy); the new search filters are case-insensitive by default + * @param array $arguments constructor arguments to pass to the (kept) filter, named + */ + public function __construct( + public string $key, + public string $filterClass, + public ?string $property = null, + public ?string $nativeType = null, + public bool $castToNativeType = false, + public ?string $filterContext = null, + public bool $caseSensitive = false, + public array $arguments = [], + ) { + } +} diff --git a/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterResolver.php b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterResolver.php new file mode 100644 index 00000000000..507d74a3815 --- /dev/null +++ b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterResolver.php @@ -0,0 +1,218 @@ + + * + * 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\Command\Upgrade; + +use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; +use ApiPlatform\Metadata\Exception\PropertyNotFoundException; +use ApiPlatform\Metadata\FilterInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Metadata\Util\TypeHelper; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Turns the legacy filters declared on a resource into the canonical {@see UpgradeApiFilterParameter} + * list that the visitor injects as QueryParameters. + * + * Properties and strategies are read from each filter's runtime `getDescription()` (the only place + * that knows what a class-level auto-detecting filter actually targets), then mapped to the canonical + * filter by {@see UpgradeApiFilterMapper}. A SearchFilter targeting an association cannot be told apart + * from one targeting a scalar field through the description alone, so the property's native type is + * resolved to decide whether it maps to an IriFilter. + * + * @internal + */ +final class UpgradeApiFilterResolver +{ + /** DateFilter null-management modes carried verbatim into the QueryParameter `filterContext`. */ + private const DATE_NULL_MANAGEMENT = [ + DateFilterInterface::EXCLUDE_NULL, + DateFilterInterface::INCLUDE_NULL_BEFORE, + DateFilterInterface::INCLUDE_NULL_AFTER, + DateFilterInterface::INCLUDE_NULL_BEFORE_AND_AFTER, + ]; + + public function __construct( + private readonly UpgradeApiFilterMapper $mapper, + private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, + private readonly ResourceClassResolverInterface $resourceClassResolver, + ) { + } + + /** + * @param list}> $filters + * one entry per `#[ApiFilter]` declaration (keyed by service id upstream so that two + * instances of the same filter class are kept distinct) + * @param list $reservedFilters in-place service filters (the resource `filters:` array) whose query keys must + * not be re-migrated: an #[ApiFilter] mapping onto one of these keys would shadow it + * + * @throws UpgradeApiFilterCollisionException when two filters map to the same parameter key, or an + * #[ApiFilter] key collides with an in-place service filter + * + * @return list + */ + public function resolve(string $resourceClass, array $filters, array $reservedFilters = []): array + { + $params = []; + $seenKeys = []; + + foreach ($reservedFilters as $reservedFilter) { + foreach (array_keys($this->group($reservedFilter->getDescription($resourceClass))) as $reservedKey) { + $seenKeys[$reservedKey] = true; + } + } + + foreach ($filters as ['filter' => $filter, 'filterClass' => $filterClass, 'arguments' => $arguments]) { + $description = $filter->getDescription($resourceClass); + // The new overlay filters do not denormalize property names, so a resource whose filtered + // properties are renamed by a name converter cannot be migrated faithfully — skip it. + $this->assertNoNameConversion($filter, $description); + + // The mode of a DateFilter (include/exclude null) lives in the constructor `properties` map + // as the value, never in getDescription(); read it straight from the filter instance. + $rawProperties = \is_callable([$filter, 'getProperties']) ? ($filter->getProperties() ?? []) : []; + + foreach ($this->group($description) as $key => $info) { + if (isset($seenKeys[$key])) { + throw new UpgradeApiFilterCollisionException($key); + } + $seenKeys[$key] = true; + + $isRelation = null !== $info['property'] && $this->isRelation($resourceClass, $info['property']); + $mapping = $this->mapper->map($filterClass, $info['strategy'], $info['type'], $isRelation); + // The new filter system infers the property from a plain key, but cannot for a nested + // (dotted) key, so it must be stated explicitly even when it equals the key. + $property = $this->explicitProperty($info['property'], $key); + + $mode = null !== $info['property'] ? ($rawProperties[$info['property']] ?? null) : null; + $filterContext = \is_string($mode) && \in_array($mode, self::DATE_NULL_MANAGEMENT, true) ? $mode : null; + + // Constructor arguments only carry over when the filter is kept as-is (custom or a + // surviving filter); a remapped filter has a different constructor. + $filterArguments = $mapping->filterClass === $filterClass ? $arguments : []; + + $params[] = new UpgradeApiFilterParameter( + key: $key, + filterClass: $mapping->filterClass, + property: $property, + nativeType: $mapping->nativeType, + castToNativeType: $mapping->castToNativeType, + filterContext: $filterContext, + caseSensitive: $mapping->caseSensitive, + arguments: $filterArguments, + ); + } + } + + return $params; + } + + /** + * Collapses a filter description into logical parameters: operator/array bracket variants + * (`quantity[gt]`, `quantity[]`) fold into their base property, and the `order[...]` family + * folds into a single `order[:property]` template. + * + * @param array> $description + * + * @return array + */ + private function group(array $description): array + { + $grouped = []; + + foreach ($description as $descKey => $meta) { + if (str_starts_with($descKey, 'order[')) { + $grouped['order[:property]'] = ['property' => null, 'strategy' => null, 'type' => null]; + continue; + } + + // ExistsFilter uses the `exists[property]` query syntax; collapse it to the catch-all template + // (the bracketed property, name-converted or not, is resolved by the filter at query time). + if (str_starts_with($descKey, 'exists[')) { + $grouped['exists[:property]'] = ['property' => null, 'strategy' => null, 'type' => null]; + continue; + } + + $key = false === ($pos = strpos($descKey, '[')) ? $descKey : substr($descKey, 0, $pos); + + $grouped[$key] ??= [ + 'property' => $meta['property'] ?? $key, + 'strategy' => $meta['strategy'] ?? null, + 'type' => $meta['type'] ?? null, + ]; + } + + return $grouped; + } + + /** + * The new overlay filters read the property as-is (no name-converter denormalization the legacy + * filters did), while the parameter factory normalizes it — so a filtered property renamed by a + * configured name converter would target the wrong field. Detect it and skip the resource. + * + * @param array> $description + * + * @throws UpgradeApiFilterNameConversionException + */ + private function assertNoNameConversion(FilterInterface $filter, array $description): void + { + $nameConverter = \is_callable([$filter, 'getNameConverter']) ? $filter->getNameConverter() : null; + if (!$nameConverter instanceof NameConverterInterface) { + return; + } + + foreach ($description as $meta) { + $property = $meta['property'] ?? null; + if (null === $property) { + continue; + } + + $real = implode('.', array_map($nameConverter->denormalize(...), explode('.', (string) $property))); + if ($real !== $property) { + throw new UpgradeApiFilterNameConversionException($property); + } + } + } + + private function explicitProperty(?string $property, string $key): ?string + { + if (null === $property) { + return null; + } + + return ($property !== $key || str_contains($property, '.')) ? $property : null; + } + + /** + * A SearchFilter property is a relation when its native type resolves to an API resource class (an + * object, or a collection of objects). Gating on the resource resolver keeps value objects such as + * \DateTime — which also resolve to a class — out of the IriFilter mapping. + */ + private function isRelation(string $resourceClass, string $property): bool + { + try { + $type = $this->propertyMetadataFactory->create($resourceClass, $property)->getNativeType(); + } catch (PropertyNotFoundException) { + return false; + } + + if (null === $type) { + return false; + } + + $className = TypeHelper::getClassName(TypeHelper::getCollectionValueType($type) ?? $type); + + return null !== $className && $this->resourceClassResolver->isResourceClass($className); + } +} diff --git a/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterSkipException.php b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterSkipException.php new file mode 100644 index 00000000000..a4ea6cff64b --- /dev/null +++ b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterSkipException.php @@ -0,0 +1,23 @@ + + * + * 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\Command\Upgrade; + +/** + * Base class for the reasons a resource cannot be auto-migrated and is reported and skipped by the command. + * + * @internal + */ +abstract class UpgradeApiFilterSkipException extends \RuntimeException +{ +} diff --git a/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterVisitor.php b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterVisitor.php new file mode 100644 index 00000000000..8f3e723cce5 --- /dev/null +++ b/src/Symfony/Bundle/Command/Upgrade/UpgradeApiFilterVisitor.php @@ -0,0 +1,268 @@ + + * + * 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\Command\Upgrade; + +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\QueryParameter; +use PhpParser\BuilderHelpers; +use PhpParser\Node; +use PhpParser\NodeVisitorAbstract; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * Rewrites legacy `#[ApiFilter]` declarations on a resource class to `QueryParameter` + * entries on the `#[ApiResource]` attribute, dropping the now-unused imports. + * + * @internal + */ +final class UpgradeApiFilterVisitor extends NodeVisitorAbstract +{ + /** DateFilter null-management mode value => the constant name to reference on the filter class. */ + private const FILTER_CONTEXT_CONSTANTS = [ + 'exclude_null' => 'EXCLUDE_NULL', + 'include_null_before' => 'INCLUDE_NULL_BEFORE', + 'include_null_after' => 'INCLUDE_NULL_AFTER', + 'include_null_before_and_after' => 'INCLUDE_NULL_BEFORE_AND_AFTER', + ]; + + /** @var list short names of filter classes referenced by removed `#[ApiFilter]` attributes */ + private array $removedFilterShortNames = []; + + /** + * @param string $className FQCN of the resource class to transform + * @param list $parameters resolved QueryParameter targets to inject + */ + public function __construct( + private readonly string $className, + private readonly array $parameters, + ) { + } + + public function enterNode(Node $node): ?Node + { + if ($node instanceof Node\Stmt\Class_ && $this->isTargetClass($node)) { + $this->removeApiFilterAttributes($node); + $this->injectParameters($node); + } + + return null; + } + + public function leaveNode(Node $node): ?Node + { + if ($node instanceof Node\Stmt\Namespace_) { + $this->rewriteUses($node); + } + + return null; + } + + private function isTargetClass(Node\Stmt\Class_ $node): bool + { + return null !== $node->name && $node->name->toString() === $this->shortName($this->className); + } + + private function removeApiFilterAttributes(Node\Stmt\Class_ $node): void + { + $this->stripApiFilter($node); + + foreach ($node->getProperties() as $property) { + $this->stripApiFilter($property); + } + + $constructor = $node->getMethod('__construct'); + if (null !== $constructor) { + foreach ($constructor->params as $param) { + $this->stripApiFilter($param); + } + } + } + + private function stripApiFilter(Node\Stmt\Class_|Node\Stmt\Property|Node\Param $node): void + { + foreach ($node->attrGroups as $gi => $group) { + foreach ($group->attrs as $ai => $attr) { + if ('ApiFilter' !== $attr->name->getLast()) { + continue; + } + + $firstArg = $attr->args[0] ?? null; + if ($firstArg?->value instanceof Node\Expr\ClassConstFetch && $firstArg->value->class instanceof Node\Name) { + $this->removedFilterShortNames[] = $firstArg->value->class->getLast(); + } + + unset($group->attrs[$ai]); + } + + $group->attrs = array_values($group->attrs); + if (!$group->attrs) { + unset($node->attrGroups[$gi]); + } + } + + $node->attrGroups = array_values($node->attrGroups); + } + + private function injectParameters(Node\Stmt\Class_ $node): void + { + if (!$this->parameters) { + return; + } + + $items = []; + foreach ($this->parameters as $parameter) { + $items[] = new Node\ArrayItem($this->buildQueryParameter($parameter), new Node\Scalar\String_($parameter->key)); + } + + $parametersArg = new Node\Arg( + new Node\Expr\Array_($items, ['kind' => Node\Expr\Array_::KIND_SHORT]), + name: new Node\Identifier('parameters'), + ); + + foreach ($node->attrGroups as $group) { + foreach ($group->attrs as $attr) { + if ('ApiResource' === $attr->name->getLast()) { + $attr->args[] = $parametersArg; + + return; + } + } + } + } + + private function buildQueryParameter(UpgradeApiFilterParameter $parameter): Node\Expr\New_ + { + $filterArgs = []; + if ($parameter->caseSensitive) { + $filterArgs[] = new Node\Arg(new Node\Expr\ConstFetch(new Node\Name('true')), name: new Node\Identifier('caseSensitive')); + } + foreach ($parameter->arguments as $name => $value) { + $filterArgs[] = new Node\Arg($this->buildValue($value), name: new Node\Identifier($name)); + } + + $args = [ + new Node\Arg( + new Node\Expr\New_(new Node\Name($this->shortName($parameter->filterClass)), $filterArgs), + name: new Node\Identifier('filter'), + ), + ]; + + if (null !== $parameter->property) { + $args[] = new Node\Arg(new Node\Scalar\String_($parameter->property), name: new Node\Identifier('property')); + } + + if (null !== $parameter->nativeType) { + $args[] = new Node\Arg($this->buildNativeType($parameter->nativeType), name: new Node\Identifier('nativeType')); + } + + if ($parameter->castToNativeType) { + $args[] = new Node\Arg(new Node\Expr\ConstFetch(new Node\Name('true')), name: new Node\Identifier('castToNativeType')); + } + + if (null !== $parameter->filterContext) { + $args[] = new Node\Arg($this->buildFilterContext($parameter), name: new Node\Identifier('filterContext')); + } + + return new Node\Expr\New_(new Node\Name('QueryParameter'), $args); + } + + /** + * Re-expresses a DateFilter null-management mode as the `DateFilter::INCLUDE_NULL_*` constant it + * came from (the filter class is already imported), falling back to a string literal otherwise. + */ + private function buildFilterContext(UpgradeApiFilterParameter $parameter): Node\Expr + { + $constant = self::FILTER_CONTEXT_CONSTANTS[$parameter->filterContext] ?? null; + if (null === $constant) { + return new Node\Scalar\String_($parameter->filterContext); + } + + return new Node\Expr\ClassConstFetch(new Node\Name($this->shortName($parameter->filterClass)), new Node\Identifier($constant)); + } + + private function buildValue(mixed $value): Node\Expr + { + return BuilderHelpers::normalizeValue($value); + } + + private function buildNativeType(string $nativeType): Node\Expr\New_ + { + $case = match ($nativeType) { + 'bool' => 'BOOL', + 'int' => 'INT', + 'float' => 'FLOAT', + default => 'STRING', + }; + + return new Node\Expr\New_(new Node\Name('BuiltinType'), [ + new Node\Arg(new Node\Expr\ClassConstFetch(new Node\Name('TypeIdentifier'), new Node\Identifier($case))), + ]); + } + + private function rewriteUses(Node\Stmt\Namespace_ $node): void + { + // Filters reused as the canonical target (survivors, custom service filters) keep their import. + $keepShortNames = array_map(fn (UpgradeApiFilterParameter $p): string => $this->shortName($p->filterClass), $this->parameters); + $removeShortNames = array_diff(array_merge(['ApiFilter'], $this->removedFilterShortNames), $keepShortNames); + $existing = []; + + foreach ($node->stmts as $k => $stmt) { + if (!$stmt instanceof Node\Stmt\Use_) { + continue; + } + + foreach ($stmt->uses as $use) { + if (\in_array($use->name->getLast(), $removeShortNames, true)) { + unset($node->stmts[$k]); + continue 2; + } + + $existing[$use->name->toString()] = true; + } + } + + $node->stmts = array_values($node->stmts); + + $imports = []; + foreach ($this->parameters as $parameter) { + $imports[$parameter->filterClass] = true; + $imports[QueryParameter::class] = true; + if (null !== $parameter->nativeType) { + $imports[BuiltinType::class] = true; + $imports[TypeIdentifier::class] = true; + } + } + + $toAdd = []; + foreach (array_keys($imports) as $fqcn) { + if (!isset($existing[$fqcn])) { + $toAdd[] = $fqcn; + } + } + + sort($toAdd); + foreach (array_reverse($toAdd) as $fqcn) { + array_unshift($node->stmts, new Node\Stmt\Use_([new Node\UseItem(new Node\Name($fqcn))])); + } + } + + private function shortName(string $fqcn): string + { + $parts = explode('\\', $fqcn); + + return end($parts); + } +} diff --git a/src/Symfony/Bundle/Command/UpgradeApiFilterCommand.php b/src/Symfony/Bundle/Command/UpgradeApiFilterCommand.php new file mode 100644 index 00000000000..3ee1a6a8f33 --- /dev/null +++ b/src/Symfony/Bundle/Command/UpgradeApiFilterCommand.php @@ -0,0 +1,226 @@ + + * + * 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\Command; + +use ApiPlatform\Metadata\FilterInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; +use ApiPlatform\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Util\AttributeFilterExtractorTrait; +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterResolver; +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterSkipException; +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterVisitor; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor\CloningVisitor; +use PhpParser\ParserFactory; +use PhpParser\PrettyPrinter\Standard; +use Psr\Container\ContainerInterface; +use SebastianBergmann\Diff\Differ; +use SebastianBergmann\Diff\Output\UnifiedDiffOutputBuilder; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Process\Process; + +/** + * Rewrites legacy `#[ApiFilter]` declarations to `QueryParameter` entries on the resource. + * + * Only `#[ApiFilter]`-generated filters (service ids prefixed `annotated_`) are migrated. + * Resources whose filters cannot be expressed as distinct QueryParameters (e.g. an exact and a + * range filter on the same property) are reported and skipped. + * + * This command is a one-shot upgrade helper for the 4.4 → 5.0 filter migration and will be removed in 6.0. + */ +#[AsCommand(name: 'api:upgrade-filter', description: 'Upgrades legacy #[ApiFilter] declarations to QueryParameter')] +final class UpgradeApiFilterCommand extends Command +{ + use AttributeFilterExtractorTrait; + + public function __construct( + private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, + private readonly ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, + private readonly ContainerInterface $filterLocator, + private readonly UpgradeApiFilterResolver $resolver, + private readonly ?string $csFixerBinary = null, + ) { + parent::__construct(); + } + + protected function configure(): void + { + $this + ->addArgument('class', InputArgument::OPTIONAL, 'Restrict the upgrade to a single resource class') + ->addOption('dry-run', 'd', InputOption::VALUE_NEGATABLE, 'Output a diff instead of writing files', true) + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Write the files in place'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $dryRun = !$input->getOption('force') && false !== $input->getOption('dry-run'); + + $classes = ($class = $input->getArgument('class')) ? [$class] : iterator_to_array($this->resourceNameCollectionFactory->create()); + $skipped = []; + $changed = 0; + + foreach ($classes as $resourceClass) { + // Legacy/ fixtures intentionally keep #[ApiFilter] as the regression suite. + if (str_contains($resourceClass, '\\Legacy\\')) { + continue; + } + + try { + $reflection = new \ReflectionClass($resourceClass); + } catch (\ReflectionException) { + continue; + } + + $filters = $this->annotatedFilters($reflection); + if (!$filters) { + continue; + } + + try { + $parameters = $this->resolver->resolve($resourceClass, $filters, $this->reservedFilters($resourceClass)); + } catch (UpgradeApiFilterSkipException $e) { + $skipped[$resourceClass] = $e->getMessage(); + continue; + } + + if (!$parameters || !($file = $reflection->getFileName())) { + continue; + } + + $original = file_get_contents($file); + $updated = $this->transform($original, $resourceClass, $parameters); + + if ($updated === $original) { + continue; + } + + ++$changed; + + if ($dryRun) { + $io->section($resourceClass); + $output->write($this->diff($original, $updated)); + continue; + } + + file_put_contents($file, $updated); + $this->fix($file); + $io->writeln(\sprintf('upgraded %s', $resourceClass)); + } + + foreach ($skipped as $class => $reason) { + $io->warning(\sprintf('Skipped %s: %s', $class, $reason)); + } + + $io->success(\sprintf('%s resource(s) %s.', $changed, $dryRun ? 'would be upgraded (dry-run)' : 'upgraded')); + + return Command::SUCCESS; + } + + /** + * Reads every `#[ApiFilter]` declaration on the resource (keyed by its generated service id so two + * instances of the same filter class stay distinct), pairing each with the configured filter instance + * and its constructor arguments. The `properties` field map is dropped: properties are resolved through + * the runtime description, not re-emitted as a filter constructor argument. + * + * @return list}> + */ + private function annotatedFilters(\ReflectionClass $reflectionClass): array + { + $filters = []; + + foreach ($this->readFilterAttributes($reflectionClass) as $id => [$arguments, $filterClass]) { + if (!$this->filterLocator->has($id)) { + continue; + } + + $filter = $this->filterLocator->get($id); + if (!$filter instanceof FilterInterface) { + continue; + } + + unset($arguments['properties']); + + $filters[] = ['filter' => $filter, 'filterClass' => $filterClass, 'arguments' => $arguments]; + } + + return $filters; + } + + /** + * In-place service filters declared on the resource through the `filters:` array (i.e. not generated + * by `#[ApiFilter]`). Their query keys are reserved: migrating an #[ApiFilter] onto one of them would + * silently shadow the service filter, so such a resource is skipped instead. + * + * @return list + */ + private function reservedFilters(string $resourceClass): array + { + $filters = []; + $seenIds = []; + + foreach ($this->resourceMetadataFactory->create($resourceClass) as $resource) { + foreach ($resource->getOperations() ?? [] as $operation) { + foreach ($operation->getFilters() ?? [] as $filterId) { + if (str_starts_with($filterId, 'annotated_') || isset($seenIds[$filterId]) || !$this->filterLocator->has($filterId)) { + continue; + } + + $seenIds[$filterId] = true; + $filter = $this->filterLocator->get($filterId); + if ($filter instanceof FilterInterface) { + $filters[] = $filter; + } + } + } + } + + return $filters; + } + + /** + * @param list $parameters + */ + private function transform(string $code, string $resourceClass, array $parameters): string + { + $parser = (new ParserFactory())->createForHostVersion(); + $oldStmts = $parser->parse($code); + $oldTokens = $parser->getTokens(); + + $newStmts = (new NodeTraverser(new CloningVisitor()))->traverse($oldStmts); + $newStmts = (new NodeTraverser(new UpgradeApiFilterVisitor($resourceClass, $parameters)))->traverse($newStmts); + + return (new Standard())->printFormatPreserving($newStmts, $oldStmts, $oldTokens); + } + + private function diff(string $from, string $to): string + { + return (new Differ(new UnifiedDiffOutputBuilder("--- original\n+++ upgraded\n")))->diff($from, $to); + } + + private function fix(string $file): void + { + if (!$this->csFixerBinary || !is_file($this->csFixerBinary)) { + return; + } + + (new Process([\PHP_BINARY, $this->csFixerBinary, 'fix', $file, '--quiet']))->run(); + } +} diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index a8140778d67..c103878d3e6 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -321,6 +321,10 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $loader->load('api.php'); $loader->load('filter.php'); + if (class_exists(\PhpParser\ParserFactory::class)) { + $loader->load('upgrade.php'); + } + if (class_exists(UuidDenormalizer::class) && class_exists(Uuid::class)) { $loader->load('ramsey_uuid.php'); } diff --git a/src/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPass.php b/src/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPass.php index 45485824678..836c54d3748 100644 --- a/src/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPass.php +++ b/src/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPass.php @@ -57,6 +57,8 @@ private function createFilterDefinitions(\ReflectionClass $resourceReflectionCla continue; } + trigger_deprecation('api-platform/core', '4.4', \sprintf('Declaring filters on "%s" with the "#[ApiFilter]" attribute is deprecated, use the "#[QueryParameter]" attribute instead. The "#[ApiFilter]" attribute will be removed in 6.0.', $resourceReflectionClass->getName())); + if (null === $filterReflectionClass = $container->getReflectionClass($filterClass, false)) { throw new InvalidArgumentException(\sprintf('Class "%s" used for service "%s" cannot be found.', $filterClass, $id)); } diff --git a/src/Symfony/Bundle/Resources/config/upgrade.php b/src/Symfony/Bundle/Resources/config/upgrade.php new file mode 100644 index 00000000000..b53ebf3d1c3 --- /dev/null +++ b/src/Symfony/Bundle/Resources/config/upgrade.php @@ -0,0 +1,40 @@ + + * + * 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\Bundle\Command\Upgrade\UpgradeApiFilterMapper; +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterResolver; +use ApiPlatform\Symfony\Bundle\Command\UpgradeApiFilterCommand; + +return static function (ContainerConfigurator $container): void { + $services = $container->services(); + + $services->set('api_platform.upgrade.filter_mapper', UpgradeApiFilterMapper::class); + + $services->set('api_platform.upgrade.filter_resolver', UpgradeApiFilterResolver::class) + ->args([ + service('api_platform.upgrade.filter_mapper'), + service('api_platform.metadata.property.metadata_factory'), + service('api_platform.resource_class_resolver'), + ]); + + $services->set('api_platform.upgrade.filter_command', UpgradeApiFilterCommand::class) + ->args([ + service('api_platform.metadata.resource.name_collection_factory'), + service('api_platform.metadata.resource.metadata_collection_factory'), + service('api_platform.filter_locator'), + service('api_platform.upgrade.filter_resolver'), + ]) + ->tag('console.command'); +}; diff --git a/src/Symfony/Tests/Bundle/Command/UpgradeApiFilterMapperTest.php b/src/Symfony/Tests/Bundle/Command/UpgradeApiFilterMapperTest.php new file mode 100644 index 00000000000..7292a603684 --- /dev/null +++ b/src/Symfony/Tests/Bundle/Command/UpgradeApiFilterMapperTest.php @@ -0,0 +1,143 @@ + + * + * 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\Tests\Bundle\Command; + +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterMapper; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\TestCase; + +final class UpgradeApiFilterMapperTest extends TestCase +{ + /** + * @param array{filterClass: string, castToNativeType: bool, nativeType: ?string, caseSensitive?: bool} $expected + */ + #[DataProvider('ormMappings')] + public function testMapOrm(string $filter, ?string $strategy, ?string $propertyNativeType, bool $isRelation, array $expected): void + { + $mapper = new UpgradeApiFilterMapper(); + $result = $mapper->map($filter, $strategy, $propertyNativeType, $isRelation); + + $this->assertSame($expected['filterClass'], $result->filterClass); + $this->assertSame($expected['castToNativeType'], $result->castToNativeType); + $this->assertSame($expected['nativeType'], $result->nativeType); + $this->assertSame($expected['caseSensitive'] ?? false, $result->caseSensitive); + } + + public static function ormMappings(): iterable + { + $orm = 'ApiPlatform\Doctrine\Orm\Filter\\'; + + yield 'Boolean -> Exact+bool+cast' => [ + $orm.'BooleanFilter', null, 'bool', false, + ['filterClass' => $orm.'ExactFilter', 'castToNativeType' => true, 'nativeType' => 'bool'], + ]; + + yield 'Numeric -> Exact+int+cast' => [ + $orm.'NumericFilter', null, 'int', false, + ['filterClass' => $orm.'ExactFilter', 'castToNativeType' => true, 'nativeType' => 'int'], + ]; + + yield 'Numeric float keeps native float' => [ + $orm.'NumericFilter', null, 'float', false, + ['filterClass' => $orm.'ExactFilter', 'castToNativeType' => true, 'nativeType' => 'float'], + ]; + + yield 'Order -> Sort, no cast/native' => [ + $orm.'OrderFilter', null, null, false, + ['filterClass' => $orm.'SortFilter', 'castToNativeType' => false, 'nativeType' => null], + ]; + + // Legacy default is case-sensitive; the new search filters default to case-insensitive, + // so a non-"i" strategy must opt back in with caseSensitive: true. + yield 'Search partial -> PartialSearchFilter case-sensitive' => [ + $orm.'SearchFilter', 'partial', 'string', false, + ['filterClass' => $orm.'PartialSearchFilter', 'castToNativeType' => false, 'nativeType' => null, 'caseSensitive' => true], + ]; + + yield 'Search ipartial -> PartialSearchFilter case-insensitive' => [ + $orm.'SearchFilter', 'ipartial', 'string', false, + ['filterClass' => $orm.'PartialSearchFilter', 'castToNativeType' => false, 'nativeType' => null, 'caseSensitive' => false], + ]; + + yield 'Search exact -> ExactFilter' => [ + $orm.'SearchFilter', 'exact', 'string', false, + ['filterClass' => $orm.'ExactFilter', 'castToNativeType' => false, 'nativeType' => null], + ]; + + yield 'Search iexact -> ExactFilter (no case option)' => [ + $orm.'SearchFilter', 'iexact', 'string', false, + ['filterClass' => $orm.'ExactFilter', 'castToNativeType' => false, 'nativeType' => null], + ]; + + yield 'Search start -> StartSearchFilter case-sensitive' => [ + $orm.'SearchFilter', 'start', 'string', false, + ['filterClass' => $orm.'StartSearchFilter', 'castToNativeType' => false, 'nativeType' => null, 'caseSensitive' => true], + ]; + + yield 'Search istart -> StartSearchFilter case-insensitive' => [ + $orm.'SearchFilter', 'istart', 'string', false, + ['filterClass' => $orm.'StartSearchFilter', 'castToNativeType' => false, 'nativeType' => null, 'caseSensitive' => false], + ]; + + yield 'Search end -> EndSearchFilter case-sensitive' => [ + $orm.'SearchFilter', 'end', 'string', false, + ['filterClass' => $orm.'EndSearchFilter', 'castToNativeType' => false, 'nativeType' => null, 'caseSensitive' => true], + ]; + + yield 'Search word_start -> WordStartSearchFilter case-sensitive' => [ + $orm.'SearchFilter', 'word_start', 'string', false, + ['filterClass' => $orm.'WordStartSearchFilter', 'castToNativeType' => false, 'nativeType' => null, 'caseSensitive' => true], + ]; + + yield 'Search iword_start -> WordStartSearchFilter case-insensitive' => [ + $orm.'SearchFilter', 'iword_start', 'string', false, + ['filterClass' => $orm.'WordStartSearchFilter', 'castToNativeType' => false, 'nativeType' => null, 'caseSensitive' => false], + ]; + + yield 'Search on relation -> IriFilter' => [ + $orm.'SearchFilter', 'exact', null, true, + ['filterClass' => $orm.'IriFilter', 'castToNativeType' => false, 'nativeType' => null], + ]; + + yield 'Date survives' => [ + $orm.'DateFilter', null, null, false, + ['filterClass' => $orm.'DateFilter', 'castToNativeType' => false, 'nativeType' => null], + ]; + + yield 'Range survives' => [ + $orm.'RangeFilter', null, null, false, + ['filterClass' => $orm.'RangeFilter', 'castToNativeType' => false, 'nativeType' => null], + ]; + + yield 'Exists survives' => [ + $orm.'ExistsFilter', null, null, false, + ['filterClass' => $orm.'ExistsFilter', 'castToNativeType' => false, 'nativeType' => null], + ]; + + yield 'custom filter passthrough' => [ + 'App\Filter\CustomFilter', null, null, false, + ['filterClass' => 'App\Filter\CustomFilter', 'castToNativeType' => false, 'nativeType' => null], + ]; + } + + public function testOdmSearchMapsToOdmCanonical(): void + { + $odm = 'ApiPlatform\Doctrine\Odm\Filter\\'; + $mapper = new UpgradeApiFilterMapper(); + + $result = $mapper->map($odm.'SearchFilter', 'partial', 'string', false); + + $this->assertSame($odm.'PartialSearchFilter', $result->filterClass); + } +} diff --git a/src/Symfony/Tests/Bundle/Command/UpgradeApiFilterResolverTest.php b/src/Symfony/Tests/Bundle/Command/UpgradeApiFilterResolverTest.php new file mode 100644 index 00000000000..c5eee45169c --- /dev/null +++ b/src/Symfony/Tests/Bundle/Command/UpgradeApiFilterResolverTest.php @@ -0,0 +1,377 @@ + + * + * 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\Tests\Bundle\Command; + +use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; +use ApiPlatform\Doctrine\Orm\Filter\DateFilter; +use ApiPlatform\Doctrine\Orm\Filter\NumericFilter; +use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; +use ApiPlatform\Doctrine\Orm\Filter\RangeFilter; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\FilterInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\ResourceClassResolverInterface; +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterCollisionException; +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterMapper; +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterNameConversionException; +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterResolver; +use Doctrine\Common\Collections\Collection; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; +use Symfony\Component\TypeInfo\Type; + +final class UpgradeApiFilterResolverTest extends TestCase +{ + /** + * @param array $nativeTypes property name => native type used to detect relations + * @param list $resourceClasses class names the resolver should treat as API resources + */ + private function resolver(array $nativeTypes = [], array $resourceClasses = []): UpgradeApiFilterResolver + { + return new UpgradeApiFilterResolver( + new UpgradeApiFilterMapper(), + $this->propertyMetadataFactory($nativeTypes), + $this->resourceClassResolver($resourceClasses), + ); + } + + /** + * @param array $nativeTypes + */ + private function propertyMetadataFactory(array $nativeTypes): PropertyMetadataFactoryInterface + { + return new class($nativeTypes) implements PropertyMetadataFactoryInterface { + public function __construct(private array $nativeTypes) + { + } + + public function create(string $resourceClass, string $property, array $options = []): ApiProperty + { + return (new ApiProperty())->withNativeType($this->nativeTypes[$property] ?? Type::string()); + } + }; + } + + /** + * @param list $resourceClasses + */ + private function resourceClassResolver(array $resourceClasses): ResourceClassResolverInterface + { + return new class($resourceClasses) implements ResourceClassResolverInterface { + public function __construct(private array $resourceClasses) + { + } + + public function isResourceClass(string $type): bool + { + return \in_array($type, $this->resourceClasses, true); + } + + public function getResourceClass(mixed $value, ?string $resourceClass = null, bool $strict = false): string + { + return $resourceClass ?? ''; + } + }; + } + + /** + * @param array $arguments + * + * @return array{filter: FilterInterface, filterClass: string, arguments: array} + */ + private function entry(string $filterClass, FilterInterface $filter, array $arguments = []): array + { + return ['filter' => $filter, 'filterClass' => $filterClass, 'arguments' => $arguments]; + } + + private function filter(array $description, ?array $properties = null, ?NameConverterInterface $nameConverter = null): FilterInterface + { + return new class($description, $properties, $nameConverter) implements FilterInterface { + public function __construct(private array $description, private ?array $properties, private ?NameConverterInterface $nameConverter) + { + } + + public function getDescription(string $resourceClass): array + { + return $this->description; + } + + public function getProperties(): ?array + { + return $this->properties; + } + + public function getNameConverter(): ?NameConverterInterface + { + return $this->nameConverter; + } + }; + } + + public function testBooleanFilterResolvesToExact(): void + { + $filter = $this->filter([ + 'active' => ['property' => 'active', 'type' => 'bool', 'strategy' => null], + ]); + + $params = $this->resolver()->resolve('App\Entity\Dummy', [$this->entry(BooleanFilter::class, $filter)]); + + $this->assertCount(1, $params); + $this->assertSame('active', $params[0]->key); + $this->assertSame('ApiPlatform\Doctrine\Orm\Filter\ExactFilter', $params[0]->filterClass); + $this->assertSame('bool', $params[0]->nativeType); + $this->assertTrue($params[0]->castToNativeType); + } + + public function testSearchFilterStrategyResolvesPerProperty(): void + { + $filter = $this->filter([ + 'name' => ['property' => 'name', 'type' => 'string', 'strategy' => 'partial'], + 'code' => ['property' => 'code', 'type' => 'string', 'strategy' => 'exact'], + ]); + + $params = $this->resolver()->resolve('App\Entity\Dummy', [$this->entry(SearchFilter::class, $filter)]); + + $byKey = []; + foreach ($params as $p) { + $byKey[$p->key] = $p->filterClass; + } + + $this->assertSame('ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter', $byKey['name']); + $this->assertSame('ApiPlatform\Doctrine\Orm\Filter\ExactFilter', $byKey['code']); + } + + public function testSearchFilterOnRelationResolvesToIri(): void + { + $filter = $this->filter([ + 'groups' => ['property' => 'groups', 'type' => 'string', 'strategy' => 'exact', 'is_collection' => false], + 'groups[]' => ['property' => 'groups', 'type' => 'string', 'strategy' => 'exact', 'is_collection' => true], + ]); + + $params = $this->resolver( + ['groups' => Type::collection(Type::object(Collection::class), Type::object(\stdClass::class))], + [\stdClass::class], + )->resolve('App\Entity\User', [$this->entry(SearchFilter::class, $filter)]); + + $this->assertCount(1, $params); + $this->assertSame('groups', $params[0]->key); + $this->assertSame('ApiPlatform\Doctrine\Orm\Filter\IriFilter', $params[0]->filterClass); + } + + public function testNestedSearchKeyEmitsExplicitProperty(): void + { + $filter = $this->filter([ + 'colors.prop' => ['property' => 'colors.prop', 'type' => 'string', 'strategy' => 'ipartial'], + ]); + + // colors.prop is a scalar reached through the colors relation, not a relation itself. + $params = $this->resolver(['colors.prop' => Type::string()]) + ->resolve('App\Entity\DummyCar', [$this->entry(SearchFilter::class, $filter)]); + + $this->assertCount(1, $params); + $this->assertSame('colors.prop', $params[0]->key); + $this->assertSame('colors.prop', $params[0]->property); + $this->assertSame('ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter', $params[0]->filterClass); + } + + public function testSearchFilterOnScalarStaysSearch(): void + { + $filter = $this->filter([ + 'name' => ['property' => 'name', 'type' => 'string', 'strategy' => 'partial'], + ]); + + $params = $this->resolver(['name' => Type::string()]) + ->resolve('App\Entity\Dummy', [$this->entry(SearchFilter::class, $filter)]); + + $this->assertCount(1, $params); + $this->assertSame('ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter', $params[0]->filterClass); + } + + public function testSearchFilterOnDateFieldIsNotTreatedAsRelation(): void + { + $filter = $this->filter([ + 'dummyDate' => ['property' => 'dummyDate', 'type' => 'string', 'strategy' => 'exact'], + ]); + + // A \DateTime field resolves to an object native type but is not an API resource. + $params = $this->resolver(['dummyDate' => Type::object(\DateTime::class)]) + ->resolve('App\Entity\Dummy', [$this->entry(SearchFilter::class, $filter)]); + + $this->assertCount(1, $params); + $this->assertSame('ApiPlatform\Doctrine\Orm\Filter\ExactFilter', $params[0]->filterClass); + } + + public function testDateFilterCarriesNullManagementAsFilterContext(): void + { + $filter = $this->filter([ + 'dateIncludeNullAfter[before]' => ['property' => 'dateIncludeNullAfter', 'type' => 'string', 'strategy' => null], + 'dateIncludeNullAfter[after]' => ['property' => 'dateIncludeNullAfter', 'type' => 'string', 'strategy' => null], + 'plainDate[before]' => ['property' => 'plainDate', 'type' => 'string', 'strategy' => null], + ], [ + 'dateIncludeNullAfter' => DateFilterInterface::INCLUDE_NULL_AFTER, + 'plainDate' => null, + ]); + + $params = $this->resolver()->resolve('App\Entity\Dummy', [$this->entry(DateFilter::class, $filter)]); + + $byKey = []; + foreach ($params as $p) { + $byKey[$p->key] = $p; + } + + $this->assertSame('ApiPlatform\Doctrine\Orm\Filter\DateFilter', $byKey['dateIncludeNullAfter']->filterClass); + $this->assertSame(DateFilterInterface::INCLUDE_NULL_AFTER, $byKey['dateIncludeNullAfter']->filterContext); + $this->assertNull($byKey['plainDate']->filterContext); + } + + public function testNameConvertedFilterIsSkipped(): void + { + // The new overlay filters do not denormalize, so a name-converted property cannot be migrated + // faithfully: the resource is skipped. + $filter = $this->filter([ + 'name_converted' => ['property' => 'name_converted', 'type' => 'string', 'strategy' => 'exact'], + ], null, new CamelCaseToSnakeCaseNameConverter()); + + $this->expectException(UpgradeApiFilterNameConversionException::class); + + $this->resolver(['nameConverted' => Type::string()]) + ->resolve('App\Entity\Converted', [$this->entry(SearchFilter::class, $filter)]); + } + + public function testFilterWithoutActualRenamingIsNotSkipped(): void + { + // A name converter that leaves the property unchanged (identity) must not trigger a skip. + $filter = $this->filter([ + 'name' => ['property' => 'name', 'type' => 'string', 'strategy' => 'exact'], + ], null, new CamelCaseToSnakeCaseNameConverter()); + + $params = $this->resolver(['name' => Type::string()]) + ->resolve('App\Entity\Dummy', [$this->entry(SearchFilter::class, $filter)]); + + $this->assertCount(1, $params); + $this->assertSame('name', $params[0]->key); + } + + public function testKeptCustomFilterCarriesConstructorArguments(): void + { + $filter = $this->filter([ + 'foobargroups[]' => ['property' => null, 'type' => 'string', 'strategy' => null], + ]); + + $params = $this->resolver()->resolve('App\Entity\Dummy', [ + $this->entry('App\Filter\GroupFilter', $filter, ['parameterName' => 'foobargroups']), + ]); + + $this->assertCount(1, $params); + $this->assertSame('foobargroups', $params[0]->key); + $this->assertSame('App\Filter\GroupFilter', $params[0]->filterClass); + $this->assertSame(['parameterName' => 'foobargroups'], $params[0]->arguments); + } + + public function testRemappedFilterDropsConstructorArguments(): void + { + $filter = $this->filter([ + 'active' => ['property' => 'active', 'type' => 'bool', 'strategy' => null], + ]); + + // BooleanFilter is remapped to ExactFilter, whose constructor differs, so legacy args are dropped. + $params = $this->resolver()->resolve('App\Entity\Dummy', [ + $this->entry(BooleanFilter::class, $filter, ['someLegacyArg' => true]), + ]); + + $this->assertSame([], $params[0]->arguments); + } + + public function testExistsFilterCollapsesToTemplateKey(): void + { + $filter = $this->filter([ + 'exists[active]' => ['property' => 'active', 'type' => 'bool', 'strategy' => null], + ]); + + $params = $this->resolver()->resolve('App\Entity\Dummy', [$this->entry('App\Filter\ExistsFilter', $filter)]); + + $this->assertCount(1, $params); + $this->assertSame('exists[:property]', $params[0]->key); + $this->assertNull($params[0]->property); + } + + public function testOrderFilterCollapsesToTemplateKey(): void + { + $filter = $this->filter([ + 'order[createdAt]' => ['property' => 'createdAt', 'type' => 'string', 'strategy' => null], + 'order[name]' => ['property' => 'name', 'type' => 'string', 'strategy' => null], + ]); + + $params = $this->resolver()->resolve('App\Entity\Dummy', [$this->entry(OrderFilter::class, $filter)]); + + $this->assertCount(1, $params); + $this->assertSame('order[:property]', $params[0]->key); + $this->assertSame('ApiPlatform\Doctrine\Orm\Filter\SortFilter', $params[0]->filterClass); + } + + public function testRangeOperatorKeysCollapseToBaseProperty(): void + { + $filter = $this->filter([ + 'quantity[gt]' => ['property' => 'quantity', 'type' => 'string', 'strategy' => null], + 'quantity[lt]' => ['property' => 'quantity', 'type' => 'string', 'strategy' => null], + ]); + + $params = $this->resolver()->resolve('App\Entity\Dummy', [$this->entry(RangeFilter::class, $filter)]); + + $this->assertCount(1, $params); + $this->assertSame('quantity', $params[0]->key); + $this->assertSame('ApiPlatform\Doctrine\Orm\Filter\RangeFilter', $params[0]->filterClass); + } + + public function testCollisionOnSameKeyThrows(): void + { + $numeric = $this->filter([ + 'quantity' => ['property' => 'quantity', 'type' => 'int', 'strategy' => null], + ]); + $range = $this->filter([ + 'quantity[gt]' => ['property' => 'quantity', 'type' => 'string', 'strategy' => null], + ]); + + $this->expectException(UpgradeApiFilterCollisionException::class); + + $this->resolver()->resolve('App\Entity\Dummy', [ + $this->entry(NumericFilter::class, $numeric), + $this->entry(RangeFilter::class, $range), + ]); + } + + public function testCollisionWithReservedServiceFilterKeyThrows(): void + { + // An #[ApiFilter] SearchFilter on dummyDate would shadow an in-place service DateFilter + // declared through the resource `filters:` array on the same query key. + $search = $this->filter([ + 'dummyDate' => ['property' => 'dummyDate', 'type' => 'string', 'strategy' => 'exact'], + ]); + $serviceDateFilter = $this->filter([ + 'dummyDate[before]' => ['property' => 'dummyDate', 'type' => 'string', 'strategy' => null], + 'dummyDate[after]' => ['property' => 'dummyDate', 'type' => 'string', 'strategy' => null], + ]); + + $this->expectException(UpgradeApiFilterCollisionException::class); + + $this->resolver()->resolve( + 'App\Entity\Dummy', + [$this->entry(SearchFilter::class, $search)], + [$serviceDateFilter], + ); + } +} diff --git a/src/Symfony/Tests/Bundle/Command/UpgradeApiFilterVisitorTest.php b/src/Symfony/Tests/Bundle/Command/UpgradeApiFilterVisitorTest.php new file mode 100644 index 00000000000..19ad6effbd1 --- /dev/null +++ b/src/Symfony/Tests/Bundle/Command/UpgradeApiFilterVisitorTest.php @@ -0,0 +1,369 @@ + + * + * 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\Tests\Bundle\Command; + +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterParameter; +use ApiPlatform\Symfony\Bundle\Command\Upgrade\UpgradeApiFilterVisitor; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor\CloningVisitor; +use PhpParser\ParserFactory; +use PhpParser\PrettyPrinter\Standard; +use PHPUnit\Framework\TestCase; + +final class UpgradeApiFilterVisitorTest extends TestCase +{ + private function transform(string $code, UpgradeApiFilterVisitor $visitor): string + { + $parser = (new ParserFactory())->createForHostVersion(); + $oldStmts = $parser->parse($code); + $oldTokens = $parser->getTokens(); + + $newStmts = (new NodeTraverser(new CloningVisitor()))->traverse($oldStmts); + $newStmts = (new NodeTraverser($visitor))->traverse($newStmts); + + return (new Standard())->printFormatPreserving($newStmts, $oldStmts, $oldTokens); + } + + public function testBooleanFilterBecomesExactFilterQueryParameter(): void + { + $before = <<<'PHP' + new QueryParameter(filter: new ExactFilter(), nativeType: new BuiltinType(TypeIdentifier::BOOL), castToNativeType: true)])] +#[ORM\Entity] +class ConvertedBoolean +{ + public $nameConverted; +} +PHP; + + $visitor = new UpgradeApiFilterVisitor('ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedBoolean', [ + new UpgradeApiFilterParameter( + key: 'nameConverted', + filterClass: 'ApiPlatform\Doctrine\Orm\Filter\ExactFilter', + nativeType: 'bool', + castToNativeType: true, + ), + ]); + + $this->assertSame($after, $this->transform($before, $visitor)); + } + + public function testCustomServiceFilterKeepsConstructorArguments(): void + { + $before = <<<'PHP' + 'foobargroups'])] +#[ApiResource] +class DummyCar +{ + public $id; +} +PHP; + + $after = <<<'PHP' + new QueryParameter(filter: new GroupFilter(parameterName: 'foobargroups'))])] +class DummyCar +{ + public $id; +} +PHP; + + $visitor = new UpgradeApiFilterVisitor('ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar', [ + new UpgradeApiFilterParameter( + key: 'foobargroups', + filterClass: 'ApiPlatform\Serializer\Filter\GroupFilter', + arguments: ['parameterName' => 'foobargroups'], + ), + ]); + + $this->assertSame($after, $this->transform($before, $visitor)); + } + + public function testCustomServiceFilterIsWrappedAsIs(): void + { + $before = <<<'PHP' + new QueryParameter(filter: new CustomFilter())])] +class DummyResource +{ + public $id; +} +PHP; + + $visitor = new UpgradeApiFilterVisitor('ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5648\DummyResource', [ + new UpgradeApiFilterParameter( + key: 'id', + filterClass: 'ApiPlatform\Tests\Fixtures\TestBundle\Filter\CustomFilter', + ), + ]); + + $this->assertSame($after, $this->transform($before, $visitor)); + } + + public function testPropertyLevelApiFilterIsStripped(): void + { + $before = <<<'PHP' + new QueryParameter(filter: new ExactFilter())])] +class DummyCarColor +{ + private string $prop = ''; +} +PHP; + + $visitor = new UpgradeApiFilterVisitor('ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCarColor', [ + new UpgradeApiFilterParameter( + key: 'prop', + filterClass: 'ApiPlatform\Doctrine\Orm\Filter\ExactFilter', + ), + ]); + + $this->assertSame($after, $this->transform($before, $visitor)); + } + + public function testCaseSensitiveSearchFilterEmitsConstructorArgument(): void + { + $before = <<<'PHP' + 'partial'])] +#[ApiResource] +class DummyCar +{ + public $name; +} +PHP; + + $after = <<<'PHP' + new QueryParameter(filter: new PartialSearchFilter(caseSensitive: true))])] +class DummyCar +{ + public $name; +} +PHP; + + $visitor = new UpgradeApiFilterVisitor('ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyCar', [ + new UpgradeApiFilterParameter( + key: 'name', + filterClass: 'ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter', + caseSensitive: true, + ), + ]); + + $this->assertSame($after, $this->transform($before, $visitor)); + } + + public function testDateFilterEmitsFilterContextConstant(): void + { + $before = <<<'PHP' + DateFilter::INCLUDE_NULL_AFTER])] +#[ApiResource] +class DummyDate +{ + public $dateIncludeNullAfter; +} +PHP; + + $after = <<<'PHP' + new QueryParameter(filter: new DateFilter(), filterContext: DateFilter::INCLUDE_NULL_AFTER)])] +class DummyDate +{ + public $dateIncludeNullAfter; +} +PHP; + + $visitor = new UpgradeApiFilterVisitor('ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyDate', [ + new UpgradeApiFilterParameter( + key: 'dateIncludeNullAfter', + filterClass: 'ApiPlatform\Doctrine\Orm\Filter\DateFilter', + filterContext: 'include_null_after', + ), + ]); + + $this->assertSame($after, $this->transform($before, $visitor)); + } + + public function testSurvivingFilterKeepsClassWithExplicitProperty(): void + { + $before = <<<'PHP' + new QueryParameter(filter: new ExistsFilter())])] +class ConvertedString +{ + public $nameConverted; +} +PHP; + + $visitor = new UpgradeApiFilterVisitor('ApiPlatform\Tests\Fixtures\TestBundle\Entity\ConvertedString', [ + new UpgradeApiFilterParameter( + key: 'nameConverted', + filterClass: 'ApiPlatform\Doctrine\Orm\Filter\ExistsFilter', + ), + ]); + + $this->assertSame($after, $this->transform($before, $visitor)); + } +} diff --git a/tests/Fixtures/TestBundle/ApiResource/JsonLd/NonResourceContainer.php b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NonResourceContainer.php index 076f8752819..5719f305a0c 100644 --- a/tests/Fixtures/TestBundle/ApiResource/JsonLd/NonResourceContainer.php +++ b/tests/Fixtures/TestBundle/ApiResource/JsonLd/NonResourceContainer.php @@ -13,26 +13,21 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd; -use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Serializer\Filter\PropertyFilter; use Symfony\Component\Serializer\Attribute\Groups; -#[ApiResource( - shortName: 'JsonLdNonResourceContainer', - normalizationContext: ['groups' => ['jsonld_non_resource']], - operations: [ - new Get( - uriTemplate: '/jsonld_non_resource_containers/{id}', - uriVariables: ['id'], - provider: [self::class, 'provide'], - ), - ], -)] -#[ApiFilter(PropertyFilter::class)] +#[ApiResource(shortName: 'JsonLdNonResourceContainer', normalizationContext: ['groups' => ['jsonld_non_resource']], operations: [ + new Get( + uriTemplate: '/jsonld_non_resource_containers/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), +], parameters: ['properties' => new QueryParameter(filter: new PropertyFilter())])] class NonResourceContainer { #[ApiProperty(identifier: true)] diff --git a/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParent.php b/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParent.php index 4eb2f7eae91..9ea0d4a7049 100644 --- a/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParent.php +++ b/tests/Fixtures/TestBundle/ApiResource/PropertyFilter/SparseFieldsetParent.php @@ -13,23 +13,20 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\PropertyFilter; -use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\Operation; +use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\Serializer\Filter\PropertyFilter; -#[ApiResource( - operations: [ - new Get( - uriTemplate: '/sparse_fieldset_parents/{id}', - uriVariables: ['id'], - provider: [self::class, 'provide'], - ), - ], -)] -#[ApiFilter(PropertyFilter::class)] +#[ApiResource(operations: [ + new Get( + uriTemplate: '/sparse_fieldset_parents/{id}', + uriVariables: ['id'], + provider: [self::class, 'provide'], + ), +], parameters: ['properties' => new QueryParameter(filter: new PropertyFilter())])] final class SparseFieldsetParent { public function __construct( diff --git a/tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php b/tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php index 8964c3d49da..eaa0793b2d6 100644 --- a/tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php +++ b/tests/Fixtures/TestBundle/Document/FilteredBooleanParameter.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; -use ApiPlatform\Doctrine\Odm\Filter\BooleanFilter; +use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; @@ -25,13 +25,15 @@ #[GetCollection( parameters: [ 'active' => new QueryParameter( - filter: new BooleanFilter(), + filter: new ExactFilter(), nativeType: new BuiltinType(TypeIdentifier::BOOL), + castToNativeType: true, ), 'enabled' => new QueryParameter( - filter: new BooleanFilter(), + filter: new ExactFilter(), property: 'active', nativeType: new BuiltinType(TypeIdentifier::BOOL), + castToNativeType: true, ), ], )] diff --git a/tests/Fixtures/TestBundle/Document/FilteredNumericParameter.php b/tests/Fixtures/TestBundle/Document/FilteredNumericParameter.php index 30ae305d677..363d8ee2da6 100644 --- a/tests/Fixtures/TestBundle/Document/FilteredNumericParameter.php +++ b/tests/Fixtures/TestBundle/Document/FilteredNumericParameter.php @@ -13,25 +13,33 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; -use ApiPlatform\Doctrine\Odm\Filter\NumericFilter; +use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; #[ApiResource] #[GetCollection( paginationItemsPerPage: 5, parameters: [ 'quantity' => new QueryParameter( - filter: new NumericFilter(), + filter: new ExactFilter(), + nativeType: new BuiltinType(TypeIdentifier::INT), + castToNativeType: true, ), 'amount' => new QueryParameter( - filter: new NumericFilter(), + filter: new ExactFilter(), property: 'quantity', + nativeType: new BuiltinType(TypeIdentifier::INT), + castToNativeType: true, ), 'ratio' => new QueryParameter( - filter: new NumericFilter(), + filter: new ExactFilter(), + nativeType: new BuiltinType(TypeIdentifier::FLOAT), + castToNativeType: true, ), ], )] diff --git a/tests/Fixtures/TestBundle/Document/FilteredOrderParameter.php b/tests/Fixtures/TestBundle/Document/FilteredOrderParameter.php index a08313f57d7..d0d994b275e 100644 --- a/tests/Fixtures/TestBundle/Document/FilteredOrderParameter.php +++ b/tests/Fixtures/TestBundle/Document/FilteredOrderParameter.php @@ -14,41 +14,29 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface; -use ApiPlatform\Doctrine\Odm\Filter\OrderFilter; +use ApiPlatform\Doctrine\Odm\Filter\SortFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; -use Symfony\Component\TypeInfo\Type\BuiltinType; -use Symfony\Component\TypeInfo\TypeIdentifier; #[ApiResource] #[GetCollection( paginationItemsPerPage: 5, parameters: [ 'createdAt' => new QueryParameter( - filter: new OrderFilter(), - nativeType: new BuiltinType(TypeIdentifier::STRING) + filter: new SortFilter(), ), 'date' => new QueryParameter( - filter: new OrderFilter(), + filter: new SortFilter(), property: 'createdAt', - nativeType: new BuiltinType(TypeIdentifier::STRING) ), 'date_null_always_first' => new QueryParameter( - filter: new OrderFilter(), + filter: new SortFilter(nullsComparison: OrderFilterInterface::NULLS_ALWAYS_FIRST), property: 'createdAt', - filterContext: ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST], - nativeType: new BuiltinType(TypeIdentifier::STRING) - ), - 'date_null_always_first_old_way' => new QueryParameter( - filter: new OrderFilter(properties: ['createdAt' => ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST]]), - property: 'createdAt', - nativeType: new BuiltinType(TypeIdentifier::STRING) ), 'order[:property]' => new QueryParameter( - filter: new OrderFilter(), - filterContext: ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST], + filter: new SortFilter(nullsComparison: OrderFilterInterface::NULLS_ALWAYS_FIRST), ), ], )] diff --git a/tests/Fixtures/TestBundle/Document/Legacy/FilteredAttributeParameter.php b/tests/Fixtures/TestBundle/Document/Legacy/FilteredAttributeParameter.php new file mode 100644 index 00000000000..c90be7a008f --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/Legacy/FilteredAttributeParameter.php @@ -0,0 +1,72 @@ + + * + * 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\Document\Legacy; + +use ApiPlatform\Doctrine\Odm\Filter\DateFilter; +use ApiPlatform\Doctrine\Odm\Filter\ExistsFilter; +use ApiPlatform\Doctrine\Odm\Filter\RangeFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Legacy regression fixture: Date/Range/Exists filters survive into 5.0 (rewritten standalone), + * so the deprecated #[ApiFilter] attribute declaration must keep working for users. The canonical + * QueryParameter form lives at Document\Filtered{Date,Range,Exists}Parameter. Remove the + * #[ApiFilter] coverage here once the attribute is gone (6.0). + */ +#[ApiResource] +#[GetCollection(uriTemplate: 'legacy_filtered_attribute_parameters{._format}')] +#[ApiFilter(DateFilter::class, properties: ['createdAt'])] +#[ApiFilter(RangeFilter::class, properties: ['quantity'])] +#[ApiFilter(ExistsFilter::class, properties: ['description'])] +#[ODM\Document] +class FilteredAttributeParameter +{ + public function __construct( + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + public ?int $id = null, + + #[ODM\Field(type: 'date_immutable', nullable: true)] + public ?\DateTimeImmutable $createdAt = null, + + #[ODM\Field(type: 'int', nullable: true)] + public ?int $quantity = null, + + #[ODM\Field(type: 'string', nullable: true)] + public ?string $description = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function getQuantity(): ?int + { + return $this->quantity; + } + + public function getDescription(): ?string + { + return $this->description; + } +} diff --git a/tests/Fixtures/TestBundle/Document/Legacy/FilteredBooleanParameter.php b/tests/Fixtures/TestBundle/Document/Legacy/FilteredBooleanParameter.php new file mode 100644 index 00000000000..7aad50050d6 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/Legacy/FilteredBooleanParameter.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\Tests\Fixtures\TestBundle\Document\Legacy; + +use ApiPlatform\Doctrine\Odm\Filter\BooleanFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * Legacy regression fixture: keeps the deprecated BooleanFilter alive until 6.0. + * The canonical replacement (ExactFilter + boolean nativeType) lives at + * Document\FilteredBooleanParameter. + */ +#[ApiResource] +#[GetCollection( + uriTemplate: 'legacy_filtered_boolean_parameters{._format}', + parameters: [ + 'active' => new QueryParameter( + filter: new BooleanFilter(), + nativeType: new BuiltinType(TypeIdentifier::BOOL), + ), + 'enabled' => new QueryParameter( + filter: new BooleanFilter(), + property: 'active', + nativeType: new BuiltinType(TypeIdentifier::BOOL), + ), + ], +)] +#[ODM\Document] +class FilteredBooleanParameter +{ + public function __construct( + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + public ?int $id = null, + + #[ODM\Field(type: 'bool', nullable: true)] + public ?bool $active = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(?bool $active): void + { + $this->active = $active; + } +} diff --git a/tests/Fixtures/TestBundle/Document/Legacy/FilteredNumericParameter.php b/tests/Fixtures/TestBundle/Document/Legacy/FilteredNumericParameter.php new file mode 100644 index 00000000000..b56f2eb38ac --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/Legacy/FilteredNumericParameter.php @@ -0,0 +1,83 @@ + + * + * 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\Document\Legacy; + +use ApiPlatform\Doctrine\Odm\Filter\NumericFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Legacy regression fixture: keeps the deprecated NumericFilter alive until 6.0. + * The canonical replacement (ExactFilter + numeric nativeType) lives at + * Document\FilteredNumericParameter. + */ +#[ApiResource] +#[GetCollection( + uriTemplate: 'legacy_filtered_numeric_parameters{._format}', + paginationItemsPerPage: 5, + parameters: [ + 'quantity' => new QueryParameter( + filter: new NumericFilter(), + ), + 'amount' => new QueryParameter( + filter: new NumericFilter(), + property: 'quantity', + ), + 'ratio' => new QueryParameter( + filter: new NumericFilter(), + ), + ], +)] +#[ODM\Document] +class FilteredNumericParameter +{ + public function __construct( + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + public ?int $id = null, + + #[ODM\Field(type: 'int', nullable: true)] + public ?int $quantity = null, + + #[ODM\Field(type: 'float', nullable: true)] + public ?float $ratio = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getQuantity(): ?int + { + return $this->quantity; + } + + public function setQuantity(?int $quantity): void + { + $this->quantity = $quantity; + } + + public function getRatio(): ?float + { + return $this->ratio; + } + + public function setRatio(?float $ratio): void + { + $this->ratio = $ratio; + } +} diff --git a/tests/Fixtures/TestBundle/Document/Legacy/FilteredOrderParameter.php b/tests/Fixtures/TestBundle/Document/Legacy/FilteredOrderParameter.php new file mode 100644 index 00000000000..bb67ffef4c5 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/Legacy/FilteredOrderParameter.php @@ -0,0 +1,87 @@ + + * + * 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\Document\Legacy; + +use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface; +use ApiPlatform\Doctrine\Odm\Filter\OrderFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * Legacy regression fixture: keeps the deprecated OrderFilter alive until 6.0, including the + * per-property `properties` nulls_comparison config form. The canonical replacement (SortFilter) + * lives at Document\FilteredOrderParameter. + */ +#[ApiResource] +#[GetCollection( + uriTemplate: 'legacy_filtered_order_parameters{._format}', + paginationItemsPerPage: 5, + parameters: [ + 'createdAt' => new QueryParameter( + filter: new OrderFilter(), + nativeType: new BuiltinType(TypeIdentifier::STRING) + ), + 'date' => new QueryParameter( + filter: new OrderFilter(), + property: 'createdAt', + nativeType: new BuiltinType(TypeIdentifier::STRING) + ), + 'date_null_always_first' => new QueryParameter( + filter: new OrderFilter(), + property: 'createdAt', + filterContext: ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST], + nativeType: new BuiltinType(TypeIdentifier::STRING) + ), + 'date_null_always_first_old_way' => new QueryParameter( + filter: new OrderFilter(properties: ['createdAt' => ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST]]), + property: 'createdAt', + nativeType: new BuiltinType(TypeIdentifier::STRING) + ), + 'order[:property]' => new QueryParameter( + filter: new OrderFilter(), + filterContext: ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST], + ), + ], +)] +#[ODM\Document] +class FilteredOrderParameter +{ + public function __construct( + #[ODM\Id(type: 'int', strategy: 'INCREMENT')] + public ?int $id = null, + + #[ODM\Field(type: 'date_immutable', nullable: true)] + public ?\DateTimeImmutable $createdAt = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(?\DateTimeImmutable $createdAt): void + { + $this->createdAt = $createdAt; + } +} diff --git a/tests/Fixtures/TestBundle/Document/SearchFilterParameter.php b/tests/Fixtures/TestBundle/Document/Legacy/SearchFilterParameter.php similarity index 87% rename from tests/Fixtures/TestBundle/Document/SearchFilterParameter.php rename to tests/Fixtures/TestBundle/Document/Legacy/SearchFilterParameter.php index f29268f455e..40c15d57dcc 100644 --- a/tests/Fixtures/TestBundle/Document/SearchFilterParameter.php +++ b/tests/Fixtures/TestBundle/Document/Legacy/SearchFilterParameter.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; +namespace ApiPlatform\Tests\Fixtures\TestBundle\Document\Legacy; use ApiPlatform\Doctrine\Odm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\ApiFilter; @@ -23,8 +23,14 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Filter\QueryParameterOdmFilter; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +/** + * Legacy regression fixture: keeps the deprecated SearchFilter alive until 6.0 through the custom + * ODMSearchFilterValueTransformer / ODMSearchTextAndDateFilter wrappers and the #[ApiFilter] + * attribute aliases referenced by QueryParameter. Canonical scalar/search coverage lives on + * ProductWithQueryParameter (ExactFilter/PartialSearchFilter). Remove in 6.0. + */ #[GetCollection( - uriTemplate: 'search_filter_parameter{._format}', + uriTemplate: 'legacy_search_filter_parameter{._format}', parameters: [ 'foo' => new QueryParameter(filter: 'app_odm_search_filter_via_parameter'), 'fooAlias' => new QueryParameter(filter: 'app_odm_search_filter_via_parameter', property: 'foo'), diff --git a/tests/Fixtures/TestBundle/Entity/DummyCar.php b/tests/Fixtures/TestBundle/Entity/DummyCar.php index 7749d3ccc9b..d61f9ba83d2 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyCar.php +++ b/tests/Fixtures/TestBundle/Entity/DummyCar.php @@ -13,16 +13,17 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; -use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; use ApiPlatform\Doctrine\Orm\Filter\DateFilter; -use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; -use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; +use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Delete; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Put; +use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\OpenApi\Model\Operation as OpenApiOperation; use ApiPlatform\Serializer\Filter\GroupFilter; use ApiPlatform\Serializer\Filter\PropertyFilter; @@ -30,13 +31,10 @@ use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute as Serializer; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; -#[ApiFilter(DateFilter::class, strategy: DateFilter::EXCLUDE_NULL)] -#[ApiFilter(BooleanFilter::class)] -#[ApiFilter(PropertyFilter::class, arguments: ['parameterName' => 'foobar'])] -#[ApiFilter(GroupFilter::class, arguments: ['parameterName' => 'foobargroups'])] -#[ApiFilter(GroupFilter::class, arguments: ['parameterName' => 'foobargroups_override'], id: 'override')] -#[ApiResource(operations: [new Get(openapi: new OpenApiOperation(tags: [])), new Put(), new Delete(), new Post(), new GetCollection()], sunset: '2050-01-01', normalizationContext: ['groups' => ['colors']])] +#[ApiResource(operations: [new Get(openapi: new OpenApiOperation(tags: [])), new Put(), new Delete(), new Post(), new GetCollection()], sunset: '2050-01-01', normalizationContext: ['groups' => ['colors']], parameters: ['availableAt' => new QueryParameter(filter: new DateFilter(), filterContext: DateFilter::EXCLUDE_NULL), 'canSell' => new QueryParameter(filter: new ExactFilter(), nativeType: new BuiltinType(TypeIdentifier::BOOL), castToNativeType: true), 'foobar' => new QueryParameter(filter: new PropertyFilter(parameterName: 'foobar')), 'foobargroups' => new QueryParameter(filter: new GroupFilter(parameterName: 'foobargroups')), 'foobargroups_override' => new QueryParameter(filter: new GroupFilter(parameterName: 'foobargroups_override')), 'colors.prop' => new QueryParameter(filter: new PartialSearchFilter(), property: 'colors.prop'), 'colors' => new QueryParameter(filter: new IriFilter()), 'secondColors' => new QueryParameter(filter: new IriFilter()), 'thirdColors' => new QueryParameter(filter: new IriFilter()), 'uuid' => new QueryParameter(filter: new IriFilter()), 'name' => new QueryParameter(filter: new PartialSearchFilter(caseSensitive: true)), 'brand' => new QueryParameter(filter: new ExactFilter())])] #[ORM\Entity] class DummyCar { @@ -46,19 +44,15 @@ class DummyCar #[ORM\Id] #[ORM\OneToOne(targetEntity: DummyCarIdentifier::class, cascade: ['persist'])] private DummyCarIdentifier $id; - #[ApiFilter(SearchFilter::class, properties: ['colors.prop' => 'ipartial', 'colors' => 'exact'])] #[ORM\OneToMany(targetEntity: DummyCarColor::class, mappedBy: 'car')] #[Serializer\Groups(['colors'])] private Collection|iterable $colors; - #[ApiFilter(SearchFilter::class, strategy: 'exact')] #[ORM\OneToMany(targetEntity: DummyCarColor::class, mappedBy: 'car')] #[Serializer\Groups(['colors'])] private Collection|iterable|null $secondColors = null; - #[ApiFilter(SearchFilter::class, strategy: 'exact')] #[ORM\OneToMany(targetEntity: DummyCarColor::class, mappedBy: 'car')] #[Serializer\Groups(['colors'])] private Collection|iterable|null $thirdColors = null; - #[ApiFilter(SearchFilter::class, strategy: 'exact')] #[ORM\ManyToMany(targetEntity: UuidIdentifierDummy::class, indexBy: 'uuid')] #[ORM\JoinColumn(name: 'car_id', referencedColumnName: 'id_id')] #[ORM\InverseJoinColumn(name: 'uuid_uuid', referencedColumnName: 'uuid')] @@ -66,14 +60,12 @@ class DummyCar #[Serializer\Groups(['colors'])] private Collection|iterable|null $uuid = null; - #[ApiFilter(SearchFilter::class, strategy: 'partial')] #[ORM\Column(type: 'string')] private string $name; #[ORM\Column(type: 'boolean')] private bool $canSell; #[ORM\Column(type: 'datetime')] private \DateTime $availableAt; - #[ApiFilter(SearchFilter::class, strategy: SearchFilter::STRATEGY_IEXACT)] #[Serializer\Groups(['colors'])] #[Serializer\SerializedName('carBrand')] #[ORM\Column] diff --git a/tests/Fixtures/TestBundle/Entity/DummyCarColor.php b/tests/Fixtures/TestBundle/Entity/DummyCarColor.php index f1d90e2ebac..c516539b3c1 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyCarColor.php +++ b/tests/Fixtures/TestBundle/Entity/DummyCarColor.php @@ -13,14 +13,14 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; -use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; -use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\QueryParameter; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Validator\Constraints as Assert; -#[ApiResource] +#[ApiResource(parameters: ['prop' => new QueryParameter(filter: new ExactFilter())])] #[ORM\Entity] class DummyCarColor { @@ -35,7 +35,6 @@ class DummyCarColor #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE', referencedColumnName: 'id_id')] #[Assert\NotBlank] private DummyCar $car; - #[ApiFilter(SearchFilter::class)] #[ORM\Column(nullable: false)] #[Assert\NotBlank] #[Groups(['colors'])] diff --git a/tests/Fixtures/TestBundle/Entity/DummyPhp8.php b/tests/Fixtures/TestBundle/Entity/DummyPhp8.php index 26fa301ca7c..a7ef7a834ef 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyPhp8.php +++ b/tests/Fixtures/TestBundle/Entity/DummyPhp8.php @@ -13,13 +13,13 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; -use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; -use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\QueryParameter; use Doctrine\ORM\Mapping as ORM; -#[ApiResource(description: 'Hey PHP 8')] +#[ApiResource(description: 'Hey PHP 8', parameters: ['filtered' => new QueryParameter(filter: new ExactFilter())])] #[ORM\Entity] class DummyPhp8 { @@ -27,7 +27,6 @@ class DummyPhp8 #[ORM\Id] #[ORM\Column(type: 'integer')] public $id; - #[ApiFilter(SearchFilter::class)] #[ORM\Column] public $filtered; diff --git a/tests/Fixtures/TestBundle/Entity/FilteredBooleanParameter.php b/tests/Fixtures/TestBundle/Entity/FilteredBooleanParameter.php index 259c2aafa48..1fcab51559b 100644 --- a/tests/Fixtures/TestBundle/Entity/FilteredBooleanParameter.php +++ b/tests/Fixtures/TestBundle/Entity/FilteredBooleanParameter.php @@ -13,7 +13,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; -use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; @@ -25,13 +25,15 @@ #[GetCollection( parameters: [ 'active' => new QueryParameter( - filter: new BooleanFilter(), + filter: new ExactFilter(), nativeType: new BuiltinType(TypeIdentifier::BOOL), + castToNativeType: true, ), 'enabled' => new QueryParameter( - filter: new BooleanFilter(), + filter: new ExactFilter(), property: 'active', nativeType: new BuiltinType(TypeIdentifier::BOOL), + castToNativeType: true, ), ], )] diff --git a/tests/Fixtures/TestBundle/Entity/FilteredNumericParameter.php b/tests/Fixtures/TestBundle/Entity/FilteredNumericParameter.php index 20e1e152be5..1a1a68b94b8 100644 --- a/tests/Fixtures/TestBundle/Entity/FilteredNumericParameter.php +++ b/tests/Fixtures/TestBundle/Entity/FilteredNumericParameter.php @@ -13,25 +13,33 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; -use ApiPlatform\Doctrine\Orm\Filter\NumericFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; #[ApiResource] #[GetCollection( paginationItemsPerPage: 5, parameters: [ 'quantity' => new QueryParameter( - filter: new NumericFilter(), + filter: new ExactFilter(), + nativeType: new BuiltinType(TypeIdentifier::INT), + castToNativeType: true, ), 'amount' => new QueryParameter( - filter: new NumericFilter(), + filter: new ExactFilter(), property: 'quantity', + nativeType: new BuiltinType(TypeIdentifier::INT), + castToNativeType: true, ), 'ratio' => new QueryParameter( - filter: new NumericFilter(), + filter: new ExactFilter(), + nativeType: new BuiltinType(TypeIdentifier::FLOAT), + castToNativeType: true, ), ], )] diff --git a/tests/Fixtures/TestBundle/Entity/FilteredOrderParameter.php b/tests/Fixtures/TestBundle/Entity/FilteredOrderParameter.php index 21bf7dbaa1a..78cb7a2c97e 100644 --- a/tests/Fixtures/TestBundle/Entity/FilteredOrderParameter.php +++ b/tests/Fixtures/TestBundle/Entity/FilteredOrderParameter.php @@ -14,41 +14,29 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface; -use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; +use ApiPlatform\Doctrine\Orm\Filter\SortFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; use Doctrine\ORM\Mapping as ORM; -use Symfony\Component\TypeInfo\Type\BuiltinType; -use Symfony\Component\TypeInfo\TypeIdentifier; #[ApiResource] #[GetCollection( paginationItemsPerPage: 5, parameters: [ 'createdAt' => new QueryParameter( - filter: new OrderFilter(), - nativeType: new BuiltinType(TypeIdentifier::STRING) + filter: new SortFilter(), ), 'date' => new QueryParameter( - filter: new OrderFilter(), + filter: new SortFilter(), property: 'createdAt', - nativeType: new BuiltinType(TypeIdentifier::STRING) ), 'date_null_always_first' => new QueryParameter( - filter: new OrderFilter(), + filter: new SortFilter(nullsComparison: OrderFilterInterface::NULLS_ALWAYS_FIRST), property: 'createdAt', - filterContext: ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST], - nativeType: new BuiltinType(TypeIdentifier::STRING) - ), - 'date_null_always_first_old_way' => new QueryParameter( - filter: new OrderFilter(properties: ['createdAt' => ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST]]), - property: 'createdAt', - nativeType: new BuiltinType(TypeIdentifier::STRING) ), 'order[:property]' => new QueryParameter( - filter: new OrderFilter(), - filterContext: ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST], + filter: new SortFilter(nullsComparison: OrderFilterInterface::NULLS_ALWAYS_FIRST), ), ], )] diff --git a/tests/Fixtures/TestBundle/Entity/Issue5735/Issue5735User.php b/tests/Fixtures/TestBundle/Entity/Issue5735/Issue5735User.php index f5018064b6c..7f60f3a096e 100644 --- a/tests/Fixtures/TestBundle/Entity/Issue5735/Issue5735User.php +++ b/tests/Fixtures/TestBundle/Entity/Issue5735/Issue5735User.php @@ -13,25 +13,21 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue5735; -use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; -use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Doctrine\Orm\Filter\IriFilter; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Uid\Uuid; -#[ApiResource( - operations: [ - new Get(), - new GetCollection(), - ], - routePrefix: '/issue5735' -)] -#[ApiFilter(SearchFilter::class, properties: ['groups' => 'exact'])] +#[ApiResource(operations: [ + new Get(), + new GetCollection(), +], routePrefix: '/issue5735', parameters: ['groups' => new QueryParameter(filter: new IriFilter())])] #[ORM\Entity] #[ORM\Table(name: 'issue5735_user')] class Issue5735User diff --git a/tests/Fixtures/TestBundle/Entity/Issue7126/DummyForBackedEnumFilter.php b/tests/Fixtures/TestBundle/Entity/Issue7126/DummyForBackedEnumFilter.php index b60a974e9e0..0ab60421fdc 100644 --- a/tests/Fixtures/TestBundle/Entity/Issue7126/DummyForBackedEnumFilter.php +++ b/tests/Fixtures/TestBundle/Entity/Issue7126/DummyForBackedEnumFilter.php @@ -13,15 +13,18 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7126; -use ApiPlatform\Doctrine\Orm\Filter\BackedEnumFilter; -use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; use Doctrine\ORM\Mapping as ORM; #[GetCollection( uriTemplate: 'backed_enum_filter{._format}', + parameters: [ + 'stringBackedEnum' => new QueryParameter(filter: new ExactFilter()), + 'integerBackedEnum' => new QueryParameter(filter: new ExactFilter()), + ], )] -#[ApiFilter(BackedEnumFilter::class, properties: ['stringBackedEnum', 'integerBackedEnum'])] #[ORM\Entity] class DummyForBackedEnumFilter { diff --git a/tests/Fixtures/TestBundle/Entity/Issue8085/DatedCursorDummy.php b/tests/Fixtures/TestBundle/Entity/Issue8085/DatedCursorDummy.php index 654402930a5..d0da06b045c 100644 --- a/tests/Fixtures/TestBundle/Entity/Issue8085/DatedCursorDummy.php +++ b/tests/Fixtures/TestBundle/Entity/Issue8085/DatedCursorDummy.php @@ -14,22 +14,18 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue8085; use ApiPlatform\Doctrine\Orm\Filter\DateFilter; -use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; use Doctrine\ORM\Mapping as ORM; -#[ApiResource( - operations: [ - new GetCollection( - paginationItemsPerPage: 3, - paginationPartial: true, - paginationViaCursor: [['field' => 'createdAt', 'direction' => 'DESC']], - ), - ], - graphQlOperations: [], -)] -#[ApiFilter(DateFilter::class, properties: ['createdAt'])] +#[ApiResource(operations: [ + new GetCollection( + paginationItemsPerPage: 3, + paginationPartial: true, + paginationViaCursor: [['field' => 'createdAt', 'direction' => 'DESC']], + ), +], graphQlOperations: [], parameters: ['createdAt' => new QueryParameter(filter: new DateFilter())])] #[ORM\Entity] #[ORM\Table(name: 'issue_8085_dated_cursor_dummy')] class DatedCursorDummy diff --git a/tests/Fixtures/TestBundle/Entity/DummyExceptionToStatus.php b/tests/Fixtures/TestBundle/Entity/Legacy/DummyExceptionToStatus.php similarity index 97% rename from tests/Fixtures/TestBundle/Entity/DummyExceptionToStatus.php rename to tests/Fixtures/TestBundle/Entity/Legacy/DummyExceptionToStatus.php index 8bf8f6693c2..1dd5550c686 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyExceptionToStatus.php +++ b/tests/Fixtures/TestBundle/Entity/Legacy/DummyExceptionToStatus.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy; use ApiPlatform\Metadata\ApiFilter; use ApiPlatform\Metadata\ApiResource; diff --git a/tests/Fixtures/TestBundle/Entity/Legacy/DummyForBackedEnumFilter.php b/tests/Fixtures/TestBundle/Entity/Legacy/DummyForBackedEnumFilter.php new file mode 100644 index 00000000000..7cc8e542f04 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Legacy/DummyForBackedEnumFilter.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\Tests\Fixtures\TestBundle\Entity\Legacy; + +use ApiPlatform\Doctrine\Orm\Filter\BackedEnumFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7126\IntegerBackedEnum; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7126\StringBackedEnum; +use Doctrine\ORM\Mapping as ORM; + +/** + * Legacy regression fixture: keeps the deprecated #[ApiFilter(BackedEnumFilter)] attribute path + * alive until 6.0. The canonical replacement lives at Entity\Issue7126\DummyForBackedEnumFilter + * (QueryParameter + ExactFilter). + */ +#[GetCollection( + uriTemplate: 'legacy_backed_enum_filter{._format}', +)] +#[ApiFilter(BackedEnumFilter::class, properties: ['stringBackedEnum', 'integerBackedEnum'])] +#[ORM\Entity] +class DummyForBackedEnumFilter +{ + #[ORM\Column(type: 'integer')] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + private ?int $id = null; + + #[ORM\Column(nullable: true, enumType: StringBackedEnum::class)] + private ?StringBackedEnum $stringBackedEnum = null; + + #[ORM\Column(nullable: true, enumType: IntegerBackedEnum::class)] + private ?IntegerBackedEnum $integerBackedEnum = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getStringBackedEnum(): ?StringBackedEnum + { + return $this->stringBackedEnum; + } + + public function setStringBackedEnum(StringBackedEnum $stringBackedEnum): void + { + $this->stringBackedEnum = $stringBackedEnum; + } + + public function getIntegerBackedEnum(): ?IntegerBackedEnum + { + return $this->integerBackedEnum; + } + + public function setIntegerBackedEnum(IntegerBackedEnum $integerBackedEnum): void + { + $this->integerBackedEnum = $integerBackedEnum; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Legacy/FilteredAttributeParameter.php b/tests/Fixtures/TestBundle/Entity/Legacy/FilteredAttributeParameter.php new file mode 100644 index 00000000000..a1629c0b351 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Legacy/FilteredAttributeParameter.php @@ -0,0 +1,74 @@ + + * + * 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\Entity\Legacy; + +use ApiPlatform\Doctrine\Orm\Filter\DateFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter; +use ApiPlatform\Doctrine\Orm\Filter\RangeFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use Doctrine\ORM\Mapping as ORM; + +/** + * Legacy regression fixture: Date/Range/Exists filters survive into 5.0 (rewritten standalone), + * so the deprecated #[ApiFilter] attribute declaration must keep working for users. The canonical + * QueryParameter form lives at Entity\Filtered{Date,Range,Exists}Parameter. Remove the #[ApiFilter] + * coverage here once the attribute is gone (6.0). + */ +#[ApiResource] +#[GetCollection(uriTemplate: 'legacy_filtered_attribute_parameters{._format}')] +#[ApiFilter(DateFilter::class, properties: ['createdAt'])] +#[ApiFilter(RangeFilter::class, properties: ['quantity'])] +#[ApiFilter(ExistsFilter::class, properties: ['description'])] +#[ORM\Entity] +class FilteredAttributeParameter +{ + public function __construct( + #[ORM\Column] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + + #[ORM\Column(nullable: true)] + public ?\DateTimeImmutable $createdAt = null, + + #[ORM\Column(nullable: true)] + public ?int $quantity = null, + + #[ORM\Column(nullable: true)] + public ?string $description = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function getQuantity(): ?int + { + return $this->quantity; + } + + public function getDescription(): ?string + { + return $this->description; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Legacy/FilteredBooleanParameter.php b/tests/Fixtures/TestBundle/Entity/Legacy/FilteredBooleanParameter.php new file mode 100644 index 00000000000..40b94dbe182 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Legacy/FilteredBooleanParameter.php @@ -0,0 +1,72 @@ + + * + * 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\Entity\Legacy; + +use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * Legacy regression fixture: keeps the deprecated BooleanFilter alive until 6.0. + * The canonical replacement (ExactFilter + boolean nativeType) lives at + * Entity\FilteredBooleanParameter. + */ +#[ApiResource] +#[GetCollection( + uriTemplate: 'legacy_filtered_boolean_parameters{._format}', + parameters: [ + 'active' => new QueryParameter( + filter: new BooleanFilter(), + nativeType: new BuiltinType(TypeIdentifier::BOOL), + ), + 'enabled' => new QueryParameter( + filter: new BooleanFilter(), + property: 'active', + nativeType: new BuiltinType(TypeIdentifier::BOOL), + ), + ], +)] +#[ORM\Entity] +class FilteredBooleanParameter +{ + public function __construct( + #[ORM\Column] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + + #[ORM\Column(nullable: true)] + public ?bool $active = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function isActive(): bool + { + return $this->active; + } + + public function setActive(?bool $isActive): void + { + $this->active = $isActive; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Legacy/FilteredNumericParameter.php b/tests/Fixtures/TestBundle/Entity/Legacy/FilteredNumericParameter.php new file mode 100644 index 00000000000..68ac9987a0f --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Legacy/FilteredNumericParameter.php @@ -0,0 +1,85 @@ + + * + * 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\Entity\Legacy; + +use ApiPlatform\Doctrine\Orm\Filter\NumericFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; + +/** + * Legacy regression fixture: keeps the deprecated NumericFilter alive until 6.0. + * The canonical replacement (ExactFilter + numeric nativeType) lives at + * Entity\FilteredNumericParameter. + */ +#[ApiResource] +#[GetCollection( + uriTemplate: 'legacy_filtered_numeric_parameters{._format}', + paginationItemsPerPage: 5, + parameters: [ + 'quantity' => new QueryParameter( + filter: new NumericFilter(), + ), + 'amount' => new QueryParameter( + filter: new NumericFilter(), + property: 'quantity', + ), + 'ratio' => new QueryParameter( + filter: new NumericFilter(), + ), + ], +)] +#[ORM\Entity] +class FilteredNumericParameter +{ + public function __construct( + #[ORM\Column] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + + #[ORM\Column(nullable: true)] + public ?int $quantity = null, + + #[ORM\Column(nullable: true)] + public ?float $ratio = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getQuantity(): ?int + { + return $this->quantity; + } + + public function setQuantity(?int $quantity): void + { + $this->quantity = $quantity; + } + + public function getRatio(): ?float + { + return $this->ratio; + } + + public function setRatio(?float $ratio): void + { + $this->ratio = $ratio; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/Legacy/FilteredOrderParameter.php b/tests/Fixtures/TestBundle/Entity/Legacy/FilteredOrderParameter.php new file mode 100644 index 00000000000..0ba2c172578 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/Legacy/FilteredOrderParameter.php @@ -0,0 +1,89 @@ + + * + * 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\Entity\Legacy; + +use ApiPlatform\Doctrine\Common\Filter\OrderFilterInterface; +use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\QueryParameter; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\TypeInfo\Type\BuiltinType; +use Symfony\Component\TypeInfo\TypeIdentifier; + +/** + * Legacy regression fixture: keeps the deprecated OrderFilter alive until 6.0, including the + * per-property `properties` nulls_comparison config form. The canonical replacement (SortFilter) + * lives at Entity\FilteredOrderParameter. + */ +#[ApiResource] +#[GetCollection( + uriTemplate: 'legacy_filtered_order_parameters{._format}', + paginationItemsPerPage: 5, + parameters: [ + 'createdAt' => new QueryParameter( + filter: new OrderFilter(), + nativeType: new BuiltinType(TypeIdentifier::STRING) + ), + 'date' => new QueryParameter( + filter: new OrderFilter(), + property: 'createdAt', + nativeType: new BuiltinType(TypeIdentifier::STRING) + ), + 'date_null_always_first' => new QueryParameter( + filter: new OrderFilter(), + property: 'createdAt', + filterContext: ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST], + nativeType: new BuiltinType(TypeIdentifier::STRING) + ), + 'date_null_always_first_old_way' => new QueryParameter( + filter: new OrderFilter(properties: ['createdAt' => ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST]]), + property: 'createdAt', + nativeType: new BuiltinType(TypeIdentifier::STRING) + ), + 'order[:property]' => new QueryParameter( + filter: new OrderFilter(), + filterContext: ['nulls_comparison' => OrderFilterInterface::NULLS_ALWAYS_FIRST], + ), + ], +)] +#[ORM\Entity] +class FilteredOrderParameter +{ + public function __construct( + #[ORM\Column] + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + public ?int $id = null, + + #[ORM\Column(nullable: true)] + public ?\DateTimeImmutable $createdAt = null, + ) { + } + + public function getId(): ?int + { + return $this->id; + } + + public function getCreatedAt(): ?\DateTimeImmutable + { + return $this->createdAt; + } + + public function setCreatedAt(?\DateTimeImmutable $createdAt): void + { + $this->createdAt = $createdAt; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php b/tests/Fixtures/TestBundle/Entity/Legacy/SearchFilterParameter.php similarity index 88% rename from tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php rename to tests/Fixtures/TestBundle/Entity/Legacy/SearchFilterParameter.php index 17247f85526..b25a41abbcd 100644 --- a/tests/Fixtures/TestBundle/Entity/SearchFilterParameter.php +++ b/tests/Fixtures/TestBundle/Entity/Legacy/SearchFilterParameter.php @@ -11,7 +11,7 @@ declare(strict_types=1); -namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; +namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy; use ApiPlatform\Doctrine\Orm\Filter\PartialSearchFilter; use ApiPlatform\Metadata\ApiFilter; @@ -24,9 +24,15 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Filter\SearchTextAndDateFilter; use Doctrine\ORM\Mapping as ORM; +/** + * Legacy regression fixture: keeps the deprecated SearchFilter alive until 6.0 through the custom + * SearchFilterValueTransformer / SearchTextAndDateFilter wrappers and the #[ApiFilter] attribute + * aliases referenced by QueryParameter. Canonical scalar/search coverage lives on + * ProductWithQueryParameter (ExactFilter/PartialSearchFilter). Remove in 6.0. + */ #[ApiResource(openapi: false)] #[GetCollection( - uriTemplate: 'search_filter_parameter{._format}', + uriTemplate: 'legacy_search_filter_parameter{._format}', parameters: [ 'foo' => new QueryParameter(filter: 'app_search_filter_via_parameter'), 'fooAlias' => new QueryParameter(filter: 'app_search_filter_via_parameter', property: 'foo'), diff --git a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php index b8a3f562363..1765296e027 100644 --- a/tests/Fixtures/TestBundle/Entity/RelatedDummy.php +++ b/tests/Fixtures/TestBundle/Entity/RelatedDummy.php @@ -14,9 +14,7 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; use ApiPlatform\Doctrine\Orm\Filter\DateFilter; -use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter; -use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; -use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\Get; @@ -24,6 +22,7 @@ use ApiPlatform\Metadata\GraphQl\Mutation; use ApiPlatform\Metadata\GraphQl\Query; use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\QueryParameter; use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\ORM\Mapping as ORM; @@ -35,15 +34,10 @@ * * @author Kévin Dunglas */ -#[ApiResource( - graphQlOperations: [ - new Query(name: 'item_query'), - new Mutation(name: 'update', normalizationContext: ['groups' => ['chicago', 'fakemanytomany']], denormalizationContext: ['groups' => ['friends']]), - ], - types: ['https://schema.org/Product'], - normalizationContext: ['groups' => ['friends']], - filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'] -)] +#[ApiResource(graphQlOperations: [ + new Query(name: 'item_query'), + new Mutation(name: 'update', normalizationContext: ['groups' => ['chicago', 'fakemanytomany']], denormalizationContext: ['groups' => ['friends']]), +], types: ['https://schema.org/Product'], normalizationContext: ['groups' => ['friends']], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], parameters: ['id' => new QueryParameter(filter: new ExactFilter()), 'symfony' => new QueryParameter(filter: new ExactFilter()), 'dummyDate' => new QueryParameter(filter: new DateFilter())])] #[ApiResource(uriTemplate: '/dummies/{id}/related_dummies{._format}', uriVariables: ['id' => new Link(fromClass: Dummy::class, identifiers: ['id'], fromProperty: 'relatedDummies')], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new GetCollection()])] #[ApiResource(uriTemplate: '/dummies/{id}/related_dummies/{relatedDummies}{._format}', uriVariables: ['id' => new Link(fromClass: Dummy::class, identifiers: ['id'], fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new Get()])] #[ApiResource(uriTemplate: '/related_dummies/{id}/id{._format}', uriVariables: ['id' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new Get()])] @@ -51,7 +45,6 @@ #[ApiResource(uriTemplate: '/related_owned_dummies/{id}/owning_dummy/related_dummies/{relatedDummies}{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwnedDummy::class, identifiers: ['id'], fromProperty: 'owningDummy'), 'owningDummy' => new Link(fromClass: Dummy::class, identifiers: [], expandedValue: 'owning_dummy', fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new Get()])] #[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy/related_dummies{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy'), 'ownedDummy' => new Link(fromClass: Dummy::class, identifiers: [], expandedValue: 'owned_dummy', fromProperty: 'relatedDummies')], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new GetCollection()])] #[ApiResource(uriTemplate: '/related_owning_dummies/{id}/owned_dummy/related_dummies/{relatedDummies}{._format}', uriVariables: ['id' => new Link(fromClass: RelatedOwningDummy::class, identifiers: ['id'], fromProperty: 'ownedDummy'), 'ownedDummy' => new Link(fromClass: Dummy::class, identifiers: [], expandedValue: 'owned_dummy', fromProperty: 'relatedDummies'), 'relatedDummies' => new Link(fromClass: self::class, identifiers: ['id'])], status: 200, types: ['https://schema.org/Product'], filters: ['related_dummy.friends', 'related_dummy.complex_sub_query'], normalizationContext: ['groups' => ['friends']], operations: [new Get()])] -#[ApiFilter(filterClass: SearchFilter::class, properties: ['id'])] #[ORM\Entity] class RelatedDummy extends ParentDummy implements \Stringable { @@ -73,8 +66,6 @@ class RelatedDummy extends ParentDummy implements \Stringable #[ApiProperty(deprecationReason: 'This property is deprecated for upgrade test')] #[ORM\Column] #[Groups(['barcelona', 'chicago', 'friends'])] - #[ApiFilter(filterClass: SearchFilter::class)] - #[ApiFilter(filterClass: ExistsFilter::class)] protected $symfony = 'symfony'; /** @@ -83,7 +74,6 @@ class RelatedDummy extends ParentDummy implements \Stringable #[ORM\Column(type: 'datetime', nullable: true)] #[Assert\DateTime] #[Groups(['friends'])] - #[ApiFilter(filterClass: DateFilter::class)] public $dummyDate; #[ORM\ManyToOne(targetEntity: ThirdLevel::class, cascade: ['persist'], inversedBy: 'relatedDummies')] diff --git a/tests/Fixtures/TestBundle/Entity/SoMany.php b/tests/Fixtures/TestBundle/Entity/SoMany.php index e3770b8007d..1de33ba402b 100644 --- a/tests/Fixtures/TestBundle/Entity/SoMany.php +++ b/tests/Fixtures/TestBundle/Entity/SoMany.php @@ -13,15 +13,13 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; -use ApiPlatform\Doctrine\Orm\Filter\OrderFilter; use ApiPlatform\Doctrine\Orm\Filter\RangeFilter; -use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Doctrine\Orm\Filter\SortFilter; use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\QueryParameter; use Doctrine\ORM\Mapping as ORM; -#[ApiFilter(RangeFilter::class, properties: ['id'])] -#[ApiFilter(OrderFilter::class, properties: ['id' => 'DESC'])] -#[ApiResource(paginationPartial: true, paginationViaCursor: [['field' => 'id', 'direction' => 'DESC']])] +#[ApiResource(paginationPartial: true, paginationViaCursor: [['field' => 'id', 'direction' => 'DESC']], parameters: ['id' => new QueryParameter(filter: new RangeFilter()), 'order[:property]' => new QueryParameter(filter: new SortFilter())])] #[ORM\Entity] class SoMany { diff --git a/tests/Functional/ExceptionToStatusTest.php b/tests/Functional/ExceptionToStatusTest.php index 66563a6d022..f95fdd9a69c 100644 --- a/tests/Functional/ExceptionToStatusTest.php +++ b/tests/Functional/ExceptionToStatusTest.php @@ -16,7 +16,7 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\ErrorWithOverridenStatus; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\Issue5924\TooManyRequests; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\DummyExceptionToStatus; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy\DummyExceptionToStatus; use ApiPlatform\Tests\RecreateSchemaTrait; use ApiPlatform\Tests\SetupClassResourcesTrait; diff --git a/tests/Functional/OpenApiTest.php b/tests/Functional/OpenApiTest.php index 3db5fdfd439..ada32910f10 100644 --- a/tests/Functional/OpenApiTest.php +++ b/tests/Functional/OpenApiTest.php @@ -424,8 +424,10 @@ public function testRetrieveTheOpenApiDocumentation(): void $this->assertFalse($json['paths']['/dummies']['get']['parameters'][4]['required']); $this->assertSame('boolean', $json['paths']['/dummies']['get']['parameters'][4]['schema']['type']); - $this->assertSame('foobar[]', $json['paths']['/dummy_cars']['get']['parameters'][9]['name']); - $this->assertSame('Allows you to reduce the response to contain only the properties you need. If your desired property is nested, you can address it using nested arrays. Example: foobar[]={propertyName}&foobar[]={anotherPropertyName}&foobar[{nestedPropertyParent}][]={nestedProperty}', $json['paths']['/dummy_cars']['get']['parameters'][9]['description']); + $dummyCarParameters = $json['paths']['/dummy_cars']['get']['parameters']; + $foobarParameter = array_values(array_filter($dummyCarParameters, static fn (array $parameter): bool => 'foobar[]' === $parameter['name'])); + $this->assertCount(1, $foobarParameter); + $this->assertSame('Allows you to reduce the response to contain only the properties you need. If your desired property is nested, you can address it using nested arrays. Example: foobar[]={propertyName}&foobar[]={anotherPropertyName}&foobar[{nestedPropertyParent}][]={nestedProperty}', $foobarParameter[0]['description']); // Webhook $this->assertSame('Something else here for example', $json['webhooks']['a/{id}']['get']['description']); diff --git a/tests/Functional/Parameters/BooleanFilterTest.php b/tests/Functional/Parameters/BooleanFilterTest.php index a856ea33790..a5dd382ac53 100644 --- a/tests/Functional/Parameters/BooleanFilterTest.php +++ b/tests/Functional/Parameters/BooleanFilterTest.php @@ -76,24 +76,21 @@ public static function booleanFilterScenariosProvider(): \Generator yield 'enabled_alias_numeric_0' => ['/filtered_boolean_parameters?enabled=0', 1, false]; } - #[DataProvider('booleanFilterNullAndEmptyScenariosProvider')] - public function testBooleanFilterWithNullAndEmptyValues(string $url): void + /** + * An empty value cannot be cast to the boolean native type, so the caster rejects it with a + * Bad Request before the filter runs. + */ + #[DataProvider('booleanFilterEmptyScenariosProvider')] + public function testBooleanFilterWithEmptyValues(string $url): void { - $response = self::createClient()->request('GET', $url); - $this->assertResponseIsSuccessful(); - - $responseData = $response->toArray(); - $filteredItems = $responseData['hydra:member']; + self::createClient()->request('GET', $url); - $expectedItemCount = 3; - $this->assertCount($expectedItemCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedItemCount, $url)); + $this->assertResponseStatusCodeSame(400); } - public static function booleanFilterNullAndEmptyScenariosProvider(): \Generator + public static function booleanFilterEmptyScenariosProvider(): \Generator { - yield 'active_null_value' => ['/filtered_boolean_parameters?active=null']; yield 'active_empty_value' => ['/filtered_boolean_parameters?active=']; - yield 'enabled_alias_null_value' => ['/filtered_boolean_parameters?enabled=null']; yield 'enabled_alias_empty_value' => ['/filtered_boolean_parameters?enabled=']; } diff --git a/tests/Functional/Parameters/DoctrineTest.php b/tests/Functional/Parameters/DoctrineTest.php index abce8d43447..0b0dff8896f 100644 --- a/tests/Functional/Parameters/DoctrineTest.php +++ b/tests/Functional/Parameters/DoctrineTest.php @@ -16,11 +16,9 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\FilterWithStateOptions; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\FilterWithStateOptionsAndNoApiFilter; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\SearchFilterParameter as SearchFilterParameterDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilterWithStateOptionsAndNoApiFilterEntity; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilterWithStateOptionsEntity; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ProductWithQueryParameter; -use ApiPlatform\Tests\Fixtures\TestBundle\Entity\SearchFilterParameter; use ApiPlatform\Tests\RecreateSchemaTrait; use ApiPlatform\Tests\SetupClassResourcesTrait; use PHPUnit\Framework\Attributes\DataProvider; @@ -38,95 +36,12 @@ final class DoctrineTest extends ApiTestCase public static function getResources(): array { return [ - SearchFilterParameter::class, FilterWithStateOptions::class, FilterWithStateOptionsAndNoApiFilter::class, ProductWithQueryParameter::class, ]; } - public function testDoctrineEntitySearchFilter(): void - { - $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; - $this->recreateSchema([$resource]); - $this->loadFixtures($resource); - $route = 'search_filter_parameter'; - $response = self::createClient()->request('GET', $route.'?foo=bar'); - $a = $response->toArray(); - $this->assertCount(2, $a['hydra:member']); - $this->assertEquals('bar', $a['hydra:member'][0]['foo']); - $this->assertEquals('bar', $a['hydra:member'][1]['foo']); - - $this->assertArraySubset(['hydra:search' => [ - 'hydra:template' => \sprintf('/%s{?foo,fooAlias,q,order[id],order[foo],searchPartial[foo],searchExact[foo],searchOnTextAndDate[foo],searchOnTextAndDate[createdAt][before],searchOnTextAndDate[createdAt][strictly_before],searchOnTextAndDate[createdAt][after],searchOnTextAndDate[createdAt][strictly_after],search[foo],search[createdAt],id,createdAt}', $route), - ]], $a); - - $this->assertArraySubset(['@type' => 'IriTemplateMapping', 'variable' => 'fooAlias', 'property' => 'foo'], $a['hydra:search']['hydra:mapping'][1]); - - $response = self::createClient()->request('GET', $route.'?fooAlias=baz'); - $a = $response->toArray(); - $this->assertCount(1, $a['hydra:member']); - $this->assertEquals('baz', $a['hydra:member'][0]['foo']); - - $response = self::createClient()->request('GET', $route.'?order[foo]=asc'); - $this->assertEquals($response->toArray()['hydra:member'][0]['foo'], 'bar'); - $response = self::createClient()->request('GET', $route.'?order[foo]=desc'); - $this->assertEquals($response->toArray()['hydra:member'][0]['foo'], 'foo'); - - $response = self::createClient()->request('GET', $route.'?searchPartial[foo]=az'); - $members = $response->toArray()['hydra:member']; - $this->assertCount(1, $members); - $this->assertArraySubset(['foo' => 'baz'], $members[0]); - - $response = self::createClient()->request('GET', $route.'?searchOnTextAndDate[foo]=bar&searchOnTextAndDate[createdAt][before]=2024-01-21'); - $members = $response->toArray()['hydra:member']; - $this->assertCount(1, $members); - $this->assertArraySubset(['foo' => 'bar', 'createdAt' => '2024-01-21T00:00:00+00:00'], $members[0]); - } - - public function testGraphQl(): void - { - if ($_SERVER['EVENT_LISTENERS_BACKWARD_COMPATIBILITY_LAYER'] ?? false) { - $this->markTestSkipped('Parameters are not supported in BC mode.'); - } - - $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; - $this->recreateSchema([$resource]); - $this->loadFixtures($resource); - $object = 'searchFilterParameters'; - $response = self::createClient()->request('POST', '/graphql', ['json' => [ - 'query' => \sprintf('{ %s(foo: "bar") { edges { node { id foo createdAt } } } }', $object), - ]]); - $this->assertEquals('bar', $response->toArray()['data'][$object]['edges'][0]['node']['foo']); - - $response = self::createClient()->request('POST', '/graphql', ['json' => [ - 'query' => \sprintf('{ %s(searchPartial: {foo: "az"}) { edges { node { id foo createdAt } } } }', $object), - ]]); - $this->assertEquals('baz', $response->toArray()['data'][$object]['edges'][0]['node']['foo']); - - $response = self::createClient()->request('POST', '/graphql', ['json' => [ - 'query' => \sprintf('{ %s(searchExact: {foo: "baz"}) { edges { node { id foo createdAt } } } }', $object), - ]]); - $this->assertEquals('baz', $response->toArray()['data'][$object]['edges'][0]['node']['foo']); - - $response = self::createClient()->request('POST', '/graphql', ['json' => [ - 'query' => \sprintf('{ %s(searchOnTextAndDate: {foo: "bar", createdAt: {before: "2024-01-21"}}) { edges { node { id foo createdAt } } } }', $object), - ]]); - $this->assertArraySubset(['foo' => 'bar', 'createdAt' => '2024-01-21T00:00:00+00:00'], $response->toArray()['data'][$object]['edges'][0]['node']); - } - - public function testPropertyPlaceholderFilter(): void - { - static::bootKernel(); - $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; - $this->recreateSchema([$resource]); - $this->loadFixtures($resource); - $route = 'search_filter_parameter'; - $response = self::createClient()->request('GET', $route.'?foo=baz'); - $a = $response->toArray(); - $this->assertEquals($a['hydra:member'][0]['foo'], 'baz'); - } - public function testStateOptions(): void { if ($this->isMongoDB()) { @@ -187,55 +102,6 @@ public function testStateOptionsAndNoApiFilter(): void $this->assertCount(1, $a['hydra:member']); } - #[DataProvider('partialFilterParameterProviderForSearchFilterParameter')] - public function testPartialSearchFilterWithSearchFilterParameter(string $url, int $expectedCount, array $expectedFoos): void - { - $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; - $this->recreateSchema([$resource]); - $this->loadFixtures($resource); - - $response = self::createClient()->request('GET', $url); - - $this->assertResponseIsSuccessful(); - - $responseData = $response->toArray(); - $filteredItems = $responseData['hydra:member']; - - $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); - - $foos = array_map(static fn ($item) => $item['foo'], $filteredItems); - sort($foos); - sort($expectedFoos); - - $this->assertSame($expectedFoos, $foos, 'The "foo" values do not match the expected values.'); - } - - public static function partialFilterParameterProviderForSearchFilterParameter(): \Generator - { - // Fixtures Recap (from DoctrineTest::loadFixtures with SearchFilterParameter): - // 3x foo = 'foo' - // 2x foo = 'bar' - // 1x foo = 'baz' - - yield 'partial match on foo (fo -> 3x foo)' => [ - '/search_filter_parameter?searchPartial[foo]=fo', - 3, - ['foo', 'foo', 'foo'], - ]; - - yield 'partial match on foo (ba -> 2x bar, 1x baz)' => [ - '/search_filter_parameter?searchPartial[foo]=ba', - 3, - ['bar', 'bar', 'baz'], - ]; - - yield 'partial match on foo (az -> 1x baz)' => [ - '/search_filter_parameter?searchPartial[foo]=az', - 1, - ['baz'], - ]; - } - public function testQueryParameterWithPropertyArgument(): void { if ($this->isMongoDB()) { @@ -279,26 +145,6 @@ public function testQueryParameterWithPropertyArgument(): void $this->assertEquals('Mega Device', $members[2]['title']); } - private function loadFixtures(string $resourceClass): void - { - $container = static::$kernel->getContainer(); - $registry = $this->isMongoDB() ? $container->get('doctrine_mongodb') : $container->get('doctrine'); - $manager = $registry->getManager(); - $date = new \DateTimeImmutable('2024-01-21'); - foreach (['foo', 'foo', 'foo', 'bar', 'bar', 'baz'] as $t) { - $s = new $resourceClass(); - $s->setFoo($t); - if ('bar' === $t) { - $s->setCreatedAt($date); - $date = new \DateTimeImmutable('2024-01-22'); - } - - $manager->persist($s); - } - - $manager->flush(); - } - private function loadProductFixtures(string $resourceClass): void { $container = static::$kernel->getContainer(); diff --git a/tests/Functional/Parameters/Legacy/AttributeFilterLegacyTest.php b/tests/Functional/Parameters/Legacy/AttributeFilterLegacyTest.php new file mode 100644 index 00000000000..57c105e6a0e --- /dev/null +++ b/tests/Functional/Parameters/Legacy/AttributeFilterLegacyTest.php @@ -0,0 +1,99 @@ + + * + * 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\Parameters\Legacy; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Legacy\FilteredAttributeParameter as FilteredAttributeParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy\FilteredAttributeParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * Regression coverage for the deprecated #[ApiFilter] attribute declaration of the surviving + * Date/Range/Exists filters. The canonical QueryParameter form is covered by the + * Date/Range/ExistsFilterTest classes. Remove together with the #[ApiFilter] attribute in 6.0. + */ +#[Group('legacy')] +final class AttributeFilterLegacyTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FilteredAttributeParameter::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entityClass = $this->isMongoDB() ? FilteredAttributeParameterDocument::class : FilteredAttributeParameter::class; + + $this->recreateSchema([$entityClass]); + $this->loadFixtures($entityClass); + } + + #[DataProvider('attributeFilterScenariosProvider')] + public function testAttributeFilterResponses(string $url, int $expectedCount): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + + $this->assertCount($expectedCount, $responseData['hydra:member'], \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + } + + public static function attributeFilterScenariosProvider(): \Generator + { + // DateFilter via #[ApiFilter] + yield 'date_after' => ['/legacy_filtered_attribute_parameters?createdAt[after]=2024-06-01', 2]; + yield 'date_before' => ['/legacy_filtered_attribute_parameters?createdAt[before]=2024-06-01', 1]; + // RangeFilter via #[ApiFilter] + yield 'range_gt' => ['/legacy_filtered_attribute_parameters?quantity[gt]=15', 2]; + yield 'range_lt' => ['/legacy_filtered_attribute_parameters?quantity[lt]=15', 1]; + // ExistsFilter via #[ApiFilter] + yield 'exists_true' => ['/legacy_filtered_attribute_parameters?exists[description]=true', 2]; + yield 'exists_false' => ['/legacy_filtered_attribute_parameters?exists[description]=false', 1]; + } + + /** + * @throws \Throwable + */ + private function loadFixtures(string $entityClass): void + { + $manager = $this->getManager(); + + $rows = [ + [new \DateTimeImmutable('2024-01-01'), 10, 'a'], + [new \DateTimeImmutable('2024-06-15'), 20, null], + [new \DateTimeImmutable('2024-12-25'), 30, 'c'], + ]; + + foreach ($rows as [$createdAt, $quantity, $description]) { + $manager->persist(new $entityClass(createdAt: $createdAt, quantity: $quantity, description: $description)); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/Legacy/BackedEnumFilterLegacyTest.php b/tests/Functional/Parameters/Legacy/BackedEnumFilterLegacyTest.php new file mode 100644 index 00000000000..9111ff689ee --- /dev/null +++ b/tests/Functional/Parameters/Legacy/BackedEnumFilterLegacyTest.php @@ -0,0 +1,91 @@ + + * + * 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\Parameters\Legacy; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7126\IntegerBackedEnum; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Issue7126\StringBackedEnum; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy\DummyForBackedEnumFilter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\Group; + +/** + * Regression coverage for the deprecated #[ApiFilter(BackedEnumFilter)] attribute path. + * The canonical equivalent is covered by ApiPlatform\Tests\Functional\BackedEnumFilterTest. + * Remove together with the deprecated filters in 6.0. + */ +#[Group('legacy')] +final class BackedEnumFilterLegacyTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [DummyForBackedEnumFilter::class]; + } + + public function testFilterStringBackedEnum(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema($this->getResources()); + $this->loadFixtures(); + $response = self::createClient()->request('GET', 'legacy_backed_enum_filter?stringBackedEnum='.StringBackedEnum::One->value); + $a = $response->toArray(); + $this->assertCount(1, $a['hydra:member']); + $this->assertEquals(StringBackedEnum::One->value, $a['hydra:member'][0]['stringBackedEnum']); + } + + public function testFilterIntegerBackedEnum(): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(); + } + + $this->recreateSchema($this->getResources()); + $this->loadFixtures(); + $response = self::createClient()->request('GET', 'legacy_backed_enum_filter?integerBackedEnum='.IntegerBackedEnum::Two->value); + $a = $response->toArray(); + $this->assertCount(1, $a['hydra:member']); + $this->assertEquals(IntegerBackedEnum::Two->value, $a['hydra:member'][0]['integerBackedEnum']); + } + + public function loadFixtures(): void + { + $container = static::$kernel->getContainer(); + $registry = $container->get('doctrine'); + $manager = $registry->getManager(); + + $dummyOne = new DummyForBackedEnumFilter(); + $dummyOne->setStringBackedEnum(StringBackedEnum::One); + $dummyOne->setIntegerBackedEnum(IntegerBackedEnum::One); + $manager->persist($dummyOne); + + $dummyTwo = new DummyForBackedEnumFilter(); + $dummyTwo->setStringBackedEnum(StringBackedEnum::Two); + $dummyTwo->setIntegerBackedEnum(IntegerBackedEnum::Two); + $manager->persist($dummyTwo); + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/Legacy/BooleanFilterLegacyTest.php b/tests/Functional/Parameters/Legacy/BooleanFilterLegacyTest.php new file mode 100644 index 00000000000..27b6e818eb0 --- /dev/null +++ b/tests/Functional/Parameters/Legacy/BooleanFilterLegacyTest.php @@ -0,0 +1,124 @@ + + * + * 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\Parameters\Legacy; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Legacy\FilteredBooleanParameter as FilteredBooleanParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy\FilteredBooleanParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * Regression coverage for the deprecated BooleanFilter. The canonical equivalent + * (ExactFilter + boolean nativeType) is covered by + * ApiPlatform\Tests\Functional\Parameters\BooleanFilterTest. + * Remove together with the deprecated filter in 6.0. + */ +#[Group('legacy')] +final class BooleanFilterLegacyTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FilteredBooleanParameter::class]; + } + + /** + * @throws MongoDBException + * @throws \Throwable + */ + protected function setUp(): void + { + $entityClass = $this->isMongoDB() ? FilteredBooleanParameterDocument::class : FilteredBooleanParameter::class; + + $this->recreateSchema([$entityClass]); + $this->loadFixtures($entityClass); + } + + #[DataProvider('booleanFilterScenariosProvider')] + public function testBooleanFilterResponses(string $url, int $expectedActiveItemCount, bool $expectedActiveStatus): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedActiveItemCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedActiveItemCount, $url)); + + foreach ($filteredItems as $item) { + $this->assertSame($expectedActiveStatus, $item['active'], \sprintf("Expected 'active' to be %s", $expectedActiveStatus)); + } + } + + public static function booleanFilterScenariosProvider(): \Generator + { + yield 'active_true' => ['/legacy_filtered_boolean_parameters?active=true', 2, true]; + yield 'active_false' => ['/legacy_filtered_boolean_parameters?active=false', 1, false]; + yield 'active_numeric_1' => ['/legacy_filtered_boolean_parameters?active=1', 2, true]; + yield 'active_numeric_0' => ['/legacy_filtered_boolean_parameters?active=0', 1, false]; + yield 'enabled_alias_true' => ['/legacy_filtered_boolean_parameters?enabled=true', 2, true]; + yield 'enabled_alias_false' => ['/legacy_filtered_boolean_parameters?enabled=false', 1, false]; + yield 'enabled_alias_numeric_1' => ['/legacy_filtered_boolean_parameters?enabled=1', 2, true]; + yield 'enabled_alias_numeric_0' => ['/legacy_filtered_boolean_parameters?enabled=0', 1, false]; + } + + #[DataProvider('booleanFilterNullAndEmptyScenariosProvider')] + public function testBooleanFilterWithNullAndEmptyValues(string $url): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $expectedItemCount = 3; + $this->assertCount($expectedItemCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedItemCount, $url)); + } + + public static function booleanFilterNullAndEmptyScenariosProvider(): \Generator + { + yield 'active_null_value' => ['/legacy_filtered_boolean_parameters?active=null']; + yield 'active_empty_value' => ['/legacy_filtered_boolean_parameters?active=']; + yield 'enabled_alias_null_value' => ['/legacy_filtered_boolean_parameters?enabled=null']; + yield 'enabled_alias_empty_value' => ['/legacy_filtered_boolean_parameters?enabled=']; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(string $entityClass): void + { + $manager = $this->getManager(); + + $booleanStates = [true, true, false, null]; + foreach ($booleanStates as $activeValue) { + $entity = new $entityClass(active: $activeValue); + $manager->persist($entity); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/Legacy/NumericFilterLegacyTest.php b/tests/Functional/Parameters/Legacy/NumericFilterLegacyTest.php new file mode 100644 index 00000000000..2c17aa56537 --- /dev/null +++ b/tests/Functional/Parameters/Legacy/NumericFilterLegacyTest.php @@ -0,0 +1,112 @@ + + * + * 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\Parameters\Legacy; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Legacy\FilteredNumericParameter as FilteredNumericParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy\FilteredNumericParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * Regression coverage for the deprecated NumericFilter. The canonical equivalent + * (ExactFilter + numeric nativeType) is covered by + * ApiPlatform\Tests\Functional\Parameters\NumericFilterTest. + * Remove together with the deprecated filter in 6.0. + */ +#[Group('legacy')] +final class NumericFilterLegacyTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FilteredNumericParameter::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entityClass = $this->isMongoDB() ? FilteredNumericParameterDocument::class : FilteredNumericParameter::class; + + $this->recreateSchema([$entityClass]); + $this->loadFixtures($entityClass); + } + + #[DataProvider('rangeFilterScenariosProvider')] + public function testRangeFilterResponses(string $url, int $expectedCount): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + } + + public static function rangeFilterScenariosProvider(): \Generator + { + yield 'quantity_int_equal' => ['/legacy_filtered_numeric_parameters?quantity=10', 1]; + yield 'ratio_float_equal' => ['/legacy_filtered_numeric_parameters?ratio=1.0', 2]; + yield 'amount_alias_int_equal' => ['/legacy_filtered_numeric_parameters?amount=20', 2]; + } + + #[DataProvider('nullAndEmptyScenariosProvider')] + public function testRangeFilterWithNullAndEmptyValues(string $url, int $expectedCount): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + } + + public static function nullAndEmptyScenariosProvider(): \Generator + { + yield 'quantity_int_null_value' => ['/legacy_filtered_numeric_parameters?quantity=null', 4]; + yield 'quantity_int_empty_value' => ['/legacy_filtered_numeric_parameters?quantity=', 4]; + yield 'ratio_float_null_value' => ['/legacy_filtered_numeric_parameters?ratio=null', 4]; + yield 'ratio_float_empty_value' => ['/legacy_filtered_numeric_parameters?ratio=', 4]; + yield 'amount_alias_int_null_value' => ['/legacy_filtered_numeric_parameters?amount=null', 4]; + yield 'amount_alias_int_empty_value' => ['/legacy_filtered_numeric_parameters?amount=', 4]; + } + + /** + * @throws \Throwable + */ + private function loadFixtures(string $entityClass): void + { + $manager = $this->getManager(); + + foreach ([[10, 1.0], [20, 2.0], [30, 3.0], [20, 1.0]] as [$quantity, $ratio]) { + $entity = new $entityClass(quantity: $quantity, ratio: $ratio); + $manager->persist($entity); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/Legacy/OrderFilterLegacyTest.php b/tests/Functional/Parameters/Legacy/OrderFilterLegacyTest.php new file mode 100644 index 00000000000..69a0ede780e --- /dev/null +++ b/tests/Functional/Parameters/Legacy/OrderFilterLegacyTest.php @@ -0,0 +1,167 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional\Parameters\Legacy; + +use ApiPlatform\Doctrine\Odm\Filter\OrderFilter; +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Legacy\FilteredOrderParameter as FilteredOrderParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy\FilteredOrderParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Doctrine\ODM\MongoDB\MongoDBException; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * Regression coverage for the deprecated OrderFilter, including its per-property `properties` + * nulls_comparison config form. The canonical equivalent (SortFilter) is covered by + * ApiPlatform\Tests\Functional\Parameters\OrderFilterTest. + * Remove together with the deprecated filter in 6.0. + */ +#[Group('legacy')] +final class OrderFilterLegacyTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [FilteredOrderParameter::class]; + } + + /** + * @throws \Throwable + */ + protected function setUp(): void + { + $entityClass = $this->isMongoDB() ? FilteredOrderParameterDocument::class : FilteredOrderParameter::class; + + $this->recreateSchema([$entityClass]); + $this->loadFixtures($entityClass); + } + + #[DataProvider('orderFilterScenariosProvider')] + public function testOrderFilterResponses(string $url, array $expectedOrder): void + { + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $orderedItems = $responseData['hydra:member']; + + $actualOrder = array_map(static fn ($item) => $item['createdAt'] ?? null, $orderedItems); + + // Default NULL order is different in PostgreSQL. + if ($this->isPostgres()) { + $actualOrder = array_values(array_filter($actualOrder)); + $expectedOrder = array_values(array_filter($expectedOrder)); + } + + $this->assertSame($expectedOrder, $actualOrder, \sprintf('Expected order does not match for URL %s', $url)); + } + + public static function orderFilterScenariosProvider(): \Generator + { + yield 'created_at_ordered_asc' => [ + '/legacy_filtered_order_parameters?createdAt=asc', + [null, '2024-01-01T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-12-25T00:00:00+00:00'], + ]; + yield 'created_at_ordered_desc' => [ + '/legacy_filtered_order_parameters?createdAt=desc', + ['2024-12-25T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-01-01T00:00:00+00:00', null], + ]; + yield 'date_alias_ordered_asc' => [ + '/legacy_filtered_order_parameters?date=asc', + [null, '2024-01-01T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-12-25T00:00:00+00:00'], + ]; + yield 'date_alias_ordered_desc' => [ + '/legacy_filtered_order_parameters?date=desc', + ['2024-12-25T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-01-01T00:00:00+00:00', null], + ]; + } + + #[DataProvider('orderFilterNullsComparisonScenariosProvider')] + public function testOrderFilterNullsComparisonResponses(string $url, array $expectedOrder): void + { + if ($this->isMongoDB()) { + $this->markTestSkipped(\sprintf('Not implemented in %s', OrderFilter::class)); + } + + $response = self::createClient()->request('GET', $url); + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $orderedItems = $responseData['hydra:member']; + + $actualOrder = array_map(static fn ($item) => $item['createdAt'] ?? null, $orderedItems); + + $this->assertSame($expectedOrder, $actualOrder, \sprintf('Expected order does not match for URL %s', $url)); + } + + public static function orderFilterNullsComparisonScenariosProvider(): \Generator + { + yield 'date_null_always_first_alias_asc' => [ + '/legacy_filtered_order_parameters?date_null_always_first=asc', + [null, '2024-01-01T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-12-25T00:00:00+00:00'], + ]; + yield 'date_null_always_first_alias_desc' => [ + '/legacy_filtered_order_parameters?date_null_always_first=desc', + [null, '2024-12-25T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-01-01T00:00:00+00:00'], + ]; + yield 'date_null_always_first_old_way_alias_asc' => [ + '/legacy_filtered_order_parameters?date_null_always_first_old_way=asc', + [null, '2024-01-01T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-12-25T00:00:00+00:00'], + ]; + yield 'date_null_always_first_old_way_alias_desc' => [ + '/legacy_filtered_order_parameters?date_null_always_first_old_way=desc', + [null, '2024-12-25T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-01-01T00:00:00+00:00'], + ]; + yield 'order_property_created_at_null_first_asc' => [ + '/legacy_filtered_order_parameters?order[createdAt]=asc', + [null, '2024-01-01T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-12-25T00:00:00+00:00'], + ]; + yield 'order_property_created_at_null_first_desc' => [ + '/legacy_filtered_order_parameters?order[createdAt]=desc', + [null, '2024-12-25T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-01-01T00:00:00+00:00'], + ]; + } + + /** + * @throws \Throwable + * @throws MongoDBException + */ + private function loadFixtures(string $entityClass): void + { + $manager = $this->getManager(); + + $dates = [ + new \DateTimeImmutable('2024-01-01'), + new \DateTimeImmutable('2024-12-25'), + null, + new \DateTimeImmutable('2024-06-15'), + ]; + + foreach ($dates as $createdAtValue) { + $entity = new $entityClass(createdAt: $createdAtValue); + $manager->persist($entity); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/Legacy/SearchFilterParameterLegacyTest.php b/tests/Functional/Parameters/Legacy/SearchFilterParameterLegacyTest.php new file mode 100644 index 00000000000..f41abf0128d --- /dev/null +++ b/tests/Functional/Parameters/Legacy/SearchFilterParameterLegacyTest.php @@ -0,0 +1,196 @@ + + * + * 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\Parameters\Legacy; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\Document\Legacy\SearchFilterParameter as SearchFilterParameterDocument; +use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Legacy\SearchFilterParameter; +use ApiPlatform\Tests\RecreateSchemaTrait; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; + +/** + * Regression coverage for the deprecated SearchFilter exercised through the custom + * SearchFilterValueTransformer / SearchTextAndDateFilter wrappers and #[ApiFilter] aliases. + * Canonical scalar/search coverage lives in DoctrineTest (ProductWithQueryParameter). + * Remove together with the deprecated filter in 6.0. + */ +#[Group('legacy')] +final class SearchFilterParameterLegacyTest extends ApiTestCase +{ + use RecreateSchemaTrait; + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [SearchFilterParameter::class]; + } + + public function testDoctrineEntitySearchFilter(): void + { + $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; + $this->recreateSchema([$resource]); + $this->loadFixtures($resource); + $route = 'legacy_search_filter_parameter'; + $response = self::createClient()->request('GET', $route.'?foo=bar'); + $a = $response->toArray(); + $this->assertCount(2, $a['hydra:member']); + $this->assertEquals('bar', $a['hydra:member'][0]['foo']); + $this->assertEquals('bar', $a['hydra:member'][1]['foo']); + + $this->assertArraySubset(['hydra:search' => [ + 'hydra:template' => \sprintf('/%s{?foo,fooAlias,q,order[id],order[foo],searchPartial[foo],searchExact[foo],searchOnTextAndDate[foo],searchOnTextAndDate[createdAt][before],searchOnTextAndDate[createdAt][strictly_before],searchOnTextAndDate[createdAt][after],searchOnTextAndDate[createdAt][strictly_after],search[foo],search[createdAt],id,createdAt}', $route), + ]], $a); + + $this->assertArraySubset(['@type' => 'IriTemplateMapping', 'variable' => 'fooAlias', 'property' => 'foo'], $a['hydra:search']['hydra:mapping'][1]); + + $response = self::createClient()->request('GET', $route.'?fooAlias=baz'); + $a = $response->toArray(); + $this->assertCount(1, $a['hydra:member']); + $this->assertEquals('baz', $a['hydra:member'][0]['foo']); + + $response = self::createClient()->request('GET', $route.'?order[foo]=asc'); + $this->assertEquals($response->toArray()['hydra:member'][0]['foo'], 'bar'); + $response = self::createClient()->request('GET', $route.'?order[foo]=desc'); + $this->assertEquals($response->toArray()['hydra:member'][0]['foo'], 'foo'); + + $response = self::createClient()->request('GET', $route.'?searchPartial[foo]=az'); + $members = $response->toArray()['hydra:member']; + $this->assertCount(1, $members); + $this->assertArraySubset(['foo' => 'baz'], $members[0]); + + $response = self::createClient()->request('GET', $route.'?searchOnTextAndDate[foo]=bar&searchOnTextAndDate[createdAt][before]=2024-01-21'); + $members = $response->toArray()['hydra:member']; + $this->assertCount(1, $members); + $this->assertArraySubset(['foo' => 'bar', 'createdAt' => '2024-01-21T00:00:00+00:00'], $members[0]); + } + + public function testGraphQl(): void + { + if ($_SERVER['EVENT_LISTENERS_BACKWARD_COMPATIBILITY_LAYER'] ?? false) { + $this->markTestSkipped('Parameters are not supported in BC mode.'); + } + + $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; + $this->recreateSchema([$resource]); + $this->loadFixtures($resource); + $object = 'searchFilterParameters'; + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => \sprintf('{ %s(foo: "bar") { edges { node { id foo createdAt } } } }', $object), + ]]); + $this->assertEquals('bar', $response->toArray()['data'][$object]['edges'][0]['node']['foo']); + + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => \sprintf('{ %s(searchPartial: {foo: "az"}) { edges { node { id foo createdAt } } } }', $object), + ]]); + $this->assertEquals('baz', $response->toArray()['data'][$object]['edges'][0]['node']['foo']); + + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => \sprintf('{ %s(searchExact: {foo: "baz"}) { edges { node { id foo createdAt } } } }', $object), + ]]); + $this->assertEquals('baz', $response->toArray()['data'][$object]['edges'][0]['node']['foo']); + + $response = self::createClient()->request('POST', '/graphql', ['json' => [ + 'query' => \sprintf('{ %s(searchOnTextAndDate: {foo: "bar", createdAt: {before: "2024-01-21"}}) { edges { node { id foo createdAt } } } }', $object), + ]]); + $this->assertArraySubset(['foo' => 'bar', 'createdAt' => '2024-01-21T00:00:00+00:00'], $response->toArray()['data'][$object]['edges'][0]['node']); + } + + public function testPropertyPlaceholderFilter(): void + { + static::bootKernel(); + $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; + $this->recreateSchema([$resource]); + $this->loadFixtures($resource); + $route = 'legacy_search_filter_parameter'; + $response = self::createClient()->request('GET', $route.'?foo=baz'); + $a = $response->toArray(); + $this->assertEquals($a['hydra:member'][0]['foo'], 'baz'); + } + + #[DataProvider('partialFilterParameterProviderForSearchFilterParameter')] + public function testPartialSearchFilterWithSearchFilterParameter(string $url, int $expectedCount, array $expectedFoos): void + { + $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; + $this->recreateSchema([$resource]); + $this->loadFixtures($resource); + + $response = self::createClient()->request('GET', $url); + + $this->assertResponseIsSuccessful(); + + $responseData = $response->toArray(); + $filteredItems = $responseData['hydra:member']; + + $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + + $foos = array_map(static fn ($item) => $item['foo'], $filteredItems); + sort($foos); + sort($expectedFoos); + + $this->assertSame($expectedFoos, $foos, 'The "foo" values do not match the expected values.'); + } + + public static function partialFilterParameterProviderForSearchFilterParameter(): \Generator + { + // Fixtures Recap (from loadFixtures with SearchFilterParameter): + // 3x foo = 'foo' + // 2x foo = 'bar' + // 1x foo = 'baz' + + yield 'partial match on foo (fo -> 3x foo)' => [ + '/legacy_search_filter_parameter?searchPartial[foo]=fo', + 3, + ['foo', 'foo', 'foo'], + ]; + + yield 'partial match on foo (ba -> 2x bar, 1x baz)' => [ + '/legacy_search_filter_parameter?searchPartial[foo]=ba', + 3, + ['bar', 'bar', 'baz'], + ]; + + yield 'partial match on foo (az -> 1x baz)' => [ + '/legacy_search_filter_parameter?searchPartial[foo]=az', + 1, + ['baz'], + ]; + } + + private function loadFixtures(string $resourceClass): void + { + $container = static::$kernel->getContainer(); + $registry = $this->isMongoDB() ? $container->get('doctrine_mongodb') : $container->get('doctrine'); + $manager = $registry->getManager(); + $date = new \DateTimeImmutable('2024-01-21'); + foreach (['foo', 'foo', 'foo', 'bar', 'bar', 'baz'] as $t) { + $s = new $resourceClass(); + $s->setFoo($t); + if ('bar' === $t) { + $s->setCreatedAt($date); + $date = new \DateTimeImmutable('2024-01-22'); + } + + $manager->persist($s); + } + + $manager->flush(); + } +} diff --git a/tests/Functional/Parameters/NumericFilterTest.php b/tests/Functional/Parameters/NumericFilterTest.php index 03512ac1462..bcae5ab1930 100644 --- a/tests/Functional/Parameters/NumericFilterTest.php +++ b/tests/Functional/Parameters/NumericFilterTest.php @@ -66,26 +66,23 @@ public static function rangeFilterScenariosProvider(): \Generator yield 'amount_alias_int_equal' => ['/filtered_numeric_parameters?amount=20', 2]; } - #[DataProvider('nullAndEmptyScenariosProvider')] - public function testRangeFilterWithNullAndEmptyValues(string $url, int $expectedCount): void + /** + * An empty value cannot be cast to the int/float native type, so the caster rejects it with a + * Bad Request before the filter runs. + */ + #[DataProvider('emptyScenariosProvider')] + public function testRangeFilterWithEmptyValues(string $url): void { - $response = self::createClient()->request('GET', $url); - $this->assertResponseIsSuccessful(); + self::createClient()->request('GET', $url); - $responseData = $response->toArray(); - $filteredItems = $responseData['hydra:member']; - - $this->assertCount($expectedCount, $filteredItems, \sprintf('Expected %d items for URL %s', $expectedCount, $url)); + $this->assertResponseStatusCodeSame(400); } - public static function nullAndEmptyScenariosProvider(): \Generator + public static function emptyScenariosProvider(): \Generator { - yield 'quantity_int_null_value' => ['/filtered_numeric_parameters?quantity=null', 4]; - yield 'quantity_int_empty_value' => ['/filtered_numeric_parameters?quantity=', 4]; - yield 'ratio_float_null_value' => ['/filtered_numeric_parameters?ratio=null', 4]; - yield 'ratio_float_empty_value' => ['/filtered_numeric_parameters?ratio=', 4]; - yield 'amount_alias_int_null_value' => ['/filtered_numeric_parameters?amount=null', 4]; - yield 'amount_alias_int_empty_value' => ['/filtered_numeric_parameters?amount=', 4]; + yield 'quantity_int_empty_value' => ['/filtered_numeric_parameters?quantity=']; + yield 'ratio_float_empty_value' => ['/filtered_numeric_parameters?ratio=']; + yield 'amount_alias_int_empty_value' => ['/filtered_numeric_parameters?amount=']; } /** diff --git a/tests/Functional/Parameters/OrderFilterTest.php b/tests/Functional/Parameters/OrderFilterTest.php index 368ca970289..e53942f5dfa 100644 --- a/tests/Functional/Parameters/OrderFilterTest.php +++ b/tests/Functional/Parameters/OrderFilterTest.php @@ -124,14 +124,6 @@ public static function orderFilterNullsComparisonScenariosProvider(): \Generator '/filtered_order_parameters?date_null_always_first=desc', [null, '2024-12-25T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-01-01T00:00:00+00:00'], ]; - yield 'date_null_always_first_old_way_alias_asc' => [ - '/filtered_order_parameters?date_null_always_first_old_way=asc', - [null, '2024-01-01T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-12-25T00:00:00+00:00'], - ]; - yield 'date_null_always_first_old_way_alias_desc' => [ - '/filtered_order_parameters?date_null_always_first_old_way=desc', - [null, '2024-12-25T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-01-01T00:00:00+00:00'], - ]; yield 'order_property_created_at_null_first_asc' => [ '/filtered_order_parameters?order[createdAt]=asc', [null, '2024-01-01T00:00:00+00:00', '2024-06-15T00:00:00+00:00', '2024-12-25T00:00:00+00:00'], diff --git a/tests/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPassTest.php b/tests/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPassTest.php index e2a31ebad61..6f35b793b75 100644 --- a/tests/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPassTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/Compiler/AttributeFilterPassTest.php @@ -15,6 +15,8 @@ use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\AttributeFilterPass; +use ApiPlatform\Tests\Symfony\Bundle\DependencyInjection\Compiler\Resource\LegacyFilteredResource; +use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; @@ -37,6 +39,7 @@ public function testConstruct(): void $this->assertInstanceOf(CompilerPassInterface::class, $attributeFilterPass); } + #[IgnoreDeprecations] public function testProcess(): void { $containerBuilderProphecy = $this->prophesize(ContainerBuilder::class); @@ -57,6 +60,37 @@ public function testProcess(): void $attributeFilterPass->process($containerBuilderProphecy->reveal()); } + public function testProcessTriggersApiFilterDeprecation(): void + { + $containerBuilderProphecy = $this->prophesize(ContainerBuilder::class); + $containerBuilderProphecy->getParameter('api_platform.resource_class_directories')->willReturn([ + __DIR__.'/Resource/', + ]); + $containerBuilderProphecy->has(Argument::type('string'))->willReturn(false, true); + $containerBuilderProphecy->getReflectionClass(BooleanFilter::class, false)->willReturn(new \ReflectionClass(BooleanFilter::class)); + $containerBuilderProphecy->has(BooleanFilter::class)->willReturn(true); + $containerBuilderProphecy->findDefinition(BooleanFilter::class)->willReturn(new Definition(BooleanFilter::class)); + $containerBuilderProphecy->setDefinition(Argument::type('string'), Argument::type(Definition::class))->willReturn(new Definition(BooleanFilter::class)); + + $deprecations = []; + set_error_handler(static function (int $type, string $message) use (&$deprecations): bool { + $deprecations[] = $message; + + return true; + }, \E_USER_DEPRECATED); + + try { + (new AttributeFilterPass())->process($containerBuilderProphecy->reveal()); + } finally { + restore_error_handler(); + } + + $apiFilterDeprecations = array_values(array_filter($deprecations, static fn (string $m): bool => str_contains($m, '#[ApiFilter]'))); + $this->assertCount(1, $apiFilterDeprecations, 'Processing a resource declaring #[ApiFilter] must trigger one deprecation.'); + $this->assertStringContainsString(LegacyFilteredResource::class, $apiFilterDeprecations[0]); + } + + #[IgnoreDeprecations] public function testProcessInvalidFilterClass(): void { $this->expectException(DependencyInjectionInvalidArgumentException::class); diff --git a/tests/Symfony/Bundle/DependencyInjection/Compiler/Resource/LegacyFilteredResource.php b/tests/Symfony/Bundle/DependencyInjection/Compiler/Resource/LegacyFilteredResource.php new file mode 100644 index 00000000000..61a71a89361 --- /dev/null +++ b/tests/Symfony/Bundle/DependencyInjection/Compiler/Resource/LegacyFilteredResource.php @@ -0,0 +1,23 @@ + + * + * 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\Resource; + +use ApiPlatform\Doctrine\Orm\Filter\BooleanFilter; +use ApiPlatform\Metadata\ApiFilter; + +#[ApiFilter(BooleanFilter::class, properties: ['active'])] +class LegacyFilteredResource +{ + public bool $active = false; +} From 22ece51994e4d660b5eafbc7257b48d3495f221e Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 25 Jun 2026 17:18:03 +0200 Subject: [PATCH 39/84] chore: open 5.0 development line Bump dev-main branch alias 4.4.x-dev -> 5.0.x-dev across root and all 21 subpackage composer.json files. Update COMPOSER_ROOT_VERSION to 5.0.x-dev (was stale at 4.3.x-dev). The 4.4 branch was cut from the prior 4.4.x-dev HEAD before this bump to carry the 4.4 maintenance line. --- .github/workflows/ci.yml | 2 +- composer.json | 2 +- src/Doctrine/Common/composer.json | 2 +- src/Doctrine/Odm/composer.json | 2 +- src/Doctrine/Orm/composer.json | 2 +- src/Documentation/composer.json | 2 +- src/Elasticsearch/composer.json | 2 +- src/GraphQl/composer.json | 2 +- src/Hal/composer.json | 2 +- src/HttpCache/composer.json | 2 +- src/Hydra/composer.json | 2 +- src/JsonApi/composer.json | 2 +- src/JsonLd/composer.json | 2 +- src/JsonSchema/composer.json | 2 +- src/Laravel/composer.json | 2 +- src/Mcp/composer.json | 2 +- src/Metadata/composer.json | 2 +- src/OpenApi/composer.json | 2 +- src/RamseyUuid/composer.json | 2 +- src/Serializer/composer.json | 2 +- src/State/composer.json | 2 +- src/Symfony/composer.json | 2 +- src/Validator/composer.json | 2 +- 23 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc3a74bdf3b..e07616c2d83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ concurrency: env: COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COMPOSER_ROOT_VERSION: "4.3.x-dev" + COMPOSER_ROOT_VERSION: "5.0.x-dev" jobs: architecture: diff --git a/composer.json b/composer.json index a91224ff2ce..033c9a0e580 100644 --- a/composer.json +++ b/composer.json @@ -50,7 +50,7 @@ "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev", "dev-4.2": "4.2.x-dev", - "dev-main": "4.4.x-dev" + "dev-main": "5.0.x-dev" }, "symfony": { "require": "^6.4 || ^7.1 || ^8.0" diff --git a/src/Doctrine/Common/composer.json b/src/Doctrine/Common/composer.json index 779cc26c7c8..bb59e57d827 100644 --- a/src/Doctrine/Common/composer.json +++ b/src/Doctrine/Common/composer.json @@ -61,7 +61,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" diff --git a/src/Doctrine/Odm/composer.json b/src/Doctrine/Odm/composer.json index af514fb7349..ea08fa27d8b 100644 --- a/src/Doctrine/Odm/composer.json +++ b/src/Doctrine/Odm/composer.json @@ -62,7 +62,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" diff --git a/src/Doctrine/Orm/composer.json b/src/Doctrine/Orm/composer.json index 3f78c59b5f1..27b9d510e43 100644 --- a/src/Doctrine/Orm/composer.json +++ b/src/Doctrine/Orm/composer.json @@ -63,7 +63,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" diff --git a/src/Documentation/composer.json b/src/Documentation/composer.json index 0b46b329fa5..fc662ae3d86 100644 --- a/src/Documentation/composer.json +++ b/src/Documentation/composer.json @@ -25,7 +25,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" diff --git a/src/Elasticsearch/composer.json b/src/Elasticsearch/composer.json index a956d6acea0..c777e7f4c0f 100644 --- a/src/Elasticsearch/composer.json +++ b/src/Elasticsearch/composer.json @@ -64,7 +64,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" diff --git a/src/GraphQl/composer.json b/src/GraphQl/composer.json index 3b128514480..42520e313f9 100644 --- a/src/GraphQl/composer.json +++ b/src/GraphQl/composer.json @@ -64,7 +64,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" diff --git a/src/Hal/composer.json b/src/Hal/composer.json index 1d8bd5f3685..1e592df2f4f 100644 --- a/src/Hal/composer.json +++ b/src/Hal/composer.json @@ -48,7 +48,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" diff --git a/src/HttpCache/composer.json b/src/HttpCache/composer.json index 13f838942bd..6dec4dc00bd 100644 --- a/src/HttpCache/composer.json +++ b/src/HttpCache/composer.json @@ -55,7 +55,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" diff --git a/src/Hydra/composer.json b/src/Hydra/composer.json index 273754f45f3..43650727ff8 100644 --- a/src/Hydra/composer.json +++ b/src/Hydra/composer.json @@ -62,7 +62,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" diff --git a/src/JsonApi/composer.json b/src/JsonApi/composer.json index ef3ba825579..ad9e949a4c2 100644 --- a/src/JsonApi/composer.json +++ b/src/JsonApi/composer.json @@ -57,7 +57,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" diff --git a/src/JsonLd/composer.json b/src/JsonLd/composer.json index 7c87f7ac5a7..b3f31ceb1f3 100644 --- a/src/JsonLd/composer.json +++ b/src/JsonLd/composer.json @@ -51,7 +51,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" diff --git a/src/JsonSchema/composer.json b/src/JsonSchema/composer.json index 0fdfad5584f..1473acf9221 100644 --- a/src/JsonSchema/composer.json +++ b/src/JsonSchema/composer.json @@ -56,7 +56,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" diff --git a/src/Laravel/composer.json b/src/Laravel/composer.json index d19afa36de9..4363297acee 100644 --- a/src/Laravel/composer.json +++ b/src/Laravel/composer.json @@ -98,7 +98,7 @@ ] }, "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" diff --git a/src/Mcp/composer.json b/src/Mcp/composer.json index 389c2dc1f95..49bd901274e 100644 --- a/src/Mcp/composer.json +++ b/src/Mcp/composer.json @@ -50,7 +50,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev" + "dev-main": "5.0.x-dev" }, "symfony": { "require": "^6.4 || ^7.0 || ^8.0" diff --git a/src/Metadata/composer.json b/src/Metadata/composer.json index 0433c6e8e72..826624f44d2 100644 --- a/src/Metadata/composer.json +++ b/src/Metadata/composer.json @@ -73,7 +73,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" diff --git a/src/OpenApi/composer.json b/src/OpenApi/composer.json index 11ea83cbc44..a711f6be553 100644 --- a/src/OpenApi/composer.json +++ b/src/OpenApi/composer.json @@ -66,7 +66,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" diff --git a/src/RamseyUuid/composer.json b/src/RamseyUuid/composer.json index ed025c5c9c4..2ff387bdfb8 100644 --- a/src/RamseyUuid/composer.json +++ b/src/RamseyUuid/composer.json @@ -50,7 +50,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" diff --git a/src/Serializer/composer.json b/src/Serializer/composer.json index 36b4517b39c..fe32b839c53 100644 --- a/src/Serializer/composer.json +++ b/src/Serializer/composer.json @@ -69,7 +69,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" diff --git a/src/State/composer.json b/src/State/composer.json index 10700330cad..4e3c2ddfa74 100644 --- a/src/State/composer.json +++ b/src/State/composer.json @@ -68,7 +68,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index c139336e1b9..2a0045deeb8 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -109,7 +109,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev" + "dev-main": "5.0.x-dev" }, "symfony": { "require": "^6.4 || ^7.0 || ^8.0" diff --git a/src/Validator/composer.json b/src/Validator/composer.json index 298f6527eed..ae8a9315c34 100644 --- a/src/Validator/composer.json +++ b/src/Validator/composer.json @@ -52,7 +52,7 @@ }, "extra": { "branch-alias": { - "dev-main": "4.4.x-dev", + "dev-main": "5.0.x-dev", "dev-4.2": "4.2.x-dev", "dev-3.4": "3.4.x-dev", "dev-4.1": "4.1.x-dev" From 06a3b72ba30c52748adecf38aeb402ed878f7b60 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 25 Jun 2026 17:25:50 +0200 Subject: [PATCH 40/84] ci: set COMPOSER_ROOT_VERSION to 4.4.x-dev The 4.4 maintenance branch inherited a stale 4.3.x-dev value from main. Each release branch pins its own dev version (cf. 4.3, 4.2). --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc3a74bdf3b..0c02ff4cb8b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ concurrency: env: COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} - COMPOSER_ROOT_VERSION: "4.3.x-dev" + COMPOSER_ROOT_VERSION: "4.4.x-dev" jobs: architecture: From 3cdbaf4e49a8f1776b48d7f64da80d301eba775a Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 25 Jun 2026 18:06:33 +0200 Subject: [PATCH 41/84] doc: changelog 4.4.0-alpha.1 --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8539b44569..a184c82a1d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## v4.4.0-alpha.1 + +### Bug fixes + +* [9b7ace54f](https://github.com/api-platform/core/commit/9b7ace54fdef376d249243f1220d7df638f19b34) fix(graphql): build filter args from parameters (#8347) +* [a47e36c33](https://github.com/api-platform/core/commit/a47e36c33b8436abe2e52413dee3acb71e83843f) fix(state): scope ReadLinkParameterProvider to current Link's class (#7943) +* [c2909a1ff](https://github.com/api-platform/core/commit/c2909a1ff2016fb78ff81ea9f5fa97452e28fcd4) fix(mcp): fallback to sdk handler when not found (#7818) + + +### Features + +* [0fb1dc8f6](https://github.com/api-platform/core/commit/0fb1dc8f6ff5457df773299f0c7eb7071494be69) feat(symfony): api:upgrade-filter codemod + filter fixture migration (#8344) +* [2ff386bd8](https://github.com/api-platform/core/commit/2ff386bd854fbf0192d521be641fb7304b65688d) feat(symfony,laravel): `withCredentials` option to Swagger UI (#8197) +* [373b56b98](https://github.com/api-platform/core/commit/373b56b98c01ee09583714b79ab0aa0bf3232508) feat(doctrine): deprecate the extends-AbstractFilter form of Date/Range/Exists filters (#8340) +* [48bc56e9a](https://github.com/api-platform/core/commit/48bc56e9ab34532d87aafb9abd2e6c6c98768571) feat(metadata): document BackwardCompatibleFilterDescriptionTrait as public API (#8326) +* [4bf850fc8](https://github.com/api-platform/core/commit/4bf850fc8957616f94c3faf5a1abc799826c6379) feat(doctrine): add StartSearchFilter and WordStartSearchFilter (ORM + ODM) (#8328) +* [5ddf94aeb](https://github.com/api-platform/core/commit/5ddf94aeb9b560fd981bab4ddf62ad8d16641cd1) feat(jsonld): add resource-level jsonldContext for namespace prefixes (#8204) +* [6942dc0a1](https://github.com/api-platform/core/commit/6942dc0a1bc708c0f86c454a8553c25d7e9e7fff) feat(doctrine): promote OrFilter out of @experimental (#8324) +* [72b02afb0](https://github.com/api-platform/core/commit/72b02afb031d9aad0e84d2f8c230905f3a4437a9) feat(hydra): use hydra:memberAssertion instead of owl:equivalentClass (#7944) +* [75f9056d3](https://github.com/api-platform/core/commit/75f9056d32d696fdfd729ead9d4dd5e443eb6062) feat(openapi): support OpenAPI 3.2.0 (#8350) +* [8f48b9dbc](https://github.com/api-platform/core/commit/8f48b9dbc02e1dd35e02151edcab1bb0138d1ed9) feat(symfony): deprecate jsonapi.use_iri_as_id defaulting to true (#8327) +* [9179b3667](https://github.com/api-platform/core/commit/9179b366710e50085b796430e390a6a158f40e24) feat(doctrine): per-property filter map in FreeTextQueryFilter (#8257) +* [94f3c7fe8](https://github.com/api-platform/core/commit/94f3c7fe8b681dc76d7d061092916d4bd7c1b900) feat(openapi): Scalar API Reference documentation support (#7817) +* [98dc77ba7](https://github.com/api-platform/core/commit/98dc77ba734d4fb9dfd46d76885a706aed3b6405) feat(doctrine): state options repositoryMethod for query builder (#7115) +* [9b1a58fd5](https://github.com/api-platform/core/commit/9b1a58fd533839a94f7ead264645410745f104fa) feat(doctrine): deprecate the legacy SearchFilter/Boolean/Numeric/BackedEnum/OrderFilter (#8341) +* [af0a0ab6c](https://github.com/api-platform/core/commit/af0a0ab6c286b5e30160dce9f4bcc23836125dab) feat(doctrine): promote ComparisonFilter out of @experimental (#8323) +* [b0f6dbd63](https://github.com/api-platform/core/commit/b0f6dbd63b1314efadedfd3a0b35474f6f1b8cbf) feat(doctrine): add EndSearchFilter primary for ORM and ODM (#8319) +* [b2f1a5ac3](https://github.com/api-platform/core/commit/b2f1a5ac34c6b6eb71bce40a91e34c5bee63f514) feat(metadata): throwOnNotFound option (#6027) +* [c3fd6dd6b](https://github.com/api-platform/core/commit/c3fd6dd6b5dedbc96851ccc2fcc6d01ac2fc4e46) feat(doctrine): deprecate AbstractFilter base class (#8330) +* [c9e5071d9](https://github.com/api-platform/core/commit/c9e5071d973caedeb9b554f885dedbc89743b93d) feat(symfony): deprecate Symfony Security AccessDeniedException (#8318) +* [cc0ae1254](https://github.com/api-platform/core/commit/cc0ae1254acaf9742c7f899dd24a14d46c89ca6e) feat: support dynamic HTTP response status code via request attribute (#7904) + ## v4.3.14 ### Bug fixes From b3f02f4e08edbcb25777815c1f38920ea187a5a9 Mon Sep 17 00:00:00 2001 From: soyuka Date: Thu, 25 Jun 2026 18:15:00 +0200 Subject: [PATCH 42/84] chore: require ^4.4@alpha for inter-package dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #7115 added cross-package calls new in 4.4 — getStateOptionsRepositoryMethod() in api-platform/state and the repositoryMethod constructor argument in api-platform/doctrine-common — that 4.4 provider code invokes. The old ^4.2/^4.3 sibling floors let `composer update --prefer-lowest` pull releases lacking these symbols, so the per-component lowest CI jobs failed with "undefined method" / "unknown named parameter". Floor all api-platform/* inter-package constraints at ^4.4. The @alpha stability flag is required because the subpackages set minimum-stability:beta, under which a plain ^4.4 would not match the 4.4.0-alpha prereleases. Revert to plain ^4.4 once 4.4.0 stable ships. --- src/Doctrine/Common/composer.json | 4 ++-- src/Doctrine/Odm/composer.json | 8 ++++---- src/Doctrine/Orm/composer.json | 8 ++++---- src/Documentation/composer.json | 2 +- src/Elasticsearch/composer.json | 6 +++--- src/GraphQl/composer.json | 8 ++++---- src/Hal/composer.json | 10 ++++----- src/HttpCache/composer.json | 4 ++-- src/Hydra/composer.json | 18 ++++++++-------- src/JsonApi/composer.json | 10 ++++----- src/JsonLd/composer.json | 6 +++--- src/JsonSchema/composer.json | 2 +- src/Laravel/composer.json | 26 +++++++++++------------ src/Mcp/composer.json | 4 ++-- src/Metadata/composer.json | 6 +++--- src/OpenApi/composer.json | 14 ++++++------- src/RamseyUuid/composer.json | 2 +- src/Serializer/composer.json | 14 ++++++------- src/State/composer.json | 6 +++--- src/Symfony/composer.json | 34 +++++++++++++++---------------- src/Validator/composer.json | 4 ++-- 21 files changed, 98 insertions(+), 98 deletions(-) diff --git a/src/Doctrine/Common/composer.json b/src/Doctrine/Common/composer.json index 779cc26c7c8..b4f06cf68c7 100644 --- a/src/Doctrine/Common/composer.json +++ b/src/Doctrine/Common/composer.json @@ -24,8 +24,8 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.2.6", - "api-platform/state": "^4.2.4", + "api-platform/metadata": "^4.4@alpha", + "api-platform/state": "^4.4@alpha", "doctrine/collections": "^2.1 || ^3.0", "doctrine/common": "^3.2.2", "doctrine/persistence": "^3.2 || ^4.0" diff --git a/src/Doctrine/Odm/composer.json b/src/Doctrine/Odm/composer.json index af514fb7349..e90a260c2ec 100644 --- a/src/Doctrine/Odm/composer.json +++ b/src/Doctrine/Odm/composer.json @@ -25,10 +25,10 @@ ], "require": { "php": ">=8.2", - "api-platform/doctrine-common": "^4.2.23", - "api-platform/metadata": "^4.2", - "api-platform/serializer": "^4.2.16", - "api-platform/state": "^4.2.4", + "api-platform/doctrine-common": "^4.4@alpha", + "api-platform/metadata": "^4.4@alpha", + "api-platform/serializer": "^4.4@alpha", + "api-platform/state": "^4.4@alpha", "doctrine/mongodb-odm": "^2.10", "symfony/property-info": "^6.4 || ^7.1 || ^8.0", "symfony/type-info": "^7.3 || ^8.0" diff --git a/src/Doctrine/Orm/composer.json b/src/Doctrine/Orm/composer.json index 3f78c59b5f1..53ac6225bd2 100644 --- a/src/Doctrine/Orm/composer.json +++ b/src/Doctrine/Orm/composer.json @@ -24,10 +24,10 @@ ], "require": { "php": ">=8.2", - "api-platform/doctrine-common": "^4.2.23", - "api-platform/metadata": "^4.2", - "api-platform/serializer": "^4.2.16", - "api-platform/state": "^4.2.4", + "api-platform/doctrine-common": "^4.4@alpha", + "api-platform/metadata": "^4.4@alpha", + "api-platform/serializer": "^4.4@alpha", + "api-platform/state": "^4.4@alpha", "composer/semver": "^3.4", "doctrine/orm": "^2.17 || ^3.0.1" }, diff --git a/src/Documentation/composer.json b/src/Documentation/composer.json index 0b46b329fa5..cb0346846a2 100644 --- a/src/Documentation/composer.json +++ b/src/Documentation/composer.json @@ -21,7 +21,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.3" + "api-platform/metadata": "^4.4@alpha" }, "extra": { "branch-alias": { diff --git a/src/Elasticsearch/composer.json b/src/Elasticsearch/composer.json index a956d6acea0..a587c37b5e0 100644 --- a/src/Elasticsearch/composer.json +++ b/src/Elasticsearch/composer.json @@ -24,9 +24,9 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.3", - "api-platform/serializer": "^4.3.12", - "api-platform/state": "^4.3", + "api-platform/metadata": "^4.4@alpha", + "api-platform/serializer": "^4.4@alpha", + "api-platform/state": "^4.4@alpha", "elasticsearch/elasticsearch": "^7.17 || ^8.4 || ^9.0", "symfony/cache": "^6.4 || ^7.0 || ^8.0", "symfony/console": "^6.4 || ^7.0 || ^8.0", diff --git a/src/GraphQl/composer.json b/src/GraphQl/composer.json index 3b128514480..f355ce252f6 100644 --- a/src/GraphQl/composer.json +++ b/src/GraphQl/composer.json @@ -21,9 +21,9 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.3", - "api-platform/state": "^4.3", - "api-platform/serializer": "^4.3.12", + "api-platform/metadata": "^4.4@alpha", + "api-platform/state": "^4.4@alpha", + "api-platform/serializer": "^4.4@alpha", "symfony/property-info": "^7.1 || ^8.0", "symfony/serializer": "^6.4 || ^7.1 || ^8.0", "symfony/type-info": "^7.3 || ^8.0", @@ -32,7 +32,7 @@ }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", - "api-platform/validator": "^4.3.1", + "api-platform/validator": "^4.4@alpha", "twig/twig": "^1.42.3 || ^2.12 || ^3.0", "symfony/mercure-bundle": "*", "symfony/routing": "^6.4 || ^7.0 || ^8.0", diff --git a/src/Hal/composer.json b/src/Hal/composer.json index 1d8bd5f3685..1d06e9fb96b 100644 --- a/src/Hal/composer.json +++ b/src/Hal/composer.json @@ -22,10 +22,10 @@ ], "require": { "php": ">=8.2", - "api-platform/state": "^4.3", - "api-platform/metadata": "^4.3", - "api-platform/documentation": "^4.3", - "api-platform/serializer": "^4.3.12", + "api-platform/state": "^4.4@alpha", + "api-platform/metadata": "^4.4@alpha", + "api-platform/documentation": "^4.4@alpha", + "api-platform/serializer": "^4.4@alpha", "symfony/type-info": "^7.3 || ^8.0" }, "autoload": { @@ -65,7 +65,7 @@ "test": "./vendor/bin/phpunit" }, "require-dev": { - "api-platform/json-schema": "^4.3", + "api-platform/json-schema": "^4.4@alpha", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2" }, diff --git a/src/HttpCache/composer.json b/src/HttpCache/composer.json index 13f838942bd..210f560e526 100644 --- a/src/HttpCache/composer.json +++ b/src/HttpCache/composer.json @@ -23,8 +23,8 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.3", - "api-platform/state": "^4.3", + "api-platform/metadata": "^4.4@alpha", + "api-platform/state": "^4.4@alpha", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0" }, "require-dev": { diff --git a/src/Hydra/composer.json b/src/Hydra/composer.json index 273754f45f3..82ec7d60508 100644 --- a/src/Hydra/composer.json +++ b/src/Hydra/composer.json @@ -25,19 +25,19 @@ ], "require": { "php": ">=8.2", - "api-platform/state": "^4.3", - "api-platform/documentation": "^4.3", - "api-platform/metadata": "^4.3", - "api-platform/jsonld": "^4.3", - "api-platform/json-schema": "^4.3", - "api-platform/serializer": "^4.3.12", + "api-platform/state": "^4.4@alpha", + "api-platform/documentation": "^4.4@alpha", + "api-platform/metadata": "^4.4@alpha", + "api-platform/jsonld": "^4.4@alpha", + "api-platform/json-schema": "^4.4@alpha", + "api-platform/serializer": "^4.4@alpha", "symfony/web-link": "^6.4 || ^7.1 || ^8.0", "symfony/type-info": "^7.3 || ^8.0" }, "require-dev": { - "api-platform/doctrine-odm": "^4.3", - "api-platform/doctrine-orm": "^4.3", - "api-platform/doctrine-common": "^4.3", + "api-platform/doctrine-odm": "^4.4@alpha", + "api-platform/doctrine-orm": "^4.4@alpha", + "api-platform/doctrine-common": "^4.4@alpha", "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2" diff --git a/src/JsonApi/composer.json b/src/JsonApi/composer.json index ef3ba825579..4b619712e72 100644 --- a/src/JsonApi/composer.json +++ b/src/JsonApi/composer.json @@ -22,11 +22,11 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.3", - "api-platform/json-schema": "^4.3", - "api-platform/metadata": "^4.3", - "api-platform/serializer": "^4.3.12", - "api-platform/state": "^4.3", + "api-platform/documentation": "^4.4@alpha", + "api-platform/json-schema": "^4.4@alpha", + "api-platform/metadata": "^4.4@alpha", + "api-platform/serializer": "^4.4@alpha", + "api-platform/state": "^4.4@alpha", "symfony/error-handler": "^6.4 || ^7.0 || ^8.0", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", "symfony/type-info": "^7.3 || ^8.0" diff --git a/src/JsonLd/composer.json b/src/JsonLd/composer.json index 7c87f7ac5a7..ba8457bcf31 100644 --- a/src/JsonLd/composer.json +++ b/src/JsonLd/composer.json @@ -24,9 +24,9 @@ ], "require": { "php": ">=8.2", - "api-platform/state": "^4.3", - "api-platform/metadata": "^4.3", - "api-platform/serializer": "^4.3.12" + "api-platform/state": "^4.4@alpha", + "api-platform/metadata": "^4.4@alpha", + "api-platform/serializer": "^4.4@alpha" }, "autoload": { "psr-4": { diff --git a/src/JsonSchema/composer.json b/src/JsonSchema/composer.json index 0fdfad5584f..14cd0d6a859 100644 --- a/src/JsonSchema/composer.json +++ b/src/JsonSchema/composer.json @@ -25,7 +25,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.3", + "api-platform/metadata": "^4.4@alpha", "symfony/console": "^6.4 || ^7.0 || ^8.0", "symfony/property-info": "^6.4 || ^7.1 || ^8.0", "symfony/serializer": "^6.4 || ^7.0 || ^8.0", diff --git a/src/Laravel/composer.json b/src/Laravel/composer.json index d19afa36de9..fc00e3684c4 100644 --- a/src/Laravel/composer.json +++ b/src/Laravel/composer.json @@ -28,16 +28,16 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.3", - "api-platform/hal": "^4.3", - "api-platform/hydra": "^4.3", - "api-platform/json-api": "^4.3", - "api-platform/json-schema": "^4.3", - "api-platform/jsonld": "^4.3", - "api-platform/metadata": "^4.3", - "api-platform/openapi": "^4.3", - "api-platform/serializer": "^4.3.12", - "api-platform/state": "^4.3", + "api-platform/documentation": "^4.4@alpha", + "api-platform/hal": "^4.4@alpha", + "api-platform/hydra": "^4.4@alpha", + "api-platform/json-api": "^4.4@alpha", + "api-platform/json-schema": "^4.4@alpha", + "api-platform/jsonld": "^4.4@alpha", + "api-platform/metadata": "^4.4@alpha", + "api-platform/openapi": "^4.4@alpha", + "api-platform/serializer": "^4.4@alpha", + "api-platform/state": "^4.4@alpha", "illuminate/config": "^11.0 || ^12.0 || ^13.0", "illuminate/container": "^11.0 || ^12.0 || ^13.0", "illuminate/contracts": "^11.0 || ^12.0 || ^13.0", @@ -53,9 +53,9 @@ "willdurand/negotiation": "^3.1" }, "require-dev": { - "api-platform/graphql": "^4.3", - "api-platform/http-cache": "^4.3", - "api-platform/mcp": "^4.3", + "api-platform/graphql": "^4.4@alpha", + "api-platform/http-cache": "^4.4@alpha", + "api-platform/mcp": "^4.4@alpha", "doctrine/dbal": "^4.0", "larastan/larastan": "^2.0 || ^3.0", "laravel/sanctum": "^4.0", diff --git a/src/Mcp/composer.json b/src/Mcp/composer.json index 389c2dc1f95..9b8f8593105 100644 --- a/src/Mcp/composer.json +++ b/src/Mcp/composer.json @@ -28,8 +28,8 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.3", - "api-platform/json-schema": "^4.3", + "api-platform/metadata": "^4.4@alpha", + "api-platform/json-schema": "^4.4@alpha", "mcp/sdk": "^0.6", "symfony/object-mapper": "^7.4 || ^8.0", "symfony/polyfill-php85": "^1.32" diff --git a/src/Metadata/composer.json b/src/Metadata/composer.json index 0433c6e8e72..792a203f048 100644 --- a/src/Metadata/composer.json +++ b/src/Metadata/composer.json @@ -36,9 +36,9 @@ "symfony/type-info": "^7.3 || ^8.0" }, "require-dev": { - "api-platform/json-schema": "^4.3", - "api-platform/openapi": "^4.3", - "api-platform/state": "^4.3", + "api-platform/json-schema": "^4.4@alpha", + "api-platform/openapi": "^4.4@alpha", + "api-platform/state": "^4.4@alpha", "phpspec/prophecy-phpunit": "^2.2", "phpstan/phpdoc-parser": "^1.29 || ^2.0", "phpunit/phpunit": "^11.5 || ^12.2", diff --git a/src/OpenApi/composer.json b/src/OpenApi/composer.json index 11ea83cbc44..d0bd2f8a0b9 100644 --- a/src/OpenApi/composer.json +++ b/src/OpenApi/composer.json @@ -28,9 +28,9 @@ ], "require": { "php": ">=8.2", - "api-platform/json-schema": "^4.3", - "api-platform/metadata": "^4.3", - "api-platform/state": "^4.3", + "api-platform/json-schema": "^4.4@alpha", + "api-platform/metadata": "^4.4@alpha", + "api-platform/state": "^4.4@alpha", "symfony/console": "^6.4 || ^7.0 || ^8.0", "symfony/filesystem": "^6.4 || ^7.0 || ^8.0", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", @@ -40,10 +40,10 @@ "require-dev": { "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", - "api-platform/doctrine-common": "^4.3", - "api-platform/doctrine-orm": "^4.3", - "api-platform/doctrine-odm": "^4.3", - "api-platform/serializer": "^4.3.12", + "api-platform/doctrine-common": "^4.4@alpha", + "api-platform/doctrine-orm": "^4.4@alpha", + "api-platform/doctrine-odm": "^4.4@alpha", + "api-platform/serializer": "^4.4@alpha", "symfony/type-info": "^7.3 || ^8.0" }, "autoload": { diff --git a/src/RamseyUuid/composer.json b/src/RamseyUuid/composer.json index ed025c5c9c4..beacdeea745 100644 --- a/src/RamseyUuid/composer.json +++ b/src/RamseyUuid/composer.json @@ -23,7 +23,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.3", + "api-platform/metadata": "^4.4@alpha", "symfony/serializer": "^6.4 || ^7.0 || ^8.0" }, "require-dev": { diff --git a/src/Serializer/composer.json b/src/Serializer/composer.json index 36b4517b39c..edb2ddc652b 100644 --- a/src/Serializer/composer.json +++ b/src/Serializer/composer.json @@ -23,19 +23,19 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.3", - "api-platform/state": "^4.3", + "api-platform/metadata": "^4.4@alpha", + "api-platform/state": "^4.4@alpha", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", "symfony/property-info": "^6.4 || ^7.1 || ^8.0", "symfony/serializer": "^6.4.37 || ^7.4.9 || ^8.0.9", "symfony/validator": "^6.4.11 || ^7.0 || ^8.0" }, "require-dev": { - "api-platform/doctrine-common": "^4.3", - "api-platform/doctrine-odm": "^4.3", - "api-platform/doctrine-orm": "^4.3", - "api-platform/json-schema": "^4.3", - "api-platform/openapi": "^4.3", + "api-platform/doctrine-common": "^4.4@alpha", + "api-platform/doctrine-odm": "^4.4@alpha", + "api-platform/doctrine-orm": "^4.4@alpha", + "api-platform/json-schema": "^4.4@alpha", + "api-platform/openapi": "^4.4@alpha", "doctrine/collections": "^2.1", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", diff --git a/src/State/composer.json b/src/State/composer.json index 10700330cad..189311bd2b5 100644 --- a/src/State/composer.json +++ b/src/State/composer.json @@ -28,7 +28,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.3", + "api-platform/metadata": "^4.4@alpha", "psr/container": "^1.0 || ^2.0", "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", "symfony/serializer": "^6.4 || ^7.0 || ^8.0", @@ -36,8 +36,8 @@ "symfony/deprecation-contracts": "^3.1" }, "require-dev": { - "api-platform/serializer": "^4.3.12", - "api-platform/validator": "^4.3.1", + "api-platform/serializer": "^4.4@alpha", + "api-platform/validator": "^4.4@alpha", "phpunit/phpunit": "^11.5 || ^12.2", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", "symfony/object-mapper": "^7.4 || ^8.0", diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index c139336e1b9..11eff52ca35 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -29,16 +29,16 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.3", - "api-platform/http-cache": "^4.3", - "api-platform/json-schema": "^4.3", - "api-platform/jsonld": "^4.3", - "api-platform/hydra": "^4.3", - "api-platform/metadata": "^4.3", - "api-platform/serializer": "^4.3.12", - "api-platform/state": "^4.3", - "api-platform/validator": "^4.3.1", - "api-platform/openapi": "^4.3", + "api-platform/documentation": "^4.4@alpha", + "api-platform/http-cache": "^4.4@alpha", + "api-platform/json-schema": "^4.4@alpha", + "api-platform/jsonld": "^4.4@alpha", + "api-platform/hydra": "^4.4@alpha", + "api-platform/metadata": "^4.4@alpha", + "api-platform/serializer": "^4.4@alpha", + "api-platform/state": "^4.4@alpha", + "api-platform/validator": "^4.4@alpha", + "api-platform/openapi": "^4.4@alpha", "symfony/asset": "^6.4 || ^7.0 || ^8.0", "symfony/finder": "^6.4 || ^7.0 || ^8.0", "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", @@ -49,13 +49,13 @@ "willdurand/negotiation": "^3.1" }, "require-dev": { - "api-platform/doctrine-common": "^4.3", - "api-platform/doctrine-odm": "^4.3", - "api-platform/doctrine-orm": "^4.3", - "api-platform/elasticsearch": "^4.3", - "api-platform/graphql": "^4.3", - "api-platform/hal": "^4.3", - "api-platform/json-api": "^4.3", + "api-platform/doctrine-common": "^4.4@alpha", + "api-platform/doctrine-odm": "^4.4@alpha", + "api-platform/doctrine-orm": "^4.4@alpha", + "api-platform/elasticsearch": "^4.4@alpha", + "api-platform/graphql": "^4.4@alpha", + "api-platform/hal": "^4.4@alpha", + "api-platform/json-api": "^4.4@alpha", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", diff --git a/src/Validator/composer.json b/src/Validator/composer.json index 298f6527eed..bbe41332fbf 100644 --- a/src/Validator/composer.json +++ b/src/Validator/composer.json @@ -23,8 +23,8 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.3", - "api-platform/state": "^4.3", + "api-platform/metadata": "^4.4@alpha", + "api-platform/state": "^4.4@alpha", "symfony/type-info": "^7.3 || ^8.0", "symfony/http-kernel": "^6.4.13 || ^7.1 || ^8.0", "symfony/serializer": "^6.4 || ^7.1 || ^8.0", From ee81a5db0ab8895ab041c47ecd6a9184921920ce Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 26 Jun 2026 10:39:26 +0200 Subject: [PATCH 43/84] test(serializer): skip union-collection IRI test on legacy property-info (#8355) --- src/Serializer/Tests/AbstractItemNormalizerTest.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index d556840d1e0..2471d79b20c 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -1287,6 +1287,12 @@ public function testUnionTypeDenormalizationFallsThroughAfterTypeConfusionGuardM public function testUnionTypeCollectionDenormalizationAcceptsAnyMember(): void { + // The union-collection IRI guard relies on the native type; the legacy + // property-info path (< 7.1) only keeps the first collection value type. + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); + } + $data = ['attachments' => ['/related_dummies/1']]; $relatedDummy = new RelatedDummy(); From 309690699afe23fae59bceabb1080b08b7bcd267 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Fri, 26 Jun 2026 11:30:48 +0200 Subject: [PATCH 44/84] test(functional): skip union-collection IRI test on legacy property-info (#8356) --- tests/Functional/UnionIriCollectionTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/Functional/UnionIriCollectionTest.php b/tests/Functional/UnionIriCollectionTest.php index 25d21098b58..b8c53720adc 100644 --- a/tests/Functional/UnionIriCollectionTest.php +++ b/tests/Functional/UnionIriCollectionTest.php @@ -18,6 +18,7 @@ use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\UnionIriCollection\Container; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\UnionIriCollection\Foo; use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; final class UnionIriCollectionTest extends ApiTestCase { @@ -35,6 +36,12 @@ public static function getResources(): array public function testDenormalizeCollectionAcceptsIriOfEachUnionMember(): void { + // The union-collection IRI guard relies on the native type; the legacy + // property-info path (< 7.1) only keeps the first collection value type. + if (!method_exists(PropertyInfoExtractor::class, 'getType')) { + $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); + } + $response = self::createClient()->request('POST', '/union_iri_collection_containers', [ 'headers' => ['Content-Type' => 'application/ld+json', 'Accept' => 'application/ld+json'], 'json' => ['attachments' => ['/union_iri_collection_foos/1', '/union_iri_collection_bars/2']], From dee0df1efb75a69405391f925fa11d5a91fe6595 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 26 Jun 2026 11:35:01 +0200 Subject: [PATCH 45/84] doc: changelog 4.4.0-alpha.2 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index a184c82a1d0..73585a32685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## v4.4.0-alpha.2 + +### Bug fixes + +* [b3f02f4e0](https://github.com/api-platform/core/commit/b3f02f4e08edbcb25777815c1f38920ea187a5a9) fix(laravel): require `api-platform/metadata` `^4.4@alpha` so inter-package dependencies resolve to 4.4 (fixes a broken `composer require api-platform/laravel` install where `SortFilterInterface` was missing) + ## v4.4.0-alpha.1 ### Bug fixes From 63d345dac5b455b1a9e78afb556b5f248471e5f2 Mon Sep 17 00:00:00 2001 From: soyuka Date: Mon, 29 Jun 2026 13:22:12 +0200 Subject: [PATCH 46/84] chore: bump inter-package constraints to ^5.0@alpha Completes 22ece5199 (open 5.0 dev line): root is 5.0.x-dev but the self-referential api-platform/* constraints were left at ^4.4@alpha, which is unresolvable in the component split-tests (published 4.4 alphas require metadata ^4.4, conflicting with the 5.0 root). --- src/Doctrine/Common/composer.json | 4 ++-- src/Doctrine/Odm/composer.json | 8 ++++---- src/Doctrine/Orm/composer.json | 8 ++++---- src/Documentation/composer.json | 2 +- src/Elasticsearch/composer.json | 6 +++--- src/GraphQl/composer.json | 8 ++++---- src/Hal/composer.json | 10 ++++----- src/HttpCache/composer.json | 4 ++-- src/Hydra/composer.json | 18 ++++++++-------- src/JsonApi/composer.json | 10 ++++----- src/JsonLd/composer.json | 6 +++--- src/JsonSchema/composer.json | 2 +- src/Laravel/composer.json | 26 +++++++++++------------ src/Mcp/composer.json | 4 ++-- src/Metadata/composer.json | 6 +++--- src/OpenApi/composer.json | 14 ++++++------- src/RamseyUuid/composer.json | 2 +- src/Serializer/composer.json | 14 ++++++------- src/State/composer.json | 6 +++--- src/Symfony/composer.json | 34 +++++++++++++++---------------- src/Validator/composer.json | 4 ++-- 21 files changed, 98 insertions(+), 98 deletions(-) diff --git a/src/Doctrine/Common/composer.json b/src/Doctrine/Common/composer.json index e9f756b2d75..691e584c3ff 100644 --- a/src/Doctrine/Common/composer.json +++ b/src/Doctrine/Common/composer.json @@ -24,8 +24,8 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.4@alpha", - "api-platform/state": "^4.4@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "doctrine/collections": "^2.1 || ^3.0", "doctrine/common": "^3.2.2", "doctrine/persistence": "^3.2 || ^4.0" diff --git a/src/Doctrine/Odm/composer.json b/src/Doctrine/Odm/composer.json index b7f172879ba..6bcd6a15e2d 100644 --- a/src/Doctrine/Odm/composer.json +++ b/src/Doctrine/Odm/composer.json @@ -25,10 +25,10 @@ ], "require": { "php": ">=8.2", - "api-platform/doctrine-common": "^4.4@alpha", - "api-platform/metadata": "^4.4@alpha", - "api-platform/serializer": "^4.4@alpha", - "api-platform/state": "^4.4@alpha", + "api-platform/doctrine-common": "^5.0@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "doctrine/mongodb-odm": "^2.10", "symfony/property-info": "^6.4 || ^7.1 || ^8.0", "symfony/type-info": "^7.3 || ^8.0" diff --git a/src/Doctrine/Orm/composer.json b/src/Doctrine/Orm/composer.json index dc60ce984cd..26365f59127 100644 --- a/src/Doctrine/Orm/composer.json +++ b/src/Doctrine/Orm/composer.json @@ -24,10 +24,10 @@ ], "require": { "php": ">=8.2", - "api-platform/doctrine-common": "^4.4@alpha", - "api-platform/metadata": "^4.4@alpha", - "api-platform/serializer": "^4.4@alpha", - "api-platform/state": "^4.4@alpha", + "api-platform/doctrine-common": "^5.0@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "composer/semver": "^3.4", "doctrine/orm": "^2.17 || ^3.0.1" }, diff --git a/src/Documentation/composer.json b/src/Documentation/composer.json index 22b43496836..b02d87e1497 100644 --- a/src/Documentation/composer.json +++ b/src/Documentation/composer.json @@ -21,7 +21,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.4@alpha" + "api-platform/metadata": "^5.0@alpha" }, "extra": { "branch-alias": { diff --git a/src/Elasticsearch/composer.json b/src/Elasticsearch/composer.json index 52208106bdc..d4f8c21fb46 100644 --- a/src/Elasticsearch/composer.json +++ b/src/Elasticsearch/composer.json @@ -24,9 +24,9 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.4@alpha", - "api-platform/serializer": "^4.4@alpha", - "api-platform/state": "^4.4@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "elasticsearch/elasticsearch": "^7.17 || ^8.4 || ^9.0", "symfony/cache": "^6.4 || ^7.0 || ^8.0", "symfony/console": "^6.4 || ^7.0 || ^8.0", diff --git a/src/GraphQl/composer.json b/src/GraphQl/composer.json index 21e77f11a69..95d34cf1480 100644 --- a/src/GraphQl/composer.json +++ b/src/GraphQl/composer.json @@ -21,9 +21,9 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.4@alpha", - "api-platform/state": "^4.4@alpha", - "api-platform/serializer": "^4.4@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", "symfony/property-info": "^7.1 || ^8.0", "symfony/serializer": "^6.4 || ^7.1 || ^8.0", "symfony/type-info": "^7.3 || ^8.0", @@ -32,7 +32,7 @@ }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", - "api-platform/validator": "^4.4@alpha", + "api-platform/validator": "^5.0@alpha", "twig/twig": "^1.42.3 || ^2.12 || ^3.0", "symfony/mercure-bundle": "*", "symfony/routing": "^6.4 || ^7.0 || ^8.0", diff --git a/src/Hal/composer.json b/src/Hal/composer.json index 526ae25eb8e..936e958c574 100644 --- a/src/Hal/composer.json +++ b/src/Hal/composer.json @@ -22,10 +22,10 @@ ], "require": { "php": ">=8.2", - "api-platform/state": "^4.4@alpha", - "api-platform/metadata": "^4.4@alpha", - "api-platform/documentation": "^4.4@alpha", - "api-platform/serializer": "^4.4@alpha", + "api-platform/state": "^5.0@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/documentation": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", "symfony/type-info": "^7.3 || ^8.0" }, "autoload": { @@ -65,7 +65,7 @@ "test": "./vendor/bin/phpunit" }, "require-dev": { - "api-platform/json-schema": "^4.4@alpha", + "api-platform/json-schema": "^5.0@alpha", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2" }, diff --git a/src/HttpCache/composer.json b/src/HttpCache/composer.json index 13acef4c792..2b2f5b97bb4 100644 --- a/src/HttpCache/composer.json +++ b/src/HttpCache/composer.json @@ -23,8 +23,8 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.4@alpha", - "api-platform/state": "^4.4@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0" }, "require-dev": { diff --git a/src/Hydra/composer.json b/src/Hydra/composer.json index 94e19bef444..e9007492121 100644 --- a/src/Hydra/composer.json +++ b/src/Hydra/composer.json @@ -25,19 +25,19 @@ ], "require": { "php": ">=8.2", - "api-platform/state": "^4.4@alpha", - "api-platform/documentation": "^4.4@alpha", - "api-platform/metadata": "^4.4@alpha", - "api-platform/jsonld": "^4.4@alpha", - "api-platform/json-schema": "^4.4@alpha", - "api-platform/serializer": "^4.4@alpha", + "api-platform/state": "^5.0@alpha", + "api-platform/documentation": "^5.0@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/jsonld": "^5.0@alpha", + "api-platform/json-schema": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", "symfony/web-link": "^6.4 || ^7.1 || ^8.0", "symfony/type-info": "^7.3 || ^8.0" }, "require-dev": { - "api-platform/doctrine-odm": "^4.4@alpha", - "api-platform/doctrine-orm": "^4.4@alpha", - "api-platform/doctrine-common": "^4.4@alpha", + "api-platform/doctrine-odm": "^5.0@alpha", + "api-platform/doctrine-orm": "^5.0@alpha", + "api-platform/doctrine-common": "^5.0@alpha", "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2" diff --git a/src/JsonApi/composer.json b/src/JsonApi/composer.json index e7d490c9a57..c0226c9ea86 100644 --- a/src/JsonApi/composer.json +++ b/src/JsonApi/composer.json @@ -22,11 +22,11 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.4@alpha", - "api-platform/json-schema": "^4.4@alpha", - "api-platform/metadata": "^4.4@alpha", - "api-platform/serializer": "^4.4@alpha", - "api-platform/state": "^4.4@alpha", + "api-platform/documentation": "^5.0@alpha", + "api-platform/json-schema": "^5.0@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "symfony/error-handler": "^6.4 || ^7.0 || ^8.0", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", "symfony/type-info": "^7.3 || ^8.0" diff --git a/src/JsonLd/composer.json b/src/JsonLd/composer.json index 3d0bb7b01b2..43bb9b7dabc 100644 --- a/src/JsonLd/composer.json +++ b/src/JsonLd/composer.json @@ -24,9 +24,9 @@ ], "require": { "php": ">=8.2", - "api-platform/state": "^4.4@alpha", - "api-platform/metadata": "^4.4@alpha", - "api-platform/serializer": "^4.4@alpha" + "api-platform/state": "^5.0@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha" }, "autoload": { "psr-4": { diff --git a/src/JsonSchema/composer.json b/src/JsonSchema/composer.json index 95799bd231a..9319b5e97fc 100644 --- a/src/JsonSchema/composer.json +++ b/src/JsonSchema/composer.json @@ -25,7 +25,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.4@alpha", + "api-platform/metadata": "^5.0@alpha", "symfony/console": "^6.4 || ^7.0 || ^8.0", "symfony/property-info": "^6.4 || ^7.1 || ^8.0", "symfony/serializer": "^6.4 || ^7.0 || ^8.0", diff --git a/src/Laravel/composer.json b/src/Laravel/composer.json index dc6ef72053f..e55b0542d8f 100644 --- a/src/Laravel/composer.json +++ b/src/Laravel/composer.json @@ -28,16 +28,16 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.4@alpha", - "api-platform/hal": "^4.4@alpha", - "api-platform/hydra": "^4.4@alpha", - "api-platform/json-api": "^4.4@alpha", - "api-platform/json-schema": "^4.4@alpha", - "api-platform/jsonld": "^4.4@alpha", - "api-platform/metadata": "^4.4@alpha", - "api-platform/openapi": "^4.4@alpha", - "api-platform/serializer": "^4.4@alpha", - "api-platform/state": "^4.4@alpha", + "api-platform/documentation": "^5.0@alpha", + "api-platform/hal": "^5.0@alpha", + "api-platform/hydra": "^5.0@alpha", + "api-platform/json-api": "^5.0@alpha", + "api-platform/json-schema": "^5.0@alpha", + "api-platform/jsonld": "^5.0@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/openapi": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "illuminate/config": "^11.0 || ^12.0 || ^13.0", "illuminate/container": "^11.0 || ^12.0 || ^13.0", "illuminate/contracts": "^11.0 || ^12.0 || ^13.0", @@ -53,9 +53,9 @@ "willdurand/negotiation": "^3.1" }, "require-dev": { - "api-platform/graphql": "^4.4@alpha", - "api-platform/http-cache": "^4.4@alpha", - "api-platform/mcp": "^4.4@alpha", + "api-platform/graphql": "^5.0@alpha", + "api-platform/http-cache": "^5.0@alpha", + "api-platform/mcp": "^5.0@alpha", "doctrine/dbal": "^4.0", "larastan/larastan": "^2.0 || ^3.0", "laravel/sanctum": "^4.0", diff --git a/src/Mcp/composer.json b/src/Mcp/composer.json index 0d4d583e4bc..1d0417aef8c 100644 --- a/src/Mcp/composer.json +++ b/src/Mcp/composer.json @@ -28,8 +28,8 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.4@alpha", - "api-platform/json-schema": "^4.4@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/json-schema": "^5.0@alpha", "mcp/sdk": "^0.6", "symfony/object-mapper": "^7.4 || ^8.0", "symfony/polyfill-php85": "^1.32" diff --git a/src/Metadata/composer.json b/src/Metadata/composer.json index 55873c0b4f0..d0fb4ee15c4 100644 --- a/src/Metadata/composer.json +++ b/src/Metadata/composer.json @@ -36,9 +36,9 @@ "symfony/type-info": "^7.3 || ^8.0" }, "require-dev": { - "api-platform/json-schema": "^4.4@alpha", - "api-platform/openapi": "^4.4@alpha", - "api-platform/state": "^4.4@alpha", + "api-platform/json-schema": "^5.0@alpha", + "api-platform/openapi": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "phpspec/prophecy-phpunit": "^2.2", "phpstan/phpdoc-parser": "^1.29 || ^2.0", "phpunit/phpunit": "^11.5 || ^12.2", diff --git a/src/OpenApi/composer.json b/src/OpenApi/composer.json index 8b8ab727048..da97fb58f9c 100644 --- a/src/OpenApi/composer.json +++ b/src/OpenApi/composer.json @@ -28,9 +28,9 @@ ], "require": { "php": ">=8.2", - "api-platform/json-schema": "^4.4@alpha", - "api-platform/metadata": "^4.4@alpha", - "api-platform/state": "^4.4@alpha", + "api-platform/json-schema": "^5.0@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "symfony/console": "^6.4 || ^7.0 || ^8.0", "symfony/filesystem": "^6.4 || ^7.0 || ^8.0", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", @@ -40,10 +40,10 @@ "require-dev": { "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", - "api-platform/doctrine-common": "^4.4@alpha", - "api-platform/doctrine-orm": "^4.4@alpha", - "api-platform/doctrine-odm": "^4.4@alpha", - "api-platform/serializer": "^4.4@alpha", + "api-platform/doctrine-common": "^5.0@alpha", + "api-platform/doctrine-orm": "^5.0@alpha", + "api-platform/doctrine-odm": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", "symfony/type-info": "^7.3 || ^8.0" }, "autoload": { diff --git a/src/RamseyUuid/composer.json b/src/RamseyUuid/composer.json index 13f42fab222..4d903f8db8e 100644 --- a/src/RamseyUuid/composer.json +++ b/src/RamseyUuid/composer.json @@ -23,7 +23,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.4@alpha", + "api-platform/metadata": "^5.0@alpha", "symfony/serializer": "^6.4 || ^7.0 || ^8.0" }, "require-dev": { diff --git a/src/Serializer/composer.json b/src/Serializer/composer.json index 08b9f70a8cc..f7a5bb9ed12 100644 --- a/src/Serializer/composer.json +++ b/src/Serializer/composer.json @@ -23,19 +23,19 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.4@alpha", - "api-platform/state": "^4.4@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", "symfony/property-info": "^6.4 || ^7.1 || ^8.0", "symfony/serializer": "^6.4.37 || ^7.4.9 || ^8.0.9", "symfony/validator": "^6.4.11 || ^7.0 || ^8.0" }, "require-dev": { - "api-platform/doctrine-common": "^4.4@alpha", - "api-platform/doctrine-odm": "^4.4@alpha", - "api-platform/doctrine-orm": "^4.4@alpha", - "api-platform/json-schema": "^4.4@alpha", - "api-platform/openapi": "^4.4@alpha", + "api-platform/doctrine-common": "^5.0@alpha", + "api-platform/doctrine-odm": "^5.0@alpha", + "api-platform/doctrine-orm": "^5.0@alpha", + "api-platform/json-schema": "^5.0@alpha", + "api-platform/openapi": "^5.0@alpha", "doctrine/collections": "^2.1", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", diff --git a/src/State/composer.json b/src/State/composer.json index ef9ddd810a0..a7c0f1b41b8 100644 --- a/src/State/composer.json +++ b/src/State/composer.json @@ -28,7 +28,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.4@alpha", + "api-platform/metadata": "^5.0@alpha", "psr/container": "^1.0 || ^2.0", "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", "symfony/serializer": "^6.4 || ^7.0 || ^8.0", @@ -36,8 +36,8 @@ "symfony/deprecation-contracts": "^3.1" }, "require-dev": { - "api-platform/serializer": "^4.4@alpha", - "api-platform/validator": "^4.4@alpha", + "api-platform/serializer": "^5.0@alpha", + "api-platform/validator": "^5.0@alpha", "phpunit/phpunit": "^11.5 || ^12.2", "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", "symfony/object-mapper": "^7.4 || ^8.0", diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index 2da5990d953..ec64a14b7f3 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -29,16 +29,16 @@ ], "require": { "php": ">=8.2", - "api-platform/documentation": "^4.4@alpha", - "api-platform/http-cache": "^4.4@alpha", - "api-platform/json-schema": "^4.4@alpha", - "api-platform/jsonld": "^4.4@alpha", - "api-platform/hydra": "^4.4@alpha", - "api-platform/metadata": "^4.4@alpha", - "api-platform/serializer": "^4.4@alpha", - "api-platform/state": "^4.4@alpha", - "api-platform/validator": "^4.4@alpha", - "api-platform/openapi": "^4.4@alpha", + "api-platform/documentation": "^5.0@alpha", + "api-platform/http-cache": "^5.0@alpha", + "api-platform/json-schema": "^5.0@alpha", + "api-platform/jsonld": "^5.0@alpha", + "api-platform/hydra": "^5.0@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/serializer": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", + "api-platform/validator": "^5.0@alpha", + "api-platform/openapi": "^5.0@alpha", "symfony/asset": "^6.4 || ^7.0 || ^8.0", "symfony/finder": "^6.4 || ^7.0 || ^8.0", "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", @@ -49,13 +49,13 @@ "willdurand/negotiation": "^3.1" }, "require-dev": { - "api-platform/doctrine-common": "^4.4@alpha", - "api-platform/doctrine-odm": "^4.4@alpha", - "api-platform/doctrine-orm": "^4.4@alpha", - "api-platform/elasticsearch": "^4.4@alpha", - "api-platform/graphql": "^4.4@alpha", - "api-platform/hal": "^4.4@alpha", - "api-platform/json-api": "^4.4@alpha", + "api-platform/doctrine-common": "^5.0@alpha", + "api-platform/doctrine-odm": "^5.0@alpha", + "api-platform/doctrine-orm": "^5.0@alpha", + "api-platform/elasticsearch": "^5.0@alpha", + "api-platform/graphql": "^5.0@alpha", + "api-platform/hal": "^5.0@alpha", + "api-platform/json-api": "^5.0@alpha", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", diff --git a/src/Validator/composer.json b/src/Validator/composer.json index 821a455bfce..4b0bb81aadc 100644 --- a/src/Validator/composer.json +++ b/src/Validator/composer.json @@ -23,8 +23,8 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.4@alpha", - "api-platform/state": "^4.4@alpha", + "api-platform/metadata": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "symfony/type-info": "^7.3 || ^8.0", "symfony/http-kernel": "^6.4.13 || ^7.1 || ^8.0", "symfony/serializer": "^6.4 || ^7.1 || ^8.0", From d37a753790f8a8e1481a118b6a8fc9a08f57e962 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 29 Jun 2026 16:28:25 +0200 Subject: [PATCH 47/84] feat(doctrine): standalone Date/Exists filters, ComparisonFilter [between], deprecate RangeFilter (#8351) --- .../Common/Filter/NameConverterAwareTrait.php | 59 +++++++++++ .../Filter/PropertyAwareFilterTrait.php | 38 +++++++ src/Doctrine/Odm/Filter/ComparisonFilter.php | 35 ++++++ src/Doctrine/Odm/Filter/DateFilter.php | 58 +++++++++- src/Doctrine/Odm/Filter/ExistsFilter.php | 46 ++++++-- src/Doctrine/Odm/Filter/RangeFilter.php | 2 +- src/Doctrine/Orm/Filter/ComparisonFilter.php | 39 +++++++ src/Doctrine/Orm/Filter/DateFilter.php | 100 +++++++++++++++++- src/Doctrine/Orm/Filter/ExactFilter.php | 18 ++++ src/Doctrine/Orm/Filter/ExistsFilter.php | 86 ++++++++++++++- src/Doctrine/Orm/Filter/RangeFilter.php | 2 +- .../Document/FilteredRangeParameter.php | 7 +- .../Entity/FilteredRangeParameter.php | 7 +- .../Functional/Parameters/DateFilterTest.php | 11 ++ .../Parameters/ExistsFilterTest.php | 11 ++ 15 files changed, 493 insertions(+), 26 deletions(-) create mode 100644 src/Doctrine/Common/Filter/NameConverterAwareTrait.php create mode 100644 src/Doctrine/Common/Filter/PropertyAwareFilterTrait.php diff --git a/src/Doctrine/Common/Filter/NameConverterAwareTrait.php b/src/Doctrine/Common/Filter/NameConverterAwareTrait.php new file mode 100644 index 00000000000..4b1b6bb7a87 --- /dev/null +++ b/src/Doctrine/Common/Filter/NameConverterAwareTrait.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\Doctrine\Common\Filter; + +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; + +/** + * Holds an optional name converter and (de)normalizes property names through it. + * + * @author Antoine Bluchet + */ +trait NameConverterAwareTrait +{ + private ?NameConverterInterface $nameConverter = null; + + public function hasNameConverter(): bool + { + return $this->nameConverter instanceof NameConverterInterface; + } + + public function getNameConverter(): ?NameConverterInterface + { + return $this->nameConverter; + } + + public function setNameConverter(NameConverterInterface $nameConverter): void + { + $this->nameConverter = $nameConverter; + } + + protected function denormalizePropertyName(string|int $property): string + { + if (!$this->nameConverter instanceof NameConverterInterface) { + return (string) $property; + } + + return implode('.', array_map($this->nameConverter->denormalize(...), explode('.', (string) $property))); + } + + protected function normalizePropertyName(string $property): string + { + if (!$this->nameConverter instanceof NameConverterInterface) { + return $property; + } + + return implode('.', array_map($this->nameConverter->normalize(...), explode('.', $property))); + } +} diff --git a/src/Doctrine/Common/Filter/PropertyAwareFilterTrait.php b/src/Doctrine/Common/Filter/PropertyAwareFilterTrait.php new file mode 100644 index 00000000000..970da6779e0 --- /dev/null +++ b/src/Doctrine/Common/Filter/PropertyAwareFilterTrait.php @@ -0,0 +1,38 @@ + + * + * 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\Doctrine\Common\Filter; + +/** + * @author Antoine Bluchet + */ +trait PropertyAwareFilterTrait +{ + /** + * @var array|null + */ + private ?array $properties = null; + + public function getProperties(): ?array + { + return $this->properties; + } + + /** + * @param array $properties + */ + public function setProperties(array $properties): void + { + $this->properties = $properties; + } +} diff --git a/src/Doctrine/Odm/Filter/ComparisonFilter.php b/src/Doctrine/Odm/Filter/ComparisonFilter.php index 53488ed6443..984b97a0d04 100644 --- a/src/Doctrine/Odm/Filter/ComparisonFilter.php +++ b/src/Doctrine/Odm/Filter/ComparisonFilter.php @@ -45,6 +45,12 @@ final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterI 'ne' => 'notEqual', ]; + /** + * Friendly range syntax: `?price[between]=10..100`. MongoDB has no BETWEEN keyword, so a range + * is expressed as the native `gte`/`lte` pair on the field. + */ + public const OPERATOR_BETWEEN = 'between'; + public function __construct(private readonly FilterInterface $filter) { } @@ -74,6 +80,12 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera continue; } + if (self::OPERATOR_BETWEEN === $operator) { + $this->applyBetween($aggregationBuilder, $resourceClass, $operation, $context, $parameter, $value); + + continue; + } + if (isset(self::OPERATORS[$operator])) { $this->applyOperator($aggregationBuilder, $resourceClass, $operation, $context, $parameter, self::OPERATORS[$operator], $value); } @@ -91,6 +103,7 @@ public function getOpenApiParameters(Parameter $parameter): array new OpenApiParameter(name: "{$key}[lt]", in: $in), new OpenApiParameter(name: "{$key}[lte]", in: $in), new OpenApiParameter(name: "{$key}[ne]", in: $in), + new OpenApiParameter(name: "{$key}[between]", in: $in), ]; } @@ -109,6 +122,7 @@ public function getSchema(Parameter $parameter): array 'lt' => $innerSchema, 'lte' => $innerSchema, 'ne' => $innerSchema, + 'between' => ['type' => 'string'], ], ]; } @@ -131,4 +145,25 @@ private function applyOperator(Builder $aggregationBuilder, string $resourceClas $context['match'] = $newContext['match']; } } + + /** + * @param array $context + * + * @param-out array $context + */ + private function applyBetween(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation, array &$context, Parameter $parameter, mixed $value): void + { + if (!\is_string($value)) { + return; + } + + $bounds = explode('..', $value, 2); + if (2 !== \count($bounds) || !is_numeric($bounds[0]) || !is_numeric($bounds[1])) { + return; + } + + // MongoDB range = native gte/lte pair (coerce bounds to numbers) + $this->applyOperator($aggregationBuilder, $resourceClass, $operation, $context, $parameter, 'gte', $bounds[0] + 0); + $this->applyOperator($aggregationBuilder, $resourceClass, $operation, $context, $parameter, 'lte', $bounds[1] + 0); + } } diff --git a/src/Doctrine/Odm/Filter/DateFilter.php b/src/Doctrine/Odm/Filter/DateFilter.php index e65b38a3cff..97294a7d860 100644 --- a/src/Doctrine/Odm/Filter/DateFilter.php +++ b/src/Doctrine/Odm/Filter/DateFilter.php @@ -15,6 +15,13 @@ use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; use ApiPlatform\Doctrine\Common\Filter\DateFilterTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterTrait; +use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; @@ -23,6 +30,10 @@ use ApiPlatform\Metadata\QueryParameter; use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\ODM\MongoDB\Aggregation\Builder; +use Doctrine\Persistence\ManagerRegistry; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** * The date filter allows to filter a collection by date intervals. @@ -120,20 +131,59 @@ * @author Kévin Dunglas * @author Théo FIDRY * @author Alan Poulain - * - * @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone overlay over {@see ComparisonFilter} (translating the `[before]`/`[strictly_before]`/`[after]`/`[strictly_after]` syntax) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate. */ -final class DateFilter extends AbstractFilter implements DateFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface +final class DateFilter implements DateFilterInterface, FilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface, OpenApiParameterFilterInterface, PropertyAwareFilterInterface { use DateFilterTrait; + use ManagerRegistryAwareTrait; + use MongoDbOdmPropertyHelperTrait; + use NameConverterAwareTrait; + use PropertyAwareFilterTrait; public const DOCTRINE_DATE_TYPES = [ 'date' => true, 'date_immutable' => true, ]; + private LoggerInterface $logger; + + /** + * @param array|null $properties + */ + public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, ?array $properties = null, ?NameConverterInterface $nameConverter = null) + { + $this->managerRegistry = $managerRegistry; + $this->logger = $logger ?? new NullLogger(); + $this->properties = $properties; + $this->nameConverter = $nameConverter; + } + + public function apply(Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void + { + foreach ($context['filters'] ?? [] as $property => $value) { + $this->filterProperty($this->denormalizePropertyName($property), $value, $aggregationBuilder, $resourceClass, $operation, $context); + } + } + + protected function getLogger(): LoggerInterface + { + return $this->logger; + } + + protected function isPropertyEnabled(string $property, string $resourceClass): bool + { + if (null === $this->properties) { + // to ensure sanity, nested properties must still be explicitly enabled + return !$this->isPropertyNested($property, $resourceClass); + } + + return \array_key_exists($property, $this->properties); + } + /** - * {@inheritdoc} + * @param array $context + * + * @param-out array $context */ protected function filterProperty(string $property, mixed $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { diff --git a/src/Doctrine/Odm/Filter/ExistsFilter.php b/src/Doctrine/Odm/Filter/ExistsFilter.php index bc59a3e99e6..72b8a66135b 100644 --- a/src/Doctrine/Odm/Filter/ExistsFilter.php +++ b/src/Doctrine/Odm/Filter/ExistsFilter.php @@ -15,7 +15,14 @@ use ApiPlatform\Doctrine\Common\Filter\ExistsFilterInterface; use ApiPlatform\Doctrine\Common\Filter\ExistsFilterTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterTrait; use ApiPlatform\Doctrine\Common\Filter\PropertyPlaceholderOpenApiParameterTrait; +use ApiPlatform\Doctrine\Odm\PropertyHelperTrait as MongoDbOdmPropertyHelperTrait; use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; @@ -24,6 +31,7 @@ use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; use Doctrine\Persistence\ManagerRegistry; use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** @@ -110,19 +118,43 @@ * * @author Teoh Han Hui * @author Alan Poulain - * - * @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone filter (reading its value from the QueryParameter instead of the legacy `context['filters']` lookup) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate. */ -final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface +final class ExistsFilter implements ExistsFilterInterface, FilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface, OpenApiParameterFilterInterface, PropertyAwareFilterInterface { use ExistsFilterTrait; + use ManagerRegistryAwareTrait; + use MongoDbOdmPropertyHelperTrait; + use NameConverterAwareTrait; + use PropertyAwareFilterTrait; use PropertyPlaceholderOpenApiParameterTrait; + private LoggerInterface $logger; + + /** + * @param array|null $properties + */ public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, ?NameConverterInterface $nameConverter = null) { - parent::__construct($managerRegistry, $logger, $properties, $nameConverter); - + $this->managerRegistry = $managerRegistry; + $this->logger = $logger ?? new NullLogger(); $this->existsParameterName = $existsParameterName; + $this->properties = $properties; + $this->nameConverter = $nameConverter; + } + + protected function getLogger(): LoggerInterface + { + return $this->logger; + } + + protected function isPropertyEnabled(string $property, string $resourceClass): bool + { + if (null === $this->properties) { + // to ensure sanity, nested properties must still be explicitly enabled + return !$this->isPropertyNested($property, $resourceClass); + } + + return \array_key_exists($property, $this->properties); } /** @@ -143,7 +175,9 @@ public function apply(Builder $aggregationBuilder, string $resourceClass, ?Opera } /** - * {@inheritdoc} + * @param array $context + * + * @param-out array $context */ protected function filterProperty(string $property, mixed $value, Builder $aggregationBuilder, string $resourceClass, ?Operation $operation = null, array &$context = []): void { diff --git a/src/Doctrine/Odm/Filter/RangeFilter.php b/src/Doctrine/Odm/Filter/RangeFilter.php index f72c148536e..195aacd7b66 100644 --- a/src/Doctrine/Odm/Filter/RangeFilter.php +++ b/src/Doctrine/Odm/Filter/RangeFilter.php @@ -108,7 +108,7 @@ * @author Lee Siong Chan * @author Alan Poulain * - * @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone overlay over {@see ComparisonFilter} (translating `[between]=X..Y` to `[gte]=X` + `[lte]=Y`, passing through `[gt]`/`[gte]`/`[lt]`/`[lte]`) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate. + * @deprecated since API Platform 4.4: use {@see ComparisonFilter} instead, which now covers the full range syntax (`[gt]`/`[gte]`/`[lt]`/`[lte]` and `[between]=X..Y`). This filter is removed in 6.0; the upgrade codemod rewrites it to a QueryParameter declared with `ComparisonFilter`. */ final class RangeFilter extends AbstractFilter implements RangeFilterInterface, OpenApiParameterFilterInterface { diff --git a/src/Doctrine/Orm/Filter/ComparisonFilter.php b/src/Doctrine/Orm/Filter/ComparisonFilter.php index c1362320b82..785136ef884 100644 --- a/src/Doctrine/Orm/Filter/ComparisonFilter.php +++ b/src/Doctrine/Orm/Filter/ComparisonFilter.php @@ -48,6 +48,12 @@ final class ComparisonFilter implements FilterInterface, OpenApiParameterFilterI public const ALLOWED_DQL_OPERATORS = ['=', '>', '>=', '<', '<=', '!=', '<>']; + /** + * Friendly range syntax: `?price[between]=10..100`. Translates to a single BETWEEN clause + * (or `=` when both bounds are equal), letting the SQL optimizer treat it as a bounded range. + */ + public const OPERATOR_BETWEEN = 'between'; + public function __construct(private readonly FilterInterface $filter) { } @@ -74,6 +80,12 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q continue; } + if (self::OPERATOR_BETWEEN === $operator) { + $this->applyBetween($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context, $parameter, $value); + + continue; + } + if (isset(self::OPERATORS[$operator])) { $this->applyOperator($queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context, $parameter, self::OPERATORS[$operator], $value); } @@ -91,6 +103,7 @@ public function getOpenApiParameters(Parameter $parameter): array new OpenApiParameter(name: "{$key}[lt]", in: $in), new OpenApiParameter(name: "{$key}[lte]", in: $in), new OpenApiParameter(name: "{$key}[ne]", in: $in), + new OpenApiParameter(name: "{$key}[between]", in: $in), ]; } @@ -109,6 +122,7 @@ public function getSchema(Parameter $parameter): array 'lt' => $innerSchema, 'lte' => $innerSchema, 'ne' => $innerSchema, + 'between' => ['type' => 'string'], ], ]; } @@ -131,4 +145,29 @@ private function applyOperator(QueryBuilder $queryBuilder, QueryNameGeneratorInt ['operator' => $operator, 'parameter' => $subParameter] + $context ); } + + /** + * @param array $context + */ + private function applyBetween(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation, array $context, Parameter $parameter, mixed $value): void + { + if (!\is_string($value)) { + return; + } + + $bounds = explode('..', $value, 2); + if (2 !== \count($bounds) || !is_numeric($bounds[0]) || !is_numeric($bounds[1])) { + return; + } + + // coerce to int|float so the bound is bound as a number, not a string + $subParameter = (clone $parameter)->setValue([$bounds[0] + 0, $bounds[1] + 0]); + $this->filter->apply( + $queryBuilder, + $queryNameGenerator, + $resourceClass, + $operation, + ['operator' => self::OPERATOR_BETWEEN, 'parameter' => $subParameter] + $context + ); + } } diff --git a/src/Doctrine/Orm/Filter/DateFilter.php b/src/Doctrine/Orm/Filter/DateFilter.php index 33301e41e7b..1632645eabc 100644 --- a/src/Doctrine/Orm/Filter/DateFilter.php +++ b/src/Doctrine/Orm/Filter/DateFilter.php @@ -15,6 +15,13 @@ use ApiPlatform\Doctrine\Common\Filter\DateFilterInterface; use ApiPlatform\Doctrine\Common\Filter\DateFilterTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterTrait; +use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\JsonSchemaFilterInterface; @@ -25,8 +32,15 @@ use ApiPlatform\OpenApi\Model\Parameter as OpenApiParameter; use Doctrine\DBAL\Types\Type as DBALType; use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\EntityManagerInterface; +use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\Mapping\ClassMetadata as LegacyClassMetadata; +use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; +use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** * The date filter allows to filter a collection by date intervals. @@ -124,12 +138,13 @@ * * @author Kévin Dunglas * @author Théo FIDRY - * - * @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone overlay over {@see ComparisonFilter} (translating the `[before]`/`[strictly_before]`/`[after]`/`[strictly_after]` syntax) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate. */ -final class DateFilter extends AbstractFilter implements DateFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface +final class DateFilter implements DateFilterInterface, FilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface, OpenApiParameterFilterInterface, PropertyAwareFilterInterface { use DateFilterTrait; + use ManagerRegistryAwareTrait; + use NameConverterAwareTrait; + use PropertyAwareFilterTrait; public const DOCTRINE_DATE_TYPES = [ Types::DATE_MUTABLE => true, @@ -142,6 +157,85 @@ final class DateFilter extends AbstractFilter implements DateFilterInterface, Js Types::TIME_IMMUTABLE => true, ]; + private LoggerInterface $logger; + + /** + * Resolved from the QueryBuilder in apply(); metadata is read from it so the active filter path + * never touches the injected ManagerRegistry (kept only for the deprecated getDescription() and + * for BC injection through ManagerRegistryAwareInterface). + */ + private ?EntityManagerInterface $entityManager = null; + + /** + * @param array|null $properties + */ + public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, ?array $properties = null, ?NameConverterInterface $nameConverter = null) + { + $this->managerRegistry = $managerRegistry; + $this->logger = $logger ?? new NullLogger(); + $this->properties = $properties; + $this->nameConverter = $nameConverter; + } + + public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void + { + $this->entityManager = $queryBuilder->getEntityManager(); + + foreach ($context['filters'] ?? [] as $property => $value) { + $this->filterProperty($this->denormalizePropertyName($property), $value, $queryBuilder, $queryNameGenerator, $resourceClass, $operation, $context); + } + } + + protected function getLogger(): LoggerInterface + { + return $this->logger; + } + + protected function isPropertyEnabled(string $property, string $resourceClass): bool + { + if (null === $this->properties) { + // to ensure sanity, nested properties must still be explicitly enabled + return !$this->isPropertyNested($property, $resourceClass); + } + + return \array_key_exists($property, $this->properties); + } + + protected function getClassMetadata(string $resourceClass): LegacyClassMetadata + { + if ($this->entityManager instanceof EntityManagerInterface) { + return $this->entityManager->getClassMetadata($resourceClass); + } + + // Legacy getDescription() runs without a QueryBuilder: fall back to the injected registry. + if ($this->hasManagerRegistry() && ($manager = $this->getManagerRegistry()->getManagerForClass($resourceClass))) { + return $manager->getClassMetadata($resourceClass); + } + + return new ClassMetadata($resourceClass); + } + + /** + * @return array{0: string, 1: string, 2: string[]} + */ + protected function addJoinsForNestedProperty(string $property, string $rootAlias, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $joinType): array + { + $propertyParts = $this->splitPropertyParts($property, $resourceClass); + $parentAlias = $rootAlias; + $alias = null; + + foreach ($propertyParts['associations'] as $association) { + $alias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $parentAlias, $association, $joinType); + $parentAlias = $alias; + } + + if (null === $alias) { + throw new InvalidArgumentException(\sprintf('Cannot add joins for property "%s" - property is not nested.', $property)); + } + + return [$alias, $propertyParts['field'], $propertyParts['associations']]; + } + /** * {@inheritdoc} */ diff --git a/src/Doctrine/Orm/Filter/ExactFilter.php b/src/Doctrine/Orm/Filter/ExactFilter.php index bd45dbf5d20..dfaa415f0da 100644 --- a/src/Doctrine/Orm/Filter/ExactFilter.php +++ b/src/Doctrine/Orm/Filter/ExactFilter.php @@ -46,6 +46,24 @@ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $q [$alias, $property] = $this->addNestedParameterJoins($property, $alias, $queryBuilder, $queryNameGenerator, $parameter); + if (ComparisonFilter::OPERATOR_BETWEEN === ($context['operator'] ?? null)) { + $whereClause = $context['whereClause'] ?? 'andWhere'; + + // equal bounds collapse to an equality so the optimizer skips the range scan + if ($value[0] === $value[1]) { + $queryBuilder->{$whereClause}(\sprintf('%s.%s = :%s', $alias, $property, $parameterName)) + ->setParameter($parameterName, $value[0]); + + return; + } + + $queryBuilder->{$whereClause}(\sprintf('%1$s.%2$s BETWEEN :%3$s_1 AND :%3$s_2', $alias, $property, $parameterName)) + ->setParameter($parameterName.'_1', $value[0]) + ->setParameter($parameterName.'_2', $value[1]); + + return; + } + if (\is_array($value)) { $queryBuilder ->{$context['whereClause'] ?? 'andWhere'}(\sprintf('%s.%s IN (:%s)', $alias, $property, $parameterName)); diff --git a/src/Doctrine/Orm/Filter/ExistsFilter.php b/src/Doctrine/Orm/Filter/ExistsFilter.php index 057e731e17f..28fad250c6e 100644 --- a/src/Doctrine/Orm/Filter/ExistsFilter.php +++ b/src/Doctrine/Orm/Filter/ExistsFilter.php @@ -15,13 +15,21 @@ use ApiPlatform\Doctrine\Common\Filter\ExistsFilterInterface; use ApiPlatform\Doctrine\Common\Filter\ExistsFilterTrait; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareInterface; +use ApiPlatform\Doctrine\Common\Filter\NameConverterAwareTrait; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; +use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterTrait; use ApiPlatform\Doctrine\Common\Filter\PropertyPlaceholderOpenApiParameterTrait; use ApiPlatform\Doctrine\Orm\Util\QueryBuilderHelper; use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Metadata\JsonSchemaFilterInterface; use ApiPlatform\Metadata\OpenApiParameterFilterInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Parameter; +use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Mapping\AssociationMapping; use Doctrine\ORM\Mapping\ClassMetadata; use Doctrine\ORM\Mapping\ManyToManyOwningSideMapping; @@ -29,7 +37,9 @@ use Doctrine\ORM\Query\Expr\Join; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\Mapping\ClassMetadata as LegacyClassMetadata; use Psr\Log\LoggerInterface; +use Psr\Log\NullLogger; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** @@ -116,19 +126,84 @@ * Given that the collection endpoint is `/books`, you can filter books with the following query: `/books?exists[comment]=true`. * * @author Teoh Han Hui - * - * @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone filter (reading its value from the QueryParameter instead of the legacy `context['filters']` lookup) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate. */ -final class ExistsFilter extends AbstractFilter implements ExistsFilterInterface, JsonSchemaFilterInterface, OpenApiParameterFilterInterface +final class ExistsFilter implements ExistsFilterInterface, FilterInterface, JsonSchemaFilterInterface, ManagerRegistryAwareInterface, NameConverterAwareInterface, OpenApiParameterFilterInterface, PropertyAwareFilterInterface { use ExistsFilterTrait; + use ManagerRegistryAwareTrait; + use NameConverterAwareTrait; + use PropertyAwareFilterTrait; use PropertyPlaceholderOpenApiParameterTrait; + private LoggerInterface $logger; + + /** + * Resolved from the QueryBuilder in apply(); metadata is read from it so the active filter path + * never touches the injected ManagerRegistry (kept only for the deprecated getDescription() and + * for BC injection through ManagerRegistryAwareInterface). + */ + private ?EntityManagerInterface $entityManager = null; + + /** + * @param array|null $properties + */ public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInterface $logger = null, ?array $properties = null, string $existsParameterName = self::QUERY_PARAMETER_KEY, ?NameConverterInterface $nameConverter = null) { - parent::__construct($managerRegistry, $logger, $properties, $nameConverter); - + $this->managerRegistry = $managerRegistry; + $this->logger = $logger ?? new NullLogger(); $this->existsParameterName = $existsParameterName; + $this->properties = $properties; + $this->nameConverter = $nameConverter; + } + + protected function getLogger(): LoggerInterface + { + return $this->logger; + } + + protected function isPropertyEnabled(string $property, string $resourceClass): bool + { + if (null === $this->properties) { + // to ensure sanity, nested properties must still be explicitly enabled + return !$this->isPropertyNested($property, $resourceClass); + } + + return \array_key_exists($property, $this->properties); + } + + protected function getClassMetadata(string $resourceClass): LegacyClassMetadata + { + if ($this->entityManager instanceof EntityManagerInterface) { + return $this->entityManager->getClassMetadata($resourceClass); + } + + // Legacy getDescription() runs without a QueryBuilder: fall back to the injected registry. + if ($this->hasManagerRegistry() && ($manager = $this->getManagerRegistry()->getManagerForClass($resourceClass))) { + return $manager->getClassMetadata($resourceClass); + } + + return new ClassMetadata($resourceClass); + } + + /** + * @return array{0: string, 1: string, 2: string[]} + */ + protected function addJoinsForNestedProperty(string $property, string $rootAlias, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $joinType): array + { + $propertyParts = $this->splitPropertyParts($property, $resourceClass); + $parentAlias = $rootAlias; + $alias = null; + + foreach ($propertyParts['associations'] as $association) { + $alias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $parentAlias, $association, $joinType); + $parentAlias = $alias; + } + + if (null === $alias) { + throw new InvalidArgumentException(\sprintf('Cannot add joins for property "%s" - property is not nested.', $property)); + } + + return [$alias, $propertyParts['field'], $propertyParts['associations']]; } /** @@ -136,6 +211,7 @@ public function __construct(?ManagerRegistry $managerRegistry = null, ?LoggerInt */ public function apply(QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, ?Operation $operation = null, array $context = []): void { + $this->entityManager = $queryBuilder->getEntityManager(); $parameter = $context['parameter'] ?? null; $propertyKey = $parameter?->getProperty(); diff --git a/src/Doctrine/Orm/Filter/RangeFilter.php b/src/Doctrine/Orm/Filter/RangeFilter.php index fbfa3ad3bf5..7410b9d9941 100644 --- a/src/Doctrine/Orm/Filter/RangeFilter.php +++ b/src/Doctrine/Orm/Filter/RangeFilter.php @@ -109,7 +109,7 @@ * * @author Lee Siong Chan * - * @deprecated since API Platform 4.4: extending {@see AbstractFilter} is deprecated. In 5.0 this filter is rewritten as a standalone overlay over {@see ComparisonFilter} (translating `[between]=X..Y` to `[gte]=X` + `[lte]=Y`, passing through `[gt]`/`[gte]`/`[lt]`/`[lte]`) — same class name, same URL syntax, drop-in. Declare it through a QueryParameter to migrate. + * @deprecated since API Platform 4.4: use {@see ComparisonFilter} instead, which now covers the full range syntax (`[gt]`/`[gte]`/`[lt]`/`[lte]` and `[between]=X..Y`). This filter is removed in 6.0; the upgrade codemod rewrites it to a QueryParameter declared with `ComparisonFilter`. */ final class RangeFilter extends AbstractFilter implements RangeFilterInterface, OpenApiParameterFilterInterface { diff --git a/tests/Fixtures/TestBundle/Document/FilteredRangeParameter.php b/tests/Fixtures/TestBundle/Document/FilteredRangeParameter.php index 51eb57b6caa..6ebfc5862ee 100644 --- a/tests/Fixtures/TestBundle/Document/FilteredRangeParameter.php +++ b/tests/Fixtures/TestBundle/Document/FilteredRangeParameter.php @@ -13,7 +13,8 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Document; -use ApiPlatform\Doctrine\Odm\Filter\RangeFilter; +use ApiPlatform\Doctrine\Odm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Odm\Filter\ExactFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; @@ -25,11 +26,11 @@ paginationItemsPerPage: 5, parameters: [ 'quantity' => new QueryParameter( - filter: new RangeFilter(), + filter: new ComparisonFilter(new ExactFilter()), openApi: new Parameter('quantity', 'query', allowEmptyValue: true) ), 'amount' => new QueryParameter( - filter: new RangeFilter(), + filter: new ComparisonFilter(new ExactFilter()), property: 'quantity', openApi: new Parameter('amount', 'query', allowEmptyValue: true) ), diff --git a/tests/Fixtures/TestBundle/Entity/FilteredRangeParameter.php b/tests/Fixtures/TestBundle/Entity/FilteredRangeParameter.php index dace7ef7e1e..f4261bc1b75 100644 --- a/tests/Fixtures/TestBundle/Entity/FilteredRangeParameter.php +++ b/tests/Fixtures/TestBundle/Entity/FilteredRangeParameter.php @@ -13,7 +13,8 @@ namespace ApiPlatform\Tests\Fixtures\TestBundle\Entity; -use ApiPlatform\Doctrine\Orm\Filter\RangeFilter; +use ApiPlatform\Doctrine\Orm\Filter\ComparisonFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExactFilter; use ApiPlatform\Metadata\ApiResource; use ApiPlatform\Metadata\GetCollection; use ApiPlatform\Metadata\QueryParameter; @@ -25,11 +26,11 @@ paginationItemsPerPage: 5, parameters: [ 'quantity' => new QueryParameter( - filter: new RangeFilter(), + filter: new ComparisonFilter(new ExactFilter()), openApi: new Parameter('quantity', 'query', allowEmptyValue: true) ), 'amount' => new QueryParameter( - filter: new RangeFilter(), + filter: new ComparisonFilter(new ExactFilter()), property: 'quantity', openApi: new Parameter('amount', 'query', allowEmptyValue: true) ), diff --git a/tests/Functional/Parameters/DateFilterTest.php b/tests/Functional/Parameters/DateFilterTest.php index 72c90d214ef..dfe9a56be38 100644 --- a/tests/Functional/Parameters/DateFilterTest.php +++ b/tests/Functional/Parameters/DateFilterTest.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Tests\Functional\Parameters; +use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Doctrine\Orm\Filter\DateFilter; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\Document\FilteredDateParameter as FilteredDateParameterDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilteredDateParameter; @@ -36,6 +38,15 @@ public static function getResources(): array return [FilteredDateParameter::class]; } + public function testDateFilterIsStandalone(): void + { + self::assertNotContains( + AbstractFilter::class, + class_parents(DateFilter::class) ?: [], + 'DateFilter must not extend the deprecated AbstractFilter (5.0 standalone rewrite).' + ); + } + /** * @throws \Throwable */ diff --git a/tests/Functional/Parameters/ExistsFilterTest.php b/tests/Functional/Parameters/ExistsFilterTest.php index 122854154bf..18b20d4fbfa 100644 --- a/tests/Functional/Parameters/ExistsFilterTest.php +++ b/tests/Functional/Parameters/ExistsFilterTest.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Tests\Functional\Parameters; +use ApiPlatform\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Doctrine\Orm\Filter\ExistsFilter; use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\Document\FilteredExistsParameter as FilteredExistsParameterDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilteredExistsParameter; @@ -50,6 +52,15 @@ protected function setUp(): void $this->loadFixtures($entityClass); } + public function testExistsFilterIsStandalone(): void + { + self::assertNotContains( + AbstractFilter::class, + class_parents(ExistsFilter::class) ?: [], + 'ExistsFilter must not extend the deprecated AbstractFilter (5.0 standalone rewrite).' + ); + } + #[DataProvider('existsFilterScenariosProvider')] public function testExistsFilterResponses(string $url, int $expectedCount): void { From 8db71f6a17fdfeec1c8c640f14c84eea2a29cf8f Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 30 Jun 2026 15:35:40 +0200 Subject: [PATCH 48/84] chore: stabilize Elasticsearch, parameter providers and PropertyAwareFilterInterface (#8365) --- src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php | 2 -- src/Elasticsearch/Exception/IndexNotFoundException.php | 2 -- src/Elasticsearch/Exception/NonUniqueIdentifierException.php | 2 -- src/Elasticsearch/Extension/AbstractFilterExtension.php | 2 -- src/Elasticsearch/Extension/ConstantScoreFilterExtension.php | 2 -- .../Extension/RequestBodySearchCollectionExtensionInterface.php | 2 -- src/Elasticsearch/Extension/SortExtension.php | 2 -- src/Elasticsearch/Extension/SortFilterExtension.php | 2 -- src/Elasticsearch/Filter/AbstractFilter.php | 2 -- src/Elasticsearch/Filter/AbstractSearchFilter.php | 2 -- src/Elasticsearch/Filter/ConstantScoreFilterInterface.php | 2 -- src/Elasticsearch/Filter/FilterInterface.php | 2 -- src/Elasticsearch/Filter/OrderFilter.php | 2 -- src/Elasticsearch/Filter/SortFilterInterface.php | 2 -- src/Elasticsearch/Filter/TermFilter.php | 2 -- src/Elasticsearch/Paginator.php | 2 -- src/Elasticsearch/Serializer/DocumentNormalizer.php | 2 -- src/Elasticsearch/Serializer/ItemNormalizer.php | 2 -- .../Serializer/NameConverter/InnerFieldsNameConverter.php | 2 -- src/Elasticsearch/Util/FieldDatatypeTrait.php | 2 -- src/Laravel/State/ParameterValidatorProvider.php | 2 -- src/State/ParameterProvider/IriConverterParameterProvider.php | 2 -- src/State/ParameterProvider/ReadLinkParameterProvider.php | 2 -- src/State/Provider/SecurityParameterProvider.php | 2 -- 24 files changed, 48 deletions(-) diff --git a/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php index 33b9fad001a..80b7eabcb6e 100644 --- a/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php +++ b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php @@ -19,8 +19,6 @@ * @author Antoine Bluchet * * @method array|null getProperties() - * - * @experimental */ interface PropertyAwareFilterInterface { diff --git a/src/Elasticsearch/Exception/IndexNotFoundException.php b/src/Elasticsearch/Exception/IndexNotFoundException.php index a92528a0deb..24c4ed9e98d 100644 --- a/src/Elasticsearch/Exception/IndexNotFoundException.php +++ b/src/Elasticsearch/Exception/IndexNotFoundException.php @@ -16,8 +16,6 @@ /** * Index not found exception. * - * @experimental - * * @author Baptiste Meyer */ final class IndexNotFoundException extends \Exception implements ExceptionInterface diff --git a/src/Elasticsearch/Exception/NonUniqueIdentifierException.php b/src/Elasticsearch/Exception/NonUniqueIdentifierException.php index 624ff936c01..9d8d7710e9f 100644 --- a/src/Elasticsearch/Exception/NonUniqueIdentifierException.php +++ b/src/Elasticsearch/Exception/NonUniqueIdentifierException.php @@ -16,8 +16,6 @@ /** * Non unique identifier exception. * - * @experimental - * * @author Baptiste Meyer */ final class NonUniqueIdentifierException extends \Exception implements ExceptionInterface diff --git a/src/Elasticsearch/Extension/AbstractFilterExtension.php b/src/Elasticsearch/Extension/AbstractFilterExtension.php index 13e82800882..ac9ec377685 100644 --- a/src/Elasticsearch/Extension/AbstractFilterExtension.php +++ b/src/Elasticsearch/Extension/AbstractFilterExtension.php @@ -19,8 +19,6 @@ /** * Abstract class for easing the implementation of a filter extension. * - * @experimental - * * @author Baptiste Meyer */ abstract class AbstractFilterExtension implements RequestBodySearchCollectionExtensionInterface diff --git a/src/Elasticsearch/Extension/ConstantScoreFilterExtension.php b/src/Elasticsearch/Extension/ConstantScoreFilterExtension.php index d04eeb156ab..1736ec0e3b2 100644 --- a/src/Elasticsearch/Extension/ConstantScoreFilterExtension.php +++ b/src/Elasticsearch/Extension/ConstantScoreFilterExtension.php @@ -20,8 +20,6 @@ * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-constant-score-query.html * - * @experimental - * * @author Baptiste Meyer */ final class ConstantScoreFilterExtension extends AbstractFilterExtension diff --git a/src/Elasticsearch/Extension/RequestBodySearchCollectionExtensionInterface.php b/src/Elasticsearch/Extension/RequestBodySearchCollectionExtensionInterface.php index 5556a16ca98..0752938e9d7 100644 --- a/src/Elasticsearch/Extension/RequestBodySearchCollectionExtensionInterface.php +++ b/src/Elasticsearch/Extension/RequestBodySearchCollectionExtensionInterface.php @@ -20,8 +20,6 @@ * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-body.html * - * @experimental - * * @author Baptiste Meyer */ interface RequestBodySearchCollectionExtensionInterface diff --git a/src/Elasticsearch/Extension/SortExtension.php b/src/Elasticsearch/Extension/SortExtension.php index e327f7908f4..84da66a136e 100644 --- a/src/Elasticsearch/Extension/SortExtension.php +++ b/src/Elasticsearch/Extension/SortExtension.php @@ -25,8 +25,6 @@ * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html * - * @experimental - * * @author Baptiste Meyer */ final class SortExtension implements RequestBodySearchCollectionExtensionInterface diff --git a/src/Elasticsearch/Extension/SortFilterExtension.php b/src/Elasticsearch/Extension/SortFilterExtension.php index 84aec9efe6c..d6ef1c1a46f 100644 --- a/src/Elasticsearch/Extension/SortFilterExtension.php +++ b/src/Elasticsearch/Extension/SortFilterExtension.php @@ -20,8 +20,6 @@ * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html * - * @experimental - * * @author Baptiste Meyer */ final class SortFilterExtension extends AbstractFilterExtension diff --git a/src/Elasticsearch/Filter/AbstractFilter.php b/src/Elasticsearch/Filter/AbstractFilter.php index a305a57e03b..3083a42ebe1 100644 --- a/src/Elasticsearch/Filter/AbstractFilter.php +++ b/src/Elasticsearch/Filter/AbstractFilter.php @@ -31,8 +31,6 @@ /** * Abstract class with helpers for easing the implementation of a filter. * - * @experimental - * * @author Baptiste Meyer */ abstract class AbstractFilter implements FilterInterface diff --git a/src/Elasticsearch/Filter/AbstractSearchFilter.php b/src/Elasticsearch/Filter/AbstractSearchFilter.php index a20fe911f97..075ce9a55fd 100644 --- a/src/Elasticsearch/Filter/AbstractSearchFilter.php +++ b/src/Elasticsearch/Filter/AbstractSearchFilter.php @@ -30,8 +30,6 @@ /** * Abstract class with helpers for easing the implementation of a search filter like a term filter or a match filter. * - * @experimental - * * @internal * * @author Baptiste Meyer diff --git a/src/Elasticsearch/Filter/ConstantScoreFilterInterface.php b/src/Elasticsearch/Filter/ConstantScoreFilterInterface.php index 638be2d10a8..0c390414aa6 100644 --- a/src/Elasticsearch/Filter/ConstantScoreFilterInterface.php +++ b/src/Elasticsearch/Filter/ConstantScoreFilterInterface.php @@ -16,8 +16,6 @@ /** * Elasticsearch filter interface for a constant score query. * - * @experimental - * * @author Baptiste Meyer */ interface ConstantScoreFilterInterface extends FilterInterface diff --git a/src/Elasticsearch/Filter/FilterInterface.php b/src/Elasticsearch/Filter/FilterInterface.php index 13d4df2a0b1..bf2bdd35ee3 100644 --- a/src/Elasticsearch/Filter/FilterInterface.php +++ b/src/Elasticsearch/Filter/FilterInterface.php @@ -19,8 +19,6 @@ /** * Elasticsearch filter interface. * - * @experimental - * * @author Baptiste Meyer */ interface FilterInterface extends BaseFilterInterface diff --git a/src/Elasticsearch/Filter/OrderFilter.php b/src/Elasticsearch/Filter/OrderFilter.php index 481de5a1fd0..d0c1a7fc0ff 100644 --- a/src/Elasticsearch/Filter/OrderFilter.php +++ b/src/Elasticsearch/Filter/OrderFilter.php @@ -105,8 +105,6 @@ * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html * - * @experimental - * * @author Baptiste Meyer */ final class OrderFilter extends AbstractFilter implements SortFilterInterface diff --git a/src/Elasticsearch/Filter/SortFilterInterface.php b/src/Elasticsearch/Filter/SortFilterInterface.php index 0434889c3ae..b94f6080683 100644 --- a/src/Elasticsearch/Filter/SortFilterInterface.php +++ b/src/Elasticsearch/Filter/SortFilterInterface.php @@ -16,8 +16,6 @@ /** * Elasticsearch filter interface for sorting. * - * @experimental - * * @author Baptiste Meyer */ interface SortFilterInterface extends FilterInterface diff --git a/src/Elasticsearch/Filter/TermFilter.php b/src/Elasticsearch/Filter/TermFilter.php index fba2c549c64..ff86bb0cf00 100644 --- a/src/Elasticsearch/Filter/TermFilter.php +++ b/src/Elasticsearch/Filter/TermFilter.php @@ -98,8 +98,6 @@ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-term-query.html * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-terms-query.html * - * @experimental - * * @author Baptiste Meyer */ final class TermFilter extends AbstractSearchFilter diff --git a/src/Elasticsearch/Paginator.php b/src/Elasticsearch/Paginator.php index 2a1c6edc70e..9b6e7ee46e8 100644 --- a/src/Elasticsearch/Paginator.php +++ b/src/Elasticsearch/Paginator.php @@ -21,8 +21,6 @@ /** * Paginator for Elasticsearch. * - * @experimental - * * @author Baptiste Meyer */ final class Paginator implements \IteratorAggregate, PaginatorInterface diff --git a/src/Elasticsearch/Serializer/DocumentNormalizer.php b/src/Elasticsearch/Serializer/DocumentNormalizer.php index 189561f800b..6188b15606f 100644 --- a/src/Elasticsearch/Serializer/DocumentNormalizer.php +++ b/src/Elasticsearch/Serializer/DocumentNormalizer.php @@ -32,8 +32,6 @@ /** * Document denormalizer for Elasticsearch. * - * @experimental - * * @author Baptiste Meyer */ final class DocumentNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface diff --git a/src/Elasticsearch/Serializer/ItemNormalizer.php b/src/Elasticsearch/Serializer/ItemNormalizer.php index e3cece34f23..10a53d5af28 100644 --- a/src/Elasticsearch/Serializer/ItemNormalizer.php +++ b/src/Elasticsearch/Serializer/ItemNormalizer.php @@ -22,8 +22,6 @@ /** * Item normalizer decorator that prevents {@see \ApiPlatform\Serializer\ItemNormalizer} * from taking over for the {@see DocumentNormalizer::FORMAT} format because of priorities. - * - * @experimental */ final class ItemNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface { diff --git a/src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php b/src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php index dbf5b306e61..6ad041fa238 100644 --- a/src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php +++ b/src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php @@ -19,8 +19,6 @@ /** * Converts inner fields with a inner name converter. * - * @experimental - * * @author Baptiste Meyer */ final class InnerFieldsNameConverter implements NameConverterInterface diff --git a/src/Elasticsearch/Util/FieldDatatypeTrait.php b/src/Elasticsearch/Util/FieldDatatypeTrait.php index 25a0fe81bc8..6b5cf242ffb 100644 --- a/src/Elasticsearch/Util/FieldDatatypeTrait.php +++ b/src/Elasticsearch/Util/FieldDatatypeTrait.php @@ -27,8 +27,6 @@ * * @internal * - * @experimental - * * @author Baptiste Meyer */ trait FieldDatatypeTrait diff --git a/src/Laravel/State/ParameterValidatorProvider.php b/src/Laravel/State/ParameterValidatorProvider.php index 72276824602..89306e7bf59 100644 --- a/src/Laravel/State/ParameterValidatorProvider.php +++ b/src/Laravel/State/ParameterValidatorProvider.php @@ -25,8 +25,6 @@ * Validates parameters using the Laravel validator. * * @implements ProviderInterface - * - * @experimental */ final class ParameterValidatorProvider implements ProviderInterface { diff --git a/src/State/ParameterProvider/IriConverterParameterProvider.php b/src/State/ParameterProvider/IriConverterParameterProvider.php index 3d28f5be729..e8147041d0a 100644 --- a/src/State/ParameterProvider/IriConverterParameterProvider.php +++ b/src/State/ParameterProvider/IriConverterParameterProvider.php @@ -23,8 +23,6 @@ use Psr\Log\LoggerInterface; /** - * @experimental - * * @author Vincent Amstoutz */ final readonly class IriConverterParameterProvider implements ParameterProviderInterface diff --git a/src/State/ParameterProvider/ReadLinkParameterProvider.php b/src/State/ParameterProvider/ReadLinkParameterProvider.php index 906eb0ac9d1..4a43f6c3c20 100644 --- a/src/State/ParameterProvider/ReadLinkParameterProvider.php +++ b/src/State/ParameterProvider/ReadLinkParameterProvider.php @@ -26,8 +26,6 @@ /** * Checks if the linked resources have security attributes and prepares them for access checking. - * - * @experimental */ final class ReadLinkParameterProvider implements ParameterProviderInterface { diff --git a/src/State/Provider/SecurityParameterProvider.php b/src/State/Provider/SecurityParameterProvider.php index 238908b2362..321bd8922c7 100644 --- a/src/State/Provider/SecurityParameterProvider.php +++ b/src/State/Provider/SecurityParameterProvider.php @@ -30,8 +30,6 @@ * Loops over parameters to check parameter security. * Throws an exception if security is not granted. * - * @experimental - * * @implements ProviderInterface */ final class SecurityParameterProvider implements ProviderInterface From 1e6d13ae117471dd6e549cd1cc5dc8816d5804fd Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 30 Jun 2026 17:13:00 +0200 Subject: [PATCH 49/84] feat!: core 5.0 cleanups (getProperties interface, json:api status string) (#8366) --- .../Common/Filter/PropertyAwareFilterInterface.php | 12 ++++-------- src/Doctrine/Common/ParameterExtensionTrait.php | 6 +----- src/JsonApi/Serializer/ErrorNormalizer.php | 7 +++---- src/Laravel/State/ValidateProvider.php | 6 ------ src/Laravel/Tests/JsonApiTest.php | 6 +++--- .../ParameterResourceMetadataCollectionFactory.php | 8 +------- tests/Functional/JsonApi/ErrorTest.php | 7 +++---- 7 files changed, 15 insertions(+), 37 deletions(-) diff --git a/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php index 80b7eabcb6e..65710033fce 100644 --- a/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php +++ b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php @@ -14,11 +14,7 @@ namespace ApiPlatform\Doctrine\Common\Filter; /** - * TODO: 5.x uncomment method. - * * @author Antoine Bluchet - * - * @method array|null getProperties() */ interface PropertyAwareFilterInterface { @@ -27,8 +23,8 @@ interface PropertyAwareFilterInterface */ public function setProperties(array $properties): void; - // /** - // * @return string[] - // */ - // public function getProperties(): ?array; + /** + * @return string[] + */ + public function getProperties(): ?array; } diff --git a/src/Doctrine/Common/ParameterExtensionTrait.php b/src/Doctrine/Common/ParameterExtensionTrait.php index 57f1ff140c0..7950b20eaa1 100644 --- a/src/Doctrine/Common/ParameterExtensionTrait.php +++ b/src/Doctrine/Common/ParameterExtensionTrait.php @@ -51,11 +51,7 @@ private function configureFilter(object $filter, Parameter $parameter): void } if ($filter instanceof PropertyAwareFilterInterface) { - $properties = []; - // Check if the filter has getProperties method (e.g., if it's an AbstractFilter) - if (method_exists($filter, 'getProperties')) { // @phpstan-ignore-line todo 5.x remove this check @see interface - $properties = $filter->getProperties() ?? []; - } + $properties = $filter->getProperties() ?? []; $propertyKey = $parameter->getProperty() ?? $parameter->getKey(); foreach ($parameter->getProperties() ?? [$propertyKey] as $property) { diff --git a/src/JsonApi/Serializer/ErrorNormalizer.php b/src/JsonApi/Serializer/ErrorNormalizer.php index 3b6d2fba917..2076a1c1d8a 100644 --- a/src/JsonApi/Serializer/ErrorNormalizer.php +++ b/src/JsonApi/Serializer/ErrorNormalizer.php @@ -45,10 +45,9 @@ public function normalize(mixed $data, ?string $format = null, array $context = $error['code'] = $data->getId(); } - // TODO: change this 5.x - // if (isset($error['status'])) { - // $error['status'] = (string) $error['status']; - // } + if (isset($error['status'])) { + $error['status'] = (string) $error['status']; + } if (!isset($error['violations'])) { return ['errors' => [$error]]; diff --git a/src/Laravel/State/ValidateProvider.php b/src/Laravel/State/ValidateProvider.php index 3fc959f48a9..e5af05d82c2 100644 --- a/src/Laravel/State/ValidateProvider.php +++ b/src/Laravel/State/ValidateProvider.php @@ -115,12 +115,6 @@ private function getBodyForValidation(mixed $body): array return $v; } - // hopefully this path never gets used, its there for BC-layer only - // TODO: remove in 5.0 - if ($s = json_encode($body)) { - return json_decode($s, true); - } - throw new RuntimeException('Could not transform the denormalized body in an array for validation'); } } diff --git a/src/Laravel/Tests/JsonApiTest.php b/src/Laravel/Tests/JsonApiTest.php index 8bb06a6f09c..1c987a4dd6c 100644 --- a/src/Laravel/Tests/JsonApiTest.php +++ b/src/Laravel/Tests/JsonApiTest.php @@ -250,13 +250,13 @@ public function testValidateJsonApi(): void [ 'detail' => 'The prop field is required.', 'title' => 'Validation Error', - 'status' => 422, + 'status' => '422', 'code' => '58350900e0fc6b8e/prop', ], [ 'detail' => 'The max field must be less than 2.', 'title' => 'Validation Error', - 'status' => 422, + 'status' => '422', 'code' => '58350900e0fc6b8e/max', ], ], @@ -294,7 +294,7 @@ public function testNotFound(): void $this->assertJsonContains([ 'links' => ['type' => '/errors/404'], 'title' => 'An error occurred', - 'status' => 404, + 'status' => '404', 'detail' => 'Not Found', ], $response->json()['errors'][0]); } diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index f55734196e5..16ab8549f1e 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -212,13 +212,7 @@ private function getProperties(string $resourceClass, ?Parameter $parameter = nu } if (($filter = $this->getFilterInstance($parameter->getFilter())) && $filter instanceof PropertyAwareFilterInterface) { - if (!method_exists($filter, 'getProperties')) { // todo 5.x remove this check - trigger_deprecation('api-platform/core', 'In API Platform 5.0 "%s" will implement a method named "getProperties"', PropertyAwareFilterInterface::class); - $refl = new \ReflectionClass($filter); - $filterProperties = $refl->hasProperty('properties') ? $refl->getProperty('properties')->getValue($filter) : []; - } else { - $filterProperties = array_keys($filter->getProperties() ?? []); - } + $filterProperties = array_keys($filter->getProperties() ?? []); foreach ($filterProperties as $prop) { if (!\in_array($prop, $propertyNames, true)) { diff --git a/tests/Functional/JsonApi/ErrorTest.php b/tests/Functional/JsonApi/ErrorTest.php index 96a813c1d0b..f665605f19f 100644 --- a/tests/Functional/JsonApi/ErrorTest.php +++ b/tests/Functional/JsonApi/ErrorTest.php @@ -43,8 +43,7 @@ public function testErrorResourceRendersInJsonApiFormat(): void $this->assertJsonContains([ 'errors' => [ [ - // TODO: change this to '400' in 5.x - 'status' => 400, + 'status' => '400', 'detail' => 'Resource "nonexistent" not found.', ], ], @@ -91,7 +90,7 @@ public function testRfc7807ErrorRendersJsonApiFormat(): void $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); $body = $response->toArray(false); $this->assertSame('An error occurred', $body['errors'][0]['title']); - $this->assertSame(400, $body['errors'][0]['status']); + $this->assertSame('400', $body['errors'][0]['status']); $this->assertArrayHasKey('detail', $body['errors'][0]); $this->assertArrayHasKey('type', $body['errors'][0]); } @@ -110,7 +109,7 @@ public function testNotFoundRouteRendersJsonApiFormat(): void $this->assertResponseHeaderSame('content-type', 'application/vnd.api+json; charset=utf-8'); $body = $response->toArray(false); $this->assertSame('An error occurred', $body['errors'][0]['title']); - $this->assertSame(404, $body['errors'][0]['status']); + $this->assertSame('404', $body['errors'][0]['status']); $this->assertArrayHasKey('detail', $body['errors'][0]); $this->assertArrayHasKey('type', $body['errors'][0]); } From a8af988dee9aa5fa0ef2b086d2f0f8aec8a17add Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 30 Jun 2026 19:07:49 +0200 Subject: [PATCH 50/84] chore(metadata): remove stale 3.0 TODO in IdentifiersExtractor (#8368) --- src/Metadata/IdentifiersExtractor.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/Metadata/IdentifiersExtractor.php b/src/Metadata/IdentifiersExtractor.php index 7c0c8c5c98c..0f820de937d 100644 --- a/src/Metadata/IdentifiersExtractor.php +++ b/src/Metadata/IdentifiersExtractor.php @@ -177,11 +177,6 @@ private function getIdentifierValue(object $item, string $class, string $propert throw new RuntimeException('Not able to retrieve identifiers.'); } - /** - * TODO: in 3.0 this method just uses $identifierValue instanceof \Stringable and we remove the weird behavior. - * - * @param mixed|\Stringable $identifierValue - */ private function resolveIdentifierValue(mixed $identifierValue, string $parameterName): float|bool|int|string { if (null === $identifierValue) { From 4a9a14507e5ca97c85fcf8dd1e240008f000c525 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 30 Jun 2026 19:15:47 +0200 Subject: [PATCH 51/84] feat!: remove legacy PropertyInfo Type system (#8364) --- composer.json | 2 +- docs/composer.json | 2 +- src/Doctrine/Odm/composer.json | 2 +- src/Doctrine/Orm/composer.json | 2 +- src/Elasticsearch/Filter/AbstractFilter.php | 97 ---- .../Filter/AbstractSearchFilter.php | 38 +- src/Elasticsearch/Util/FieldDatatypeTrait.php | 31 -- src/Elasticsearch/composer.json | 2 +- .../Resolver/Factory/ResolverFactory.php | 18 +- src/GraphQl/Tests/Type/FieldsBuilderTest.php | 5 - src/GraphQl/Tests/Type/TypeBuilderTest.php | 36 -- src/GraphQl/Tests/Type/TypeConverterTest.php | 179 ------ .../Type/ContextAwareTypeBuilderInterface.php | 8 - src/GraphQl/Type/FieldsBuilder.php | 54 +- src/GraphQl/Type/TypeBuilder.php | 11 - src/GraphQl/Type/TypeConverter.php | 72 +-- src/GraphQl/Type/TypeConverterInterface.php | 7 +- src/Hal/Serializer/ItemNormalizer.php | 35 +- .../Serializer/DocumentationNormalizer.php | 167 ++---- .../ConstraintViolationListNormalizer.php | 12 +- src/JsonApi/Util/ResourceLinkageResolver.php | 20 - .../Factory/SchemaPropertyMetadataFactory.php | 236 -------- src/JsonSchema/SchemaFactory.php | 141 +---- .../SchemaPropertyMetadataFactoryTest.php | 71 --- src/JsonSchema/Tests/SchemaFactoryTest.php | 296 ---------- src/JsonSchema/composer.json | 2 +- src/Metadata/ApiProperty.php | 48 -- .../Extractor/XmlPropertyExtractor.php | 1 - .../Extractor/YamlPropertyExtractor.php | 1 - src/Metadata/Extractor/schema/properties.xsd | 13 - src/Metadata/IdentifiersExtractor.php | 25 - .../AttributePropertyMetadataFactory.php | 14 - .../ExtractorPropertyMetadataFactory.php | 12 - .../PropertyInfoPropertyMetadataFactory.php | 23 +- .../SerializerPropertyMetadataFactory.php | 68 --- .../Util/PropertyInfoToTypeInfoHelperTest.php | 104 ---- .../Util/PropertyInfoToTypeInfoHelper.php | 307 ----------- src/Metadata/composer.json | 2 +- src/OpenApi/Factory/OpenApiFactory.php | 48 +- src/OpenApi/Factory/TypeFactoryTrait.php | 42 +- src/Serializer/AbstractItemNormalizer.php | 260 +-------- .../Tests/AbstractItemNormalizerTest.php | 515 ++++-------------- src/Serializer/composer.json | 2 +- .../PropertySchemaChoiceRestrictionTest.php | 89 --- ...chemaGreaterThanOrEqualRestrictionTest.php | 33 -- ...opertySchemaGreaterThanRestrictionTest.php | 36 -- ...tySchemaLessThanOrEqualRestrictionTest.php | 33 -- .../PropertySchemaLessThanRestrictionTest.php | 36 -- .../PropertySchemaOneOfRestrictionTest.php | 20 - .../PropertySchemaRangeRestrictionTest.php | 45 -- .../ValidatorPropertyMetadataFactoryTest.php | 166 ------ .../PropertySchemaChoiceRestriction.php | 45 +- ...rtySchemaGreaterThanOrEqualRestriction.php | 19 +- .../PropertySchemaGreaterThanRestriction.php | 19 +- .../PropertySchemaLengthRestriction.php | 15 +- ...opertySchemaLessThanOrEqualRestriction.php | 19 +- .../PropertySchemaLessThanRestriction.php | 19 +- .../PropertySchemaRangeRestriction.php | 19 +- src/Symfony/composer.json | 2 +- .../TestBundle/GraphQl/Type/TypeConverter.php | 17 - .../config/api_resources_odm/properties.xml | 6 +- .../config/api_resources_orm/properties.xml | 6 +- 62 files changed, 288 insertions(+), 3387 deletions(-) delete mode 100644 src/Metadata/Tests/Util/PropertyInfoToTypeInfoHelperTest.php delete mode 100644 src/Metadata/Util/PropertyInfoToTypeInfoHelper.php diff --git a/composer.json b/composer.json index 033c9a0e580..6e918b0fc3d 100644 --- a/composer.json +++ b/composer.json @@ -116,7 +116,7 @@ "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", + "symfony/property-info": "^7.1 || ^8.0", "symfony/serializer": "^6.4.37 || ^7.4.9 || ^8.0.9", "symfony/translation-contracts": "^3.3", "symfony/type-info": "^7.4 || ^8.0", diff --git a/docs/composer.json b/docs/composer.json index bbc61158231..c3625686908 100644 --- a/docs/composer.json +++ b/docs/composer.json @@ -22,7 +22,7 @@ "phpstan/phpdoc-parser": "^1.15", "symfony/framework-bundle": "^7.0", "symfony/property-access": "^7.0", - "symfony/property-info": "^7.0", + "symfony/property-info": "^7.1 || ^8.0", "symfony/runtime": "^7.0", "symfony/security-bundle": "^7.0", "symfony/type-info": "^7.3-dev", diff --git a/src/Doctrine/Odm/composer.json b/src/Doctrine/Odm/composer.json index 6bcd6a15e2d..e0cb5dcc965 100644 --- a/src/Doctrine/Odm/composer.json +++ b/src/Doctrine/Odm/composer.json @@ -30,7 +30,7 @@ "api-platform/serializer": "^5.0@alpha", "api-platform/state": "^5.0@alpha", "doctrine/mongodb-odm": "^2.10", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", + "symfony/property-info": "^7.1 || ^8.0", "symfony/type-info": "^7.3 || ^8.0" }, "require-dev": { diff --git a/src/Doctrine/Orm/composer.json b/src/Doctrine/Orm/composer.json index 26365f59127..c5b0c1404ac 100644 --- a/src/Doctrine/Orm/composer.json +++ b/src/Doctrine/Orm/composer.json @@ -41,7 +41,7 @@ "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", "symfony/serializer": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", + "symfony/property-info": "^7.1 || ^8.0", "symfony/uid": "^6.4 || ^7.0 || ^8.0", "symfony/validator": "^6.4.11 || ^7.0 || ^8.0", "symfony/yaml": "^6.4 || ^7.0 || ^8.0", diff --git a/src/Elasticsearch/Filter/AbstractFilter.php b/src/Elasticsearch/Filter/AbstractFilter.php index 3083a42ebe1..e05adabf959 100644 --- a/src/Elasticsearch/Filter/AbstractFilter.php +++ b/src/Elasticsearch/Filter/AbstractFilter.php @@ -19,8 +19,6 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; @@ -81,10 +79,6 @@ protected function hasProperty(string $resourceClass, string $property): bool */ protected function getMetadata(string $resourceClass, string $property): array { - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - return $this->getLegacyMetadata($resourceClass, $property); - } - $noop = [null, null, null, null]; if (!$this->hasProperty($resourceClass, $property)) { @@ -176,95 +170,4 @@ protected function getMetadata(string $resourceClass, string $property): array return [$type, $hasAssociation, $currentResourceClass, $currentProperty]; } - - protected function getLegacyMetadata(string $resourceClass, string $property): array - { - $noop = [null, null, null, null]; - - if (!$this->hasProperty($resourceClass, $property)) { - return $noop; - } - - $properties = explode('.', $property); - $totalProperties = \count($properties); - $currentResourceClass = $resourceClass; - $hasAssociation = false; - $currentProperty = null; - $type = null; - - foreach ($properties as $index => $currentProperty) { - try { - $propertyMetadata = $this->propertyMetadataFactory->create($currentResourceClass, $currentProperty); - } catch (PropertyNotFoundException) { - return $noop; - } - - $types = $propertyMetadata->getBuiltinTypes(); - - if (null === $types) { - return $noop; - } - - ++$index; - - // check each type before deciding if it's noop or not - // e.g: maybe the first type is noop, but the second is valid - $isNoop = false; - - foreach ($types as $type) { - $builtinType = $type->getBuiltinType(); - - if (LegacyType::BUILTIN_TYPE_OBJECT !== $builtinType && LegacyType::BUILTIN_TYPE_ARRAY !== $builtinType) { - if ($totalProperties === $index) { - break 2; - } - - $isNoop = true; - - continue; - } - - if ($type->isCollection() && null === $type = $type->getCollectionValueTypes()[0] ?? null) { - $isNoop = true; - - continue; - } - - if (LegacyType::BUILTIN_TYPE_ARRAY === $builtinType && LegacyType::BUILTIN_TYPE_OBJECT !== $type->getBuiltinType()) { - if ($totalProperties === $index) { - break 2; - } - - $isNoop = true; - - continue; - } - - if (null === $className = $type->getClassName()) { - $isNoop = true; - - continue; - } - - if ($isResourceClass = $this->resourceClassResolver->isResourceClass($className)) { - $currentResourceClass = $className; - } elseif ($totalProperties !== $index) { - $isNoop = true; - - continue; - } - - $hasAssociation = $totalProperties === $index && $isResourceClass; - $isNoop = false; - - break; - } - - if ($isNoop) { - return $noop; - } - } - - return [$type, $hasAssociation, $currentResourceClass, $currentProperty]; - } } diff --git a/src/Elasticsearch/Filter/AbstractSearchFilter.php b/src/Elasticsearch/Filter/AbstractSearchFilter.php index 075ce9a55fd..c6e90cdf776 100644 --- a/src/Elasticsearch/Filter/AbstractSearchFilter.php +++ b/src/Elasticsearch/Filter/AbstractSearchFilter.php @@ -21,7 +21,6 @@ use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\WrappingTypeInterface; @@ -110,27 +109,8 @@ public function getDescription(string $resourceClass): array */ abstract protected function getQuery(string $property, array $values, ?string $nestedPath): array; - protected function getPhpType(LegacyType|Type $type): string + protected function getPhpType(Type $type): string { - if ($type instanceof LegacyType) { - switch ($builtinType = $type->getBuiltinType()) { - case LegacyType::BUILTIN_TYPE_ARRAY: - case LegacyType::BUILTIN_TYPE_INT: - case LegacyType::BUILTIN_TYPE_FLOAT: - case LegacyType::BUILTIN_TYPE_BOOL: - case LegacyType::BUILTIN_TYPE_STRING: - return $builtinType; - case LegacyType::BUILTIN_TYPE_OBJECT: - if (null !== ($className = $type->getClassName()) && is_a($className, \DateTimeInterface::class, true)) { - return \DateTimeInterface::class; - } - - // no break - default: - return 'string'; - } - } - if ($type->isIdentifiedBy(TypeIdentifier::ARRAY, TypeIdentifier::INT, TypeIdentifier::FLOAT, TypeIdentifier::BOOL, TypeIdentifier::STRING)) { while ($type instanceof WrappingTypeInterface) { $type = $type->getWrappedType(); @@ -178,22 +158,8 @@ protected function getIdentifierValue(string $iri, string $property): mixed return $iri; } - protected function hasValidValues(array $values, LegacyType|Type $type): bool + protected function hasValidValues(array $values, Type $type): bool { - if ($type instanceof LegacyType) { - foreach ($values as $value) { - if ( - null !== $value - && LegacyType::BUILTIN_TYPE_INT === $type->getBuiltinType() - && false === filter_var($value, \FILTER_VALIDATE_INT) - ) { - return false; - } - } - - return true; - } - foreach ($values as $value) { if ( null !== $value diff --git a/src/Elasticsearch/Util/FieldDatatypeTrait.php b/src/Elasticsearch/Util/FieldDatatypeTrait.php index 6b5cf242ffb..c5e22643419 100644 --- a/src/Elasticsearch/Util/FieldDatatypeTrait.php +++ b/src/Elasticsearch/Util/FieldDatatypeTrait.php @@ -17,8 +17,6 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\TypeHelper; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -66,35 +64,6 @@ private function getNestedFieldPath(string $resourceClass, string $property): ?s return null; } - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $types = $propertyMetadata->getBuiltinTypes() ?? []; - - foreach ($types as $type) { - if ( - LegacyType::BUILTIN_TYPE_OBJECT === $type->getBuiltinType() - && null !== ($nextResourceClass = $type->getClassName()) - && $this->resourceClassResolver->isResourceClass($nextResourceClass) - ) { - $nestedPath = $this->getNestedFieldPath($nextResourceClass, implode('.', $properties)); - - return null === $nestedPath ? $nestedPath : "$currentProperty.$nestedPath"; - } - - if ( - null !== ($type = $type->getCollectionValueTypes()[0] ?? null) - && LegacyType::BUILTIN_TYPE_OBJECT === $type->getBuiltinType() - && null !== ($className = $type->getClassName()) - && $this->resourceClassResolver->isResourceClass($className) - ) { - $nestedPath = $this->getNestedFieldPath($className, implode('.', $properties)); - - return null === $nestedPath ? $currentProperty : "$currentProperty.$nestedPath"; - } - } - - return null; - } - $type = $propertyMetadata->getNativeType(); if (null === $type) { diff --git a/src/Elasticsearch/composer.json b/src/Elasticsearch/composer.json index d4f8c21fb46..041809891e9 100644 --- a/src/Elasticsearch/composer.json +++ b/src/Elasticsearch/composer.json @@ -31,7 +31,7 @@ "symfony/cache": "^6.4 || ^7.0 || ^8.0", "symfony/console": "^6.4 || ^7.0 || ^8.0", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", + "symfony/property-info": "^7.1 || ^8.0", "symfony/serializer": "^6.4 || ^7.0 || ^8.0", "symfony/type-info": "^7.3 || ^8.0", "symfony/uid": "^6.4 || ^7.0 || ^8.0" diff --git a/src/GraphQl/Resolver/Factory/ResolverFactory.php b/src/GraphQl/Resolver/Factory/ResolverFactory.php index 302bdea66eb..2728c4cdc2c 100644 --- a/src/GraphQl/Resolver/Factory/ResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ResolverFactory.php @@ -27,7 +27,6 @@ use ApiPlatform\State\ProviderInterface; use GraphQL\Type\Definition\ResolveInfo; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\TypeInfo\Type\CollectionType; class ResolverFactory implements ResolverFactoryInterface @@ -66,20 +65,11 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul $propertyMetadata = $rootClass ? $propertyMetadataFactory?->create($rootClass, $info->fieldName) : null; - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata?->getNativeType(); + $type = $propertyMetadata?->getNativeType(); - // Data already fetched and normalized (field or nested resource) - if ($body || null === $resourceClass || ($type && !$type->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType))) { - return $body; - } - } else { - $type = $propertyMetadata?->getBuiltinTypes()[0] ?? null; - - // Data already fetched and normalized (field or nested resource) - if ($body || null === $resourceClass || ($type && !$type->isCollection())) { - return $body; - } + // Data already fetched and normalized (field or nested resource) + if ($body || null === $resourceClass || ($type && !$type->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType))) { + return $body; } } diff --git a/src/GraphQl/Tests/Type/FieldsBuilderTest.php b/src/GraphQl/Tests/Type/FieldsBuilderTest.php index 268c7d56295..5bae2e0e9b4 100644 --- a/src/GraphQl/Tests/Type/FieldsBuilderTest.php +++ b/src/GraphQl/Tests/Type/FieldsBuilderTest.php @@ -485,11 +485,6 @@ public function testGetResourceObjectTypeFields(string $resourceClass, Operation }); $typeConverter = new class implements TypeConverterInterface { - public function convertType(\Symfony\Component\PropertyInfo\Type $type, bool $input, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth): GraphQLType|string|null - { - return null; - } - public function resolveType(string $type): ?GraphQLType { return null; diff --git a/src/GraphQl/Tests/Type/TypeBuilderTest.php b/src/GraphQl/Tests/Type/TypeBuilderTest.php index ddef6ba5b4e..5143ee53564 100644 --- a/src/GraphQl/Tests/Type/TypeBuilderTest.php +++ b/src/GraphQl/Tests/Type/TypeBuilderTest.php @@ -37,14 +37,11 @@ use GraphQL\Type\Definition\ResolveInfo; use GraphQL\Type\Definition\Type as GraphQLType; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; use Psr\Container\ContainerInterface; -use Symfony\Component\PropertyInfo\Type as LegacyType; -use Symfony\Component\TypeInfo\Type; /** * @author Alan Poulain @@ -608,37 +605,4 @@ public function testGetEnumType(): void 'values' => $enumValues, ]), $this->typeBuilder->getEnumType($operation)); } - - #[IgnoreDeprecations] - public function testIsCollectionLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - - $this->expectUserDeprecationMessage('Since api-platform/graphql 4.2: The "ApiPlatform\GraphQl\Type\TypeBuilder::isCollection()" method is deprecated and will be removed.'); - $this->assertFalse($this->typeBuilder->isCollection(new LegacyType(LegacyType::BUILTIN_TYPE_BOOL))); - $this->assertFalse($this->typeBuilder->isCollection(new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT))); - $this->assertFalse($this->typeBuilder->isCollection(new LegacyType(LegacyType::BUILTIN_TYPE_RESOURCE, false, null, false))); - $this->assertFalse($this->typeBuilder->isCollection(new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, null, true))); - $this->assertFalse($this->typeBuilder->isCollection(new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true))); - $this->assertFalse($this->typeBuilder->isCollection(new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true, null, new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT)))); - $this->assertFalse($this->typeBuilder->isCollection(new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'className', true))); - $this->assertTrue($this->typeBuilder->isCollection(new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, null, true, null, new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'className')))); - $this->assertTrue($this->typeBuilder->isCollection(new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true, null, new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'className')))); - } - - public static function typesProvider(): array - { - return [ - [Type::bool(), false], - [Type::object(), false], - [Type::resource(), false], - [Type::collection(Type::object(\Stringable::class)), false], - [Type::array(), false], - [Type::array(Type::object()), false], - [Type::collection(Type::object(\Traversable::class), Type::object(\Stringable::class)), true], - [Type::array(Type::object(\Stringable::class)), true], - ]; - } } diff --git a/src/GraphQl/Tests/Type/TypeConverterTest.php b/src/GraphQl/Tests/Type/TypeConverterTest.php index 9e601c15fd0..62f87bbdeec 100644 --- a/src/GraphQl/Tests/Type/TypeConverterTest.php +++ b/src/GraphQl/Tests/Type/TypeConverterTest.php @@ -31,12 +31,10 @@ use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type as GraphQLType; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Prophecy\Prophecy\ObjectProphecy; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; /** @@ -64,45 +62,6 @@ protected function setUp(): void $this->typeConverter = new TypeConverter($this->typeBuilderProphecy->reveal(), $this->typesContainerProphecy->reveal(), $this->resourceMetadataCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal()); } - #[IgnoreDeprecations] - public function testConvertTypeLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - - $testCases = [ - [new LegacyType(LegacyType::BUILTIN_TYPE_BOOL), false, 0, GraphQLType::boolean()], - [new LegacyType(LegacyType::BUILTIN_TYPE_INT), false, 0, GraphQLType::int()], - [new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), false, 0, GraphQLType::float()], - [new LegacyType(LegacyType::BUILTIN_TYPE_STRING), false, 0, GraphQLType::string()], - [new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY), false, 0, 'Iterable'], - [new LegacyType(LegacyType::BUILTIN_TYPE_ITERABLE), false, 0, 'Iterable'], - [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, \DateTimeInterface::class), false, 0, GraphQLType::string()], - [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class), false, 0, new EnumType(['name' => 'GenderTypeEnum', 'values' => []])], - [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT), false, 0, null], - [new LegacyType(LegacyType::BUILTIN_TYPE_CALLABLE), false, 0, null], - [new LegacyType(LegacyType::BUILTIN_TYPE_NULL), false, 0, null], - [new LegacyType(LegacyType::BUILTIN_TYPE_RESOURCE), false, 0, null], - ]; - - foreach ($testCases as [$type, $input, $depth, $expectedGraphqlType]) { - /* @var LegacyType $type */ - /* @var bool $input */ - /* @var int $depth */ - /* @var GraphQLType|string|null $expectedGraphqlType */ - $this->expectUserDeprecationMessage('Since api-platform/graphql 4.2: The "ApiPlatform\GraphQl\Type\TypeConverter::convertType()" method is deprecated, use "ApiPlatform\GraphQl\Type\TypeConverter::convertPhpType()" instead.'); - - $this->typeBuilderProphecy->isCollection($type)->willReturn(false); - $this->resourceMetadataCollectionFactoryProphecy->create(Argument::type('string'))->willReturn(new ResourceMetadataCollection('resourceClass')); - $this->typeBuilderProphecy->getEnumType(Argument::type(Operation::class))->willReturn($expectedGraphqlType); - - $operation = (new Query())->withName('test'); - $graphqlType = $this->typeConverter->convertType($type, $input, $operation, 'resourceClass', 'rootClass', null, $depth); - $this->assertSame($expectedGraphqlType, $graphqlType); - } - } - #[DataProvider('convertTypeProvider')] public function testConvertType(Type $type, bool $input, int $depth, GraphQLType|string|null $expectedGraphqlType): void { @@ -132,23 +91,6 @@ public static function convertTypeProvider(): array ]; } - #[IgnoreDeprecations] - public function testConvertTypeNoGraphQlResourceMetadataLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - - $type = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'dummy'); - - $this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(false); - $this->resourceMetadataCollectionFactoryProphecy->create('dummy')->shouldBeCalled()->willReturn(new ResourceMetadataCollection('dummy', [new ApiResource()])); - - $operation = (new Query())->withName('test'); - $graphqlType = $this->typeConverter->convertType($type, false, $operation, 'resourceClass', 'rootClass', null, 0); - $this->assertNull($graphqlType); - } - public function testConvertTypeNoGraphQlResourceMetadata(): void { $type = Type::object('dummy'); @@ -160,24 +102,6 @@ public function testConvertTypeNoGraphQlResourceMetadata(): void $this->assertNull($graphqlType); } - #[IgnoreDeprecations] - public function testConvertTypeNodeResourceLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $type = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'node'); - - $this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(false); - $this->resourceMetadataCollectionFactoryProphecy->create('node')->shouldBeCalled()->willReturn(new ResourceMetadataCollection('node', [(new ApiResource())->withShortName('Node')->withGraphQlOperations(['test' => new Query()])])); - - $this->expectException(\UnexpectedValueException::class); - $this->expectExceptionMessage('A "Node" resource cannot be used with GraphQL because the type is already used by the Relay specification.'); - - $operation = (new Query())->withName('test'); - $this->typeConverter->convertType($type, false, $operation, 'resourceClass', 'rootClass', null, 0); - } - public function testConvertTypeNodeResource(): void { $type = Type::object('node'); @@ -191,22 +115,6 @@ public function testConvertTypeNodeResource(): void $this->typeConverter->convertPhpType($type, false, $operation, 'resourceClass', 'rootClass', null, 0); } - #[IgnoreDeprecations] - public function testConvertTypeResourceClassNotFoundLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $type = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'dummy'); - - $this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(false); - $this->resourceMetadataCollectionFactoryProphecy->create('dummy')->shouldBeCalled()->willThrow(new ResourceClassNotFoundException()); - - $operation = (new Query())->withName('test'); - $graphqlType = $this->typeConverter->convertType($type, false, $operation, 'resourceClass', 'rootClass', null, 0); - $this->assertNull($graphqlType); - } - public function testConvertTypeResourceClassNotFound(): void { $type = Type::object('dummy'); @@ -218,24 +126,6 @@ public function testConvertTypeResourceClassNotFound(): void $this->assertNull($graphqlType); } - #[IgnoreDeprecations] - public function testConvertTypeResourceIriLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $type = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'dummy'); - - $graphqlResourceMetadata = new ResourceMetadataCollection('dummy', [(new ApiResource())->withGraphQlOperations(['test' => new Query()])]); - $this->resourceMetadataCollectionFactoryProphecy->create('dummy')->willReturn($graphqlResourceMetadata); - $this->typeBuilderProphecy->isCollection($type)->willReturn(false); - $this->propertyMetadataFactoryProphecy->create('rootClass', 'dummyProperty', Argument::type('array'))->shouldBeCalled()->willReturn((new ApiProperty())->withWritableLink(false)); - - $operation = (new Query())->withName('test'); - $graphqlType = $this->typeConverter->convertType($type, true, $operation, 'dummy', 'rootClass', 'dummyProperty', 1); - $this->assertSame(GraphQLType::string(), $graphqlType); - } - public function testConvertTypeResourceIri(): void { $type = Type::object('dummy'); @@ -249,27 +139,6 @@ public function testConvertTypeResourceIri(): void $this->assertSame(GraphQLType::string(), $graphqlType); } - #[IgnoreDeprecations] - public function testConvertTypeInputResourceLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $type = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'dummy'); - $operation = new Query(); - $propertyMetadata = (new ApiProperty())->withWritableLink(true); - $graphqlResourceMetadata = new ResourceMetadataCollection('dummy', [(new ApiResource())->withGraphQlOperations(['item_query' => $operation])]); - $expectedGraphqlType = new ObjectType(['name' => 'resourceObjectType', 'fields' => []]); - - $this->resourceMetadataCollectionFactoryProphecy->create('dummy')->willReturn($graphqlResourceMetadata); - $this->typeBuilderProphecy->isCollection($type)->willReturn(false); - $this->propertyMetadataFactoryProphecy->create('rootClass', 'dummyProperty', Argument::type('array'))->shouldBeCalled()->willReturn((new ApiProperty())->withWritableLink(true)); - $this->typeBuilderProphecy->getResourceObjectType($graphqlResourceMetadata, $operation, $propertyMetadata, ['input' => true, 'wrapped' => false, 'depth' => 1])->shouldBeCalled()->willReturn($expectedGraphqlType); - - $graphqlType = $this->typeConverter->convertType($type, true, $operation, 'dummy', 'rootClass', 'dummyProperty', 1); - $this->assertSame($expectedGraphqlType, $graphqlType); - } - public function testConvertTypeInputResource(): void { $type = Type::object('dummy'); @@ -286,37 +155,6 @@ public function testConvertTypeInputResource(): void $this->assertSame($expectedGraphqlType, $graphqlType); } - #[IgnoreDeprecations] - public function testConvertTypeCollectionResourceLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $fixtures = [ - [new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, null, true, null, new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'dummyValue')), new ObjectType(['name' => 'resourceObjectType', 'fields' => []])], - [new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true, null, new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, 'dummyValue')), new ObjectType(['name' => 'resourceObjectType', 'fields' => []])], - ]; - - foreach ($fixtures as [$type, $expectedGraphqlType]) { - $collectionOperation = new QueryCollection(); - $graphqlResourceMetadata = new ResourceMetadataCollection('dummyValue', [ - (new ApiResource())->withShortName('DummyValue')->withGraphQlOperations(['collection_query' => $collectionOperation]), - ]); - - $this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(true); - $this->resourceMetadataCollectionFactoryProphecy->create('dummyValue')->shouldBeCalled()->willReturn($graphqlResourceMetadata); - $this->typeBuilderProphecy->getResourceObjectType($graphqlResourceMetadata, $collectionOperation, null, [ - 'input' => false, - 'wrapped' => false, - 'depth' => 0, - ])->shouldBeCalled()->willReturn($expectedGraphqlType); - - $rootOperation = (new Query())->withName('test'); - $graphqlType = $this->typeConverter->convertType($type, false, $rootOperation, 'resourceClass', 'rootClass', null, 0); - $this->assertSame($expectedGraphqlType, $graphqlType); - } - } - #[DataProvider('convertTypeResourceProvider')] public function testConvertTypeCollectionResource(Type $type, ObjectType $expectedGraphqlType): void { @@ -345,23 +183,6 @@ public static function convertTypeResourceProvider(): array ]; } - #[IgnoreDeprecations] - public function testConvertTypeCollectionEnumLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $type = new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true, null, new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class)); - $expectedGraphqlType = new EnumType(['name' => 'GenderTypeEnum', 'values' => []]); - $this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(true); - $this->resourceMetadataCollectionFactoryProphecy->create(GenderTypeEnum::class)->shouldBeCalled()->willReturn(new ResourceMetadataCollection(GenderTypeEnum::class, [])); - $this->typeBuilderProphecy->getEnumType(Argument::type(Operation::class))->willReturn($expectedGraphqlType); - - $rootOperation = (new Query())->withName('test'); - $graphqlType = $this->typeConverter->convertType($type, false, $rootOperation, 'resourceClass', 'rootClass', null, 0); - $this->assertSame($expectedGraphqlType, $graphqlType); - } - public function testConvertTypeCollectionEnum(): void { $type = Type::array(Type::object(GenderTypeEnum::class)); diff --git a/src/GraphQl/Type/ContextAwareTypeBuilderInterface.php b/src/GraphQl/Type/ContextAwareTypeBuilderInterface.php index d945ff175e5..cff80e405cb 100644 --- a/src/GraphQl/Type/ContextAwareTypeBuilderInterface.php +++ b/src/GraphQl/Type/ContextAwareTypeBuilderInterface.php @@ -18,7 +18,6 @@ use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use GraphQL\Type\Definition\InterfaceType; use GraphQL\Type\Definition\Type as GraphQLType; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; /** @@ -55,11 +54,4 @@ public function getPaginatedCollectionType(GraphQLType $resourceType, Operation * Gets the type corresponding to an enum. */ public function getEnumType(Operation $operation): GraphQLType; - - /** - * Returns true if a type is a collection. - * - * @deprecated since 4.2 - */ - public function isCollection(LegacyType $type): bool; } diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index 93e86b95ad6..7c39aa21690 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -30,7 +30,6 @@ use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\SortFilterInterface; use ApiPlatform\Metadata\Util\Inflector; -use ApiPlatform\Metadata\Util\PropertyInfoToTypeInfoHelper; use ApiPlatform\Metadata\Util\TypeHelper; use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\Util\StateOptionsTrait; @@ -41,8 +40,6 @@ use GraphQL\Type\Definition\Type as GraphQLType; use GraphQL\Type\Definition\WrappingType; use Psr\Container\ContainerInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; @@ -224,37 +221,16 @@ public function getResourceObjectTypeFields(?string $resourceClass, Operation $o ]; $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, $context); - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyTypes = $propertyMetadata->getBuiltinTypes(); - - if ( - !$propertyTypes - || (!$input && false === $propertyMetadata->isReadable()) - || ($input && false === $propertyMetadata->isWritable()) - ) { - continue; - } - - // guess union/intersect types: check each type until finding a valid one - foreach ($propertyTypes as $propertyType) { - if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getDeprecationReason(), $propertyType, $resourceClass, $input, $operation, $depth, null !== $propertyMetadata->getSecurity())) { - $fields['id' === $property ? '_id' : $this->normalizePropertyName($property, $resourceClass)] = $fieldConfiguration; - // stop at the first valid type - break; - } - } - } else { - if ( - !($propertyType = $propertyMetadata->getNativeType()) - || (!$input && false === $propertyMetadata->isReadable()) - || ($input && false === $propertyMetadata->isWritable()) - ) { - continue; - } + if ( + !($propertyType = $propertyMetadata->getNativeType()) + || (!$input && false === $propertyMetadata->isReadable()) + || ($input && false === $propertyMetadata->isWritable()) + ) { + continue; + } - if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getDeprecationReason(), $propertyType, $resourceClass, $input, $operation, $depth, null !== $propertyMetadata->getSecurity())) { - $fields['id' === $property ? '_id' : $this->normalizePropertyName($property, $resourceClass)] = $fieldConfiguration; - } + if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getDeprecationReason(), $propertyType, $resourceClass, $input, $operation, $depth, null !== $propertyMetadata->getSecurity())) { + $fields['id' === $property ? '_id' : $this->normalizePropertyName($property, $resourceClass)] = $fieldConfiguration; } } } @@ -318,12 +294,8 @@ public function resolveResourceArgs(array $args, Operation $operation): array * * @see http://webonyx.github.io/graphql-php/type-system/object-types/ */ - private function getResourceFieldConfiguration(?string $property, ?string $fieldDescription, ?string $deprecationReason, Type|LegacyType $type, string $rootResource, bool $input, Operation $rootOperation, int $depth = 0, bool $forceNullable = false): ?array + private function getResourceFieldConfiguration(?string $property, ?string $fieldDescription, ?string $deprecationReason, Type $type, string $rootResource, bool $input, Operation $rootOperation, int $depth = 0, bool $forceNullable = false): ?array { - if ($type instanceof LegacyType) { - $type = PropertyInfoToTypeInfoHelper::convertLegacyTypesToType([$type]); - } - try { $isCollectionType = $type->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType) && ($v = TypeHelper::getCollectionValueType($type)) && TypeHelper::getClassName($v); @@ -822,12 +794,8 @@ private function nativeTypeToGraphQLType(Type $type): GraphQLType * * @throws InvalidTypeException */ - private function convertType(Type|LegacyType $type, bool $input, Operation $resourceOperation, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth, bool $forceNullable = false): GraphQLType|ListOfType|NonNull + private function convertType(Type $type, bool $input, Operation $resourceOperation, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth, bool $forceNullable = false): GraphQLType|ListOfType|NonNull { - if ($type instanceof LegacyType) { - $type = PropertyInfoToTypeInfoHelper::convertLegacyTypesToType([$type]); - } - $graphqlType = $this->typeConverter->convertPhpType($type, $input, $rootOperation, $resourceClass, $rootResource, $property, $depth); if (null === $graphqlType) { diff --git a/src/GraphQl/Type/TypeBuilder.php b/src/GraphQl/Type/TypeBuilder.php index a0f346a54d4..1433c546404 100644 --- a/src/GraphQl/Type/TypeBuilder.php +++ b/src/GraphQl/Type/TypeBuilder.php @@ -30,7 +30,6 @@ use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type as GraphQLType; use Psr\Container\ContainerInterface; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; /** @@ -221,16 +220,6 @@ public function getEnumType(Operation $operation): GraphQLType return $enumType; } - /** - * {@inheritdoc} - */ - public function isCollection(LegacyType $type): bool - { - trigger_deprecation('api-platform/graphql', '4.2', 'The "%s()" method is deprecated and will be removed.', __METHOD__, self::class); - - return $type->isCollection() && ($collectionValueType = $type->getCollectionValueTypes()[0] ?? null) && null !== $collectionValueType->getClassName(); - } - private function getCursorBasedPaginationFields(GraphQLType $resourceType): array { $namedType = GraphQLType::getNamedType($resourceType); diff --git a/src/GraphQl/Type/TypeConverter.php b/src/GraphQl/Type/TypeConverter.php index ca74645aa51..e8273dd5009 100644 --- a/src/GraphQl/Type/TypeConverter.php +++ b/src/GraphQl/Type/TypeConverter.php @@ -29,7 +29,6 @@ use GraphQL\Language\Parser; use GraphQL\Type\Definition\NullableType; use GraphQL\Type\Definition\Type as GraphQLType; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -46,40 +45,6 @@ public function __construct(private readonly ContextAwareTypeBuilderInterface $t { } - /** - * {@inheritdoc} - */ - public function convertType(LegacyType $type, bool $input, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth): GraphQLType|string|null - { - trigger_deprecation('api-platform/graphql', '4.2', 'The "%s()" method is deprecated, use "%s::convertPhpType()" instead.', __METHOD__, self::class); - - switch ($type->getBuiltinType()) { - case LegacyType::BUILTIN_TYPE_BOOL: - return GraphQLType::boolean(); - case LegacyType::BUILTIN_TYPE_INT: - return GraphQLType::int(); - case LegacyType::BUILTIN_TYPE_FLOAT: - return GraphQLType::float(); - case LegacyType::BUILTIN_TYPE_STRING: - return GraphQLType::string(); - case LegacyType::BUILTIN_TYPE_ARRAY: - case LegacyType::BUILTIN_TYPE_ITERABLE: - if ($resourceType = $this->getResourceType($type, $input, $rootOperation, $rootResource, $property, $depth)) { - return $resourceType; - } - - return 'Iterable'; - case LegacyType::BUILTIN_TYPE_OBJECT: - if (is_a($type->getClassName(), \DateTimeInterface::class, true)) { - return GraphQLType::string(); - } - - return $this->getResourceType($type, $input, $rootOperation, $rootResource, $property, $depth); - default: - return null; - } - } - /** * {@inheritdoc} */ @@ -134,35 +99,22 @@ public function resolveType(string $type): GraphQLType throw new InvalidArgumentException(\sprintf('The type "%s" was not resolved.', $type)); } - private function getResourceType(Type|LegacyType $type, bool $input, Operation $rootOperation, string $rootResource, ?string $property, int $depth): ?GraphQLType + private function getResourceType(Type $type, bool $input, Operation $rootOperation, string $rootResource, ?string $property, int $depth): ?GraphQLType { - if ($type instanceof Type) { - $isCollection = $type->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType); - - if ($isCollection) { - $type = TypeHelper::getCollectionValueType($type); - } + $isCollection = $type->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType); - /** @var class-string|null $resourceClass */ - $resourceClass = null; - $typeIsResourceClass = static function (Type $type) use (&$resourceClass): bool { - return $type instanceof ObjectType && $resourceClass = $type->getClassName(); - }; + if ($isCollection) { + $type = TypeHelper::getCollectionValueType($type); + } - if (!$type->isSatisfiedBy($typeIsResourceClass)) { - return null; - } - } else { - $isCollection = $this->typeBuilder->isCollection($type); - if ($isCollection && $collectionValueType = $type->getCollectionValueTypes()[0] ?? null) { - $resourceClass = $collectionValueType->getClassName(); - } else { - $resourceClass = $type->getClassName(); - } + /** @var class-string|null $resourceClass */ + $resourceClass = null; + $typeIsResourceClass = static function (Type $type) use (&$resourceClass): bool { + return $type instanceof ObjectType && $resourceClass = $type->getClassName(); + }; - if (null === $resourceClass) { - return null; - } + if (!$type->isSatisfiedBy($typeIsResourceClass)) { + return null; } try { diff --git a/src/GraphQl/Type/TypeConverterInterface.php b/src/GraphQl/Type/TypeConverterInterface.php index 99b19837e32..d858a42341b 100644 --- a/src/GraphQl/Type/TypeConverterInterface.php +++ b/src/GraphQl/Type/TypeConverterInterface.php @@ -15,25 +15,20 @@ use ApiPlatform\Metadata\GraphQl\Operation; use GraphQL\Type\Definition\Type as GraphQLType; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; /** * Converts a type to its GraphQL equivalent. * * @author Alan Poulain - * - * @method GraphQLType|string|null convertPhpType(Type $type, bool $input, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth) */ interface TypeConverterInterface { /** - * @deprecated since 4.1, use "convertPhpType" instead - * * Converts a built-in type to its GraphQL equivalent. * A string can be returned for a custom registered type. */ - public function convertType(LegacyType $type, bool $input, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth): GraphQLType|string|null; + public function convertPhpType(Type $type, bool $input, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth): GraphQLType|string|null; /** * Resolves a type written with the GraphQL type system to its object representation. diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php index 61af0539b0e..64ab681d6ab 100644 --- a/src/Hal/Serializer/ItemNormalizer.php +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -27,8 +27,6 @@ use ApiPlatform\Serializer\OperationResourceClassResolverInterface; use ApiPlatform\Serializer\TagCollectorInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Exception\CircularReferenceException; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; @@ -184,14 +182,10 @@ private function getComponents(object $object, ?string $format, array $context): foreach ($attributes as $attribute) { $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options); - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata->getNativeType(); - $types = $type instanceof CompositeTypeInterface ? $type->getTypes() : (null === $type ? [] : [$type]); - /** @var class-string|null $className */ - $className = null; - } else { - $types = $propertyMetadata->getBuiltinTypes() ?? []; - } + $type = $propertyMetadata->getNativeType(); + $types = $type instanceof CompositeTypeInterface ? $type->getTypes() : (null === $type ? [] : [$type]); + /** @var class-string|null $className */ + $className = null; // prevent declaring $attribute as attribute if it's already declared as relationship $isRelationship = false; @@ -202,23 +196,10 @@ private function getComponents(object $object, ?string $format, array $context): foreach ($types as $type) { $isOne = $isMany = false; - /** @var Type|LegacyType|null $valueType */ - $valueType = null; - - if ($type instanceof LegacyType) { - if ($type->isCollection()) { - $valueType = $type->getCollectionValueTypes()[0] ?? null; - $isMany = null !== $valueType && ($className = $valueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); - } else { - $className = $type->getClassName(); - $isOne = $className && $this->resourceClassResolver->isResourceClass($className); - } - } elseif ($type instanceof Type) { - if ($type->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType)) { - $isMany = TypeHelper::getCollectionValueType($type)?->isSatisfiedBy($typeIsResourceClass); - } else { - $isOne = $type->isSatisfiedBy($typeIsResourceClass); - } + if ($type->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType)) { + $isMany = TypeHelper::getCollectionValueType($type)?->isSatisfiedBy($typeIsResourceClass); + } else { + $isOne = $type->isSatisfiedBy($typeIsResourceClass); } if (!$isOne && !$isMany) { diff --git a/src/Hydra/Serializer/DocumentationNormalizer.php b/src/Hydra/Serializer/DocumentationNormalizer.php index 14fbee9c03d..5c4ceafeef1 100644 --- a/src/Hydra/Serializer/DocumentationNormalizer.php +++ b/src/Hydra/Serializer/DocumentationNormalizer.php @@ -29,8 +29,6 @@ use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\UrlGeneratorInterface; use ApiPlatform\Metadata\Util\TypeHelper; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -363,108 +361,53 @@ private function getRange(ApiProperty $propertyMetadata): array|string|null $types = []; - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $nativeType = $propertyMetadata->getNativeType(); - if (null === $nativeType) { - return null; - } - - if ($nativeType->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType)) { - $nativeType = TypeHelper::getCollectionValueType($nativeType); - } - - // Check for specific types after potentially unwrapping the collection - if (null === $nativeType) { - return null; // Should not happen if collection had a value type, but safety check - } + $nativeType = $propertyMetadata->getNativeType(); + if (null === $nativeType) { + return null; + } - if ($nativeType->isIdentifiedBy(TypeIdentifier::STRING)) { - $types[] = 'xsd:string'; - } + if ($nativeType->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType)) { + $nativeType = TypeHelper::getCollectionValueType($nativeType); + } - if ($nativeType->isIdentifiedBy(TypeIdentifier::INT)) { - $types[] = 'xsd:integer'; - } + // Check for specific types after potentially unwrapping the collection + if (null === $nativeType) { + return null; // Should not happen if collection had a value type, but safety check + } - if ($nativeType->isIdentifiedBy(TypeIdentifier::FLOAT)) { - $types[] = 'xsd:decimal'; - } + if ($nativeType->isIdentifiedBy(TypeIdentifier::STRING)) { + $types[] = 'xsd:string'; + } - if ($nativeType->isIdentifiedBy(TypeIdentifier::BOOL)) { - $types[] = 'xsd:boolean'; - } + if ($nativeType->isIdentifiedBy(TypeIdentifier::INT)) { + $types[] = 'xsd:integer'; + } - if ($nativeType->isIdentifiedBy(\DateTimeInterface::class)) { - $types[] = 'xsd:dateTime'; - } + if ($nativeType->isIdentifiedBy(TypeIdentifier::FLOAT)) { + $types[] = 'xsd:decimal'; + } - /** @var class-string|null $className */ - $className = null; + if ($nativeType->isIdentifiedBy(TypeIdentifier::BOOL)) { + $types[] = 'xsd:boolean'; + } - $typeIsResourceClass = function (Type $type) use (&$className): bool { - return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); - }; + if ($nativeType->isIdentifiedBy(\DateTimeInterface::class)) { + $types[] = 'xsd:dateTime'; + } - if ($nativeType->isSatisfiedBy($typeIsResourceClass) && $className) { - $resourceMetadata = $this->resourceMetadataFactory->create($className); - $operation = $resourceMetadata->getOperation(); + /** @var class-string|null $className */ + $className = null; - if (!\in_array("#{$operation->getShortName()}", $types, true)) { - $types[] = "#{$operation->getShortName()}"; - } - } - // TODO: remove in 5.x - } else { - $builtInTypes = $propertyMetadata->getBuiltinTypes() ?? []; + $typeIsResourceClass = function (Type $type) use (&$className): bool { + return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); + }; - foreach ($builtInTypes as $type) { - if ($type->isCollection() && null !== $collectionType = $type->getCollectionValueTypes()[0] ?? null) { - $type = $collectionType; - } + if ($nativeType->isSatisfiedBy($typeIsResourceClass) && $className) { + $resourceMetadata = $this->resourceMetadataFactory->create($className); + $operation = $resourceMetadata->getOperation(); - switch ($type->getBuiltinType()) { - case LegacyType::BUILTIN_TYPE_STRING: - if (!\in_array('xsd:string', $types, true)) { - $types[] = 'xsd:string'; - } - break; - case LegacyType::BUILTIN_TYPE_INT: - if (!\in_array('xsd:integer', $types, true)) { - $types[] = 'xsd:integer'; - } - break; - case LegacyType::BUILTIN_TYPE_FLOAT: - if (!\in_array('xsd:decimal', $types, true)) { - $types[] = 'xsd:decimal'; - } - break; - case LegacyType::BUILTIN_TYPE_BOOL: - if (!\in_array('xsd:boolean', $types, true)) { - $types[] = 'xsd:boolean'; - } - break; - case LegacyType::BUILTIN_TYPE_OBJECT: - if (null === $className = $type->getClassName()) { - continue 2; - } - - if (is_a($className, \DateTimeInterface::class, true)) { - if (!\in_array('xsd:dateTime', $types, true)) { - $types[] = 'xsd:dateTime'; - } - break; - } - - if ($this->resourceClassResolver->isResourceClass($className)) { - $resourceMetadata = $this->resourceMetadataFactory->create($className); - $operation = $resourceMetadata->getOperation(); - - if (!\in_array("#{$operation->getShortName()}", $types, true)) { - $types[] = "#{$operation->getShortName()}"; - } - break; - } - } + if (!\in_array("#{$operation->getShortName()}", $types, true)) { + $types[] = "#{$operation->getShortName()}"; } } @@ -479,38 +422,20 @@ private function getRange(ApiProperty $propertyMetadata): array|string|null private function isSingleRelation(ApiProperty $propertyMetadata): bool { - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $nativeType = $propertyMetadata->getNativeType(); - if (null === $nativeType) { - return false; - } - - if ($nativeType instanceof CollectionType) { - return false; - } - - $typeIsResourceClass = function (Type $type) use (&$className): bool { - return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); - }; - - return $nativeType->isSatisfiedBy($typeIsResourceClass); + $nativeType = $propertyMetadata->getNativeType(); + if (null === $nativeType) { + return false; } - // TODO: remove in 5.x - $builtInTypes = $propertyMetadata->getBuiltinTypes() ?? []; - - foreach ($builtInTypes as $type) { - $className = $type->getClassName(); - if ( - !$type->isCollection() - && null !== $className - && $this->resourceClassResolver->isResourceClass($className) - ) { - return true; - } + if ($nativeType instanceof CollectionType) { + return false; } - return false; + $typeIsResourceClass = function (Type $type) use (&$className): bool { + return $type instanceof ObjectType && $this->resourceClassResolver->isResourceClass($className = $type->getClassName()); + }; + + return $nativeType->isSatisfiedBy($typeIsResourceClass); } /** diff --git a/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php b/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php index c20fedffd09..ce0c728c12f 100644 --- a/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php +++ b/src/JsonApi/Serializer/ConstraintViolationListNormalizer.php @@ -14,7 +14,6 @@ namespace ApiPlatform\JsonApi\Serializer; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -94,15 +93,8 @@ private function getSourcePointerFromViolation(ConstraintViolationInterface $vio $fieldName = $this->nameConverter->normalize($fieldName, $class, self::FORMAT); } - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata->getBuiltinTypes()[0] ?? null; - if ($type && null !== $type->getClassName()) { - return "data/relationships/$fieldName"; - } - } else { - if ($propertyMetadata->getNativeType()?->isSatisfiedBy(static fn ($t) => $t instanceof ObjectType)) { - return "data/relationships/$fieldName"; - } + if ($propertyMetadata->getNativeType()?->isSatisfiedBy(static fn ($t) => $t instanceof ObjectType)) { + return "data/relationships/$fieldName"; } return "data/attributes/$fieldName"; diff --git a/src/JsonApi/Util/ResourceLinkageResolver.php b/src/JsonApi/Util/ResourceLinkageResolver.php index 73cacb9d67c..9c4757347cb 100644 --- a/src/JsonApi/Util/ResourceLinkageResolver.php +++ b/src/JsonApi/Util/ResourceLinkageResolver.php @@ -16,7 +16,6 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\TypeHelper; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CompositeTypeInterface; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -48,25 +47,6 @@ public function getRelationships(ApiProperty $propertyMetadata): array { $relationships = []; - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - foreach ($propertyMetadata->getBuiltinTypes() ?? [] as $type) { - if ($type->isCollection()) { - $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; - if ($collectionValueType && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className)) { - $relationships[] = [$className, true]; - } - - continue; - } - - if (($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className)) { - $relationships[] = [$className, false]; - } - } - - return $relationships; - } - if (null === $type = $propertyMetadata->getNativeType()) { return $relationships; } diff --git a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index 6e55fa4b467..0acc60bbcc0 100644 --- a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -19,10 +19,7 @@ use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; -use Doctrine\Common\Collections\ArrayCollection; use Ramsey\Uuid\UuidInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\BuiltinType; use Symfony\Component\TypeInfo\Type\CollectionType; @@ -103,10 +100,6 @@ public function create(string $resourceClass, string $property, array $options = $propertySchema['externalDocs'] = ['url' => $iri]; } - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - return $propertyMetadata->withSchema($this->getLegacyTypeSchema($propertyMetadata, $propertySchema, $resourceClass, $property, $link)); - } - return $propertyMetadata->withSchema($this->getTypeSchema($propertyMetadata, $propertySchema, $link)); } @@ -350,235 +343,6 @@ private function getClassSchemaDefinition(?string $className, ?bool $readableLin return ['type' => Schema::UNKNOWN_TYPE]; } - private function getLegacyTypeSchema(ApiProperty $propertyMetadata, array $propertySchema, string $resourceClass, string $property, ?bool $link): array - { - $types = $propertyMetadata->getBuiltinTypes() ?? []; - $className = ($types[0] ?? null)?->getClassName() ?? null; - - if (null !== $propertyMetadata->getUriTemplate() || (!\array_key_exists('readOnly', $propertySchema) && false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) && !$className) { - $propertySchema['readOnly'] = true; - } - - if (!\array_key_exists('default', $propertySchema) && !empty($default = $propertyMetadata->getDefault()) && (!$className || !$this->isResourceClass($className))) { - if ($default instanceof \BackedEnum) { - $default = $default->value; - } - $propertySchema['default'] = $default; - } - - if (!\array_key_exists('example', $propertySchema) && !empty($example = $propertyMetadata->getExample())) { - $propertySchema['example'] = $example; - } - - // never override the following keys if at least one is already set or if there's a custom openapi context - if ( - [] === $types - || ($propertySchema['type'] ?? $propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) - || \array_key_exists('type', $propertyMetadata->getOpenapiContext() ?? []) - ) { - return $propertySchema; - } - - if ($propertyMetadata->getUriTemplate()) { - return $propertySchema + [ - 'type' => 'string', - 'format' => 'iri-reference', - 'example' => 'https://example.com/', - ]; - } - - $valueSchema = []; - foreach ($types as $type) { - // Temp fix for https://github.com/symfony/symfony/pull/52699 - if (ArrayCollection::class === $type->getClassName()) { - $type = new LegacyType($type->getBuiltinType(), $type->isNullable(), $type->getClassName(), true, $type->getCollectionKeyTypes(), $type->getCollectionValueTypes()); - } - - if ($isCollection = $type->isCollection()) { - $keyType = $type->getCollectionKeyTypes()[0] ?? null; - $valueType = $type->getCollectionValueTypes()[0] ?? null; - } else { - $keyType = null; - $valueType = $type; - } - - if (null === $valueType) { - $builtinType = 'string'; - $className = null; - } else { - $builtinType = $valueType->getBuiltinType(); - $className = $valueType->getClassName(); - } - - if ($isCollection && null !== $propertyMetadata->getUriTemplate()) { - $keyType = null; - $isCollection = false; - } - - $propertyType = $this->getLegacyType(new LegacyType($builtinType, $type->isNullable(), $className, $isCollection, $keyType, $valueType), $link); - if (!\in_array($propertyType, $valueSchema, true)) { - $valueSchema[] = $propertyType; - } - } - - if (1 === \count($valueSchema)) { - return $propertySchema + $valueSchema[0]; - } - - // multiple builtInTypes detected: determine oneOf/allOf if union vs intersect types - try { - $reflectionClass = new \ReflectionClass($resourceClass); - $reflectionProperty = $reflectionClass->getProperty($property); - $composition = $reflectionProperty->getType() instanceof \ReflectionUnionType ? 'oneOf' : 'allOf'; - } catch (\ReflectionException) { - // cannot detect types - $composition = 'anyOf'; - } - - return $propertySchema + [$composition => $valueSchema]; - } - - private function getLegacyType(LegacyType $type, ?bool $readableLink = null): array - { - if (!$type->isCollection()) { - return $this->addNullabilityToTypeDefinition($this->legacyTypeToArray($type, $readableLink), $type); - } - - $keyType = $type->getCollectionKeyTypes()[0] ?? null; - $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new LegacyType($type->getBuiltinType(), false, $type->getClassName(), false); - - if (null !== $keyType && LegacyType::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) { - return $this->addNullabilityToTypeDefinition([ - 'type' => 'object', - 'additionalProperties' => $this->getLegacyType($subType, $readableLink), - ], $type); - } - - return $this->addNullabilityToTypeDefinition([ - 'type' => 'array', - 'items' => $this->getLegacyType($subType, $readableLink), - ], $type); - } - - private function legacyTypeToArray(LegacyType $type, ?bool $readableLink = null): array - { - return match ($type->getBuiltinType()) { - LegacyType::BUILTIN_TYPE_INT => ['type' => 'integer'], - LegacyType::BUILTIN_TYPE_FLOAT => ['type' => 'number'], - LegacyType::BUILTIN_TYPE_BOOL => ['type' => 'boolean'], - LegacyType::BUILTIN_TYPE_OBJECT => $this->getLegacyClassType($type->getClassName(), $type->isNullable(), $readableLink), - default => ['type' => 'string'], - }; - } - - /** - * Gets the JSON Schema document which specifies the data type corresponding to the given PHP class, and recursively adds needed new schema to the current schema if provided. - * - * Note: if the class is not part of exceptions listed above, any class is considered as a resource. - * - * @throws PropertyNotFoundException - * - * @return array - */ - private function getLegacyClassType(?string $className, bool $nullable, ?bool $readableLink): array - { - if (null === $className) { - return ['type' => 'string']; - } - - if (is_a($className, \DateTimeInterface::class, true)) { - return [ - 'type' => 'string', - 'format' => 'date-time', - ]; - } - - if (is_a($className, \DateInterval::class, true)) { - return [ - 'type' => 'string', - 'format' => 'duration', - ]; - } - - if (is_a($className, UuidInterface::class, true) || is_a($className, Uuid::class, true)) { - return [ - 'type' => 'string', - 'format' => 'uuid', - ]; - } - - if (is_a($className, Ulid::class, true)) { - return [ - 'type' => 'string', - 'format' => 'ulid', - ]; - } - - if (is_a($className, \SplFileInfo::class, true)) { - return [ - 'type' => 'string', - 'format' => 'binary', - ]; - } - - if (is_a($className, \BcMath\Number::class, true)) { - return [ - 'type' => 'string', - 'format' => 'string', - ]; - } - - $isResourceClass = $this->isResourceClass($className); - if (!$isResourceClass && is_a($className, \BackedEnum::class, true)) { - $enumCases = array_map(static fn (\BackedEnum $enum): string|int => $enum->value, $className::cases()); - - $type = \is_string($enumCases[0] ?? '') ? 'string' : 'integer'; - - if ($nullable) { - $enumCases[] = null; - } - - return [ - 'type' => $type, - 'enum' => $enumCases, - ]; - } - - if (false === $readableLink && $isResourceClass) { - return [ - 'type' => 'string', - 'format' => 'iri-reference', - 'example' => 'https://example.com/', - ]; - } - - // When this is set, we compute the schema at SchemaFactory::buildPropertySchema as it - // will end up being a $ref to another class schema, we don't have enough informations here - return ['type' => Schema::UNKNOWN_TYPE]; - } - - /** - * @param array $jsonSchema - * - * @return array - */ - private function addNullabilityToTypeDefinition(array $jsonSchema, LegacyType $type): array - { - if (!$type->isNullable()) { - return $jsonSchema; - } - - if (\array_key_exists('$ref', $jsonSchema)) { - return ['anyOf' => [$jsonSchema, ['type' => 'null']]]; - } - - return [...$jsonSchema, ...[ - 'type' => \is_array($jsonSchema['type']) - ? array_merge($jsonSchema['type'], ['null']) - : [$jsonSchema['type'], 'null'], - ]]; - } - private function getSchemaValue(array $schema, string $key): array|string|null { if (isset($schema['items'])) { diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index 1157b458cb3..1f5d5d67f11 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -23,7 +23,6 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\TypeHelper; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\TypeInfo\Type\BuiltinType; @@ -158,150 +157,12 @@ public function buildSchema(string $className, string $format = 'json', string $ $definition['required'][] = $normalizedPropertyName; } - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $this->buildLegacyPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format, $type); - } else { - $this->buildPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format, $type); - } + $this->buildPropertySchema($schema, $definitionName, $normalizedPropertyName, $propertyMetadata, $serializerContext, $format, $type); } return $schema; } - /** - * Builds the JSON Schema for a property using the legacy PropertyInfo component. - */ - private function buildLegacyPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format, string $parentType): void - { - $version = $schema->getVersion(); - if (Schema::VERSION_SWAGGER === $version || Schema::VERSION_OPENAPI === $version) { - $additionalPropertySchema = $propertyMetadata->getOpenapiContext(); - } else { - $additionalPropertySchema = $propertyMetadata->getJsonSchemaContext(); - } - - $propertySchema = array_merge( - $propertyMetadata->getSchema() ?? [], - $additionalPropertySchema ?? [] - ); - - // @see https://github.com/api-platform/core/issues/6299 - if (Schema::UNKNOWN_TYPE === ($propertySchema['type'] ?? null) && isset($propertySchema['$ref'])) { - unset($propertySchema['type']); - } - - $extraProperties = $propertyMetadata->getExtraProperties(); - // see AttributePropertyMetadataFactory - if (true === ($extraProperties[SchemaPropertyMetadataFactory::JSON_SCHEMA_USER_DEFINED] ?? false)) { - // schema seems to have been declared by the user: do not override nor complete user value - $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); - - return; - } - - $types = $propertyMetadata->getBuiltinTypes() ?? []; - - // never override the following keys if at least one is already set - // or if property has no type(s) defined - // or if property schema is already fully defined (type=string + format || enum) - $propertySchemaType = $propertySchema['type'] ?? false; - - $isUnknown = Schema::UNKNOWN_TYPE === $propertySchemaType - || ('array' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['items']['type'] ?? null)) - || ('object' === $propertySchemaType && Schema::UNKNOWN_TYPE === ($propertySchema['additionalProperties']['type'] ?? null)); - - // Scalar properties - if ( - !$isUnknown && ( - [] === $types - || ($propertySchema['$ref'] ?? $propertySchema['anyOf'] ?? $propertySchema['allOf'] ?? $propertySchema['oneOf'] ?? false) - || (\is_array($propertySchemaType) ? \array_key_exists('string', $propertySchemaType) : 'string' !== $propertySchemaType) - || ($propertySchema['format'] ?? $propertySchema['enum'] ?? false) - ) - ) { - if (isset($propertySchema['$ref'])) { - unset($propertySchema['type']); - } - - $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); - - return; - } - - // property schema is created in SchemaPropertyMetadataFactory, but it cannot build resource reference ($ref) - // complete property schema with resource reference ($ref) only if it's related to an object - $version = $schema->getVersion(); - $refs = []; - $isNullable = null; - - foreach ($types as $type) { - $subSchema = new Schema($version); - $subSchema->setDefinitions($schema->getDefinitions()); // Populate definitions of the main schema - - $isCollection = $type->isCollection(); - if ($isCollection) { - $valueType = $type->getCollectionValueTypes()[0] ?? null; - } else { - $valueType = $type; - } - - $className = $valueType?->getClassName(); - if (null === $className) { - continue; - } - - $childSerializerContext = $serializerContext + [self::FORCE_SUBSCHEMA => true, 'gen_id' => $propertyMetadata->getGenId() ?? true]; - if (isset($serializerContext[AbstractNormalizer::ATTRIBUTES])) { - $attributes = $serializerContext[AbstractNormalizer::ATTRIBUTES]; - if (\is_array($attributes) && \array_key_exists($normalizedPropertyName, $attributes) && \is_array($attributes[$normalizedPropertyName])) { - $childSerializerContext[AbstractNormalizer::ATTRIBUTES] = $attributes[$normalizedPropertyName]; - } else { - unset($childSerializerContext[AbstractNormalizer::ATTRIBUTES]); - } - } - - $subSchemaFactory = $this->schemaFactory ?: $this; - $subSchema = $subSchemaFactory->buildSchema( - $className, - $format, - $parentType, - null, - $subSchema, - $childSerializerContext, - false, - ); - - if (!isset($subSchema['$ref'])) { - continue; - } - - if ($isCollection) { - $key = ($propertySchema['type'] ?? null) === 'object' ? 'additionalProperties' : 'items'; - $propertySchema[$key]['$ref'] = $subSchema['$ref']; - unset($propertySchema[$key]['type']); - break; - } - - $refs[] = ['$ref' => $subSchema['$ref']]; - $isNullable = $isNullable ?? $type->isNullable(); - } - - if ($isNullable) { - $refs[] = ['type' => 'null']; - } - - $c = \count($refs); - if ($c > 1) { - $propertySchema['anyOf'] = $refs; - unset($propertySchema['type']); - } elseif (1 === $c) { - $propertySchema['$ref'] = $refs[0]['$ref']; - unset($propertySchema['type']); - } - - $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = new \ArrayObject($propertySchema); - } - private function buildPropertySchema(Schema $schema, string $definitionName, string $normalizedPropertyName, ApiProperty $propertyMetadata, array $serializerContext, string $format, string $parentType): void { $version = $schema->getVersion(); diff --git a/src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php b/src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php index ece12edc0a4..cfc960ddacd 100644 --- a/src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php +++ b/src/JsonSchema/Tests/Metadata/Property/Factory/SchemaPropertyMetadataFactoryTest.php @@ -24,30 +24,12 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Metadata\ResourceClassResolverInterface; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; class SchemaPropertyMetadataFactoryTest extends TestCase { - #[IgnoreDeprecations] - public function testEnumLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $this->expectUserDeprecationMessage('Since api_platform/metadata 4.2: The "builtinTypes" argument of "ApiPlatform\Metadata\ApiProperty" is deprecated, use "nativeType" instead.'); - $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); - $apiProperty = new ApiProperty(builtinTypes: [new LegacyType(builtinType: 'object', nullable: true, class: IntEnumAsIdentifier::class)]); - $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); - $decorated->expects($this->once())->method('create')->with(DummyWithEnum::class, 'intEnumAsIdentifier')->willReturn($apiProperty); - $schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated); - $apiProperty = $schemaPropertyMetadataFactory->create(DummyWithEnum::class, 'intEnumAsIdentifier'); - $this->assertEquals(['type' => ['integer', 'null'], 'enum' => [1, 2, null]], $apiProperty->getSchema()); - } - public function testEnum(): void { $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); @@ -59,25 +41,6 @@ public function testEnum(): void $this->assertEquals(['type' => ['integer', 'null'], 'enum' => [1, 2, null]], $apiProperty->getSchema()); } - #[IgnoreDeprecations] - public function testWithCustomOpenApiContextLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $this->expectUserDeprecationMessage('Since api_platform/metadata 4.2: The "builtinTypes" argument of "ApiPlatform\Metadata\ApiProperty" is deprecated, use "nativeType" instead.'); - $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); - $apiProperty = new ApiProperty( - builtinTypes: [new LegacyType(builtinType: 'object', nullable: true, class: IntEnumAsIdentifier::class)], - openapiContext: ['type' => 'object', 'properties' => ['alpha' => ['type' => 'integer']]], - ); - $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); - $decorated->expects($this->once())->method('create')->with(DummyWithCustomOpenApiContext::class, 'acme')->willReturn($apiProperty); - $schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated); - $apiProperty = $schemaPropertyMetadataFactory->create(DummyWithCustomOpenApiContext::class, 'acme'); - $this->assertEquals([], $apiProperty->getSchema()); - } - public function testWithCustomOpenApiContext(): void { $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); @@ -92,40 +55,6 @@ public function testWithCustomOpenApiContext(): void $this->assertEquals([], $apiProperty->getSchema()); } - #[IgnoreDeprecations] - public function testWithCustomOpenApiContextWithoutTypeDefinitionLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $this->expectUserDeprecationMessage('Since api_platform/metadata 4.2: The "builtinTypes" argument of "ApiPlatform\Metadata\ApiProperty" is deprecated, use "nativeType" instead.'); - $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); - $apiProperty = new ApiProperty( - openapiContext: ['description' => 'My description'], - builtinTypes: [new LegacyType(builtinType: 'bool')], - ); - $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); - $decorated->expects($this->once())->method('create')->with(DummyWithCustomOpenApiContext::class, 'foo')->willReturn($apiProperty); - $schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated); - $apiProperty = $schemaPropertyMetadataFactory->create(DummyWithCustomOpenApiContext::class, 'foo'); - $this->assertEquals([ - 'type' => 'boolean', - ], $apiProperty->getSchema()); - - $apiProperty = new ApiProperty( - openapiContext: ['iris' => 'https://schema.org/Date'], - builtinTypes: [new LegacyType(builtinType: 'object', class: \DateTimeImmutable::class)], - ); - $decorated = $this->createMock(PropertyMetadataFactoryInterface::class); - $decorated->expects($this->once())->method('create')->with(DummyWithCustomOpenApiContext::class, 'bar')->willReturn($apiProperty); - $schemaPropertyMetadataFactory = new SchemaPropertyMetadataFactory($resourceClassResolver, $decorated); - $apiProperty = $schemaPropertyMetadataFactory->create(DummyWithCustomOpenApiContext::class, 'bar'); - $this->assertEquals([ - 'type' => 'string', - 'format' => 'date-time', - ], $apiProperty->getSchema()); - } - public function testWithCustomOpenApiContextWithoutTypeDefinition(): void { $resourceClassResolver = $this->createMock(ResourceClassResolverInterface::class); diff --git a/src/JsonSchema/Tests/SchemaFactoryTest.php b/src/JsonSchema/Tests/SchemaFactoryTest.php index 10bee202881..648bcca79b8 100644 --- a/src/JsonSchema/Tests/SchemaFactoryTest.php +++ b/src/JsonSchema/Tests/SchemaFactoryTest.php @@ -36,11 +36,9 @@ use ApiPlatform\Metadata\Resource\Factory\ResourceMetadataCollectionFactoryInterface; use ApiPlatform\Metadata\Resource\ResourceMetadataCollection; use ApiPlatform\Metadata\ResourceClassResolverInterface; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Component\TypeInfo\Type; @@ -48,86 +46,6 @@ class SchemaFactoryTest extends TestCase { use ProphecyTrait; - #[IgnoreDeprecations] - public function testBuildSchemaForNonResourceClassLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $this->expectUserDeprecationMessage('Since api-platform/metadata 4.2: The "ApiPlatform\Metadata\ApiProperty::withBuiltinTypes()" method is deprecated, use "ApiPlatform\Metadata\ApiProperty::withNativeType()" instead.'); - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(NotAResource::class, Argument::cetera())->willReturn(new PropertyNameCollection(['foo', 'bar', 'genderType'])); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]) - ->withReadable(true) - ->withSchema(['type' => 'string']) - ); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]) - ->withReadable(true) - ->withDefault('default_bar') - ->withExample('example_bar') - ->withSchema(['type' => 'integer', 'default' => 'default_bar', 'example' => 'example_bar']) - ); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'genderType', Argument::cetera())->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT)]) - ->withReadable(true) - ->withDefault('male') - ->withSchema(['type' => 'object', 'default' => 'male', 'example' => 'male']) - ); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); - - $definitionNameFactory = new DefinitionNameFactory(); - - $schemaFactory = new SchemaFactory( - resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), - propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), - propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), - resourceClassResolver: $resourceClassResolverProphecy->reveal(), - definitionNameFactory: $definitionNameFactory, - ); - $resultSchema = $schemaFactory->buildSchema(NotAResource::class); - - $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); - $definitions = $resultSchema->getDefinitions(); - - $this->assertSame((new \ReflectionClass(NotAResource::class))->getShortName(), $rootDefinitionKey); - $this->assertTrue(isset($definitions[$rootDefinitionKey])); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]); - $this->assertSame('object', $definitions[$rootDefinitionKey]['type']); - $this->assertArrayNotHasKey('additionalProperties', $definitions[$rootDefinitionKey]); - $this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]); - $this->assertArrayHasKey('foo', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['foo']); - $this->assertArrayNotHasKey('default', $definitions[$rootDefinitionKey]['properties']['foo']); - $this->assertArrayNotHasKey('example', $definitions[$rootDefinitionKey]['properties']['foo']); - $this->assertSame('string', $definitions[$rootDefinitionKey]['properties']['foo']['type']); - $this->assertArrayHasKey('bar', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['bar']); - $this->assertArrayHasKey('default', $definitions[$rootDefinitionKey]['properties']['bar']); - $this->assertArrayHasKey('example', $definitions[$rootDefinitionKey]['properties']['bar']); - $this->assertSame('integer', $definitions[$rootDefinitionKey]['properties']['bar']['type']); - $this->assertSame('default_bar', $definitions[$rootDefinitionKey]['properties']['bar']['default']); - $this->assertSame('example_bar', $definitions[$rootDefinitionKey]['properties']['bar']['example']); - - $this->assertArrayHasKey('genderType', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['genderType']); - $this->assertArrayHasKey('default', $definitions[$rootDefinitionKey]['properties']['genderType']); - $this->assertArrayHasKey('example', $definitions[$rootDefinitionKey]['properties']['genderType']); - $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['genderType']['type']); - $this->assertSame('male', $definitions[$rootDefinitionKey]['properties']['genderType']['default']); - $this->assertSame('male', $definitions[$rootDefinitionKey]['properties']['genderType']['example']); - } - public function testBuildSchemaForNonResourceClass(): void { if (!method_exists(PropertyInfoExtractor::class, 'getType')) { // @phpstan-ignore-line symfony/property-info 6.4 is still allowed and this may be true @@ -230,80 +148,6 @@ public function testBuildSchemaForNonResourceClass(): void $this->assertSame('#/definitions/GenericChild', $definitions[$rootDefinitionKey]['properties']['items']['$ref']); } - #[IgnoreDeprecations] - public function testBuildSchemaForNonResourceClassWithUnionIntersectTypesLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $this->expectUserDeprecationMessage('Since api-platform/metadata 4.2: The "ApiPlatform\Metadata\ApiProperty::withBuiltinTypes()" method is deprecated, use "ApiPlatform\Metadata\ApiProperty::withNativeType()" instead.'); - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, Argument::cetera())->willReturn(new PropertyNameCollection(['ignoredProperty', 'unionType', 'intersectType'])); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'ignoredProperty', Argument::cetera())->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING, nullable: true)]) - ->withReadable(true) - ->withSchema(['type' => ['string', 'null']]) - ); - $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'unionType', Argument::cetera())->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING, nullable: true), new LegacyType(LegacyType::BUILTIN_TYPE_INT, nullable: true), new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT, nullable: true)]) - ->withReadable(true) - ->withSchema(['oneOf' => [ - ['type' => ['string', 'null']], - ['type' => ['integer', 'null']], - ]]) - ); - $propertyMetadataFactoryProphecy->create(NotAResourceWithUnionIntersectTypes::class, 'intersectType', Argument::cetera())->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, class: Serializable::class), new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, class: DummyResourceInterface::class)]) - ->withReadable(true) - ->withSchema(['type' => 'object']) - ); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->isResourceClass(NotAResourceWithUnionIntersectTypes::class)->willReturn(false); - - $definitionNameFactory = new DefinitionNameFactory(); - - $schemaFactory = new SchemaFactory( - resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), - propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), - propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), - resourceClassResolver: $resourceClassResolverProphecy->reveal(), - definitionNameFactory: $definitionNameFactory, - ); - $resultSchema = $schemaFactory->buildSchema(NotAResourceWithUnionIntersectTypes::class); - - $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); - $definitions = $resultSchema->getDefinitions(); - - $this->assertSame((new \ReflectionClass(NotAResourceWithUnionIntersectTypes::class))->getShortName(), $rootDefinitionKey); - $this->assertTrue(isset($definitions[$rootDefinitionKey])); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]); - $this->assertSame('object', $definitions[$rootDefinitionKey]['type']); - $this->assertArrayNotHasKey('additionalProperties', $definitions[$rootDefinitionKey]); - $this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]); - - $this->assertArrayHasKey('ignoredProperty', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['ignoredProperty']); - $this->assertSame(['string', 'null'], $definitions[$rootDefinitionKey]['properties']['ignoredProperty']['type']); - $this->assertArrayHasKey('unionType', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('oneOf', $definitions[$rootDefinitionKey]['properties']['unionType']); - $this->assertCount(2, $definitions[$rootDefinitionKey]['properties']['unionType']['oneOf']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['unionType']['oneOf'][0]); - $this->assertSame(['string', 'null'], $definitions[$rootDefinitionKey]['properties']['unionType']['oneOf'][0]['type']); - $this->assertSame(['integer', 'null'], $definitions[$rootDefinitionKey]['properties']['unionType']['oneOf'][1]['type']); - - $this->assertArrayHasKey('intersectType', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['intersectType']); - $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['intersectType']['type']); - } - public function testBuildSchemaForNonResourceClassWithUnionIntersectTypes(): void { $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); @@ -374,89 +218,6 @@ public function testBuildSchemaForNonResourceClassWithUnionIntersectTypes(): voi $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['intersectType']['type']); } - #[IgnoreDeprecations] - public function testBuildSchemaWithSerializerGroupsLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $this->expectUserDeprecationMessage('Since api-platform/metadata 4.2: The "ApiPlatform\Metadata\ApiProperty::withBuiltinTypes()" method is deprecated, use "ApiPlatform\Metadata\ApiProperty::withNativeType()" instead.'); - $shortName = (new \ReflectionClass(OverriddenOperationDummy::class))->getShortName(); - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - $operation = (new Put())->withName('put')->withNormalizationContext([ - 'groups' => 'overridden_operation_dummy_put', - AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false, - ])->withShortName($shortName)->withValidationContext(['groups' => ['validation_groups_dummy_put']]); - $resourceMetadataFactoryProphecy->create(OverriddenOperationDummy::class) - ->willReturn( - new ResourceMetadataCollection(OverriddenOperationDummy::class, [ - (new ApiResource())->withOperations(new Operations(['put' => $operation])), - ]) - ); - - $serializerGroup = 'custom_operation_dummy'; - - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(OverriddenOperationDummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['alias', 'description', 'genderType'])); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'alias', Argument::type('array'))->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]) - ->withReadable(true) - ->withSchema(['type' => 'string']) - ); - $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'description', Argument::type('array'))->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]) - ->withReadable(true) - ->withSchema(['type' => 'string']) - ); - $propertyMetadataFactoryProphecy->create(OverriddenOperationDummy::class, 'genderType', Argument::type('array'))->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, GenderTypeEnum::class)]) - ->withReadable(true) - ->withDefault(GenderTypeEnum::MALE) - ->withSchema(['type' => 'object']) - ); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->isResourceClass(OverriddenOperationDummy::class)->willReturn(true); - $resourceClassResolverProphecy->isResourceClass(GenderTypeEnum::class)->willReturn(true); - - $definitionNameFactory = new DefinitionNameFactory(); - - $schemaFactory = new SchemaFactory( - resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), - propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), - propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), - resourceClassResolver: $resourceClassResolverProphecy->reveal(), - definitionNameFactory: $definitionNameFactory, - ); - $resultSchema = $schemaFactory->buildSchema(OverriddenOperationDummy::class, 'json', Schema::TYPE_OUTPUT, null, null, ['groups' => $serializerGroup, AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false]); - - $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); - $definitions = $resultSchema->getDefinitions(); - - $this->assertSame((new \ReflectionClass(OverriddenOperationDummy::class))->getShortName().'-'.$serializerGroup, $rootDefinitionKey); - $this->assertTrue(isset($definitions[$rootDefinitionKey])); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]); - $this->assertSame('object', $definitions[$rootDefinitionKey]['type']); - $this->assertFalse($definitions[$rootDefinitionKey]['additionalProperties']); - $this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]); - $this->assertArrayHasKey('alias', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['alias']); - $this->assertSame('string', $definitions[$rootDefinitionKey]['properties']['alias']['type']); - $this->assertArrayHasKey('description', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['description']); - $this->assertSame('string', $definitions[$rootDefinitionKey]['properties']['description']['type']); - $this->assertArrayHasKey('genderType', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['genderType']); - $this->assertArrayNotHasKey('default', $definitions[$rootDefinitionKey]['properties']['genderType']); - $this->assertArrayNotHasKey('example', $definitions[$rootDefinitionKey]['properties']['genderType']); - $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['genderType']['type']); - } - public function testBuildSchemaWithSerializerGroups(): void { $shortName = (new \ReflectionClass(OverriddenOperationDummy::class))->getShortName(); @@ -623,63 +384,6 @@ public function testBuildSchemaWithSerializerAttributes(): void $this->assertSame('string', $definitions[$childDefinitionKey]['properties']['name']['type']); } - #[IgnoreDeprecations] - public function testBuildSchemaForAssociativeArrayLegacy(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - $this->expectUserDeprecationMessage('Since api-platform/metadata 4.2: The "ApiPlatform\Metadata\ApiProperty::withBuiltinTypes()" method is deprecated, use "ApiPlatform\Metadata\ApiProperty::withNativeType()" instead.'); - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - - $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactoryProphecy->create(NotAResource::class, Argument::cetera())->willReturn(new PropertyNameCollection(['foo', 'bar'])); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'foo', Argument::cetera())->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), new LegacyType(LegacyType::BUILTIN_TYPE_STRING))]) - ->withReadable(true) - ->withSchema(['type' => 'array', 'items' => ['string', 'int']]) - ); - $propertyMetadataFactoryProphecy->create(NotAResource::class, 'bar', Argument::cetera())->willReturn( - (new ApiProperty()) - ->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, false, null, true, new LegacyType(LegacyType::BUILTIN_TYPE_STRING), new LegacyType(LegacyType::BUILTIN_TYPE_STRING))]) - ->withReadable(true) - ->withSchema(['type' => 'object', 'additionalProperties' => 'string']) - ); - - $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); - - $definitionNameFactory = new DefinitionNameFactory(); - - $schemaFactory = new SchemaFactory( - resourceMetadataFactory: $resourceMetadataFactoryProphecy->reveal(), - propertyNameCollectionFactory: $propertyNameCollectionFactoryProphecy->reveal(), - propertyMetadataFactory: $propertyMetadataFactoryProphecy->reveal(), - resourceClassResolver: $resourceClassResolverProphecy->reveal(), - definitionNameFactory: $definitionNameFactory, - ); - $resultSchema = $schemaFactory->buildSchema(NotAResource::class); - - $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); - $definitions = $resultSchema->getDefinitions(); - - $this->assertSame((new \ReflectionClass(NotAResource::class))->getShortName(), $rootDefinitionKey); - $this->assertTrue(isset($definitions[$rootDefinitionKey])); - $this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]); - $this->assertArrayHasKey('foo', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['foo']); - $this->assertArrayNotHasKey('additionalProperties', $definitions[$rootDefinitionKey]['properties']['foo']); - $this->assertSame('array', $definitions[$rootDefinitionKey]['properties']['foo']['type']); - $this->assertArrayHasKey('bar', $definitions[$rootDefinitionKey]['properties']); - $this->assertArrayHasKey('type', $definitions[$rootDefinitionKey]['properties']['bar']); - $this->assertArrayHasKey('additionalProperties', $definitions[$rootDefinitionKey]['properties']['bar']); - $this->assertSame('object', $definitions[$rootDefinitionKey]['properties']['bar']['type']); - $this->assertSame('string', $definitions[$rootDefinitionKey]['properties']['bar']['additionalProperties']); - } - public function testBuildSchemaForAssociativeArray(): void { $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); diff --git a/src/JsonSchema/composer.json b/src/JsonSchema/composer.json index 9319b5e97fc..66af13e6a61 100644 --- a/src/JsonSchema/composer.json +++ b/src/JsonSchema/composer.json @@ -27,7 +27,7 @@ "php": ">=8.2", "api-platform/metadata": "^5.0@alpha", "symfony/console": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", + "symfony/property-info": "^7.1 || ^8.0", "symfony/serializer": "^6.4 || ^7.0 || ^8.0", "symfony/type-info": "^7.3 || ^8.0", "symfony/uid": "^6.4 || ^7.0 || ^8.0" diff --git a/src/Metadata/ApiProperty.php b/src/Metadata/ApiProperty.php index 28c6d09870d..a6dc14c8dda 100644 --- a/src/Metadata/ApiProperty.php +++ b/src/Metadata/ApiProperty.php @@ -13,8 +13,6 @@ namespace ApiPlatform\Metadata; -use ApiPlatform\Metadata\Util\PropertyInfoToTypeInfoHelper; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Attribute\Context; use Symfony\Component\Serializer\Attribute\Groups; use Symfony\Component\Serializer\Attribute\Ignore; @@ -50,7 +48,6 @@ final class ApiProperty * @param string|\Stringable|null $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization * @param string[]|null $types the RDF types of this property * @param string[]|null $iris - * @param LegacyType[]|null $builtinTypes * @param string|null $uriTemplate whether to return the subRessource collection IRI instead of an iterable of IRI * @param string|null $property The property name * @param Context|Groups|Ignore|SerializedName|SerializedPath|MaxDepth|array $serialize Serializer attributes @@ -208,12 +205,6 @@ public function __construct( */ private string|\Stringable|null $securityPostDenormalize = null, array|string|null $types = null, - /* - * The related php types. - * - * deprecated since 4.2, use "nativeType" instead. - */ - private ?array $builtinTypes = null, private ?array $schema = null, private ?bool $initializable = null, private $iris = null, @@ -232,13 +223,6 @@ public function __construct( $this->types = \is_string($types) ? (array) $types : $types; $this->serialize = (null === $serialize || \is_array($serialize)) ? $serialize : [$serialize]; $this->nativeType = $nativeType; - - if ($this->builtinTypes) { - trigger_deprecation('api_platform/metadata', '4.2', \sprintf('The "builtinTypes" argument of "%s" is deprecated, use "nativeType" instead.', __CLASS__)); - $this->nativeType ??= PropertyInfoToTypeInfoHelper::convertLegacyTypesToType($this->builtinTypes); - } elseif ($this->nativeType && class_exists(LegacyType::class)) { - $this->builtinTypes = PropertyInfoToTypeInfoHelper::convertTypeToLegacyTypes($this->nativeType) ?? []; - } } public function getProperty(): ?string @@ -525,38 +509,6 @@ public function withTypes(array|string $types = []): static return $self; } - /** - * deprecated since 4.2, use "getNativeType" instead. - * - * @return LegacyType[]|null - */ - public function getBuiltinTypes(): ?array - { - trigger_deprecation('api-platform/metadata', '4.2', 'The "%s()" method is deprecated, use "%s::getNativeType()" instead.', __METHOD__, self::class); - - if (null === $this->builtinTypes && null !== $this->nativeType) { - $this->builtinTypes = PropertyInfoToTypeInfoHelper::convertTypeToLegacyTypes($this->nativeType) ?? []; - } - - return $this->builtinTypes; - } - - /** - * deprecated since 4.2, use "withNativeType" instead. - * - * @param LegacyType[] $builtinTypes - */ - public function withBuiltinTypes(array $builtinTypes = []): static - { - trigger_deprecation('api-platform/metadata', '4.2', 'The "%s()" method is deprecated, use "%s::withNativeType()" instead.', __METHOD__, self::class); - - $self = clone $this; - $self->builtinTypes = $builtinTypes; - $self->nativeType = PropertyInfoToTypeInfoHelper::convertLegacyTypesToType($builtinTypes); - - return $self; - } - public function getNativeType(): ?Type { return $this->nativeType; diff --git a/src/Metadata/Extractor/XmlPropertyExtractor.php b/src/Metadata/Extractor/XmlPropertyExtractor.php index d1d9a13b755..98d02d22ebd 100644 --- a/src/Metadata/Extractor/XmlPropertyExtractor.php +++ b/src/Metadata/Extractor/XmlPropertyExtractor.php @@ -66,7 +66,6 @@ protected function extractPath(string $path): void 'security' => $this->phpize($property, 'security', 'string'), 'securityPostDenormalize' => $this->phpize($property, 'securityPostDenormalize', 'string'), 'types' => $this->buildArrayValue($property, 'type'), - 'builtinTypes' => $this->buildArrayValue($property, 'builtinType'), 'schema' => isset($property->schema->values) ? $this->buildValues($property->schema->values) : null, 'initializable' => $this->phpize($property, 'initializable', 'bool'), 'extraProperties' => $this->buildExtraProperties($property, 'extraProperties'), diff --git a/src/Metadata/Extractor/YamlPropertyExtractor.php b/src/Metadata/Extractor/YamlPropertyExtractor.php index c15c32ab277..1b95ed8a873 100644 --- a/src/Metadata/Extractor/YamlPropertyExtractor.php +++ b/src/Metadata/Extractor/YamlPropertyExtractor.php @@ -90,7 +90,6 @@ private function buildProperties(array $resourcesYaml): void 'extraProperties' => $this->buildAttribute($propertyValues, 'extraProperties'), 'default' => $propertyValues['default'] ?? null, 'example' => $propertyValues['example'] ?? null, - 'builtinTypes' => $this->buildAttribute($propertyValues, 'builtinTypes'), 'schema' => $this->buildAttribute($propertyValues, 'schema'), 'genId' => $this->phpize($propertyValues, 'genId', 'bool'), 'uriTemplate' => $this->phpize($propertyValues, 'uriTemplate', 'string'), diff --git a/src/Metadata/Extractor/schema/properties.xsd b/src/Metadata/Extractor/schema/properties.xsd index 689852a92c3..bb953e00232 100644 --- a/src/Metadata/Extractor/schema/properties.xsd +++ b/src/Metadata/Extractor/schema/properties.xsd @@ -21,7 +21,6 @@ - @@ -74,18 +73,6 @@ - - - - - - - - - - - - diff --git a/src/Metadata/IdentifiersExtractor.php b/src/Metadata/IdentifiersExtractor.php index 0f820de937d..3b3015a6fc9 100644 --- a/src/Metadata/IdentifiersExtractor.php +++ b/src/Metadata/IdentifiersExtractor.php @@ -24,7 +24,6 @@ use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; /** * {@inheritdoc} @@ -131,30 +130,6 @@ private function getIdentifierValue(object $item, string $class, string $propert foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $propertyName) { $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName); - // TODO: remove in 5.x - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $types = $propertyMetadata->getBuiltinTypes(); - if (null === ($type = $types[0] ?? null)) { - continue; - } - - try { - if ($type->isCollection()) { - $collectionValueType = $type->getCollectionValueTypes()[0] ?? null; - - if (null !== $collectionValueType && $collectionValueType->getClassName() === $class) { - return $this->resolveIdentifierValue($this->propertyAccessor->getValue($item, \sprintf('%s[0].%s', $propertyName, $property)), $parameterName); - } - } - - if ($type->getClassName() === $class) { - return $this->resolveIdentifierValue($this->propertyAccessor->getValue($item, "$propertyName.$property"), $parameterName); - } - } catch (NoSuchPropertyException $e) { - throw new RuntimeException('Not able to retrieve identifiers.', $e->getCode(), $e); - } - } - if (null === $type = $propertyMetadata->getNativeType()) { continue; } diff --git a/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php b/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php index 1d5d659e7db..1ace680ce6e 100644 --- a/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/AttributePropertyMetadataFactory.php @@ -17,7 +17,6 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Exception\PropertyNotFoundException; use ApiPlatform\Metadata\Util\Reflection; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** @@ -140,19 +139,6 @@ private function createMetadata(ApiProperty $attribute, ?ApiProperty $propertyMe foreach (get_class_methods(ApiProperty::class) as $method) { if (preg_match('/^(?:get|is)(.*)/', (string) $method, $matches)) { - // BC layer, to remove in 5.0 - if ('getBuiltinTypes' === $method) { - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - continue; - } - - if ($builtinTypes = $attribute->getBuiltinTypes()) { - $propertyMetadata = $propertyMetadata->withBuiltinTypes($builtinTypes); - } - - continue; - } - if (null !== $val = $attribute->{$method}()) { $propertyMetadata = $propertyMetadata->{"with{$matches[1]}"}($val); } diff --git a/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php b/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php index dad89450030..30dc24e2e70 100644 --- a/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php @@ -19,8 +19,6 @@ use ApiPlatform\Metadata\Exception\RuntimeException; use ApiPlatform\Metadata\Extractor\PropertyExtractorInterface; use PHPStan\PhpDocParser\Parser\PhpDocParser; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeResolver\StringTypeResolver; @@ -61,16 +59,6 @@ public function create(string $resourceClass, string $property, array $options = $apiProperty = new ApiProperty(); foreach ($propertyMetadata as $key => $value) { - if ('builtinTypes' === $key && null !== $value) { - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - continue; - } - - $apiProperty = $apiProperty->withBuiltinTypes(array_map(static fn (string $builtinType): LegacyType => new LegacyType($builtinType), $value)); - - continue; - } - if ('nativeType' === $key && null !== $value) { if (class_exists(PhpDocParser::class)) { $apiProperty = $apiProperty->withNativeType((new StringTypeResolver())->resolve($value)); diff --git a/src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php b/src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php index ba9b24b8adf..e04f172a83d 100644 --- a/src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/PropertyInfoPropertyMetadataFactory.php @@ -15,10 +15,7 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Exception\PropertyNotFoundException; -use Doctrine\Common\Collections\ArrayCollection; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; -use Symfony\Component\PropertyInfo\Type; /** * PropertyInfo metadata loader decorator. @@ -46,24 +43,8 @@ public function create(string $resourceClass, string $property, array $options = } } - // TODO: remove in 5.x - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - if (!$propertyMetadata->getBuiltinTypes()) { - $types = $this->propertyInfo->getTypes($resourceClass, $property, $options) ?? []; // @phpstan-ignore-line - - foreach ($types as $i => $type) { - // Temp fix for https://github.com/symfony/symfony/pull/52699 - if (ArrayCollection::class === $type->getClassName()) { - $types[$i] = new Type($type->getBuiltinType(), $type->isNullable(), $type->getClassName(), true, $type->getCollectionKeyTypes(), $type->getCollectionValueTypes()); - } - } - - $propertyMetadata = $propertyMetadata->withBuiltinTypes($types); - } - } else { - if (!$propertyMetadata->getNativeType()) { - $propertyMetadata = $propertyMetadata->withNativeType($this->propertyInfo->getType($resourceClass, $property, $options)); - } + if (!$propertyMetadata->getNativeType()) { + $propertyMetadata = $propertyMetadata->withNativeType($this->propertyInfo->getType($resourceClass, $property, $options)); } if (null === $propertyMetadata->getDescription() && null !== $description = $this->propertyInfo->getShortDescription($resourceClass, $property, $options)) { diff --git a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php index d7412a59268..bb430c46f80 100644 --- a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php @@ -17,7 +17,6 @@ use ApiPlatform\Metadata\Exception\ResourceClassNotFoundException; use ApiPlatform\Metadata\ResourceClassResolverInterface; use ApiPlatform\Metadata\Util\ResourceClassInfoTrait; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface as SerializerClassMetadataFactoryInterface; use Symfony\Component\TypeInfo\Type; @@ -74,20 +73,6 @@ public function create(string $resourceClass, string $property, array $options = $propertyMetadata = $this->transformReadWrite($propertyMetadata, $resourceClass, $property, $normalizationGroups, $denormalizationGroups, $normalizationAttributes, $denormalizationAttributes, $ignoredAttributes); - // TODO: remove in 5.x - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $types = $propertyMetadata->getBuiltinTypes() ?? []; - - if (!$this->isResourceClass($resourceClass) && $types) { - foreach ($types as $builtinType) { - if ($builtinType->isCollection()) { - return $propertyMetadata->withReadableLink(true)->withWritableLink(true); - } - } - } - - return $this->transformLinkStatusLegacy($propertyMetadata, $property, $normalizationGroups, $denormalizationGroups, $normalizationAttributes, $denormalizationAttributes, $types); - } $type = $propertyMetadata->getNativeType(); if (null !== $type && !$this->isResourceClass($resourceClass) && $type->isSatisfiedBy(static fn (Type $t): bool => $t instanceof CollectionType)) { return $propertyMetadata->withReadableLink(true)->withWritableLink(true); @@ -126,59 +111,6 @@ private function transformReadWrite(ApiProperty $propertyMetadata, string $resou return $propertyMetadata; } - /** - * Sets readableLink/writableLink based on matching normalization/denormalization groups/attributes. - * - * If normalization/denormalization groups/attributes are not specified, - * set link status to false since embedding of resource must be explicitly enabled - * - * @param string[]|null $normalizationGroups - * @param string[]|null $denormalizationGroups - */ - private function transformLinkStatusLegacy(ApiProperty $propertyMetadata, string $propertyName, ?array $normalizationGroups = null, ?array $denormalizationGroups = null, ?array $normalizationAttributes = null, ?array $denormalizationAttributes = null, ?array $types = null): ApiProperty - { - // No need to check link status if property is not readable and not writable - if (false === $propertyMetadata->isReadable() && false === $propertyMetadata->isWritable()) { - return $propertyMetadata; - } - - foreach ($types as $type) { - if ( - $type->isCollection() - && $collectionValueType = $type->getCollectionValueTypes()[0] ?? null - ) { - $relatedClass = $collectionValueType->getClassName(); - } else { - $relatedClass = $type->getClassName(); - } - - // if property is not a resource relation, don't set link status (as it would have no meaning) - if (null === $relatedClass || !$this->isResourceClass($relatedClass)) { - continue; - } - - // find the resource class - // this prevents serializer groups on non-resource child class from incorrectly influencing the decision - if (null !== $this->resourceClassResolver) { - $relatedClass = $this->resourceClassResolver->getResourceClass(null, $relatedClass); - } - - $relatedGroups = $this->getClassSerializerGroups($relatedClass); - - if (null === $propertyMetadata->isReadableLink()) { - $propertyMetadata = $propertyMetadata->withReadableLink((null !== $normalizationGroups && !empty(array_intersect($normalizationGroups, $relatedGroups))) || (null !== $normalizationAttributes && $this->isPropertyInAttributes($propertyName, $normalizationAttributes))); - } - - if (null === $propertyMetadata->isWritableLink()) { - $propertyMetadata = $propertyMetadata->withWritableLink((null !== $denormalizationGroups && !empty(array_intersect($denormalizationGroups, $relatedGroups))) || (null !== $denormalizationAttributes && $this->isPropertyInAttributes($propertyName, $denormalizationAttributes))); - } - - return $propertyMetadata; - } - - return $propertyMetadata; - } - /** * Sets readableLink/writableLink based on matching normalization/denormalization groups/attributes. * diff --git a/src/Metadata/Tests/Util/PropertyInfoToTypeInfoHelperTest.php b/src/Metadata/Tests/Util/PropertyInfoToTypeInfoHelperTest.php deleted file mode 100644 index 0725ed3ad9d..00000000000 --- a/src/Metadata/Tests/Util/PropertyInfoToTypeInfoHelperTest.php +++ /dev/null @@ -1,104 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -/* - * This file is part of the Symfony package. - * - * (c) Fabien Potencier - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace ApiPlatform\Metadata\Tests\Util; - -use ApiPlatform\Metadata\Util\PropertyInfoToTypeInfoHelper; -use PHPUnit\Framework\TestCase; -use Symfony\Component\PropertyInfo\Type as LegacyType; -use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\TypeIdentifier; - -class PropertyInfoToTypeInfoHelperTest extends TestCase -{ - public function testConvertLegacyTypesToType(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - - $type = Type::collection(Type::builtin(TypeIdentifier::ARRAY), Type::int(), Type::string()); // @phpstan-ignore-line - - $tests = [ - [null, null], - [Type::null(), [new LegacyType('null')]], - // [Type::void(), [new LegacyType('void')]], - [Type::int(), [new LegacyType('int')]], - [Type::object(\stdClass::class), [new LegacyType('object', false, \stdClass::class)]], - [ - Type::generic(Type::object(\stdClass::class), Type::string(), Type::int()), - [new LegacyType('object', false, 'stdClass', false, [new LegacyType('string')], new LegacyType('int'))], - ], - [Type::nullable(Type::int()), [new LegacyType('int', true)]], - [Type::union(Type::int(), Type::string()), [new LegacyType('int'), new LegacyType('string')]], - [ - Type::union(Type::int(), Type::string(), Type::null()), - [new LegacyType('int', true), new LegacyType('string', true)], - ], - [$type, [new LegacyType('array', false, null, true, [new LegacyType('string')], new LegacyType('int'))]], - ]; - - foreach ($tests as [$expected, $legacyTypes]) { - $this->assertEquals($expected, PropertyInfoToTypeInfoHelper::convertLegacyTypesToType($legacyTypes)); - } - } - - public function testConvertTypeToLegacyTypes(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped(); - } - - $tests = [ - [null, null], - [null, Type::mixed()], - [null, Type::never()], - [[new LegacyType('null')], Type::null()], - [[new LegacyType('null')], Type::void()], - [[new LegacyType('int')], Type::int()], - [[new LegacyType('object', false, \stdClass::class)], Type::object(\stdClass::class)], - [ - [new LegacyType('object', false, \Traversable::class, true, null, new LegacyType('int'))], - Type::generic(Type::object(\Traversable::class), Type::int()), - ], - [ - [new LegacyType('array', false, null, true, new LegacyType('int'), new LegacyType('string'))], - Type::generic(Type::builtin(TypeIdentifier::ARRAY), Type::int(), Type::string()), // @phpstan-ignore-line - ], - [ - [new LegacyType('array', false, null, true, new LegacyType('int'), new LegacyType('string'))], - Type::collection(Type::builtin(TypeIdentifier::ARRAY), Type::string(), Type::int()), // @phpstan-ignore-line - ], - [[new LegacyType('int', true)], Type::nullable(Type::int())], - [[new LegacyType('int'), new LegacyType('string')], Type::union(Type::int(), Type::string())], - [ - [new LegacyType('int', true), new LegacyType('string', true)], - Type::union(Type::int(), Type::string(), Type::null()), - ], - [[new LegacyType('object', false, \Stringable::class), new LegacyType('object', false, \Traversable::class)], Type::intersection(Type::object(\Traversable::class), Type::object(\Stringable::class))], - ]; - - foreach ($tests as [$expected, $type]) { - $this->assertEquals($expected, PropertyInfoToTypeInfoHelper::convertTypeToLegacyTypes($type)); - } - } -} diff --git a/src/Metadata/Util/PropertyInfoToTypeInfoHelper.php b/src/Metadata/Util/PropertyInfoToTypeInfoHelper.php deleted file mode 100644 index bda642848f8..00000000000 --- a/src/Metadata/Util/PropertyInfoToTypeInfoHelper.php +++ /dev/null @@ -1,307 +0,0 @@ - - * - * 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\Util; - -use Symfony\Component\PropertyInfo\Type as LegacyType; -use Symfony\Component\TypeInfo\Exception\InvalidArgumentException; -use Symfony\Component\TypeInfo\Type; -use Symfony\Component\TypeInfo\Type\BuiltinType; -use Symfony\Component\TypeInfo\Type\CollectionType; -use Symfony\Component\TypeInfo\Type\GenericType; -use Symfony\Component\TypeInfo\Type\IntersectionType; -use Symfony\Component\TypeInfo\Type\NullableType; -use Symfony\Component\TypeInfo\Type\ObjectType; -use Symfony\Component\TypeInfo\Type\UnionType; -use Symfony\Component\TypeInfo\TypeIdentifier; - -/** - * A helper about PropertyInfo Type conversion. - * - * @see https://github.com/mtarld/symfony/commits/backup/chore/deprecate-property-info-type/ - * - * @author Mathias Arlaud - * - * @internal - */ -final class PropertyInfoToTypeInfoHelper -{ - /** - * Converts a {@see LegacyType} to what is should have been in the "symfony/type-info" component. - * - * @param list|null $legacyTypes - */ - public static function convertLegacyTypesToType(?array $legacyTypes): ?Type - { - if (!$legacyTypes) { - return null; - } - - $types = []; - $nullable = false; - - foreach (array_map(self::convertLegacyTypeToType(...), $legacyTypes) as $type) { - if ($type->isNullable()) { - $nullable = true; - - if ($type instanceof BuiltinType && TypeIdentifier::NULL === $type->getTypeIdentifier()) { - continue; - } - - $type = self::unwrapNullableType($type); - } - - if ($type instanceof UnionType) { - $types = [$types, ...$type->getTypes()]; - - continue; - } - - $types[] = $type; - } - - if ($nullable && [] === $types) { - return Type::null(); - } - - $type = \count($types) > 1 ? Type::union(...$types) : $types[0]; - if ($nullable) { - $type = Type::nullable($type); - } - - return $type; - } - - /** - * @param list $collectionKeyTypes - * @param list $collectionValueTypes - */ - public static function createTypeFromLegacyValues(string $builtinType, bool $nullable, ?string $class, bool $collection, array $collectionKeyTypes, array $collectionValueTypes): Type - { - $variableTypes = []; - - if ($collectionKeyTypes) { - $collectionKeyTypes = array_unique(array_map(self::convertLegacyTypeToType(...), $collectionKeyTypes)); - $variableTypes[] = \count($collectionKeyTypes) > 1 ? Type::union(...$collectionKeyTypes) : $collectionKeyTypes[0]; - } - - if ($collectionValueTypes) { - if (!$collectionKeyTypes) { - $variableTypes[] = \is_array($collectionKeyTypes) ? Type::mixed() : Type::union(Type::int(), Type::string()); // @phpstan-ignore-line - } - - $collectionValueTypes = array_unique(array_map(self::convertLegacyTypeToType(...), $collectionValueTypes)); - $variableTypes[] = \count($collectionValueTypes) > 1 ? Type::union(...$collectionValueTypes) : $collectionValueTypes[0]; - } - - if ($collectionKeyTypes && !$collectionValueTypes) { - $variableTypes[] = Type::mixed(); - } - - try { - $type = null !== $class ? Type::object($class) : Type::builtin(TypeIdentifier::from($builtinType)); - } catch (\ValueError) { - throw new InvalidArgumentException(\sprintf('"%s" is not a valid PHP type.', $builtinType)); - } - - if (\count($variableTypes)) { - // hack to have generic without classname - // this is required because some tests are using invalid data - if (null === $class && 'object' === $builtinType) { - $type = Type::object(\stdClass::class); - } - $type = Type::generic($type, ...$variableTypes); - } - - if ($collection) { - $type = Type::collection($type); - } - - if ($nullable && !$type->isNullable()) { - $type = Type::nullable($type); - } - - return $type; - } - - public static function unwrapNullableType(Type $type): Type - { - // BC layer for "symfony/type-info" < 7.2 - if (method_exists($type, 'asNonNullable')) { - return (!$type instanceof UnionType) ? $type : $type->asNonNullable(); - } - - if (!$type instanceof NullableType) { - return $type; - } - - return $type->getWrappedType(); - } - - /** - * Recursive method that converts {@see LegacyType} to its related {@see Type}. - */ - private static function convertLegacyTypeToType(LegacyType $legacyType): Type - { - return self::createTypeFromLegacyValues( - $legacyType->getBuiltinType(), - $legacyType->isNullable(), - $legacyType->getClassName(), - $legacyType->isCollection(), - $legacyType->getCollectionKeyTypes(), - $legacyType->getCollectionValueTypes(), - ); - } - - /** - * Converts a {@see Type} to what is should have been in the "symfony/property-info" component. - * - * @return list|null - */ - public static function convertTypeToLegacyTypes(?Type $type): ?array - { - if (null === $type) { - return null; - } - - if (\in_array((string) $type, ['mixed', 'never'], true)) { - return null; - } - - if (\in_array((string) $type, ['null', 'void'], true)) { - return [new LegacyType('null')]; - } - - $legacyType = self::convertTypeToLegacy($type); - - if (!\is_array($legacyType)) { - $legacyType = [$legacyType]; - } - - return $legacyType; - } - - /** - * Recursive method that converts {@see Type} to its related {@see LegacyType} (or list of {@see @LegacyType}). - * - * @return LegacyType|list - */ - private static function convertTypeToLegacy(Type $type): LegacyType|array - { - $nullable = false; - - if ($type instanceof NullableType) { - $nullable = true; - $type = $type->getWrappedType(); - } - - if ($type instanceof UnionType) { - $unionTypes = []; - foreach ($type->getTypes() as $t) { - if ($t instanceof IntersectionType) { - throw new \LogicException(\sprintf('DNF types are not supported by "%s".', LegacyType::class)); - } - - if ($nullable) { - $t = Type::nullable($t); - } - - $unionTypes[] = $t; - } - - /** @var list $legacyTypes */ - $legacyTypes = array_map(self::convertTypeToLegacy(...), $unionTypes); - - if (1 === \count($legacyTypes)) { - return $legacyTypes[0]; - } - - return $legacyTypes; - } - - if ($type instanceof IntersectionType) { - /** @var list $legacyTypes */ - $legacyTypes = array_map(self::convertTypeToLegacy(...), $type->getTypes()); - - if (1 === \count($legacyTypes)) { - return $legacyTypes[0]; - } - - return $legacyTypes; - } - - if ($type instanceof CollectionType) { - $type = $type->getWrappedType(); - if ($nullable) { - $type = Type::nullable($type); - } - - return self::convertTypeToLegacy($type); - } - - $typeIdentifier = TypeIdentifier::MIXED; - $className = null; - $collectionKeyType = $collectionValueType = null; - - if ($type instanceof GenericType) { - $wrappedType = $type->getWrappedType(); - - if ($wrappedType instanceof BuiltinType) { - $typeIdentifier = $wrappedType->getTypeIdentifier(); - } elseif ($wrappedType instanceof ObjectType) { - $typeIdentifier = TypeIdentifier::OBJECT; - $className = $wrappedType->getClassName(); - } - - $variableTypes = $type->getVariableTypes(); - - if (2 === \count($variableTypes)) { - if ('int|string' !== (string) $variableTypes[0]) { - $collectionKeyType = self::convertTypeToLegacy($variableTypes[0]); - } - $collectionValueType = self::convertTypeToLegacy($variableTypes[1]); - } elseif (1 === \count($variableTypes)) { - $collectionValueType = self::convertTypeToLegacy($variableTypes[0]); - } - } elseif ($type instanceof ObjectType) { - $typeIdentifier = TypeIdentifier::OBJECT; - $className = $type->getClassName(); - } elseif ($type instanceof BuiltinType) { - $typeIdentifier = $type->getTypeIdentifier(); - } - - if (TypeIdentifier::MIXED === $typeIdentifier) { - return [ - new LegacyType(LegacyType::BUILTIN_TYPE_INT, true), - new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT, true), - new LegacyType(LegacyType::BUILTIN_TYPE_STRING, true), - new LegacyType(LegacyType::BUILTIN_TYPE_BOOL, true), - new LegacyType(LegacyType::BUILTIN_TYPE_RESOURCE, true), - new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, true), - new LegacyType(LegacyType::BUILTIN_TYPE_ARRAY, true), - new LegacyType(LegacyType::BUILTIN_TYPE_NULL, true), - new LegacyType(LegacyType::BUILTIN_TYPE_CALLABLE, true), - new LegacyType(LegacyType::BUILTIN_TYPE_ITERABLE, true), - ]; - } - - return new LegacyType( - builtinType: $typeIdentifier->value, - nullable: $nullable, - class: $className, - collection: $type instanceof GenericType, - collectionKeyType: $collectionKeyType, - collectionValueType: $collectionValueType, - ); - } -} diff --git a/src/Metadata/composer.json b/src/Metadata/composer.json index d0fb4ee15c4..dccb5ef01f2 100644 --- a/src/Metadata/composer.json +++ b/src/Metadata/composer.json @@ -31,7 +31,7 @@ "doctrine/inflector": "^2.0", "psr/cache": "^1.0 || ^2.0 || ^3.0", "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", + "symfony/property-info": "^7.1 || ^8.0", "symfony/string": "^6.4 || ^7.0 || ^8.0", "symfony/type-info": "^7.3 || ^8.0" }, diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 6273ea8ba51..1612d6e22e9 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -58,8 +58,6 @@ use ApiPlatform\State\Util\StateOptionsTrait; use ApiPlatform\Validator\Exception\ValidationException; use Psr\Container\ContainerInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Routing\RouteCollection; use Symfony\Component\Routing\RouterInterface; use Symfony\Component\TypeInfo\Type; @@ -728,28 +726,21 @@ private function getFilterParameter(string $name, array $description, string $sh if (!isset($description['openapi']) || $description['openapi'] instanceof Parameter) { $schema = $description['schema'] ?? []; - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - if (isset($description['type']) && \in_array($description['type'], TypeIdentifier::values(), true) && !isset($schema['type'])) { - $type = Type::builtin($description['type']); - if ($description['is_collection'] ?? false) { - $type = Type::array($type, Type::int()); - } - - $schema += $this->getType($type); - } - // TODO: remove in 5.x - } else { - if (isset($description['type']) && \in_array($description['type'], LegacyType::$builtinTypes, true) && !isset($schema['type'])) { - $schema += $this->getType(new LegacyType($description['type'], false, null, $description['is_collection'] ?? false)); + if (isset($description['type']) && \in_array($description['type'], TypeIdentifier::values(), true) && !isset($schema['type'])) { + $type = Type::builtin($description['type']); + if ($description['is_collection'] ?? false) { + $type = Type::array($type, Type::int()); } + + $schema += $this->getType($type); } if (!isset($schema['type'])) { $schema['type'] = 'string'; } - $arrayValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::ARRAY->value : LegacyType::BUILTIN_TYPE_ARRAY; - $objectValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::OBJECT->value : LegacyType::BUILTIN_TYPE_OBJECT; + $arrayValueType = TypeIdentifier::ARRAY->value; + $objectValueType = TypeIdentifier::OBJECT->value; $isArraySchema = 'array' === ($schema['type'] ?? null); $style = $isArraySchema && \in_array( @@ -776,26 +767,19 @@ private function getFilterParameter(string $name, array $description, string $sh $schema = $description['schema'] ?? null; if (!$schema) { - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - if (isset($description['type']) && \in_array($description['type'], TypeIdentifier::values(), true)) { - $type = Type::builtin($description['type']); - if ($description['is_collection'] ?? false) { - $type = Type::array($type, key: Type::int()); - } - $schema = $this->getType($type); - } else { - $schema = ['type' => 'string']; + if (isset($description['type']) && \in_array($description['type'], TypeIdentifier::values(), true)) { + $type = Type::builtin($description['type']); + if ($description['is_collection'] ?? false) { + $type = Type::array($type, key: Type::int()); } - // TODO: remove in 5.x + $schema = $this->getType($type); } else { - $schema = isset($description['type']) && \in_array($description['type'], LegacyType::$builtinTypes, true) - ? $this->getType(new LegacyType($description['type'], false, null, $description['is_collection'] ?? false)) - : ['type' => 'string']; + $schema = ['type' => 'string']; } } - $arrayValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::ARRAY->value : LegacyType::BUILTIN_TYPE_ARRAY; - $objectValueType = method_exists(PropertyInfoExtractor::class, 'getType') ? TypeIdentifier::OBJECT->value : LegacyType::BUILTIN_TYPE_OBJECT; + $arrayValueType = TypeIdentifier::ARRAY->value; + $objectValueType = TypeIdentifier::OBJECT->value; $isArraySchema = 'array' === $schema['type']; diff --git a/src/OpenApi/Factory/TypeFactoryTrait.php b/src/OpenApi/Factory/TypeFactoryTrait.php index f711e92a2db..d5386240e18 100644 --- a/src/OpenApi/Factory/TypeFactoryTrait.php +++ b/src/OpenApi/Factory/TypeFactoryTrait.php @@ -14,7 +14,6 @@ namespace ApiPlatform\OpenApi\Factory; use Ramsey\Uuid\UuidInterface; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type as NativeType; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\Type\ObjectType; @@ -30,30 +29,9 @@ trait TypeFactoryTrait /** * @return array */ - private function getType(LegacyType|NativeType $type): array + private function getType(NativeType $type): array { - if ($type instanceof NativeType) { - return $this->getNativeType($type); - } - - if ($type->isCollection()) { - $keyType = $type->getCollectionKeyTypes()[0] ?? null; - $subType = ($type->getCollectionValueTypes()[0] ?? null) ?? new LegacyType($type->getBuiltinType(), false, $type->getClassName(), false); - - if (null !== $keyType && LegacyType::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) { - return $this->addNullabilityToTypeDefinition([ - 'type' => 'object', - 'additionalProperties' => $this->getType($subType), - ], $type); - } - - return $this->addNullabilityToTypeDefinition([ - 'type' => 'array', - 'items' => $this->getType($subType), - ], $type); - } - - return $this->addNullabilityToTypeDefinition($this->makeLegacyBasicType($type), $type); + return $this->getNativeType($type); } /** @@ -81,20 +59,6 @@ private function getNativeType(NativeType $type): array return $this->addNullabilityToTypeDefinition($this->makeBasicType($type), $type); } - /** - * @return array - */ - private function makeLegacyBasicType(LegacyType $type): array - { - return match ($type->getBuiltinType()) { - LegacyType::BUILTIN_TYPE_INT => ['type' => 'integer'], - LegacyType::BUILTIN_TYPE_FLOAT => ['type' => 'number'], - LegacyType::BUILTIN_TYPE_BOOL => ['type' => 'boolean'], - LegacyType::BUILTIN_TYPE_OBJECT => $this->getClassType($type->getClassName(), $type->isNullable()), - default => ['type' => 'string'], - }; - } - /** * @return array */ @@ -182,7 +146,7 @@ private function getClassType(?string $className, bool $nullable): array * * @return array */ - private function addNullabilityToTypeDefinition(array $jsonSchema, LegacyType|NativeType $type): array + private function addNullabilityToTypeDefinition(array $jsonSchema, NativeType $type): array { if (!$type->isNullable()) { return $jsonSchema; diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 50f2984ce28..674a240a470 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -33,8 +33,6 @@ use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Encoder\CsvEncoder; use Symfony\Component\Serializer\Encoder\XmlEncoder; use Symfony\Component\Serializer\Exception\LogicException; @@ -597,29 +595,6 @@ protected function setAttributeValue(object $object, string $attribute, mixed $v } } - /** - * @deprecated since 4.1, use "validateAttributeType" instead - * - * Validates the type of the value. Allows using integers as floats for JSON formats. - * - * @throws NotNormalizableValueException - */ - protected function validateType(string $attribute, LegacyType $type, mixed $value, ?string $format = null, array $context = []): void - { - trigger_deprecation('api-platform/serializer', '4.1', 'The "%s()" method is deprecated, use "%s::validateAttributeType()" instead.', __METHOD__, self::class); - - $builtinType = $type->getBuiltinType(); - if (LegacyType::BUILTIN_TYPE_FLOAT === $builtinType && null !== $format && str_contains($format, 'json')) { - $isValid = \is_float($value) || \is_int($value); - } else { - $isValid = \call_user_func('is_'.$builtinType, $value); - } - - if (!$isValid) { - throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" attribute must be "%s", "%s" given.', $attribute, $builtinType, \gettype($value)), $value, [$builtinType], $context['deserialization_path'] ?? null); - } - } - /** * Validates the type of the value. Allows using integers as floats for JSON formats. * @@ -638,52 +613,6 @@ protected function validateAttributeType(string $attribute, Type $type, mixed $v } } - /** - * @deprecated since 4.1, use "denormalizeObjectCollection" instead. - * - * Denormalizes a collection of objects. - * - * @throws NotNormalizableValueException - */ - protected function denormalizeCollection(string $attribute, ApiProperty $propertyMetadata, LegacyType $type, string $className, mixed $value, ?string $format, array $context): array - { - trigger_deprecation('api-platform/serializer', '4.1', 'The "%s()" method is deprecated, use "%s::denormalizeObjectCollection()" instead.', __METHOD__, self::class); - - if (!\is_array($value)) { - throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the "%s" attribute must be "array", "%s" given.', $attribute, \gettype($value)), $value, ['array'], $context['deserialization_path'] ?? null); - } - - $values = []; - $childContext = $this->createChildContext($this->createOperationContext($context, $className), $attribute, $format); - $collectionKeyTypes = $type->getCollectionKeyTypes(); - foreach ($value as $index => $obj) { - $currentChildContext = $childContext; - if (isset($childContext['deserialization_path'])) { - $currentChildContext['deserialization_path'] = "{$childContext['deserialization_path']}[{$index}]"; - } - - // no typehint provided on collection key - if (!$collectionKeyTypes) { - $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $currentChildContext); - continue; - } - - // validate collection key typehint - foreach ($collectionKeyTypes as $collectionKeyType) { - $collectionKeyBuiltinType = $collectionKeyType->getBuiltinType(); - if (!\call_user_func('is_'.$collectionKeyBuiltinType, $index)) { - continue; - } - - $values[$index] = $this->denormalizeRelation($attribute, $propertyMetadata, $className, $obj, $format, $currentChildContext); - continue 2; - } - throw NotNormalizableValueException::createForUnexpectedDataType(\sprintf('The type of the key "%s" must be "%s", "%s" given.', $index, $collectionKeyTypes[0]->getBuiltinType(), \gettype($index)), $index, [$collectionKeyTypes[0]->getBuiltinType()], ($context['deserialization_path'] ?? false) ? \sprintf('key(%s)', $context['deserialization_path']) : null, true); - } - - return $values; - } - /** * Denormalizes a collection of objects. * @@ -859,134 +788,6 @@ protected function getAttributeValue(object $object, string $attribute, ?string return $this->propertyAccessor->getValue($object, $attribute); } - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $types = $propertyMetadata->getBuiltinTypes() ?? []; - - foreach ($types as $type) { - if ( - $type->isCollection() - && ($collectionValueType = $type->getCollectionValueTypes()[0] ?? null) - && ($className = $collectionValueType->getClassName()) - && $this->resourceClassResolver->isResourceClass($className) - ) { - $childContext = $this->createChildContext($this->createOperationContext($context, $className, $propertyMetadata), $attribute, $format); - - // @see ApiPlatform\Hal\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content - // @see ApiPlatform\JsonApi\Serializer\ItemNormalizer:getComponents logic for intentional duplicate content - if ('jsonld' === $format && $itemUriTemplate = $propertyMetadata->getUriTemplate()) { - $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation( - operationName: $itemUriTemplate, - forceCollection: true, - httpOperation: true - ); - - return $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); - } - - $attributeValue = $this->propertyAccessor->getValue($object, $attribute); - - if (null === $attributeValue && $type->isNullable()) { - return null; - } - - if (!is_iterable($attributeValue)) { - throw new UnexpectedValueException('Unexpected non-iterable value for to-many relation.'); - } - - $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); - - $data = $this->normalizeCollectionOfRelations($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext); - $context['data'] = $data; - $context['type'] = $type; - - if ($this->tagCollector) { - $this->tagCollector->collect($context); - } - - return $data; - } - - if ( - ($className = $type->getClassName()) - && $this->resourceClassResolver->isResourceClass($className) - ) { - $childContext = $this->createChildContext($this->createOperationContext($context, $className, $propertyMetadata), $attribute, $format); - unset($childContext['iri'], $childContext['uri_variables'], $childContext['item_uri_template']); - if ('jsonld' === $format && $uriTemplate = $propertyMetadata->getUriTemplate()) { - $operation = $this->resourceMetadataCollectionFactory->create($className)->getOperation( - operationName: $uriTemplate, - httpOperation: true - ); - - return $this->iriConverter->getIriFromResource($object, UrlGeneratorInterface::ABS_PATH, $operation, $childContext); - } - - $attributeValue = $this->propertyAccessor->getValue($object, $attribute); - - if (!\is_object($attributeValue) && null !== $attributeValue) { - throw new UnexpectedValueException('Unexpected non-object value for to-one relation.'); - } - - $resourceClass = $this->resourceClassResolver->getResourceClass($attributeValue, $className); - - $data = $this->normalizeRelation($propertyMetadata, $attributeValue, $resourceClass, $format, $childContext); - $context['data'] = $data; - $context['type'] = $type; - - if ($this->tagCollector) { - $this->tagCollector->collect($context); - } - - return $data; - } - - if (!$this->serializer instanceof NormalizerInterface) { - throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class)); - } - - unset( - $context['resource_class'], - $context['force_resource_class'], - $context['uri_variables'], - ); - - // Anonymous resources - if ($className) { - $childContext = $this->createChildContext($this->createOperationContext($context, $className, $propertyMetadata), $attribute, $format); - $attributeValue = $this->propertyAccessor->getValue($object, $attribute); - - return $this->serializer->normalize($attributeValue, $format, $childContext); - } - - if ('array' === $type->getBuiltinType()) { - if ($className = ($type->getCollectionValueTypes()[0] ?? null)?->getClassName()) { - $context = $this->createOperationContext($context, $className, $propertyMetadata); - } - - $childContext = $this->createChildContext($context, $attribute, $format); - $childContext['output']['gen_id'] ??= $propertyMetadata->getGenId() ?? true; - - $attributeValue = $this->propertyAccessor->getValue($object, $attribute); - - return $this->serializer->normalize($attributeValue, $format, $childContext); - } - } - - if (!$this->serializer instanceof NormalizerInterface) { - throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', NormalizerInterface::class)); - } - - unset( - $context['resource_class'], - $context['force_resource_class'], - $context['uri_variables'] - ); - - $attributeValue = $this->propertyAccessor->getValue($object, $attribute); - - return $this->serializer->normalize($attributeValue, $format, $context); - } - $type = $propertyMetadata->getNativeType(); $nullable = false; @@ -1201,13 +1002,8 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value { $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context)); - $type = null; - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $types = $propertyMetadata->getBuiltinTypes() ?? []; - } else { - $type = $propertyMetadata->getNativeType(); - $types = $type instanceof CompositeTypeInterface ? $type->getTypes() : (null === $type ? [] : [$type]); - } + $type = $propertyMetadata->getNativeType(); + $types = $type instanceof CompositeTypeInterface ? $type->getTypes() : (null === $type ? [] : [$type]); $className = null; $typeIsResourceClass = function (Type $type) use (&$className): bool { @@ -1218,11 +1014,7 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value $denormalizationException = null; foreach ($types as $t) { - if ($type instanceof Type) { - $isNullable = $type->isNullable(); - } else { - $isNullable = $t->isNullable(); - } + $isNullable = $type->isNullable(); if (null === $value && ($isNullable || ($context[static::DISABLE_TYPE_ENFORCEMENT] ?? false))) { return $value; @@ -1232,37 +1024,29 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value if ($t instanceof CollectionType) { $collectionValueType = $t->getCollectionValueType(); - } elseif ($t instanceof LegacyType) { - $collectionValueType = $t->getCollectionValueTypes()[0] ?? null; } /* From @see AbstractObjectNormalizer::validateAndDenormalize() */ // Fix a collection that contains the only one element // This is special to xml format only if ('xml' === $format && null !== $collectionValueType && (!\is_array($value) || !\is_int(key($value)))) { - $isMixedType = $collectionValueType instanceof Type && $collectionValueType->isIdentifiedBy(TypeIdentifier::MIXED); + $isMixedType = $collectionValueType->isIdentifiedBy(TypeIdentifier::MIXED); if (!$isMixedType) { $value = [$value]; } } - if (($collectionValueType instanceof Type && $collectionValueType->isSatisfiedBy($typeIsResourceClass)) - || ($t instanceof LegacyType && $t->isCollection() && null !== $collectionValueType && null !== ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className)) - ) { + if ($collectionValueType instanceof Type && $collectionValueType->isSatisfiedBy($typeIsResourceClass)) { $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className); $context['resource_class'] = $resourceClass; unset($context['uri_variables']); // Validate the IRI target against the declared collection value type so a union // (array) accepts an IRI pointing to any of its members, not just the first. - if ($collectionValueType instanceof Type) { - $context['relation_native_type'] = $collectionValueType; - } + $context['relation_native_type'] = $collectionValueType; try { - return $t instanceof Type - ? $this->denormalizeObjectCollection($attribute, $propertyMetadata, $t, $resourceClass, $value, $format, $context) - : $this->denormalizeCollection($attribute, $propertyMetadata, $t, $resourceClass, $value, $format, $context); + return $this->denormalizeObjectCollection($attribute, $propertyMetadata, $t, $resourceClass, $value, $format, $context); } catch (NotNormalizableValueException $e) { // union/intersect types: try the next type, if not valid, an exception will be thrown at the end if ($isMultipleTypes) { @@ -1275,15 +1059,10 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value } } - if ( - ($t instanceof Type && $t->isSatisfiedBy($typeIsResourceClass)) - || ($t instanceof LegacyType && null !== ($className = $t->getClassName()) && $this->resourceClassResolver->isResourceClass($className)) - ) { + if ($t instanceof Type && $t->isSatisfiedBy($typeIsResourceClass)) { $resourceClass = $this->resourceClassResolver->getResourceClass(null, $className); $childContext = $this->createChildContext($this->createOperationContext($context, $resourceClass, $propertyMetadata), $attribute, $format); - if ($t instanceof Type) { - $childContext['relation_native_type'] = $t; - } + $childContext['relation_native_type'] = $t; try { return $this->denormalizeRelation($attribute, $propertyMetadata, $resourceClass, $value, $format, $childContext); @@ -1299,10 +1078,7 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value } } - if ( - ($t instanceof CollectionType && $collectionValueType instanceof ObjectType) - || ($t instanceof LegacyType && $t->isCollection() && null !== $collectionValueType && null !== $collectionValueType->getClassName()) - ) { + if ($t instanceof CollectionType && $collectionValueType instanceof ObjectType) { $className = $collectionValueType->getClassName(); if (!$this->serializer instanceof DenormalizerInterface) { throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); @@ -1328,10 +1104,7 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value $t = $t->getWrappedType(); } - if ( - $t instanceof ObjectType - || ($t instanceof LegacyType && null !== $t->getClassName()) - ) { + if ($t instanceof ObjectType) { if (!$this->serializer instanceof DenormalizerInterface) { throw new LogicException(\sprintf('The injected serializer must be an instance of "%s".', DenormalizerInterface::class)); } @@ -1357,14 +1130,11 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value // if a value is meant to be a string, float, int or a boolean value from the serialized representation. // That's why we have to transform the values, if one of these non-string basic datatypes is expected. if (\is_string($value) && (XmlEncoder::FORMAT === $format || CsvEncoder::FORMAT === $format)) { - if ('' === $value && $isNullable && ( - ($t instanceof Type && $t->isIdentifiedBy(TypeIdentifier::BOOL, TypeIdentifier::INT, TypeIdentifier::FLOAT)) - || ($t instanceof LegacyType && \in_array($t->getBuiltinType(), [LegacyType::BUILTIN_TYPE_BOOL, LegacyType::BUILTIN_TYPE_INT, LegacyType::BUILTIN_TYPE_FLOAT], true)) - )) { + if ('' === $value && $isNullable && $t instanceof Type && $t->isIdentifiedBy(TypeIdentifier::BOOL, TypeIdentifier::INT, TypeIdentifier::FLOAT)) { return null; } - $typeIdentifier = $t instanceof BuiltinType ? $t->getTypeIdentifier() : TypeIdentifier::tryFrom($t->getBuiltinType()); + $typeIdentifier = $t instanceof BuiltinType ? $t->getTypeIdentifier() : null; switch ($typeIdentifier) { case TypeIdentifier::BOOL: @@ -1419,9 +1189,7 @@ private function createAndValidateAttributeValue(string $attribute, mixed $value } try { - $t instanceof Type - ? $this->validateAttributeType($attribute, $t, $value, $format, $context) - : $this->validateType($attribute, $t, $value, $format, $context); + $this->validateAttributeType($attribute, $t, $value, $format, $context); $denormalizationException = null; break; diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index 5bdebb37817..b9c98a81296 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -51,8 +51,6 @@ use Prophecy\PhpUnit\ProphecyTrait; use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\Serializer\Exception\MissingConstructorArgumentsException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; @@ -110,26 +108,14 @@ public function testNormalize(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['name', 'alias', 'relatedDummy', 'relatedDummies'])); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $relatedDummyType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class); - $relatedDummiesType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $relatedDummyType); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'alias', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummyType])->withDescription('')->withReadable(true)->withWritable(false)->withReadableLink(false)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummiesType])->withReadable(true)->withWritable(false)->withReadableLink(false)); - } else { - $relatedDummyType = Type::object(RelatedDummy::class); - $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'alias', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withDescription('')->withReadable(true)->withWritable(false)->withReadableLink(false)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(false)); - } + $relatedDummyType = Type::object(RelatedDummy::class); + $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'alias', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withDescription('')->withReadable(true)->withWritable(false)->withReadableLink(false)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(false)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/1'); @@ -176,21 +162,11 @@ public function testNormalizeNullableToManyRelationReturnsNull(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['name', 'relatedDummies'])); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $relatedDummyType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class); - $relatedDummiesType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, true, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $relatedDummyType); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummiesType])->withReadable(true)->withWritable(false)->withReadableLink(false)); - } else { - $relatedDummiesType = Type::nullable(Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class), Type::int())); + $relatedDummiesType = Type::nullable(Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class), Type::int())); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(false)); - } + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(false)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/1'); @@ -234,14 +210,8 @@ public function testNormalizeWithSecuredProperty(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withSecurity('is_granted(\'ROLE_ADMIN\')')); - } else { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurity('is_granted(\'ROLE_ADMIN\')')); - } + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurity('is_granted(\'ROLE_ADMIN\')')); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/secured_dummies/1'); @@ -401,34 +371,15 @@ public function testNormalizePropertyAsIriWithUriTemplate(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'propertyCollectionIriOnlyRelation', Argument::type('array'))->willReturn( - (new ApiProperty())->withReadable(true)->withUriTemplate('/property-collection-relations')->withBuiltinTypes([ - new LegacyType('iterable', false, null, true, new LegacyType('int', false, null, false), new LegacyType('object', false, PropertyCollectionIriOnlyRelation::class, false)), - ]) - ); - $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'iterableIri', Argument::type('array'))->willReturn( - (new ApiProperty())->withReadable(true)->withUriTemplate('/parent/{parentId}/another-collection-operations')->withBuiltinTypes([ - new LegacyType('iterable', false, null, true, new LegacyType('int', false, null, false), new LegacyType('object', false, PropertyCollectionIriOnlyRelation::class, false)), - ]) - ); - $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'toOneRelation', Argument::type('array'))->willReturn( - (new ApiProperty())->withReadable(true)->withUriTemplate('/parent/{parentId}/another-collection-operations/{id}')->withBuiltinTypes([ - new LegacyType('object', false, PropertyCollectionIriOnlyRelation::class, false), - ]) - ); - } else { - $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'propertyCollectionIriOnlyRelation', Argument::type('array'))->willReturn( - (new ApiProperty())->withReadable(true)->withUriTemplate('/property-collection-relations')->withNativeType(Type::list(Type::object(PropertyCollectionIriOnlyRelation::class))) - ); - $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'iterableIri', Argument::type('array'))->willReturn( - (new ApiProperty())->withReadable(true)->withUriTemplate('/parent/{parentId}/another-collection-operations')->withNativeType(Type::iterable(Type::object(PropertyCollectionIriOnlyRelation::class))) - ); - $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'toOneRelation', Argument::type('array'))->willReturn( - (new ApiProperty())->withReadable(true)->withUriTemplate('/parent/{parentId}/another-collection-operations/{id}')->withNativeType(Type::object(PropertyCollectionIriOnlyRelation::class)) - ); - } + $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'propertyCollectionIriOnlyRelation', Argument::type('array'))->willReturn( + (new ApiProperty())->withReadable(true)->withUriTemplate('/property-collection-relations')->withNativeType(Type::list(Type::object(PropertyCollectionIriOnlyRelation::class))) + ); + $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'iterableIri', Argument::type('array'))->willReturn( + (new ApiProperty())->withReadable(true)->withUriTemplate('/parent/{parentId}/another-collection-operations')->withNativeType(Type::iterable(Type::object(PropertyCollectionIriOnlyRelation::class))) + ); + $propertyMetadataFactoryProphecy->create(PropertyCollectionIriOnly::class, 'toOneRelation', Argument::type('array'))->willReturn( + (new ApiProperty())->withReadable(true)->withUriTemplate('/parent/{parentId}/another-collection-operations/{id}')->withNativeType(Type::object(PropertyCollectionIriOnlyRelation::class)) + ); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResource($propertyCollectionIriOnly, UrlGeneratorInterface::ABS_URL, null, Argument::any())->willReturn('/property-collection-relations', '/parent/42/another-collection-operations'); @@ -483,13 +434,8 @@ public function testDenormalizeWithSecuredPropertyAndThrowOnAccessDeniedExtraPro $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')')->withExtraProperties(['throw_on_access_denied' => true])); - } else { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')')->withExtraProperties(['throw_on_access_denied' => true])); - } + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')')->withExtraProperties(['throw_on_access_denied' => true])); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -536,13 +482,8 @@ public function testDenormalizeWithSecuredPropertyAndThrowOnAccessDeniedExtraPro $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')')); - } else { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')')); - } + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')')); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -593,13 +534,8 @@ public function testDenormalizeWithSecuredPropertyAndThrowOnAccessDeniedExtraPro $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')')); - } else { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')')); - } + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurityPostDenormalize('is_granted(\'ROLE_ADMIN\')')); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -651,14 +587,8 @@ public function testDenormalizeWithSecuredProperty(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withSecurity('is_granted(\'ROLE_ADMIN\')')); - } else { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurity('is_granted(\'ROLE_ADMIN\')')); - } + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withSecurity('is_granted(\'ROLE_ADMIN\')')); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -701,14 +631,8 @@ public function testDenormalizeCreateWithDeniedPostDenormalizeSecuredProperty(): $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withWritable(true)->withSecurityPostDenormalize('false')->withDefault('')); - } else { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withWritable(true)->withSecurityPostDenormalize('false')->withDefault('')); - } + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withWritable(true)->withSecurityPostDenormalize('false')->withDefault('')); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -754,14 +678,8 @@ public function testDenormalizeUpdateWithSecuredProperty(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withWritable(true)->withSecurity('true')); - } else { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withWritable(true)->withSecurity('true')); - } + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withWritable(true)->withSecurity('true')); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -814,14 +732,8 @@ public function testDenormalizeUpdateWithDeniedSecuredProperty(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withWritable(true)->withSecurity('false')); - } else { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withWritable(true)->withSecurity('false')); - } + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withWritable(true)->withSecurity('false')); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -874,14 +786,8 @@ public function testDenormalizeUpdateWithDeniedPostDenormalizeSecuredProperty(): $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)->withWritable(true)->withSecurityPostDenormalize('false')); - } else { - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withWritable(true)->withSecurityPostDenormalize('false')); - } + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'ownerOnlyProperty', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)->withWritable(true)->withSecurityPostDenormalize('false')); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -929,22 +835,12 @@ public function testNormalizeReadableLinks(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['relatedDummy', 'relatedDummies'])); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $relatedDummyType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class); - $relatedDummiesType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $relatedDummyType); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummyType])->withReadable(true)->withWritable(false)->withReadableLink(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummiesType])->withReadable(true)->withWritable(false)->withReadableLink(true)); - } else { - $relatedDummyType = Type::object(RelatedDummy::class); - $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withReadable(true)->withWritable(false)->withReadableLink(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(true)); - } + $relatedDummyType = Type::object(RelatedDummy::class); + $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withReadable(true)->withWritable(false)->withReadableLink(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/1'); @@ -997,20 +893,11 @@ public function testNormalizePolymorphicRelations(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(DummyTableInheritanceRelated::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['children'])); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $abstractDummyType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, DummyTableInheritance::class); - $abstractDummiesType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $abstractDummyType); + $abstractDummyType = Type::object(DummyTableInheritance::class); + $abstractDummiesType = Type::collection(Type::object(ArrayCollection::class), $abstractDummyType, Type::int()); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(DummyTableInheritanceRelated::class, 'children', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$abstractDummiesType])->withReadable(true)->withWritable(false)->withReadableLink(true)); - } else { - $abstractDummyType = Type::object(DummyTableInheritance::class); - $abstractDummiesType = Type::collection(Type::object(ArrayCollection::class), $abstractDummyType, Type::int()); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(DummyTableInheritanceRelated::class, 'children', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($abstractDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(true)); - } + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(DummyTableInheritanceRelated::class, 'children', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($abstractDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/1'); @@ -1060,26 +947,14 @@ public function testDenormalize(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['name', 'relatedDummy', 'relatedDummies'])); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $relatedDummyType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class); - $relatedDummiesType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $relatedDummyType); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); + $relatedDummyType = Type::object(RelatedDummy::class); + $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummyType])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummiesType])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } else { - $relatedDummyType = Type::object(RelatedDummy::class); - $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getResourceFromIri('/dummies/1', Argument::type('array'))->willReturn($relatedDummy1); @@ -1182,30 +1057,16 @@ public function testDenormalizeWritableLinks(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['name', 'relatedDummy', 'relatedDummies', 'relatedDummiesWithUnionTypes'])); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $relatedDummyType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class); - $relatedDummiesType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $relatedDummyType); - $relatedDummiesWithUnionTypesIntType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $relatedDummyType); - $relatedDummiesWithUnionTypesFloatType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), $relatedDummyType); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummyType])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummiesType])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummiesWithUnionTypes', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummiesWithUnionTypesIntType, $relatedDummiesWithUnionTypesFloatType])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - } else { - $relatedDummyType = Type::object(RelatedDummy::class); - $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); - $relatedDummiesWithUnionTypesIntType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); - $relatedDummiesWithUnionTypesFloatType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::float()); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummiesWithUnionTypes', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::union($relatedDummiesWithUnionTypesIntType, $relatedDummiesWithUnionTypesFloatType))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - } + $relatedDummyType = Type::object(RelatedDummy::class); + $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); + $relatedDummiesWithUnionTypesIntType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); + $relatedDummiesWithUnionTypesFloatType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::float()); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummiesWithUnionTypes', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::union($relatedDummiesWithUnionTypesIntType, $relatedDummiesWithUnionTypesFloatType))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -1287,12 +1148,6 @@ public function testUnionTypeDenormalizationFallsThroughAfterTypeConfusionGuardM public function testUnionTypeCollectionDenormalizationAcceptsAnyMember(): void { - // The union-collection IRI guard relies on the native type; the legacy - // property-info path (< 7.1) only keeps the first collection value type. - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); - } - $data = ['attachments' => ['/related_dummies/1']]; $relatedDummy = new RelatedDummy(); @@ -1414,18 +1269,10 @@ public function testDenormalizeRelationNotFoundReturnsNull(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['relatedDummy'])); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $relatedDummyType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummyType])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } else { - $relatedDummyType = Type::object(RelatedDummy::class); + $relatedDummyType = Type::object(RelatedDummy::class); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getResourceFromIri('/dummies/not_found', Argument::type('array'))->willThrow(new ItemNotFoundException()); @@ -1467,16 +1314,9 @@ public function testBadRelationType(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( - (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class)])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) - ); - } else { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( - (new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) - ); - } + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) + ); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -1508,16 +1348,9 @@ public function testBadRelationTypeWithExceptionToValidationErrors(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( - (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class)])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) - ); - } else { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( - (new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) - ); - } + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) + ); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -1551,16 +1384,9 @@ public function testDeserializationPathForNotDenormalizableRelations(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn( - (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, null, new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class))])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true) - ); - } else { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn( - (new ApiProperty())->withNativeType(Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class)))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true) - ); - } + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class)))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true) + ); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getResourceFromIri(Argument::cetera())->willThrow(new InvalidArgumentException('Invalid IRI')); @@ -1657,16 +1483,9 @@ public function testInnerDocumentNotAllowed(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( - (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class)])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) - ); - } else { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( - (new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) - ); - } + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn( + (new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false) + ); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -1701,12 +1520,7 @@ public function testBadType(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'foo', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)])->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } else { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'foo', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } + $propertyMetadataFactoryProphecy->create(Dummy::class, 'foo', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -1736,12 +1550,7 @@ public function testTypeChecksCanBeDisabled(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'foo', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)])->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } else { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'foo', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } + $propertyMetadataFactoryProphecy->create(Dummy::class, 'foo', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -1775,12 +1584,7 @@ public function testJsonAllowIntAsFloat(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'foo', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)])->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } else { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'foo', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } + $propertyMetadataFactoryProphecy->create(Dummy::class, 'foo', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -1830,27 +1634,11 @@ public function testDenormalizeBadKeyType(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class)])->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - - $type = new LegacyType( - LegacyType::BUILTIN_TYPE_OBJECT, - false, - ArrayCollection::class, - true, - new LegacyType(LegacyType::BUILTIN_TYPE_INT), - new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class) - ); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$type])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - } else { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - - $type = Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class), Type::int()); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($type)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - } + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::object(RelatedDummy::class))->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); + + $type = Type::collection(Type::object(ArrayCollection::class), Type::object(RelatedDummy::class), Type::int()); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($type)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -1882,12 +1670,7 @@ public function testNullable(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING, true)])->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } else { - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::nullable(Type::string()))->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); - } + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::nullable(Type::string()))->withDescription('')->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(false)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -1930,34 +1713,18 @@ public function testDenormalizeBasicTypePropertiesFromXml(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolTrue1', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_BOOL)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolFalse1', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_BOOL)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolTrue2', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_BOOL)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolFalse2', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_BOOL)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'int1', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'int2', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'float1', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'float2', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'float3', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'floatNaN', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'floatInf', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)])->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'floatNegInf', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)])->withDescription('')->withReadable(false)->withWritable(true)); - } else { - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolTrue1', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::bool())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolFalse1', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::bool())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolTrue2', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::bool())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolFalse2', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::bool())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'int1', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::int())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'int2', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::int())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'float1', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'float2', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'float3', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'floatNaN', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'floatInf', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'floatNegInf', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); - } + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolTrue1', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::bool())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolFalse1', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::bool())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolTrue2', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::bool())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'boolFalse2', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::bool())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'int1', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::int())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'int2', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::int())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'float1', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'float2', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'float3', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'floatNaN', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'floatInf', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(ObjectWithBasicProperties::class, 'floatNegInf', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::float())->withDescription('')->withReadable(false)->withWritable(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -2021,20 +1788,11 @@ public function testDenormalizeCollectionDecodedFromXmlWithOneChild(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['relatedDummies'])); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $relatedDummyType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class); - $relatedDummiesType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $relatedDummyType); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummiesType])->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - } else { - $relatedDummyType = Type::object(RelatedDummy::class); - $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); + $relatedDummyType = Type::object(RelatedDummy::class); + $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); - } + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(false)->withWritable(true)->withReadableLink(false)->withWritableLink(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); @@ -2071,12 +1829,7 @@ public function testDenormalizePopulatingNonCloneableObject(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(NonCloneableDummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(false)->withWritable(true)); - } else { - $propertyMetadataFactoryProphecy->create(NonCloneableDummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); - } + $propertyMetadataFactoryProphecy->create(NonCloneableDummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(false)->withWritable(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); @@ -2111,12 +1864,7 @@ public function testDenormalizeObjectWithNullDisabledTypeEnforcement(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(DtoWithNullValue::class, 'dummy', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, nullable: true)])->withDescription('')->withReadable(true)->withWritable(true)); - } else { - $propertyMetadataFactoryProphecy->create(DtoWithNullValue::class, 'dummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::nullable(Type::object()))->withDescription('')->withReadable(true)->withWritable(true)); - } + $propertyMetadataFactoryProphecy->create(DtoWithNullValue::class, 'dummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::nullable(Type::object()))->withDescription('')->withReadable(true)->withWritable(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); @@ -2152,26 +1900,14 @@ public function testCacheKey(): void $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['name', 'alias', 'relatedDummy', 'relatedDummies'])); - // BC layer for api-platform/metadata < 4.1 - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $relatedDummyType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, RelatedDummy::class); - $relatedDummiesType = new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT, false, ArrayCollection::class, true, new LegacyType(LegacyType::BUILTIN_TYPE_INT), $relatedDummyType); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'alias', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummyType])->withDescription('')->withReadable(true)->withWritable(false)->withReadableLink(false)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([$relatedDummiesType])->withReadable(true)->withWritable(false)->withReadableLink(false)); - } else { - $relatedDummyType = Type::object(RelatedDummy::class); - $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); - - $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'alias', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withDescription('')->withReadable(true)->withWritable(false)->withReadableLink(false)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(false)); - } + $relatedDummyType = Type::object(RelatedDummy::class); + $relatedDummiesType = Type::collection(Type::object(ArrayCollection::class), $relatedDummyType, Type::int()); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'alias', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withDescription('')->withReadable(true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummyType)->withDescription('')->withReadable(true)->withWritable(false)->withReadableLink(false)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummies', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType($relatedDummiesType)->withReadable(true)->withWritable(false)->withReadableLink(false)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResource($dummy, Argument::cetera())->willReturn('/dummies/1'); @@ -2269,15 +2005,9 @@ public function testDenormalizeReportsAllMissingConstructorArguments(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withReadable(true)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'rating', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)])->withReadable(true)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'comment', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withReadable(true)->withWritable(true)); - } else { - $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'rating', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'comment', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); - } + $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'rating', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::int())->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(DummyWithMultipleRequiredConstructorArgs::class, 'comment', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); @@ -2309,13 +2039,8 @@ public function testDenormalizeNullableConstructorArgWithoutDefault(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $propertyMetadataFactoryProphecy->create(DummyWithNullableConstructorArg::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)])->withReadable(true)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(DummyWithNullableConstructorArg::class, 'description', Argument::type('array'))->willReturn((new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING, true)])->withReadable(true)->withWritable(true)); - } else { - $propertyMetadataFactoryProphecy->create(DummyWithNullableConstructorArg::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); - $propertyMetadataFactoryProphecy->create(DummyWithNullableConstructorArg::class, 'description', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::nullable(Type::string()))->withReadable(true)->withWritable(true)); - } + $propertyMetadataFactoryProphecy->create(DummyWithNullableConstructorArg::class, 'title', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::string())->withReadable(true)->withWritable(true)); + $propertyMetadataFactoryProphecy->create(DummyWithNullableConstructorArg::class, 'description', Argument::type('array'))->willReturn((new ApiProperty())->withNativeType(Type::nullable(Type::string()))->withReadable(true)->withWritable(true)); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); diff --git a/src/Serializer/composer.json b/src/Serializer/composer.json index f7a5bb9ed12..1560a7f588f 100644 --- a/src/Serializer/composer.json +++ b/src/Serializer/composer.json @@ -26,7 +26,7 @@ "api-platform/metadata": "^5.0@alpha", "api-platform/state": "^5.0@alpha", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", + "symfony/property-info": "^7.1 || ^8.0", "symfony/serializer": "^6.4.37 || ^7.4.9 || ^8.0.9", "symfony/validator": "^6.4.11 || ^7.0 || ^8.0" }, diff --git a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php index 9fd2a6f7536..d79d69fe6e9 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestrictionTest.php @@ -16,10 +16,8 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaChoiceRestriction; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\Choice; @@ -39,32 +37,6 @@ protected function setUp(): void $this->propertySchemaChoiceRestriction = new PropertySchemaChoiceRestriction(); } - #[IgnoreDeprecations] - public function testSupports(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'supported string' => [new Choice(choices: ['a', 'b']), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), true], - 'supported int' => [new Choice(choices: [1, 2]), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), true], - 'supported float' => [new Choice(choices: [1.1, 2.2]), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - 'supported string/int/float with union types' => [new Choice(choices: [1, 2, 1.1, 2.2, 'a', 'b']), (new ApiProperty())->withBuiltinTypes([ - new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), - new LegacyType(LegacyType::BUILTIN_TYPE_INT), - new LegacyType(LegacyType::BUILTIN_TYPE_STRING), - ]), true], - - 'not supported constraint' => [new Positive(), new ApiProperty(), false], - 'not supported type' => [new Choice(choices: [new \stdClass(), new \stdClass()]), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_OBJECT)]), false], - ]; - - foreach ($cases as [$constraint, $propertyMetadata, $expectedResult]) { - self::assertSame($expectedResult, $this->propertySchemaChoiceRestriction->supports($constraint, $propertyMetadata)); - } - } - #[DataProvider('supportsNativeProvider')] public function testSupportsNative(Constraint $constraint, ApiProperty $propertyMetadata, bool $expectedResult): void { @@ -84,67 +56,6 @@ public static function supportsNativeProvider(): \Generator yield 'not supported type' => [new Choice(choices: [new \stdClass(), new \stdClass()]), (new ApiProperty())->withNativeType(Type::object()), false]; } - #[IgnoreDeprecations] - public function testCreate(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'single string choice' => [new Choice(choices: ['a', 'b']), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), ['enum' => ['a', 'b']]], - 'multi string choice' => [new Choice(choices: ['a', 'b'], multiple: true), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b']]]], - 'multi string choice min' => [new Choice(choices: ['a', 'b'], multiple: true, min: 2), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b']], 'minItems' => 2]], - 'multi string choice max' => [new Choice(choices: ['a', 'b', 'c', 'd'], multiple: true, max: 4), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'maxItems' => 4]], - 'multi string choice min/max' => [new Choice(choices: ['a', 'b', 'c', 'd'], multiple: true, min: 2, max: 4), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'minItems' => 2, 'maxItems' => 4]], - - 'single int choice' => [new Choice(choices: [1, 2]), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), ['enum' => [1, 2]]], - 'multi int choice' => [new Choice(choices: [1, 2], multiple: true), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1, 2]]]], - 'multi int choice min' => [new Choice(choices: [1, 2], multiple: true, min: 2), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1, 2]], 'minItems' => 2]], - 'multi int choice max' => [new Choice(choices: [1, 2, 3, 4], multiple: true, max: 4), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1, 2, 3, 4]], 'maxItems' => 4]], - 'multi int choice min/max' => [new Choice(choices: [1, 2, 3, 4], multiple: true, min: 2, max: 4), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1, 2, 3, 4]], 'minItems' => 2, 'maxItems' => 4]], - - 'single float choice' => [new Choice(choices: [1.1, 2.2]), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), ['enum' => [1.1, 2.2]]], - 'multi float choice' => [new Choice(choices: [1.1, 2.2], multiple: true), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1.1, 2.2]]]], - 'multi float choice min' => [new Choice(choices: [1.1, 2.2], multiple: true, min: 2), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1.1, 2.2]], 'minItems' => 2]], - 'multi float choice max' => [new Choice(choices: [1.1, 2.2, 3.3, 4.4], multiple: true, max: 4), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1.1, 2.2, 3.3, 4.4]], 'maxItems' => 4]], - 'multi float choice min/max' => [new Choice(choices: [1.1, 2.2, 3.3, 4.4], multiple: true, min: 2, max: 4), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), ['type' => 'array', 'items' => ['type' => 'number', 'enum' => [1.1, 2.2, 3.3, 4.4]], 'minItems' => 2, 'maxItems' => 4]], - - 'single string/int/float choice with union types' => [new Choice(choices: [1, 2, 'a', 'b', 1.1, 2.2]), (new ApiProperty())->withBuiltinTypes([ - new LegacyType(LegacyType::BUILTIN_TYPE_STRING), - new LegacyType(LegacyType::BUILTIN_TYPE_INT), - new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), - ]), ['enum' => [1, 2, 'a', 'b', 1.1, 2.2]]], - 'multi string/int/float choice with union types' => [new Choice(choices: [1, 2, 'a', 'b', 1.1, 2.2], multiple: true), (new ApiProperty())->withBuiltinTypes([ - new LegacyType(LegacyType::BUILTIN_TYPE_STRING), - new LegacyType(LegacyType::BUILTIN_TYPE_INT), - new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), - ]), ['type' => 'array', 'items' => ['type' => ['number', 'string'], 'enum' => [1, 2, 'a', 'b', 1.1, 2.2]]]], - 'multi string/int/float choice min with union types' => [new Choice(choices: [1, 2, 'a', 'b', 1.1, 2.2], multiple: true, min: 2), (new ApiProperty())->withBuiltinTypes([ - new LegacyType(LegacyType::BUILTIN_TYPE_STRING), - new LegacyType(LegacyType::BUILTIN_TYPE_INT), - new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), - ]), ['type' => 'array', 'items' => ['type' => ['number', 'string'], 'enum' => [1, 2, 'a', 'b', 1.1, 2.2]], 'minItems' => 2]], - 'multi string/int/float choice max with union types' => [new Choice(choices: [1, 2, 'a', 'b', 1.1, 2.2, 3.3, 4.4], multiple: true, max: 4), (new ApiProperty())->withBuiltinTypes([ - new LegacyType(LegacyType::BUILTIN_TYPE_STRING), - new LegacyType(LegacyType::BUILTIN_TYPE_INT), - new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), - ]), ['type' => 'array', 'items' => ['type' => ['number', 'string'], 'enum' => [1, 2, 'a', 'b', 1.1, 2.2, 3.3, 4.4]], 'maxItems' => 4]], - 'multi string/int/float choice min/max with union types' => [new Choice(choices: [1, 2, 'a', 'b', 1.1, 2.2, 3.3, 4.4], multiple: true, min: 2, max: 4), (new ApiProperty())->withBuiltinTypes([ - new LegacyType(LegacyType::BUILTIN_TYPE_STRING), - new LegacyType(LegacyType::BUILTIN_TYPE_INT), - new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), - ]), ['type' => 'array', 'items' => ['type' => ['number', 'string'], 'enum' => [1, 2, 'a', 'b', 1.1, 2.2, 3.3, 4.4]], 'minItems' => 2, 'maxItems' => 4]], - - 'single choice callback' => [new Choice(callback: ChoiceCallback::getChoices(...)), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), ['enum' => ['a', 'b', 'c', 'd']]], - 'multi choice callback' => [new Choice(callback: ChoiceCallback::getChoices(...), multiple: true), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']]]], - ]; - - foreach ($cases as [$constraint, $propertyMetadata, $expectedResult]) { - self::assertSame($expectedResult, $this->propertySchemaChoiceRestriction->create($constraint, $propertyMetadata)); - } - } - #[DataProvider('createNativeProvider')] public function testCreateNative(Choice $constraint, ApiProperty $propertyMetadata, array $expectedResult): void { diff --git a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestrictionTest.php b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestrictionTest.php index 257ef139707..8a1505ced29 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestrictionTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestrictionTest.php @@ -16,10 +16,8 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaGreaterThanOrEqualRestriction; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\GreaterThanOrEqual; @@ -40,27 +38,6 @@ protected function setUp(): void $this->propertySchemaGreaterThanOrEqualRestriction = new PropertySchemaGreaterThanOrEqualRestriction(); } - #[IgnoreDeprecations] - public function testSupports(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'supported int/float with union types' => [new GreaterThanOrEqual(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT), new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - 'supported int' => [new GreaterThanOrEqual(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), true], - 'supported float' => [new GreaterThanOrEqual(value: 10.99), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - 'supported positive or zero' => [new PositiveOrZero(), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), true], - 'not supported positive' => [new Positive(), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), false], - 'not supported property path' => [new GreaterThanOrEqual(propertyPath: 'greaterThanMe'), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), false], - ]; - - foreach ($cases as [$constraint, $propertyMetadata, $expectedResult]) { - self::assertSame($expectedResult, $this->propertySchemaGreaterThanOrEqualRestriction->supports($constraint, $propertyMetadata)); - } - } - #[DataProvider('supportsProviderWithNativeType')] public function testSupportsWithNativeType(Constraint $constraint, ApiProperty $propertyMetadata, bool $expectedResult): void { @@ -77,16 +54,6 @@ public static function supportsProviderWithNativeType(): \Generator yield 'native type: not supported property path' => [new GreaterThanOrEqual(propertyPath: 'greaterThanMe'), (new ApiProperty())->withNativeType(Type::int()), false]; } - #[IgnoreDeprecations] - public function testCreate(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - self::assertEquals(['minimum' => 10], $this->propertySchemaGreaterThanOrEqualRestriction->create(new GreaterThanOrEqual(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]))); - } - public function testCreateWithNativeType(): void { self::assertEquals(['minimum' => 10], $this->propertySchemaGreaterThanOrEqualRestriction->create(new GreaterThanOrEqual(value: 10), (new ApiProperty())->withNativeType(Type::int()))); diff --git a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php index 591af26ff52..ba5f9c400b3 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestrictionTest.php @@ -16,10 +16,8 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaGreaterThanRestriction; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\GreaterThan; @@ -40,27 +38,6 @@ protected function setUp(): void $this->propertySchemaGreaterThanRestriction = new PropertySchemaGreaterThanRestriction(); } - #[IgnoreDeprecations] - public function testSupports(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'supported int/float with union types' => [new GreaterThan(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT), new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - 'supported int' => [new GreaterThan(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), true], - 'supported float' => [new GreaterThan(value: 10.99), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - 'supported positive' => [new Positive(), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), true], - 'not supported positive or zero' => [new PositiveOrZero(), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), false], - 'not supported property path' => [new GreaterThan(propertyPath: 'greaterThanMe'), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), false], - ]; - - foreach ($cases as [$constraint, $propertyMetadata, $expectedResult]) { - self::assertSame($expectedResult, $this->propertySchemaGreaterThanRestriction->supports($constraint, $propertyMetadata)); - } - } - #[DataProvider('supportsProviderWithNativeType')] public function testSupportsWithNativeType(Constraint $constraint, ApiProperty $propertyMetadata, bool $expectedResult): void { @@ -77,19 +54,6 @@ public static function supportsProviderWithNativeType(): \Generator yield 'native type: not supported property path' => [new GreaterThan(propertyPath: 'greaterThanMe'), (new ApiProperty())->withNativeType(Type::int()), false]; } - #[IgnoreDeprecations] - public function testCreate(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - self::assertEquals([ - 'exclusiveMinimum' => 10, - 'minimum' => 10, - ], $this->propertySchemaGreaterThanRestriction->create(new GreaterThan(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]))); - } - public function testCreateWithNativeType(): void { self::assertEquals([ diff --git a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestrictionTest.php b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestrictionTest.php index d50ee64b01f..86bca89718f 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestrictionTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestrictionTest.php @@ -16,10 +16,8 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLessThanOrEqualRestriction; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\LessThanOrEqual; @@ -40,27 +38,6 @@ protected function setUp(): void $this->propertySchemaLessThanOrEqualRestriction = new PropertySchemaLessThanOrEqualRestriction(); } - #[IgnoreDeprecations] - public function testSupports(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'supported int/float with union types' => [new LessThanOrEqual(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT), new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - 'supported int' => [new LessThanOrEqual(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), true], - 'supported float' => [new LessThanOrEqual(value: 10.99), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - 'supported negative or zero' => [new NegativeOrZero(), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), true], - 'not supported negative' => [new Negative(), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), false], - 'not supported property path' => [new LessThanOrEqual(propertyPath: 'greaterThanMe'), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), false], - ]; - - foreach ($cases as [$constraint, $propertyMetadata, $expectedResult]) { - self::assertSame($expectedResult, $this->propertySchemaLessThanOrEqualRestriction->supports($constraint, $propertyMetadata)); - } - } - #[DataProvider('supportsProviderWithNativeType')] public function testSupportsWithNativeType(Constraint $constraint, ApiProperty $propertyMetadata, bool $expectedResult): void { @@ -77,16 +54,6 @@ public static function supportsProviderWithNativeType(): \Generator yield 'native type: not supported property path' => [new LessThanOrEqual(propertyPath: 'greaterThanMe'), (new ApiProperty())->withNativeType(Type::int()), false]; } - #[IgnoreDeprecations] - public function testCreate(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - self::assertEquals(['maximum' => 10], $this->propertySchemaLessThanOrEqualRestriction->create(new LessThanOrEqual(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]))); - } - public function testCreateWithNativeType(): void { self::assertEquals(['maximum' => 10], $this->propertySchemaLessThanOrEqualRestriction->create(new LessThanOrEqual(value: 10), (new ApiProperty())->withNativeType(Type::int()))); diff --git a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php index ca403b15687..42b0d9b4798 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestrictionTest.php @@ -16,10 +16,8 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLessThanRestriction; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\LessThan; @@ -40,27 +38,6 @@ protected function setUp(): void $this->propertySchemaLessThanRestriction = new PropertySchemaLessThanRestriction(); } - #[IgnoreDeprecations] - public function testSupports(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'supported int/float with union types' => [new LessThan(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT), new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - 'supported int' => [new LessThan(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), true], - 'supported float' => [new LessThan(value: 10.99), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - 'supported negative' => [new Negative(), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), true], - 'not supported negative or zero' => [new NegativeOrZero(), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), false], - 'not supported property path' => [new LessThan(propertyPath: 'greaterThanMe'), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), false], - ]; - - foreach ($cases as [$constraint, $propertyMetadata, $expectedResult]) { - self::assertSame($expectedResult, $this->propertySchemaLessThanRestriction->supports($constraint, $propertyMetadata)); - } - } - #[DataProvider('supportsProviderWithNativeType')] public function testSupportsWithNativeType(Constraint $constraint, ApiProperty $propertyMetadata, bool $expectedResult): void { @@ -77,19 +54,6 @@ public static function supportsProviderWithNativeType(): \Generator yield 'native type: not supported property path' => [new LessThan(propertyPath: 'greaterThanMe'), (new ApiProperty())->withNativeType(Type::int()), false]; } - #[IgnoreDeprecations] - public function testCreate(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - self::assertEquals([ - 'exclusiveMaximum' => 10, - 'maximum' => 10, - ], $this->propertySchemaLessThanRestriction->create(new LessThan(value: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]))); - } - public function testCreateWithNativeType(): void { self::assertEquals([ diff --git a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaOneOfRestrictionTest.php b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaOneOfRestrictionTest.php index b5b219aeda2..41cda4771c1 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaOneOfRestrictionTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaOneOfRestrictionTest.php @@ -21,7 +21,6 @@ use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\AtLeastOneOf; @@ -80,25 +79,6 @@ public static function supportsProviderWithNativeType(): \Generator yield 'native type: not supported' => [new Positive(), (new ApiProperty())->withNativeType(Type::mixed()), false]; } - #[IgnoreDeprecations] - public function testCreate(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'not supported constraints' => [new AtLeastOneOf([new Positive(), new Length(min: 3)]), new ApiProperty(), []], - 'one supported constraint' => [new AtLeastOneOf([new Positive(), new Length(min: 3)]), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), [ - 'oneOf' => [['minLength' => 3]], - ]], - ]; - - foreach ($cases as [$constraint, $propertyMetadata, $expectedResult]) { - self::assertSame($expectedResult, $this->propertySchemaOneOfRestriction->create($constraint, $propertyMetadata)); - } - } - #[DataProvider('createProviderWithNativeType')] public function testCreateWithNativeType(AtLeastOneOf $constraint, ApiProperty $propertyMetadata, array $expectedResult): void { diff --git a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestrictionTest.php b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestrictionTest.php index b6c7325329f..d98d556343d 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestrictionTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestrictionTest.php @@ -16,10 +16,8 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRangeRestriction; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\Validator\Constraint; use Symfony\Component\Validator\Constraints\Length; @@ -39,27 +37,6 @@ protected function setUp(): void $this->propertySchemaRangeRestriction = new PropertySchemaRangeRestriction(); } - #[IgnoreDeprecations] - public function testSupports(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'supported int/float with union types' => [new Range(min: 1, max: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT), new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - 'supported int' => [new Range(min: 1, max: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), true], - 'supported float' => [new Range(min: 1, max: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), true], - - 'not supported constraint' => [new Length(min: 1), new ApiProperty(), false], - 'not supported type' => [new Range(min: 1), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), false], - ]; - - foreach ($cases as [$constraint, $propertyMetadata, $expectedResult]) { - self::assertSame($expectedResult, $this->propertySchemaRangeRestriction->supports($constraint, $propertyMetadata)); - } - } - #[DataProvider('supportsProviderWithNativeType')] public function testSupportsWithNativeType(Constraint $constraint, ApiProperty $propertyMetadata, bool $expectedResult): void { @@ -76,28 +53,6 @@ public static function supportsProviderWithNativeType(): \Generator yield 'native type: not supported type' => [new Range(min: 1), (new ApiProperty())->withNativeType(Type::string()), false]; } - #[IgnoreDeprecations] - public function testCreate(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'int min' => [new Range(min: 1), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), ['minimum' => 1]], - 'int max' => [new Range(max: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), ['maximum' => 10]], - 'int min max' => [new Range(min: 1, max: 10), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), ['minimum' => 1, 'maximum' => 10]], - - 'float min' => [new Range(min: 1.5), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), ['minimum' => 1.5]], - 'float max' => [new Range(max: 10.5), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), ['maximum' => 10.5]], - 'float min max' => [new Range(min: 1.5, max: 10.5), (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), ['minimum' => 1.5, 'maximum' => 10.5]], - ]; - - foreach ($cases as [$constraint, $propertyMetadata, $expectedResult]) { - self::assertSame($expectedResult, $this->propertySchemaRangeRestriction->create($constraint, $propertyMetadata)); - } - } - #[DataProvider('createProviderWithNativeType')] public function testCreateWithNativeType(Range $constraint, ApiProperty $propertyMetadata, array $expectedResult): void { diff --git a/src/Symfony/Tests/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php b/src/Symfony/Tests/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php index 9b743e9e015..2aa1abf35dd 100644 --- a/src/Symfony/Tests/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php +++ b/src/Symfony/Tests/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php @@ -45,10 +45,8 @@ use ApiPlatform\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaUniqueRestriction; use ApiPlatform\Symfony\Validator\Metadata\Property\ValidatorPropertyMetadataFactory; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\Validator\Constraints\GroupSequence; use Symfony\Component\Validator\Constraints\Hostname; @@ -561,46 +559,6 @@ public function testCreateWithPropertyUniqueRestriction(): void $this->assertEquals(['uniqueItems' => true], $schema); } - #[IgnoreDeprecations] - public function testLegacyCreateWithRangeConstraint(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'min int' => ['type' => new LegacyType(LegacyType::BUILTIN_TYPE_INT), 'property' => 'dummyIntMin', 'expectedSchema' => ['minimum' => 1]], - 'max int' => ['type' => new LegacyType(LegacyType::BUILTIN_TYPE_INT), 'property' => 'dummyIntMax', 'expectedSchema' => ['maximum' => 10]], - 'min/max int' => ['type' => new LegacyType(LegacyType::BUILTIN_TYPE_INT), 'property' => 'dummyIntMinMax', 'expectedSchema' => ['minimum' => 1, 'maximum' => 10]], - 'min float' => ['type' => new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), 'property' => 'dummyFloatMin', 'expectedSchema' => ['minimum' => 1.5]], - 'max float' => ['type' => new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), 'property' => 'dummyFloatMax', 'expectedSchema' => ['maximum' => 10.5]], - 'min/max float' => ['type' => new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT), 'property' => 'dummyFloatMinMax', 'expectedSchema' => ['minimum' => 1.5, 'maximum' => 10.5]], - ]; - - foreach ($cases as ['type' => $type, 'property' => $property, 'expectedSchema' => $expectedSchema]) { - $validatorClassMetadata = new ClassMetadata(DummyRangeValidatedEntity::class); - (new AttributeLoader())->loadClassMetadata($validatorClassMetadata); - - $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); - $validatorMetadataFactory->getMetadataFor(DummyRangeValidatedEntity::class) - ->willReturn($validatorClassMetadata) - ->shouldBeCalled(); - - $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); - $decoratedPropertyMetadataFactory->create(DummyRangeValidatedEntity::class, $property, [])->willReturn( - (new ApiProperty())->withBuiltinTypes([$type]) - )->shouldBeCalled(); - $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( - $validatorMetadataFactory->reveal(), - $decoratedPropertyMetadataFactory->reveal(), - [new PropertySchemaRangeRestriction()] - ); - $schema = $validationPropertyMetadataFactory->create(DummyRangeValidatedEntity::class, $property)->getSchema(); - - $this->assertEquals($expectedSchema, $schema); - } - } - #[DataProvider('provideRangeConstraintCasesWithNativeType')] public function testCreateWithRangeConstraintWithNativeType(Type $type, string $property, array $expectedSchema): void // Use new Type { @@ -636,49 +594,6 @@ public static function provideRangeConstraintCasesWithNativeType(): \Generator yield 'native type: min/max float' => ['type' => Type::float(), 'property' => 'dummyFloatMinMax', 'expectedSchema' => ['minimum' => 1.5, 'maximum' => 10.5]]; } - #[IgnoreDeprecations] - public function testCreateWithPropertyChoiceRestriction(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - 'single choice' => ['propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), 'property' => 'dummySingleChoice', 'expectedSchema' => ['enum' => ['a', 'b']]], - 'single choice callback' => ['propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), 'property' => 'dummySingleChoiceCallback', 'expectedSchema' => ['enum' => ['a', 'b', 'c', 'd']]], - 'multi choice' => ['propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), 'property' => 'dummyMultiChoice', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b']]]], - 'multi choice callback' => ['propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), 'property' => 'dummyMultiChoiceCallback', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']]]], - 'multi choice min' => ['propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), 'property' => 'dummyMultiChoiceMin', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'minItems' => 2]], - 'multi choice max' => ['propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), 'property' => 'dummyMultiChoiceMax', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'maxItems' => 4]], - 'multi choice min/max' => ['propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_STRING)]), 'property' => 'dummyMultiChoiceMinMax', 'expectedSchema' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => ['a', 'b', 'c', 'd']], 'minItems' => 2, 'maxItems' => 4]], - ]; - - foreach ($cases as ['propertyMetadata' => $propertyMetadata, 'property' => $property, 'expectedSchema' => $expectedSchema]) { - $validatorClassMetadata = new ClassMetadata(DummyValidatedChoiceEntity::class); - (new AttributeLoader())->loadClassMetadata($validatorClassMetadata); - - $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); - $validatorMetadataFactory->getMetadataFor(DummyValidatedChoiceEntity::class) - ->willReturn($validatorClassMetadata) - ->shouldBeCalled(); - - $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); - $decoratedPropertyMetadataFactory->create(DummyValidatedChoiceEntity::class, $property, [])->willReturn( - $propertyMetadata - )->shouldBeCalled(); - - $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( - $validatorMetadataFactory->reveal(), - $decoratedPropertyMetadataFactory->reveal(), - [new PropertySchemaChoiceRestriction()] - ); - - $schema = $validationPropertyMetadataFactory->create(DummyValidatedChoiceEntity::class, $property)->getSchema(); - - $this->assertEquals($expectedSchema, $schema); - } - } - #[DataProvider('provideChoiceConstraintCasesWithNativeType')] public function testCreateWithPropertyChoiceRestrictionWithNativeType(ApiProperty $propertyMetadata, string $property, array $expectedSchema): void { @@ -821,87 +736,6 @@ public function testCreateWithPropertyCollectionRestriction(): void ], $schema); } - #[IgnoreDeprecations] - public function testCreateWithPropertyNumericRestriction(): void - { - if (!class_exists(LegacyType::class)) { - $this->markTestSkipped('symfony/property-info is not installed.'); - } - - $cases = [ - [ - 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), - 'property' => 'greaterThanMe', - 'expectedSchema' => ['exclusiveMinimum' => 10, 'minimum' => 10], - ], - [ - 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), - 'property' => 'greaterThanOrEqualToMe', - 'expectedSchema' => ['minimum' => 10.99], - ], - [ - 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), - 'property' => 'lessThanMe', - 'expectedSchema' => ['exclusiveMaximum' => 99, 'maximum' => 99], - ], - [ - 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_FLOAT)]), - 'property' => 'lessThanOrEqualToMe', - 'expectedSchema' => ['maximum' => 99.33], - ], - [ - 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), - 'property' => 'positive', - 'expectedSchema' => ['exclusiveMinimum' => 0, 'minimum' => 0], - ], - [ - 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), - 'property' => 'positiveOrZero', - 'expectedSchema' => ['minimum' => 0], - ], - [ - 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), - 'property' => 'negative', - 'expectedSchema' => ['exclusiveMaximum' => 0, 'maximum' => 0], - ], - [ - 'propertyMetadata' => (new ApiProperty())->withBuiltinTypes([new LegacyType(LegacyType::BUILTIN_TYPE_INT)]), - 'property' => 'negativeOrZero', - 'expectedSchema' => ['maximum' => 0], - ], - ]; - - foreach ($cases as ['propertyMetadata' => $propertyMetadata, 'property' => $property, 'expectedSchema' => $expectedSchema]) { - $validatorClassMetadata = new ClassMetadata(DummyNumericValidatedEntity::class); - (new AttributeLoader())->loadClassMetadata($validatorClassMetadata); - - $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); - $validatorMetadataFactory->getMetadataFor(DummyNumericValidatedEntity::class) - ->willReturn($validatorClassMetadata) - ->shouldBeCalled(); - - $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); - $decoratedPropertyMetadataFactory->create(DummyNumericValidatedEntity::class, $property, [])->willReturn( - $propertyMetadata - )->shouldBeCalled(); - - $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( - $validatorMetadataFactory->reveal(), - $decoratedPropertyMetadataFactory->reveal(), - [ - new PropertySchemaGreaterThanOrEqualRestriction(), - new PropertySchemaGreaterThanRestriction(), - new PropertySchemaLessThanOrEqualRestriction(), - new PropertySchemaLessThanRestriction(), - ] - ); - - $schema = $validationPropertyMetadataFactory->create(DummyNumericValidatedEntity::class, $property)->getSchema(); - - $this->assertEquals($expectedSchema, $schema); - } - } - #[DataProvider('provideNumericConstraintCasesWithNativeType')] public function testCreateWithPropertyNumericRestrictionWithNativeType(ApiProperty $propertyMetadata, string $property, array $expectedSchema): void { diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php index f23b20a5d8a..7a1e0d58c1f 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaChoiceRestriction.php @@ -15,8 +15,6 @@ use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Util\TypeHelper; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\Type\CollectionType; use Symfony\Component\TypeInfo\TypeIdentifier; @@ -84,41 +82,24 @@ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): return false; } - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $nativeType = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false - ? Type::string() - : $propertyMetadata->getNativeType(); + $nativeType = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false + ? Type::string() + : $propertyMetadata->getNativeType(); - $isValidScalarType = static fn (Type $t): bool => $t->isSatisfiedBy( - static fn (Type $subType): bool => $subType->isIdentifiedBy(TypeIdentifier::STRING, TypeIdentifier::INT, TypeIdentifier::FLOAT) - ); + $isValidScalarType = static fn (Type $t): bool => $t->isSatisfiedBy( + static fn (Type $subType): bool => $subType->isIdentifiedBy(TypeIdentifier::STRING, TypeIdentifier::INT, TypeIdentifier::FLOAT) + ); - if ($isValidScalarType($nativeType)) { - return true; - } - - if ($nativeType->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType)) { - if (null !== ($collectionValueType = TypeHelper::getCollectionValueType($nativeType)) && $isValidScalarType($collectionValueType)) { - return true; - } - } - - return false; - } - - $types = array_map(static fn (LegacyType $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); - if ($propertyMetadata->getExtraProperties()['nested_schema'] ?? false) { - $types = [LegacyType::BUILTIN_TYPE_STRING]; + if ($isValidScalarType($nativeType)) { + return true; } - if ( - null !== ($builtinType = ($propertyMetadata->getBuiltinTypes()[0] ?? null)) - && $builtinType->isCollection() - && \count($builtinType->getCollectionValueTypes()) > 0 - ) { - $types = array_unique(array_merge($types, array_map(static fn (LegacyType $type) => $type->getBuiltinType(), $builtinType->getCollectionValueTypes()))); + if ($nativeType->isSatisfiedBy(static fn ($t) => $t instanceof CollectionType)) { + if (null !== ($collectionValueType = TypeHelper::getCollectionValueType($nativeType)) && $isValidScalarType($collectionValueType)) { + return true; + } } - return \count($types) > 0 && \count(array_intersect($types, [LegacyType::BUILTIN_TYPE_STRING, LegacyType::BUILTIN_TYPE_INT, LegacyType::BUILTIN_TYPE_FLOAT])) > 0; + return false; } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestriction.php index 7d8c9d05428..d9251a1141f 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanOrEqualRestriction.php @@ -14,8 +14,6 @@ namespace ApiPlatform\Symfony\Validator\Metadata\Property\Restriction; use ApiPlatform\Metadata\ApiProperty; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\Validator\Constraint; @@ -44,19 +42,10 @@ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): return false; } - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false - ? Type::int() - : $propertyMetadata->getNativeType(); + $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false + ? Type::int() + : $propertyMetadata->getNativeType(); - return $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT); - } - - $types = array_map(static fn (LegacyType $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); - if ($propertyMetadata->getExtraProperties()['nested_schema'] ?? false) { - $types = [LegacyType::BUILTIN_TYPE_INT]; - } - - return \count($types) > 0 && \count(array_intersect($types, [LegacyType::BUILTIN_TYPE_INT, LegacyType::BUILTIN_TYPE_FLOAT])) > 0; + return $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php index 0e33eca2ea7..1d1d95800a1 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaGreaterThanRestriction.php @@ -14,8 +14,6 @@ namespace ApiPlatform\Symfony\Validator\Metadata\Property\Restriction; use ApiPlatform\Metadata\ApiProperty; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\Validator\Constraint; @@ -48,19 +46,10 @@ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): return false; } - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false - ? Type::int() - : $propertyMetadata->getNativeType(); + $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false + ? Type::int() + : $propertyMetadata->getNativeType(); - return $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT); - } - - $types = array_map(static fn (LegacyType $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); - if ($propertyMetadata->getExtraProperties()['nested_schema'] ?? false) { - $types = [LegacyType::BUILTIN_TYPE_INT]; - } - - return \count($types) && array_intersect($types, [LegacyType::BUILTIN_TYPE_INT, LegacyType::BUILTIN_TYPE_FLOAT]); + return $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php index 07b2f76d588..d3b22652189 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php @@ -14,8 +14,6 @@ namespace ApiPlatform\Symfony\Validator\Metadata\Property\Restriction; use ApiPlatform\Metadata\ApiProperty; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\Validator\Constraint; @@ -53,17 +51,8 @@ public function create(Constraint $constraint, ApiProperty $propertyMetadata): a */ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): bool { - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false ? Type::string() : $propertyMetadata->getNativeType(); + $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false ? Type::string() : $propertyMetadata->getNativeType(); - return $constraint instanceof Length && $type?->isIdentifiedBy(TypeIdentifier::STRING); - } - - $types = array_map(static fn (LegacyType $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); - if ($propertyMetadata->getExtraProperties()['nested_schema'] ?? false) { - $types = [LegacyType::BUILTIN_TYPE_STRING]; - } - - return $constraint instanceof Length && \count($types) && \in_array(LegacyType::BUILTIN_TYPE_STRING, $types, true); + return $constraint instanceof Length && $type?->isIdentifiedBy(TypeIdentifier::STRING); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestriction.php index f1141818a07..bde2f7d8045 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanOrEqualRestriction.php @@ -14,8 +14,6 @@ namespace ApiPlatform\Symfony\Validator\Metadata\Property\Restriction; use ApiPlatform\Metadata\ApiProperty; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\Validator\Constraint; @@ -47,19 +45,10 @@ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): return false; } - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false - ? Type::int() - : $propertyMetadata->getNativeType(); + $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false + ? Type::int() + : $propertyMetadata->getNativeType(); - return $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT); - } - - $types = array_map(static fn (LegacyType $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); - if ($propertyMetadata->getExtraProperties()['nested_schema'] ?? false) { - $types = [LegacyType::BUILTIN_TYPE_INT]; - } - - return \count($types) > 0 && \count(array_intersect($types, [LegacyType::BUILTIN_TYPE_INT, LegacyType::BUILTIN_TYPE_FLOAT])) > 0; + return $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php index 7af4d9f7567..4a072ff99f9 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLessThanRestriction.php @@ -14,8 +14,6 @@ namespace ApiPlatform\Symfony\Validator\Metadata\Property\Restriction; use ApiPlatform\Metadata\ApiProperty; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\Validator\Constraint; @@ -45,19 +43,10 @@ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): return false; } - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false - ? Type::int() - : $propertyMetadata->getNativeType(); + $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false + ? Type::int() + : $propertyMetadata->getNativeType(); - return $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT); - } - - $types = array_map(static fn (LegacyType $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); - if ($propertyMetadata->getExtraProperties()['nested_schema'] ?? false) { - $types = [LegacyType::BUILTIN_TYPE_INT]; - } - - return \count($types) > 0 && \count(array_intersect($types, [LegacyType::BUILTIN_TYPE_INT, LegacyType::BUILTIN_TYPE_FLOAT])) > 0; + return $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT); } } diff --git a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestriction.php b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestriction.php index 833af136c6d..a03c1c52d0b 100644 --- a/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestriction.php +++ b/src/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRangeRestriction.php @@ -14,8 +14,6 @@ namespace ApiPlatform\Symfony\Validator\Metadata\Property\Restriction; use ApiPlatform\Metadata\ApiProperty; -use Symfony\Component\PropertyInfo\PropertyInfoExtractor; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; use Symfony\Component\TypeInfo\TypeIdentifier; use Symfony\Component\Validator\Constraint; @@ -55,19 +53,10 @@ public function supports(Constraint $constraint, ApiProperty $propertyMetadata): return false; } - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false - ? Type::int() - : $propertyMetadata->getNativeType(); + $type = $propertyMetadata->getExtraProperties()['nested_schema'] ?? false + ? Type::int() + : $propertyMetadata->getNativeType(); - return $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT); - } - - $types = array_map(static fn (LegacyType $type) => $type->getBuiltinType(), $propertyMetadata->getBuiltinTypes() ?? []); - if ($propertyMetadata->getExtraProperties()['nested_schema'] ?? false) { - $types = [LegacyType::BUILTIN_TYPE_INT]; - } - - return \count($types) > 0 && \count(array_intersect($types, [LegacyType::BUILTIN_TYPE_INT, LegacyType::BUILTIN_TYPE_FLOAT])) > 0; + return $type->isIdentifiedBy(TypeIdentifier::INT, TypeIdentifier::FLOAT); } } diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index ec64a14b7f3..700e79afe3a 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -42,7 +42,7 @@ "symfony/asset": "^6.4 || ^7.0 || ^8.0", "symfony/finder": "^6.4 || ^7.0 || ^8.0", "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.0 || ^8.0", + "symfony/property-info": "^7.1 || ^8.0", "symfony/property-access": "^6.4 || ^7.0 || ^8.0", "symfony/serializer": "^6.4 || ^7.0 || ^8.0", "symfony/security-core": "^6.4 || ^7.0 || ^8.0", diff --git a/tests/Fixtures/TestBundle/GraphQl/Type/TypeConverter.php b/tests/Fixtures/TestBundle/GraphQl/Type/TypeConverter.php index 01134af2974..51eda6a66dc 100644 --- a/tests/Fixtures/TestBundle/GraphQl/Type/TypeConverter.php +++ b/tests/Fixtures/TestBundle/GraphQl/Type/TypeConverter.php @@ -18,7 +18,6 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Document\Dummy as DummyDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Dummy; use GraphQL\Type\Definition\Type as GraphQLType; -use Symfony\Component\PropertyInfo\Type as LegacyType; use Symfony\Component\TypeInfo\Type; /** @@ -32,22 +31,6 @@ public function __construct(private readonly TypeConverterInterface $defaultType { } - /** - * {@inheritdoc} - */ - public function convertType(LegacyType $type, bool $input, Operation $rootOperation, string $resourceClass, string $rootResource, ?string $property, int $depth): GraphQLType|string|null - { - if ('dummyDate' === $property - && \in_array($rootResource, [Dummy::class, DummyDocument::class], true) - && LegacyType::BUILTIN_TYPE_OBJECT === $type->getBuiltinType() - && is_a($type->getClassName(), \DateTimeInterface::class, true) - ) { - return \DateTime::class; - } - - return $this->defaultTypeConverter->convertType($type, $input, $rootOperation, $resourceClass, $rootResource, $property, $depth); - } - /** * {@inheritdoc} */ diff --git a/tests/Fixtures/TestBundle/Resources/config/api_resources_odm/properties.xml b/tests/Fixtures/TestBundle/Resources/config/api_resources_odm/properties.xml index 22c4b56aad8..f714f755f96 100644 --- a/tests/Fixtures/TestBundle/Resources/config/api_resources_odm/properties.xml +++ b/tests/Fixtures/TestBundle/Resources/config/api_resources_odm/properties.xml @@ -8,11 +8,7 @@ readable="true" writable="false" identifier="true"/> - - string - - + description="Comment message" readable="true" writable="true"/> diff --git a/tests/Fixtures/TestBundle/Resources/config/api_resources_orm/properties.xml b/tests/Fixtures/TestBundle/Resources/config/api_resources_orm/properties.xml index 208b4e9e431..0a9f756d204 100644 --- a/tests/Fixtures/TestBundle/Resources/config/api_resources_orm/properties.xml +++ b/tests/Fixtures/TestBundle/Resources/config/api_resources_orm/properties.xml @@ -8,11 +8,7 @@ readable="true" writable="false" identifier="true"/> - - string - - + description="Comment message" readable="true" writable="true"/> From 7dfba0c12c5b237f3f45d0df5e0ec8d10caafb41 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 30 Jun 2026 19:22:06 +0200 Subject: [PATCH 52/84] docs(jsonld): drop misleading @type TODO, document intentional behavior (#8369) --- src/JsonLd/Serializer/ItemNormalizer.php | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 2c93881c19d..905b70ff657 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -158,12 +158,9 @@ private function resolveType(string $resourceClass, bool $isResourceClass, array $types = $operation instanceof HttpOperation ? $operation->getTypes() : null; if (null === $types) { - // TODO: 5.x break on this as this looks wrong, CollectionReferencingItem returns an IRI that point through - // ItemReferencedInCollection but it returns a CollectionReferencingItem therefore we should use the current - // object's class Type and not rely on operation ? if (isset($context['item_uri_template'])) { - // When the operation comes from item_uri_template, use its shortName directly - // as $resourceClass refers to the collection resource, not the item resource + // The members carry the item resource's @type to match their @id, which dereferences + // to the item_uri_template operation rather than to the collection's own resource. $types = [$operation->getShortName()]; } else { // Use resource-level shortName to avoid operation-specific overrides From e22e74464e49d0dc0bd86e7407f1e19b6c5db9ca Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Wed, 1 Jul 2026 09:50:13 +0200 Subject: [PATCH 53/84] feat!: remove deprecated APIs scheduled for 5.0 (#8367) --- phpstan.neon.dist | 4 - .../Serializer/CollectionNormalizerTest.php | 4 +- ...ReservedAttributeNameSchemaFactoryTest.php | 2 +- .../Tests/JsonSchema/SchemaFactoryTest.php | 6 +- src/JsonLd/Serializer/ItemNormalizer.php | 12 +-- src/JsonSchema/DefinitionNameFactory.php | 12 +-- src/JsonSchema/SchemaFactory.php | 4 +- src/Laravel/ApiPlatformProvider.php | 4 - .../Controller/ApiPlatformController.php | 11 +++ .../MetadataCollectionFactoryTrait.php | 9 -- ...sResourceMetadataCollectionFactoryTest.php | 26 ++---- .../Tests/Factory/OpenApiFactoryTest.php | 6 +- .../Serializer/OpenApiNormalizerTest.php | 2 +- src/State/Processor/ObjectMapperProcessor.php | 87 ------------------- src/State/Provider/DeserializeProvider.php | 12 --- .../SerializerAwareProviderInterface.php | 28 ------ src/State/SerializerAwareProviderTrait.php | 47 ---------- .../Provider/DeserializeProviderTest.php | 44 +--------- src/Symfony/Bundle/ApiPlatformBundle.php | 3 - .../ApiPlatformExtension.php | 13 +-- .../Compiler/DataProviderPass.php | 47 ---------- .../DependencyInjection/Configuration.php | 33 ------- .../Bundle/Resources/config/json_schema.php | 1 - src/Symfony/Bundle/Test/ApiTestCase.php | 17 +--- .../Exception/ValidationException.php | 17 +--- .../Fixtures/TestBundle/Document/Company.php | 1 + ...iderResourceMetadatatCollectionFactory.php | 6 -- .../TestBundle/State/SerializableProvider.php | 43 --------- tests/Fixtures/app/config/config_common.yml | 7 -- tests/Functional/AttributeResourceTest.php | 8 +- tests/Functional/CrudUriVariablesTest.php | 10 +-- .../CustomIdentifierWithSubresourceTest.php | 6 +- .../Functional/JsonLd/InheritanceIriTest.php | 4 +- .../SerializableItemDataProviderTest.php | 48 ---------- tests/Functional/MappingTest.php | 2 +- tests/Functional/OpenApiTest.php | 2 +- .../SubResource/SubResourceTest.php | 28 +++--- .../Symfony/Bundle/ApiPlatformBundleTest.php | 3 - .../DependencyInjection/ConfigurationTest.php | 9 -- 39 files changed, 77 insertions(+), 551 deletions(-) delete mode 100644 src/State/Processor/ObjectMapperProcessor.php delete mode 100644 src/State/SerializerAwareProviderInterface.php delete mode 100644 src/State/SerializerAwareProviderTrait.php delete mode 100644 src/Symfony/Bundle/DependencyInjection/Compiler/DataProviderPass.php delete mode 100644 tests/Fixtures/TestBundle/State/SerializableProvider.php delete mode 100644 tests/Functional/JsonLd/SerializableItemDataProviderTest.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 2015d5831b1..ee3f9221f42 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -93,10 +93,6 @@ parameters: - "#Call to function method_exists\\(\\) with Doctrine\\\\ODM\\\\MongoDB\\\\Mapping\\\\ClassMetadata\\|Doctrine\\\\ORM\\\\Mapping\\\\ClassMetadata and 'isChangeTrackingDef…' will always evaluate to true\\.#" - "#Call to function method_exists\\(\\) with Symfony\\\\Component\\\\Serializer\\\\Exception\\\\PartialDenormalizationException and 'getNotNormalizableV…' will always evaluate to true\\.#" - # See https://github.com/phpstan/phpstan-symfony/issues/27 - - - message: '#^Service "[^"]+" is private.$#' - path: src # Allow extra assertions in tests: https://github.com/phpstan/phpstan-strict-rules/issues/130 diff --git a/src/Hal/Tests/Serializer/CollectionNormalizerTest.php b/src/Hal/Tests/Serializer/CollectionNormalizerTest.php index 73095b1cd60..1f5bc63e3f6 100644 --- a/src/Hal/Tests/Serializer/CollectionNormalizerTest.php +++ b/src/Hal/Tests/Serializer/CollectionNormalizerTest.php @@ -125,8 +125,8 @@ private function normalizePaginator(bool $partial = false): array $paginator->method('current')->willReturn('foo'); // @phpstan-ignore-line if (!$partial) { - $paginator->method('getLastPage')->willReturn(7.); // @phpstan-ignore-line - $paginator->method('getTotalItems')->willReturn(1312.); // @phpstan-ignore-line + $paginator->method('getLastPage')->willReturn(7.); + $paginator->method('getTotalItems')->willReturn(1312.); } else { $paginator->method('count')->willReturn(12); } diff --git a/src/JsonApi/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php b/src/JsonApi/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php index 0b0d46b6452..a1865ab1942 100644 --- a/src/JsonApi/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php +++ b/src/JsonApi/Tests/JsonSchema/ReservedAttributeNameSchemaFactoryTest.php @@ -62,7 +62,7 @@ protected function setUp(): void ); } - $definitionNameFactory = new DefinitionNameFactory(null); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), diff --git a/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php b/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php index 648cfd87701..2c11df844d5 100644 --- a/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php +++ b/src/JsonApi/Tests/JsonSchema/SchemaFactoryTest.php @@ -59,7 +59,7 @@ protected function setUp(): void $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true, 'schema_type' => Schema::TYPE_INPUT])->willReturn(new PropertyNameCollection()); $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); - $definitionNameFactory = new DefinitionNameFactory(null); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), @@ -316,7 +316,7 @@ private function buildSchemaFactoryWithPolymorphicRelation(): SchemaFactory $resourceClassResolver->isResourceClass(RelatedDummy::class)->willReturn(true); $resourceClassResolver->isResourceClass(OtherRelatedDummy::class)->willReturn(true); - $definitionNameFactory = new DefinitionNameFactory(null); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), @@ -377,7 +377,7 @@ private function buildSchemaFactoryWithRelation(): SchemaFactory $resourceClassResolver->isResourceClass(Dummy::class)->willReturn(true); $resourceClassResolver->isResourceClass(RelatedDummy::class)->willReturn(true); - $definitionNameFactory = new DefinitionNameFactory(null); + $definitionNameFactory = new DefinitionNameFactory(); $baseSchemaFactory = new BaseSchemaFactory( resourceMetadataFactory: $resourceMetadataFactory->reveal(), diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 905b70ff657..65436d63955 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -158,13 +158,15 @@ private function resolveType(string $resourceClass, bool $isResourceClass, array $types = $operation instanceof HttpOperation ? $operation->getTypes() : null; if (null === $types) { - if (isset($context['item_uri_template'])) { - // The members carry the item resource's @type to match their @id, which dereferences - // to the item_uri_template operation rather than to the collection's own resource. + $typeClass = $isResourceClass ? $resourceClass : ($operation->getClass() ?? $resourceClass); + if (isset($context['item_uri_template']) || $operation->getClass() === $typeClass) { + // The operation serves the class being normalized: use its shortName so @type matches the + // (possibly deduplicated) @context. For item_uri_template, $resourceClass is the collection + // resource, so the operation remains authoritative for the item type. $types = [$operation->getShortName()]; } else { - // Use resource-level shortName to avoid operation-specific overrides - $typeClass = $isResourceClass ? $resourceClass : ($operation->getClass() ?? $resourceClass); + // Embedded/related resource: the operation belongs to another class, so fall back to its + // resource-level shortName instead of an operation-specific override. try { $types = [$this->resourceMetadataCollectionFactory->create($typeClass)[0]->getShortName()]; } catch (\Exception) { diff --git a/src/JsonSchema/DefinitionNameFactory.php b/src/JsonSchema/DefinitionNameFactory.php index 2396f9424d5..4c993ebdec8 100644 --- a/src/JsonSchema/DefinitionNameFactory.php +++ b/src/JsonSchema/DefinitionNameFactory.php @@ -26,13 +26,6 @@ final class DefinitionNameFactory implements DefinitionNameFactoryInterface private array $prefixCache = []; - public function __construct(private ?array $distinctFormats = null) - { - if ($distinctFormats) { - trigger_deprecation('api-platform/json-schema', '4.2', 'The distinctFormats argument is deprecated and will be removed in 5.0.'); - } - } - public function create(string $className, string $format = 'json', ?string $inputOrOutputClass = null, ?Operation $operation = null, array $serializerContext = []): string { if ($operation) { @@ -50,10 +43,7 @@ public function create(string $className, string $format = 'json', ?string $inpu $prefix .= self::GLUE.$this->createPrefixFromClass($inputOrOutputClass); } - // TODO: remove in 5.0 - $v = $this->distinctFormats ? ($this->distinctFormats[$format] ?? false) : true; - - if (!\in_array($format, ['json', 'merge-patch+json'], true) && $v) { + if (!\in_array($format, ['json', 'merge-patch+json'], true)) { // JSON is the default, and so isn't included in the definition name // JSON merge patch is postfixed at the end $prefix .= self::GLUE.$format; diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index 1f5d5d67f11..a643f6d7fa4 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -47,10 +47,10 @@ final class SchemaFactory implements SchemaFactoryInterface, SchemaFactoryAwareI // Edge case where the related resource is not readable (for example: NotExposed) but we have groups to read the whole related object public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; - public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, ?array $distinctFormats = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null) + public function __construct(ResourceMetadataCollectionFactoryInterface $resourceMetadataFactory, private readonly PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, private readonly PropertyMetadataFactoryInterface $propertyMetadataFactory, private readonly ?NameConverterInterface $nameConverter = null, ?ResourceClassResolverInterface $resourceClassResolver = null, private ?DefinitionNameFactoryInterface $definitionNameFactory = null) { if (!$definitionNameFactory) { - $this->definitionNameFactory = new DefinitionNameFactory($distinctFormats); + $this->definitionNameFactory = new DefinitionNameFactory(); } $this->resourceMetadataFactory = $resourceMetadataFactory; diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 9a87f5a6fd6..99d1384442b 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -935,16 +935,12 @@ public function register(): void }); $this->app->singleton(SchemaFactory::class, static function (Application $app) { - /** @var ConfigRepository */ - $config = $app['config']; - return new SchemaFactory( $app->make(ResourceMetadataCollectionFactoryInterface::class), $app->make(PropertyNameCollectionFactoryInterface::class), $app->make(PropertyMetadataFactoryInterface::class), $app->make(NameConverterInterface::class), $app->make(ResourceClassResolverInterface::class), - $config->get('api-platform.formats'), $app->make(DefinitionNameFactoryInterface::class), ); }); diff --git a/src/Laravel/Controller/ApiPlatformController.php b/src/Laravel/Controller/ApiPlatformController.php index d7c59b6f8f7..7e507bf2ce3 100644 --- a/src/Laravel/Controller/ApiPlatformController.php +++ b/src/Laravel/Controller/ApiPlatformController.php @@ -19,6 +19,7 @@ use ApiPlatform\Metadata\Operation\Factory\OperationMetadataFactoryInterface; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProviderInterface; +use ApiPlatform\State\SerializerContextBuilderInterface; use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Support\Facades\Event; @@ -77,6 +78,16 @@ public function __invoke(Request $request): Response $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]); + } + $body = $this->provider->provide($operation, $uriVariables, $context); // The provider can change the Operation, extract it again from the Request attributes diff --git a/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php b/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php index cd74e7776df..ae0fd13f366 100644 --- a/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php +++ b/src/Metadata/Resource/Factory/MetadataCollectionFactoryTrait.php @@ -239,7 +239,6 @@ private function hasSameOperation(ApiResource $resource, string $operationClass, */ private function deduplicateShortNames(array $resources): array { - $enabled = $this->defaults['extra_properties']['deduplicate_resource_short_names'] ?? false; $shortNameCounts = []; foreach ($resources as $index => $resource) { @@ -249,14 +248,6 @@ private function deduplicateShortNames(array $resources): array continue; } - if (!$enabled) { - if (1 === $shortNameCounts[$shortName]) { - trigger_deprecation('api-platform/core', '4.2', 'Having multiple "#[ApiResource]" attributes with the same "shortName" "%s" on class "%s" is deprecated and will result in automatic short name deduplication in API Platform 5.x. Set "defaults.extra_properties.deduplicate_resource_short_names" to "true" in the API Platform configuration to enable it now.', $shortName, $resource->getClass()); - } - ++$shortNameCounts[$shortName]; - continue; - } - $newShortName = $shortName.(++$shortNameCounts[$shortName]); $resource = $resource->withShortName($newShortName); diff --git a/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php b/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php index 07af77d035c..a8d6de53511 100644 --- a/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php +++ b/src/Metadata/Tests/Resource/Factory/AttributesResourceMetadataCollectionFactoryTest.php @@ -99,14 +99,14 @@ class: AttributeResource::class, graphQlOperations: $this->getDefaultGraphqlOperations('AttributeResource', AttributeResource::class, AttributeResourceProvider::class) ), new ApiResource( - shortName: 'AttributeResource', + shortName: 'AttributeResource2', class: AttributeResource::class, uriTemplate: '/dummy/{dummyId}/attribute_resources/{identifier}{._format}', operations: [ '_api_/dummy/{dummyId}/attribute_resources/{identifier}{._format}_get' => new Get( class: AttributeResource::class, uriTemplate: '/dummy/{dummyId}/attribute_resources/{identifier}{._format}', - shortName: 'AttributeResource', + shortName: 'AttributeResource2', inputFormats: ['json' => ['application/merge-patch+json']], priority: 4, status: 301, @@ -116,7 +116,7 @@ class: AttributeResource::class, '_api_/dummy/{dummyId}/attribute_resources/{identifier}{._format}_patch' => new Patch( class: AttributeResource::class, uriTemplate: '/dummy/{dummyId}/attribute_resources/{identifier}{._format}', - shortName: 'AttributeResource', + shortName: 'AttributeResource2', inputFormats: ['json' => ['application/merge-patch+json']], priority: 5, status: 301, @@ -272,11 +272,9 @@ public function testNameDeclarationShouldNotBeRemoved(): void $this->assertTrue($operations->has('password_reset')); } - public function testDeduplicateShortNamesWhenEnabled(): void + public function testDeduplicateShortNames(): void { - $factory = new AttributesResourceMetadataCollectionFactory(defaults: [ - 'extra_properties' => ['deduplicate_resource_short_names' => true], - ], graphQlEnabled: true); + $factory = new AttributesResourceMetadataCollectionFactory(graphQlEnabled: true); $collection = $factory->create(AttributeResource::class); @@ -292,20 +290,6 @@ public function testDeduplicateShortNamesWhenEnabled(): void } } - /** @group legacy */ - public function testDeduplicateShortNamesTriggersDeprecationWhenDisabled(): void - { - $factory = new AttributesResourceMetadataCollectionFactory(graphQlEnabled: true); - - $this->expectUserDeprecationMessage('Since api-platform/core 4.2: Having multiple "#[ApiResource]" attributes with the same "shortName" "AttributeResource" on class "ApiPlatform\Metadata\Tests\Fixtures\ApiResource\AttributeResource" is deprecated and will result in automatic short name deduplication in API Platform 5.x. Set "defaults.extra_properties.deduplicate_resource_short_names" to "true" in the API Platform configuration to enable it now.'); - - $collection = $factory->create(AttributeResource::class); - - // Without the flag, shortNames are NOT deduplicated - $this->assertSame('AttributeResource', $collection[0]->getShortName()); - $this->assertSame('AttributeResource', $collection[1]->getShortName()); - } - public function testWithParameters(): void { $attributeResourceMetadataCollectionFactory = new AttributesResourceMetadataCollectionFactory(); diff --git a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php index 89463d4fc63..c556998dbb1 100644 --- a/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php +++ b/src/OpenApi/Tests/Factory/OpenApiFactoryTest.php @@ -531,7 +531,7 @@ public function testInvoke(): void $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); - $definitionNameFactory = new DefinitionNameFactory([]); + $definitionNameFactory = new DefinitionNameFactory(); $schemaFactory = new SchemaFactory( resourceMetadataFactory: $resourceCollectionMetadataFactory, @@ -1397,7 +1397,7 @@ public function testGetExtensionPropertiesWithFalseValue(): void $resourceCollectionMetadataFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); $propertyNameCollectionFactory = $this->createMock(PropertyNameCollectionFactoryInterface::class); $propertyMetadataFactory = $this->createMock(PropertyMetadataFactoryInterface::class); - $definitionNameFactory = new DefinitionNameFactory([]); + $definitionNameFactory = new DefinitionNameFactory(); $resourceCollectionMetadata = new ResourceMetadataCollection(Dummy::class, [(new ApiResource(operations: [ (new Get())->withOpenapi(true)->withShortName('Dummy')->withName('api_dummies_get_collection')->withRouteName('api_dummies_get_collection'), @@ -1447,7 +1447,7 @@ public function testMetadataParameterInOpenApiOperationParametersThrows(): void $resourceCollectionMetadataFactory = $this->createMock(ResourceMetadataCollectionFactoryInterface::class); $propertyNameCollectionFactory = $this->createMock(PropertyNameCollectionFactoryInterface::class); $propertyMetadataFactory = $this->createMock(PropertyMetadataFactoryInterface::class); - $definitionNameFactory = new DefinitionNameFactory([]); + $definitionNameFactory = new DefinitionNameFactory(); $resourceCollectionMetadata = new ResourceMetadataCollection(Dummy::class, [(new ApiResource(operations: [ (new GetCollection()) diff --git a/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php b/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php index efe1f25df25..4e5000e9c97 100644 --- a/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php +++ b/src/OpenApi/Tests/Serializer/OpenApiNormalizerTest.php @@ -239,7 +239,7 @@ public function testNormalize(): void $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); - $definitionNameFactory = new DefinitionNameFactory(null); + $definitionNameFactory = new DefinitionNameFactory(); $schemaFactory = new SchemaFactory( resourceMetadataFactory: $resourceMetadataFactory, diff --git a/src/State/Processor/ObjectMapperProcessor.php b/src/State/Processor/ObjectMapperProcessor.php deleted file mode 100644 index f7bb34a367e..00000000000 --- a/src/State/Processor/ObjectMapperProcessor.php +++ /dev/null @@ -1,87 +0,0 @@ - - * - * 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\Processor; - -use ApiPlatform\Metadata\Operation; -use ApiPlatform\State\ProcessorInterface; -use ApiPlatform\State\Util\StateOptionsTrait; -use Symfony\Component\HttpFoundation\Response; -use Symfony\Component\ObjectMapper\ObjectMapperInterface; - -/** - * @deprecated since API Platform 4.3, use {@see ObjectMapperInputProcessor} and {@see ObjectMapperOutputProcessor} instead - * - * @implements ProcessorInterface - */ -final class ObjectMapperProcessor implements ProcessorInterface -{ - use StateOptionsTrait; - - /** - * @param ProcessorInterface $decorated - */ - public function __construct( - private readonly ?ObjectMapperInterface $objectMapper, - private readonly ProcessorInterface $decorated, - ) { - trigger_deprecation('api-platform/core', '4.3', 'The "%s" class is deprecated, use "%s" and "%s" instead.', self::class, ObjectMapperInputProcessor::class, ObjectMapperOutputProcessor::class); - } - - public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): object|array|null - { - $class = $operation->getInput()['class'] ?? $operation->getClass(); - - if ( - $data instanceof Response - || !$this->objectMapper - || !$operation->canWrite() - || null === $data - || !is_a($data, $class, true) - || !$operation->canMap() - ) { - return $this->decorated->process($data, $operation, $uriVariables, $context); - } - - $request = $context['request'] ?? null; - - // maps the Resource to an Entity - if ($request?->attributes->get('mapped_data')) { - $mappedData = $this->objectMapper->map($data, $request->attributes->get('mapped_data')); - } else { - $mappedData = $this->objectMapper->map($data, $this->getStateOptionsClass($operation, $operation->getClass())); - } - $request?->attributes->set('mapped_data', $mappedData); - - $persisted = $this->decorated->process( - $mappedData, - $operation, - $uriVariables, - $context, - ); - - // in some cases (delete operation), the decoration may return a null object - if (null === $persisted) { - return $persisted; - } - - $request?->attributes->set('persisted_data', $persisted); - - // return the Resource representation of the persisted entity - return $this->objectMapper->map( - // persist the entity - $persisted, - $operation->getClass() - ); - } -} diff --git a/src/State/Provider/DeserializeProvider.php b/src/State/Provider/DeserializeProvider.php index 02572ac9b1a..9ae282691ff 100644 --- a/src/State/Provider/DeserializeProvider.php +++ b/src/State/Provider/DeserializeProvider.php @@ -74,18 +74,6 @@ public function provide(Operation $operation, array $uriVariables = [], array $c throw new UnsupportedMediaTypeHttpException('Format not supported.'); } - if (null === ($serializerContext[SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE] ?? null)) { - $method = $operation->getMethod(); - $assignObjectToPopulate = 'POST' === $method - || 'PATCH' === $method - || ('PUT' === $method && !($operation->getExtraProperties()['standard_put'] ?? true)); - - if ($assignObjectToPopulate) { - $serializerContext[SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE] = true; - trigger_deprecation('api-platform/core', '5.0', 'To assign an object to populate you should set "%s" in your denormalizationContext, not defining it is deprecated.', SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE); - } - } - if (null !== $data && ($serializerContext[SerializerContextBuilderInterface::ASSIGN_OBJECT_TO_POPULATE] ?? false)) { $serializerContext[AbstractNormalizer::OBJECT_TO_POPULATE] = $data; } diff --git a/src/State/SerializerAwareProviderInterface.php b/src/State/SerializerAwareProviderInterface.php deleted file mode 100644 index 6aada8eba41..00000000000 --- a/src/State/SerializerAwareProviderInterface.php +++ /dev/null @@ -1,28 +0,0 @@ - - * - * 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; - -use Psr\Container\ContainerInterface; - -/** - * Injects serializer in providers. - * - * @author Vincent Chalamon - * - * @deprecated in 4.2, to be removed in 5.0 because it violates the dependency injection principle. - */ -interface SerializerAwareProviderInterface -{ - public function setSerializerLocator(ContainerInterface $serializerLocator): void; -} diff --git a/src/State/SerializerAwareProviderTrait.php b/src/State/SerializerAwareProviderTrait.php deleted file mode 100644 index bba3665f467..00000000000 --- a/src/State/SerializerAwareProviderTrait.php +++ /dev/null @@ -1,47 +0,0 @@ - - * - * 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; - -use Psr\Container\ContainerInterface; -use Symfony\Component\Serializer\SerializerInterface; - -/** - * Injects serializer in providers. - * - * @author Vincent Chalamon - */ -trait SerializerAwareProviderTrait -{ - /** - * @internal - */ - private ContainerInterface $serializerLocator; - - public function setSerializerLocator(ContainerInterface $serializerLocator): void - { - trigger_deprecation( - 'api-platform/core', - '4.2', - 'The "%s" interface is deprecated and will be removed in 5.0. It violates the dependency injection principle.', - SerializerAwareProviderInterface::class - ); - - $this->serializerLocator = $serializerLocator; - } - - private function getSerializer(): SerializerInterface - { - return $this->serializerLocator->get('serializer'); - } -} diff --git a/src/State/Tests/Provider/DeserializeProviderTest.php b/src/State/Tests/Provider/DeserializeProviderTest.php index 608df3bf09c..6332e30f9ea 100644 --- a/src/State/Tests/Provider/DeserializeProviderTest.php +++ b/src/State/Tests/Provider/DeserializeProviderTest.php @@ -14,15 +14,11 @@ namespace ApiPlatform\State\Tests\Provider; use ApiPlatform\Metadata\Get; -use ApiPlatform\Metadata\HttpOperation; -use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Post; -use ApiPlatform\Metadata\Put; use ApiPlatform\State\DenormalizationViolationFactoryInterface; use ApiPlatform\State\Provider\DeserializeProvider; use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\SerializerContextBuilderInterface; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\IgnoreDeprecations; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; @@ -34,10 +30,8 @@ class DeserializeProviderTest extends TestCase { - #[IgnoreDeprecations] public function testDeserialize(): void { - $this->expectUserDeprecationMessage('Since api-platform/core 5.0: To assign an object to populate you should set "api_assign_object_to_populate" in your denormalizationContext, not defining it is deprecated.'); $objectToPopulate = new \stdClass(); $serializerContext = []; $operation = new Post(deserialize: true, class: \stdClass::class); @@ -47,7 +41,7 @@ public function testDeserialize(): void $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); $serializerContextBuilder->expects($this->once())->method('createFromRequest')->willReturn($serializerContext); $serializer = $this->createMock(SerializerInterface::class); - $serializer->expects($this->once())->method('deserialize')->with('test', \stdClass::class, 'format', ['uri_variables' => ['id' => 1], AbstractNormalizer::OBJECT_TO_POPULATE => $objectToPopulate] + $serializerContext)->willReturn(new \stdClass()); + $serializer->expects($this->once())->method('deserialize')->with('test', \stdClass::class, 'format', ['uri_variables' => ['id' => 1]] + $serializerContext)->willReturn(new \stdClass()); $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); $request = new Request(content: 'test'); @@ -139,42 +133,6 @@ public function testRequestWithEmptyContentType(): void $provider->provide($operation, [], $context); } - #[DataProvider('provideMethodsTriggeringDeprecation')] - #[IgnoreDeprecations] - public function testDeserializeTriggersDeprecationWhenContextNotSet(HttpOperation $operation): void - { - $this->expectUserDeprecationMessage('Since api-platform/core 5.0: To assign an object to populate you should set "api_assign_object_to_populate" in your denormalizationContext, not defining it is deprecated.'); - - $objectToPopulate = new \stdClass(); - $serializerContext = []; - $decorated = $this->createStub(ProviderInterface::class); - $decorated->method('provide')->willReturn($objectToPopulate); - - $serializerContextBuilder = $this->createMock(SerializerContextBuilderInterface::class); - $serializerContextBuilder->method('createFromRequest')->willReturn($serializerContext); - - $serializer = $this->createMock(SerializerInterface::class); - $serializer->expects($this->once())->method('deserialize')->with( - 'test', - \stdClass::class, - 'format', - ['uri_variables' => ['id' => 1], 'object_to_populate' => $objectToPopulate] + $serializerContext - )->willReturn(new \stdClass()); - - $provider = new DeserializeProvider($decorated, $serializer, $serializerContextBuilder); - $request = new Request(content: 'test'); - $request->headers->set('CONTENT_TYPE', 'ok'); - $request->attributes->set('input_format', 'format'); - $provider->provide($operation, ['id' => 1], ['request' => $request]); - } - - public static function provideMethodsTriggeringDeprecation(): iterable - { - yield 'POST method' => [new Post(deserialize: true, class: \stdClass::class)]; - yield 'PATCH method' => [new Patch(deserialize: true, class: \stdClass::class)]; - yield 'PUT method (non-standard)' => [new Put(deserialize: true, class: \stdClass::class, extraProperties: ['standard_put' => false])]; - } - public function testDeserializeSetsObjectToPopulateWhenContextIsTrue(): void { $objectToPopulate = new \stdClass(); diff --git a/src/Symfony/Bundle/ApiPlatformBundle.php b/src/Symfony/Bundle/ApiPlatformBundle.php index 3b034ecbfef..5fde037f2d4 100644 --- a/src/Symfony/Bundle/ApiPlatformBundle.php +++ b/src/Symfony/Bundle/ApiPlatformBundle.php @@ -16,7 +16,6 @@ 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\DataProviderPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ElasticsearchClientPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ErrorResourceAttributeLoaderPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\FilterPass; @@ -47,8 +46,6 @@ public function build(ContainerBuilder $container): void { parent::build($container); - // TODO: remove in 5.x - $container->addCompilerPass(new DataProviderPass()); // 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()); diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index c103878d3e6..daed2199cdf 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -399,7 +399,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.http_cache.stale_while_revalidate', $config['defaults']['cache_headers']['stale_while_revalidate'] ?? null); $container->setParameter('api_platform.http_cache.stale_if_error', $config['defaults']['cache_headers']['stale_if_error'] ?? null); $container->setParameter('api_platform.http_cache.invalidation.max_header_length', $config['defaults']['cache_headers']['invalidation']['max_header_length'] ?? $config['http_cache']['invalidation']['max_header_length']); - $container->setParameter('api_platform.http_cache.invalidation.xkey.glue', $config['defaults']['cache_headers']['invalidation']['xkey']['glue'] ?? $config['http_cache']['invalidation']['xkey']['glue']); + $container->setParameter('api_platform.http_cache.invalidation.xkey.glue', $config['defaults']['cache_headers']['invalidation']['xkey']['glue'] ?? ' '); $container->setAlias('api_platform.path_segment_name_generator', $config['path_segment_name_generator']); $container->setAlias('api_platform.inflector', $config['inflector']); @@ -470,13 +470,6 @@ private function registerMetadataConfiguration(ContainerBuilder $container, arra $loader->load('metadata/resource_name.php'); $loader->load('metadata/property_name.php'); - if (!empty($config['resource_class_directories'])) { - $container->setParameter('api_platform.resource_class_directories', array_merge( - $config['resource_class_directories'], - $container->getParameter('api_platform.resource_class_directories') - )); - } - // V3 metadata $loader->load('metadata/php.php'); $loader->load('metadata/xml.php'); @@ -909,9 +902,7 @@ private function registerHttpCacheConfiguration(ContainerBuilder $container, arr $definition->addTag('api_platform.http_cache.http_client'); } - if (!($urls = $config['http_cache']['invalidation']['urls'])) { - $urls = $config['http_cache']['invalidation']['varnish_urls']; - } + $urls = $config['http_cache']['invalidation']['urls']; foreach ($urls as $key => $url) { $definition = new Definition(ScopingHttpClient::class, [new Reference('http_client'), $url, ['base_uri' => $url] + $config['http_cache']['invalidation']['request_options']]); diff --git a/src/Symfony/Bundle/DependencyInjection/Compiler/DataProviderPass.php b/src/Symfony/Bundle/DependencyInjection/Compiler/DataProviderPass.php deleted file mode 100644 index 78e3c47afb5..00000000000 --- a/src/Symfony/Bundle/DependencyInjection/Compiler/DataProviderPass.php +++ /dev/null @@ -1,47 +0,0 @@ - - * - * 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\State\SerializerAwareProviderInterface; -use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; -use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Reference; - -/** - * Registers data providers. - * - * @internal since 4.2 - * - * @author Kévin Dunglas - * @author Vincent Chalamon - * - * TODO: remove in 5.x - */ -final class DataProviderPass implements CompilerPassInterface -{ - /** - * {@inheritdoc} - */ - public function process(ContainerBuilder $container): void - { - $services = $container->findTaggedServiceIds('api_platform.state_provider', true); - - foreach ($services as $id => $tags) { - $definition = $container->getDefinition((string) $id); - if (is_a($definition->getClass(), SerializerAwareProviderInterface::class, true)) { - $definition->addMethodCall('setSerializerLocator', [new Reference('api_platform.serializer_locator')]); - } - } - } -} diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 27f5cfbd2be..480043544b9 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -94,10 +94,6 @@ public function getConfigTreeBuilder(): TreeBuilder ->addDefaultsIfNotSet() ->children() ->variableNode('serialize_payload_fields')->defaultValue([])->info('Set to null to serialize all payload fields when a validation error is thrown, or set the fields you want to include explicitly.')->end() - ->booleanNode('query_parameter_validation') - ->defaultValue(true) - ->setDeprecated('api-platform/symfony', '4.2', 'Will be removed in API Platform 5.0.') - ->end() ->end() ->end() ->arrayNode('jsonapi') @@ -132,11 +128,6 @@ public function getConfigTreeBuilder(): TreeBuilder ->booleanNode('enable_docs')->defaultTrue()->info('Enable the docs')->end() ->booleanNode('enable_profiler')->defaultTrue()->info('Enable the data collector and the WebProfilerBundle integration.')->end() ->booleanNode('enable_phpdoc_parser')->defaultTrue()->info('Enable resource metadata collector using PHPStan PhpDocParser.')->end() - ->booleanNode('enable_link_security') - ->defaultTrue() - ->info('Enable security for Links (sub resources).') - ->setDeprecated('api-platform/symfony', '4.2', 'This option is always enabled and will be removed in API Platform 5.0.') - ->end() ->arrayNode('collection') ->addDefaultsIfNotSet() ->children() @@ -167,10 +158,6 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() - ->arrayNode('resource_class_directories') - ->prototype('scalar')->end() - ->setDeprecated('api-platform/symfony', '4.1', 'The "resource_class_directories" configuration is deprecated, classes using #[ApiResource] attribute are autoconfigured by the dependency injection container.') - ->end() ->arrayNode('serializer') ->addDefaultsIfNotSet() ->children() @@ -297,10 +284,6 @@ private function addGraphQlSection(ArrayNodeDefinition $rootNode): void ->end() ->integerNode('max_query_depth')->defaultValue(20) ->end() - ->arrayNode('graphql_playground') - ->setDeprecated('api-platform/core', '4.0', 'The "graphql_playground" configuration is deprecated and will be ignored.') - ->canBeEnabled() - ->end() ->integerNode('max_query_complexity')->defaultValue(500) ->end() ->scalarNode('nesting_separator')->defaultValue('_')->info('The separator to use to filter nested fields.')->end() @@ -411,12 +394,6 @@ private function addHttpCacheSection(ArrayNodeDefinition $rootNode): void ->info('Enable the tags-based cache invalidation system.') ->canBeEnabled() ->children() - ->arrayNode('varnish_urls') - ->setDeprecated('api-platform/core', '3.0', 'The "varnish_urls" configuration is deprecated, use "urls" or "scoped_clients".') - ->defaultValue([]) - ->prototype('scalar')->end() - ->info('URLs of the Varnish servers to purge using cache tags when a resource is updated.') - ->end() ->arrayNode('urls') ->defaultValue([]) ->prototype('scalar')->end() @@ -443,16 +420,6 @@ private function addHttpCacheSection(ArrayNodeDefinition $rootNode): void ->defaultValue('api_platform.http_cache.purger.varnish') ->info('Specify a purger to use (available values: "api_platform.http_cache.purger.varnish.ban", "api_platform.http_cache.purger.varnish.xkey", "api_platform.http_cache.purger.souin").') ->end() - ->arrayNode('xkey') - ->setDeprecated('api-platform/core', '3.0', 'The "xkey" configuration is deprecated, use your own purger to customize surrogate keys or the appropriate parameters.') - ->addDefaultsIfNotSet() - ->children() - ->scalarNode('glue') - ->defaultValue(' ') - ->info('xkey glue between keys') - ->end() - ->end() - ->end() ->end() ->end() ->end() diff --git a/src/Symfony/Bundle/Resources/config/json_schema.php b/src/Symfony/Bundle/Resources/config/json_schema.php index ab64a31e40d..b1a21cf8c1f 100644 --- a/src/Symfony/Bundle/Resources/config/json_schema.php +++ b/src/Symfony/Bundle/Resources/config/json_schema.php @@ -30,7 +30,6 @@ service('api_platform.metadata.property.metadata_factory'), service('api_platform.name_converter')->ignoreOnInvalid(), service('api_platform.resource_class_resolver'), - [], service('api_platform.json_schema.definition_name_factory')->ignoreOnInvalid(), ]); diff --git a/src/Symfony/Bundle/Test/ApiTestCase.php b/src/Symfony/Bundle/Test/ApiTestCase.php index 90bd2a7db96..e1076128deb 100644 --- a/src/Symfony/Bundle/Test/ApiTestCase.php +++ b/src/Symfony/Bundle/Test/ApiTestCase.php @@ -33,13 +33,12 @@ abstract class ApiTestCase extends KernelTestCase /** * If you're using RecreateDatabaseTrait, RefreshDatabaseTrait, ReloadDatabaseTrait from theofidry/AliceBundle, you - * probably need to set this property to false in your test class to avoid recreating the database on each client creation. + * probably need to keep this property to false in your test class to avoid recreating the database on each client creation. * - * - `null` triggers a deprecation message and always boots the kernel * - `false` does not boot the kernel if it's already booted - * - `true` always boots the kernel without any deprecation message + * - `true` always boots the kernel */ - protected static ?bool $alwaysBootKernel = null; + protected static ?bool $alwaysBootKernel = false; private bool $symfonyErrorHandlerWasRegistered = false; @@ -80,15 +79,7 @@ private static function isSymfonyErrorHandlerRegistered(): bool */ protected static function createClient(array $kernelOptions = [], array $defaultOptions = []): Client { - if (null === static::$alwaysBootKernel) { - trigger_deprecation( - 'api-platform/symfony', - '4.1.0', - 'Currently, the kernel will always be booted when a new client is created, but in API Platform 5.0, it will not be booted unless you set `static::$alwaysBootKernel` to `true` (the default will be `false`). See https://github.com/api-platform/core/issues/6971 for more information.', - ); - } - - if (static::$alwaysBootKernel || null === static::$alwaysBootKernel) { + if (static::$alwaysBootKernel) { static::bootKernel($kernelOptions); } diff --git a/src/Validator/Exception/ValidationException.php b/src/Validator/Exception/ValidationException.php index 19a3d129d54..d1e70899719 100644 --- a/src/Validator/Exception/ValidationException.php +++ b/src/Validator/Exception/ValidationException.php @@ -104,22 +104,11 @@ class ValidationException extends RuntimeException implements ConstraintViolatio protected ?string $errorTitle = null; private ConstraintViolationListInterface $constraintViolationList; - public function __construct(string|ConstraintViolationListInterface $message = new ConstraintViolationList(), string|int|null $code = null, int|\Throwable|null $previous = null, \Throwable|string|null $errorTitle = null) + public function __construct(ConstraintViolationListInterface $message = new ConstraintViolationList(), string|int|null $code = null, int|\Throwable|null $previous = null, \Throwable|string|null $errorTitle = null) { $this->errorTitle = $errorTitle; - - if ($message instanceof ConstraintViolationListInterface) { - $this->constraintViolationList = $message; - parent::__construct($this->__toString(), $code ?? 0, $previous); - $this->detail = $this->getMessage(); - - return; - } - - $this->constraintViolationList = new ConstraintViolationList(); - - trigger_deprecation('api_platform/core', '5.0', \sprintf('The "%s" exception will have a "%s" first argument in 5.x.', self::class, ConstraintViolationListInterface::class)); - parent::__construct($message ?: $this->__toString(), $code ?? 0, $previous); + $this->constraintViolationList = $message; + parent::__construct($this->__toString(), $code ?? 0, $previous); $this->detail = $this->getMessage(); } diff --git a/tests/Fixtures/TestBundle/Document/Company.php b/tests/Fixtures/TestBundle/Document/Company.php index aa6b3e7ae7d..98000290b8c 100644 --- a/tests/Fixtures/TestBundle/Document/Company.php +++ b/tests/Fixtures/TestBundle/Document/Company.php @@ -26,6 +26,7 @@ #[Get] #[Post] #[ApiResource( + shortName: 'CompanyByRoom', uriTemplate: '/employees/{employeeId}/rooms/{roomId}/company/{companyId}', uriVariables: ['employeeId' => ['from_class' => Employee::class, 'from_property' => 'company']] )] diff --git a/tests/Fixtures/TestBundle/Metadata/ProviderResourceMetadatatCollectionFactory.php b/tests/Fixtures/TestBundle/Metadata/ProviderResourceMetadatatCollectionFactory.php index 95bcbea3682..3b8f2359128 100644 --- a/tests/Fixtures/TestBundle/Metadata/ProviderResourceMetadatatCollectionFactory.php +++ b/tests/Fixtures/TestBundle/Metadata/ProviderResourceMetadatatCollectionFactory.php @@ -21,11 +21,9 @@ use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ResourceInterface; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\Taxon; use ApiPlatform\Tests\Fixtures\TestBundle\Model\ResourceInterface as ResourceInterfaceDocument; -use ApiPlatform\Tests\Fixtures\TestBundle\Model\SerializableResource; use ApiPlatform\Tests\Fixtures\TestBundle\Model\TaxonInterface; use ApiPlatform\Tests\Fixtures\TestBundle\State\ContainNonResourceProvider; use ApiPlatform\Tests\Fixtures\TestBundle\State\ResourceInterfaceImplementationProvider; -use ApiPlatform\Tests\Fixtures\TestBundle\State\SerializableProvider; use ApiPlatform\Tests\Fixtures\TestBundle\State\TaxonItemProvider; class ProviderResourceMetadatatCollectionFactory implements ResourceMetadataCollectionFactoryInterface @@ -49,10 +47,6 @@ public function create(string $resourceClass): ResourceMetadataCollection return $this->setProvider($resourceMetadataCollection, ContainNonResourceProvider::class); } - if (SerializableResource::class === $resourceClass) { - return $this->setProvider($resourceMetadataCollection, SerializableProvider::class); - } - if (Taxon::class === $resourceClass || TaxonDocument::class === $resourceClass || TaxonInterface::class === $resourceClass) { return $this->setProvider($resourceMetadataCollection, TaxonItemProvider::class); } diff --git a/tests/Fixtures/TestBundle/State/SerializableProvider.php b/tests/Fixtures/TestBundle/State/SerializableProvider.php deleted file mode 100644 index 3eca79faf54..00000000000 --- a/tests/Fixtures/TestBundle/State/SerializableProvider.php +++ /dev/null @@ -1,43 +0,0 @@ - - * - * 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\State; - -use ApiPlatform\Metadata\Operation; -use ApiPlatform\State\ProviderInterface; -use ApiPlatform\State\SerializerAwareProviderInterface; -use ApiPlatform\State\SerializerAwareProviderTrait; - -/** - * @author Vincent Chalamon - * - * @deprecated in 4.2, to be removed in 5.0 because it violates the dependency injection principle. - */ -class SerializableProvider implements ProviderInterface, SerializerAwareProviderInterface -{ - use SerializerAwareProviderTrait; - - /** - * {@inheritDoc} - */ - public function provide(Operation $operation, array $uriVariables = [], array $context = []): object - { - return $this->getSerializer()->deserialize(<<<'JSON' -{ - "id": 1, - "foo": "Lorem", - "bar": "Ipsum" -} -JSON, $operation->getClass(), 'json'); - } -} diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index b046bc20e2f..7e3e79312d1 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -85,8 +85,6 @@ api_platform: http_cache: invalidation: enabled: true - # TODO: remove in 5.0 - enable_link_security: true # see also defaults in AppKernel doctrine_mongodb_odm: false mapping: @@ -161,11 +159,6 @@ services: tags: - name: 'api_platform.state_provider' - ApiPlatform\Tests\Fixtures\TestBundle\State\SerializableProvider: - class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\SerializableProvider' - tags: - - name: 'api_platform.state_provider' - ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider: class: 'ApiPlatform\Tests\Fixtures\TestBundle\State\FakeProvider' tags: diff --git a/tests/Functional/AttributeResourceTest.php b/tests/Functional/AttributeResourceTest.php index 9c959ff4044..753fc8a39c7 100644 --- a/tests/Functional/AttributeResourceTest.php +++ b/tests/Functional/AttributeResourceTest.php @@ -89,9 +89,9 @@ public function testAliasedResourceRedirectsAndShowsTarget(): void $this->assertResponseHeaderSame('Location', '/attribute_resources/2'); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonEquals([ - '@context' => '/contexts/AttributeResource', + '@context' => '/contexts/AttributeResource2', '@id' => '/attribute_resources/2', - '@type' => 'AttributeResource', + '@type' => 'AttributeResource2', 'identifier' => 2, 'dummy' => '/dummies/1', 'name' => 'Foo', @@ -109,9 +109,9 @@ public function testPatchAliasedResource(): void $this->assertResponseHeaderSame('Location', '/attribute_resources/2'); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonEquals([ - '@context' => '/contexts/AttributeResource', + '@context' => '/contexts/AttributeResource2', '@id' => '/attribute_resources/2', - '@type' => 'AttributeResource', + '@type' => 'AttributeResource2', 'identifier' => 2, 'dummy' => '/dummies/1', 'name' => 'Patched', diff --git a/tests/Functional/CrudUriVariablesTest.php b/tests/Functional/CrudUriVariablesTest.php index 695d3b6938c..27aed1c63aa 100644 --- a/tests/Functional/CrudUriVariablesTest.php +++ b/tests/Functional/CrudUriVariablesTest.php @@ -112,7 +112,7 @@ public function testGetEmployeesCollectionByCompany(): void $this->assertResponseStatusCodeSame(200); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonEquals([ - '@context' => '/contexts/Employee', + '@context' => '/contexts/Employee3', '@id' => '/companies/2/employees', '@type' => 'hydra:Collection', 'hydra:member' => [ @@ -143,9 +143,9 @@ public function testGetCompanyOfEmployee(): void $this->assertResponseStatusCodeSame(200); $this->assertJsonEquals([ - '@context' => '/contexts/Company', + '@context' => '/contexts/Company2', '@id' => '/employees/1/company', - '@type' => 'Company', + '@type' => 'Company2', 'id' => 1, 'name' => 'Foo Company 1', 'employees' => [], @@ -162,9 +162,9 @@ public function testGetEmployeeWithCompanyUriVariable(): void $this->assertResponseStatusCodeSame(200); $this->assertJsonEquals([ - '@context' => '/contexts/Employee', + '@context' => '/contexts/Employee2', '@id' => '/companies/1/employees/1', - '@type' => 'Employee', + '@type' => 'Employee2', 'id' => 1, 'name' => 'foo', 'company' => '/companies/1', diff --git a/tests/Functional/CustomIdentifierWithSubresourceTest.php b/tests/Functional/CustomIdentifierWithSubresourceTest.php index fd91785325b..42699db37d4 100644 --- a/tests/Functional/CustomIdentifierWithSubresourceTest.php +++ b/tests/Functional/CustomIdentifierWithSubresourceTest.php @@ -101,7 +101,7 @@ public function testGetChildDummiesOfParentBySlug(): void $this->assertResponseStatusCodeSame(200); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonEquals([ - '@context' => '/contexts/SlugChildDummy', + '@context' => '/contexts/SlugChildDummy2', '@id' => '/slug_parent_dummies/parent-dummy/child_dummies', '@type' => 'hydra:Collection', 'hydra:member' => [ @@ -126,9 +126,9 @@ public function testGetParentOfChildBySlug(): void $this->assertResponseStatusCodeSame(200); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonEquals([ - '@context' => '/contexts/SlugParentDummy', + '@context' => '/contexts/SlugParentDummy3', '@id' => '/slug_child_dummies/child-dummy/parent_dummy', - '@type' => 'SlugParentDummy', + '@type' => 'SlugParentDummy3', 'id' => 1, 'slug' => 'parent-dummy', 'childDummies' => ['/slug_child_dummies/child-dummy'], diff --git a/tests/Functional/JsonLd/InheritanceIriTest.php b/tests/Functional/JsonLd/InheritanceIriTest.php index 1e1e415d610..36580c4d365 100644 --- a/tests/Functional/JsonLd/InheritanceIriTest.php +++ b/tests/Functional/JsonLd/InheritanceIriTest.php @@ -49,13 +49,13 @@ public function testCollectionItemsUseConcreteSubtypeIris(): void $this->assertSame([ [ '@id' => '/contractor_5438/1', - '@type' => 'Contractor', + '@type' => 'Contractor5438', 'id' => 1, 'name' => 'a', ], [ '@id' => '/employee_5438/2', - '@type' => 'Employee', + '@type' => 'Employee5438', 'id' => 2, 'name' => 'b', ], diff --git a/tests/Functional/JsonLd/SerializableItemDataProviderTest.php b/tests/Functional/JsonLd/SerializableItemDataProviderTest.php deleted file mode 100644 index d20032d311c..00000000000 --- a/tests/Functional/JsonLd/SerializableItemDataProviderTest.php +++ /dev/null @@ -1,48 +0,0 @@ - - * - * 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\JsonLd; - -use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; -use ApiPlatform\Tests\Fixtures\TestBundle\Model\SerializableResource; -use ApiPlatform\Tests\SetupClassResourcesTrait; - -final class SerializableItemDataProviderTest extends ApiTestCase -{ - use SetupClassResourcesTrait; - - protected static ?bool $alwaysBootKernel = false; - - /** - * @return class-string[] - */ - public static function getResources(): array - { - return [SerializableResource::class]; - } - - public function testGetSerializableResource(): void - { - self::createClient()->request('GET', '/serializable_resources/1'); - - $this->assertResponseStatusCodeSame(200); - $this->assertJsonEquals([ - '@context' => '/contexts/SerializableResource', - '@id' => '/serializable_resources/1', - '@type' => 'SerializableResource', - 'id' => 1, - 'foo' => 'Lorem', - 'bar' => 'Ipsum', - ]); - } -} diff --git a/tests/Functional/MappingTest.php b/tests/Functional/MappingTest.php index 0b3571a0e9c..35922ff4cff 100644 --- a/tests/Functional/MappingTest.php +++ b/tests/Functional/MappingTest.php @@ -108,7 +108,7 @@ public function testShouldMapBetweenResourceAndEntity(): void /** * When an API resource has multiple #[Map] targets (e.g. MappedEntity + AnotherMappedObject), - * the ObjectMapperProcessor must resolve the correct target using stateOptions during POST. + * the ObjectMapperInputProcessor must resolve the correct target using stateOptions during POST. */ public function testPostWithMultipleMapTargetsResolvesCorrectEntity(): void { diff --git a/tests/Functional/OpenApiTest.php b/tests/Functional/OpenApiTest.php index ada32910f10..3e411c256c4 100644 --- a/tests/Functional/OpenApiTest.php +++ b/tests/Functional/OpenApiTest.php @@ -466,7 +466,7 @@ public function testRetrieveTheOpenApiDocumentation(): void $this->assertCount(7, $json['paths']['/related_dummies/{id}/related_to_dummy_friends']['get']['parameters']); // Subcollection - check schema - $this->assertSame('#/components/schemas/RelatedToDummyFriend.jsonld-fakemanytomany', $json['paths']['/related_dummies/{id}/related_to_dummy_friends']['get']['responses']['200']['content']['application/ld+json']['schema']['allOf'][1]['properties']['hydra:member']['items']['$ref']); + $this->assertSame('#/components/schemas/RelatedToDummyFriend4.jsonld-fakemanytomany', $json['paths']['/related_dummies/{id}/related_to_dummy_friends']['get']['responses']['200']['content']['application/ld+json']['schema']['allOf'][1]['properties']['hydra:member']['items']['$ref']); // Deprecations $this->assertTrue($json['paths']['/deprecated_resources']['get']['deprecated']); diff --git a/tests/Functional/SubResource/SubResourceTest.php b/tests/Functional/SubResource/SubResourceTest.php index cfc7c2328cf..f9bc7d0363c 100644 --- a/tests/Functional/SubResource/SubResourceTest.php +++ b/tests/Functional/SubResource/SubResourceTest.php @@ -164,9 +164,9 @@ public function testGetOneToOneSubResource(): void $this->assertResponseStatusCodeSame(200); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonEquals([ - '@context' => '/contexts/Answer', + '@context' => '/contexts/Answer3', '@id' => '/questions/1/answer', - '@type' => 'Answer', + '@type' => 'Answer3', 'id' => 1, 'content' => '42', 'relatedQuestions' => ['/questions/1'], @@ -188,9 +188,9 @@ public function testOneToOneSubresourceExposesInverseSideBackIri(): void $this->assertResponseStatusCodeSame(200); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonEquals([ - '@context' => '/contexts/OneToOneSubresourceAnswer', + '@context' => '/contexts/OneToOneSubresourceAnswer2', '@id' => '/one_to_one_subresource_questions/1/answer', - '@type' => 'OneToOneSubresourceAnswer', + '@type' => 'OneToOneSubresourceAnswer2', 'id' => 1, 'content' => '42', 'question' => '/one_to_one_subresource_questions/1', @@ -216,7 +216,7 @@ public function testGetRecursiveSubResource(): void $this->assertResponseStatusCodeSame(200); $this->assertJsonEquals([ - '@context' => '/contexts/Question', + '@context' => '/contexts/Question3', '@id' => '/questions/1/answer/related_questions', '@type' => 'hydra:Collection', 'hydra:member' => [[ @@ -268,7 +268,7 @@ public function testGetSubResourceItem(): void $this->assertResponseStatusCodeSame(200); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonContains([ - '@context' => '/contexts/RelatedDummy', + '@context' => '/contexts/RelatedDummy3', '@id' => '/dummies/1/related_dummies/2', '@type' => 'https://schema.org/Product', 'id' => 2, @@ -297,9 +297,9 @@ public function testGetEmbeddedRelationAtThirdLevel(): void $this->assertResponseStatusCodeSame(200); $data = $response->toArray(); - $this->assertSame('/contexts/ThirdLevel', $data['@context']); + $this->assertSame('/contexts/ThirdLevel2', $data['@context']); $this->assertSame('/dummies/1/related_dummies/1/third_level', $data['@id']); - $this->assertSame('ThirdLevel', $data['@type']); + $this->assertSame('ThirdLevel2', $data['@type']); $this->assertSame('/fourth_levels/1', $data['fourthLevel']); $this->assertSame(1, $data['id']); $this->assertSame(3, $data['level']); @@ -317,9 +317,9 @@ public function testGetEmbeddedRelationAtFourthLevel(): void $this->assertResponseStatusCodeSame(200); $this->assertResponseHeaderSame('Content-Type', 'application/ld+json; charset=utf-8'); $this->assertJsonEquals([ - '@context' => '/contexts/FourthLevel', + '@context' => '/contexts/FourthLevel2', '@id' => '/dummies/1/related_dummies/1/third_level/fourth_level', - '@type' => 'FourthLevel', + '@type' => 'FourthLevel2', 'badThirdLevel' => [], 'id' => 1, 'level' => 4, @@ -485,9 +485,9 @@ public function testOneToOneFromOwnedSide(): void $this->assertResponseStatusCodeSame(200); $this->assertJsonContains([ - '@context' => '/contexts/Dummy', + '@context' => '/contexts/Dummy2', '@id' => '/related_owned_dummies/1/owning_dummy', - '@type' => 'Dummy', + '@type' => 'Dummy2', 'name' => 'plop', 'relatedOwnedDummy' => '/related_owned_dummies/1', 'relatedOwningDummy' => null, @@ -517,9 +517,9 @@ public function testOneToOneFromOwningSide(): void $this->assertResponseStatusCodeSame(200); $this->assertJsonContains([ - '@context' => '/contexts/Dummy', + '@context' => '/contexts/Dummy3', '@id' => '/related_owning_dummies/1/owned_dummy', - '@type' => 'Dummy', + '@type' => 'Dummy3', 'name' => 'plop', 'relatedOwningDummy' => '/related_owning_dummies/1', 'relatedOwnedDummy' => null, diff --git a/tests/Symfony/Bundle/ApiPlatformBundleTest.php b/tests/Symfony/Bundle/ApiPlatformBundleTest.php index a8b85a8e4c9..45af8e628d8 100644 --- a/tests/Symfony/Bundle/ApiPlatformBundleTest.php +++ b/tests/Symfony/Bundle/ApiPlatformBundleTest.php @@ -17,7 +17,6 @@ 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\DataProviderPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ElasticsearchClientPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\ErrorResourceAttributeLoaderPass; use ApiPlatform\Symfony\Bundle\DependencyInjection\Compiler\FilterPass; @@ -47,8 +46,6 @@ public function testBuild(): void $passes = $container->getCompilerPassConfig()->getBeforeOptimizationPasses(); $passClasses = array_map(static fn (object $p): string => $p::class, $passes); - // TODO: remove in 5.x - $this->assertContains(DataProviderPass::class, $passClasses); $this->assertContains(AttributeFilterPass::class, $passClasses); $this->assertContains(AttributeResourcePass::class, $passClasses); $this->assertContains(FilterPass::class, $passClasses); diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index a31121d8a19..0e5d5dde025 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -106,7 +106,6 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'inflector' => 'api_platform.metadata.inflector', 'validator' => [ 'serialize_payload_fields' => [], - 'query_parameter_validation' => true, ], 'name_converter' => null, 'enable_swagger' => true, @@ -132,9 +131,6 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'enabled' => true, ], ], - 'graphql_playground' => [ - 'enabled' => false, - ], ], 'elasticsearch' => [ 'enabled' => false, @@ -189,11 +185,9 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'http_cache' => [ 'invalidation' => [ 'enabled' => false, - 'varnish_urls' => [], 'request_options' => [], 'max_header_length' => 7500, 'purger' => 'api_platform.http_cache.purger.varnish', - 'xkey' => ['glue' => ' '], 'urls' => [], 'scoped_clients' => [], ], @@ -213,7 +207,6 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'hub_url' => null, 'include_type' => false, ], - 'resource_class_directories' => [], 'asset_package' => null, 'openapi' => [ 'contact' => [ @@ -240,8 +233,6 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm ], 'use_symfony_listeners' => false, 'handle_symfony_errors' => false, - // TODO: remove in 5.0 - 'enable_link_security' => true, 'serializer' => [ 'hydra_prefix' => null, ], From c41505c0021ca76f89041897243eb63b30431a7b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:45:31 +0200 Subject: [PATCH 54/84] chore(deps): bump codecov/codecov-action from 5 to 7 (#8372) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e07616c2d83..ddb04657165 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -333,7 +333,7 @@ jobs: continue-on-error: true - name: Upload coverage results to Codecov if: matrix.coverage - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} directory: build/logs/phpunit @@ -436,7 +436,7 @@ jobs: continue-on-error: true - name: Upload coverage results to Codecov if: matrix.coverage - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} directory: /tmp/build/logs/phpunit @@ -612,7 +612,7 @@ jobs: path: build/logs/phpunit continue-on-error: true - name: Upload coverage results to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} directory: build/logs/phpunit @@ -692,7 +692,7 @@ jobs: path: build/logs/phpunit continue-on-error: true - name: Upload coverage results to Codecov - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} directory: build/logs/phpunit @@ -1068,7 +1068,7 @@ jobs: continue-on-error: true - name: Upload coverage results to Codecov if: matrix.coverage - uses: codecov/codecov-action@v5 + uses: codecov/codecov-action@v7 with: token: ${{ secrets.CODECOV_TOKEN }} directory: build/logs/phpunit From 70470284250a2f1bb9c2ebf68daa5f00b5c2ca88 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:45:40 +0200 Subject: [PATCH 55/84] chore(deps): bump actions/checkout from 6 to 7 (#8373) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 44 ++++++++++++++++---------------- .github/workflows/commitlint.yml | 2 +- .github/workflows/guides.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/stale.yml | 2 +- 5 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ddb04657165..603807b1891 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,7 +35,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -73,7 +73,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -96,7 +96,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -133,7 +133,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -179,7 +179,7 @@ jobs: APP_DEBUG: '1' # https://github.com/phpstan/phpstan-symfony/issues/37 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 # https://github.com/staabm/phpstan-todo-by#prerequisite - name: Get tags run: git fetch --tags origin @@ -244,7 +244,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -297,7 +297,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -391,7 +391,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -469,7 +469,7 @@ jobs: PGPASSWORD: apiplatformrocks steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup postgres run: | sudo systemctl start postgresql @@ -525,7 +525,7 @@ jobs: DATABASE_URL: mysql://root:root@127.0.0.1/api_platform_test steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -567,7 +567,7 @@ jobs: MONGODB_URL: mongodb://localhost:27017 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup MongoDB run: | sudo apt update @@ -657,7 +657,7 @@ jobs: - 1337:1337 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -733,7 +733,7 @@ jobs: APP_ENV: elasticsearch steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Configure sysctl limits run: | sudo swapoff -a @@ -806,7 +806,7 @@ jobs: --health-retries 10 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -846,7 +846,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -885,7 +885,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -929,7 +929,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -974,7 +974,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -1023,7 +1023,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -1097,7 +1097,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -1147,7 +1147,7 @@ jobs: fail-fast: false steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: @@ -1173,7 +1173,7 @@ jobs: timeout-minutes: 20 steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP uses: shivammathur/setup-php@v2 with: diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index a7e7c5af0b0..4fa2f0c07ae 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -12,7 +12,7 @@ jobs: if: github.event_name == 'pull_request_target' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: fetch-depth: 0 ref: ${{ github.event.pull_request.base.ref }} diff --git a/.github/workflows/guides.yml b/.github/workflows/guides.yml index 7ba7956730d..ac572ef67b5 100644 --- a/.github/workflows/guides.yml +++ b/.github/workflows/guides.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup PHP with pre-release PECL extension uses: shivammathur/setup-php@v2 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b0669ec0cb..6314722beaf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ jobs: private_key: ${{ secrets.API_PLATFORM_APP_PRIVATE_KEY }} - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@v7 with: token: ${{ steps.generate_token.outputs.token }} fetch-depth: 0 diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 9375d214f4a..269d8ee43f9 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -25,7 +25,7 @@ jobs: DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }} MAX_ACTIONS_PER_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.max_actions || '25' }} steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/github-script@v8 with: script: | From ccf51874b013bfa2527da15db9941ae3479433ab Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:46:02 +0200 Subject: [PATCH 56/84] chore(deps): bump actions/cache from 5 to 6 (#8375) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 38 ++++++++++++++++++------------------ .github/workflows/guides.yml | 2 +- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 603807b1891..73ec00249d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -109,7 +109,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -146,7 +146,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -195,7 +195,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -207,7 +207,7 @@ jobs: composer global link . composer require --dev doctrine/mongodb-odm-bundle - name: Cache PHPStan results - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: /tmp/phpstan key: phpstan-php${{ matrix.php }}-${{ github.sha }} @@ -257,7 +257,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -310,7 +310,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -488,7 +488,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -538,7 +538,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -589,7 +589,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -670,7 +670,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -757,7 +757,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -819,7 +819,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -859,7 +859,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -900,7 +900,7 @@ jobs: - name: Allow unstable project dependencies run: composer config minimum-stability dev - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -946,7 +946,7 @@ jobs: - name: Force Symfony 8.1 dev for framework-bundle and json-streamer run: composer require --dev --no-update --no-interaction "symfony/framework-bundle:8.1.x-dev" "symfony/json-streamer:8.1.x-dev" - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-symfony-edge-${{ hashFiles('**/composer.json') }} @@ -987,7 +987,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -1036,7 +1036,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} @@ -1113,7 +1113,7 @@ jobs: id: composercache run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} diff --git a/.github/workflows/guides.yml b/.github/workflows/guides.yml index ac572ef67b5..b9fd9d7368d 100644 --- a/.github/workflows/guides.yml +++ b/.github/workflows/guides.yml @@ -39,7 +39,7 @@ jobs: composer global config --no-plugins allow-plugins.symfony/runtime true composer global require php-documentation-generator/php-documentation-generator:dev-main - name: Cache dependencies - uses: actions/cache@v5 + uses: actions/cache@v6 with: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} From ea1f385387d3102a0abc23d43995b36ff31b7a0d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:55:29 +0200 Subject: [PATCH 57/84] chore(deps): bump actions/github-script from 8 to 9 (#8374) Signed-off-by: dependabot[bot] --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 269d8ee43f9..be1a6096000 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -26,7 +26,7 @@ jobs: MAX_ACTIONS_PER_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.max_actions || '25' }} steps: - uses: actions/checkout@v7 - - uses: actions/github-script@v8 + - uses: actions/github-script@v9 with: script: | const script = require('./.github/scripts/stale.js'); From 722ed09e4f1cdadb8dbe27661e5fb3cdfd4bf262 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 12 Jul 2026 08:42:34 +0200 Subject: [PATCH 58/84] fix(validator): forward-port enum backing type into DenormalizationViolationFactory #8389 patched normalizeExpectedTypes() in DeserializeProvider on 4.3; 4.4 moved that logic to DenormalizationViolationFactory. Port the backed-enum backing-type reporting and result dedup so the fix survives the up-merge. --- src/Validator/DenormalizationViolationFactory.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Validator/DenormalizationViolationFactory.php b/src/Validator/DenormalizationViolationFactory.php index 76b38e69cb2..2296c38ed9c 100644 --- a/src/Validator/DenormalizationViolationFactory.php +++ b/src/Validator/DenormalizationViolationFactory.php @@ -229,6 +229,13 @@ private function normalizeExpectedTypes(?array $expectedTypes): array $normalized = []; foreach ($expectedTypes ?? [] as $expectedType) { if (\is_string($expectedType) && (class_exists($expectedType) || interface_exists($expectedType))) { + // A backed enum is sent over the wire as its backing scalar (e.g. "string"), not as the + // PHP enum class, so report the JSON-visible type rather than the internal FQCN (#8388). + if (is_subclass_of($expectedType, \BackedEnum::class) && ($backingType = (new \ReflectionEnum($expectedType))->getBackingType())) { + $normalized[] = (string) $backingType; + continue; + } + $pos = strrpos($expectedType, '\\'); $normalized[] = false === $pos ? $expectedType : substr($expectedType, $pos + 1); continue; @@ -236,6 +243,6 @@ private function normalizeExpectedTypes(?array $expectedTypes): array $normalized[] = $expectedType; } - return $normalized; + return array_values(array_unique($normalized)); } } From 1cacc0ba3c65f1bb127a7564ae1eb935a9e86a90 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 12 Jul 2026 08:46:51 +0200 Subject: [PATCH 59/84] fix(test): drop stale serializer 8.1 deprecation guard on ported enum test The 3-way merge of #8389 correctly dropped the deprecation-expectation guard (imports + #[IgnoreDeprecations] + version check) that #8287 had already removed from the sibling test, since 4.4's DenormalizationViolationFactory prefers getNotNormalizableValueErrors() over the deprecated getErrors() whenever it exists and so never triggers that deprecation on symfony/serializer >=8.1. But #8389 also introduced a brand-new test reusing the same now-unimported VersionParser/IgnoreDeprecations symbols, which the textual merge could not reconcile: it left the merged file referencing classes with no matching use statement. Remove the same stale guard from the new test for the same reason #8287 removed it from the old one. --- tests/Functional/EnumDenormalizationValidationTest.php | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/Functional/EnumDenormalizationValidationTest.php b/tests/Functional/EnumDenormalizationValidationTest.php index d75e4d3cf28..e469989fa2e 100644 --- a/tests/Functional/EnumDenormalizationValidationTest.php +++ b/tests/Functional/EnumDenormalizationValidationTest.php @@ -80,13 +80,8 @@ public function testInvalidBackedEnumValueWithCollectDenormalizationErrors(): vo /** * @see https://github.com/api-platform/core/issues/8388 */ - #[IgnoreDeprecations] public function testWrongTypeForBackedEnumReportsAcceptedScalarTypes(): void { - if (InstalledVersions::satisfies(new VersionParser(), 'symfony/serializer', '>=8.1')) { - $this->expectUserDeprecationMessage('Since symfony/serializer 8.1: The "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getErrors()" method is deprecated, use "Symfony\Component\Serializer\Exception\PartialDenormalizationException::getNotNormalizableValueErrors()" instead.'); - } - $response = static::createClient()->request('POST', '/enum_validation_resources_collect', [ 'headers' => ['Content-Type' => 'application/ld+json'], 'json' => ['gender' => true], From db5c7d8b49bdc55cf9a751966100f9c2a3aa6220 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 12 Jul 2026 09:35:58 +0200 Subject: [PATCH 60/84] chore: remove @experimental from stabilized APIs (#8398) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the C.4 stabilization decision, @experimental is kept only on MCP classes. Elasticsearch, the State parameter providers (Security/IriConverter/ReadLink), PropertyAwareFilterInterface and the Laravel ParameterValidatorProvider are stable — drop the marker. Aligns 4.4 with main, which already stripped these. --- src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php | 2 -- src/Elasticsearch/Exception/IndexNotFoundException.php | 2 -- src/Elasticsearch/Exception/NonUniqueIdentifierException.php | 2 -- src/Elasticsearch/Extension/AbstractFilterExtension.php | 2 -- src/Elasticsearch/Extension/ConstantScoreFilterExtension.php | 2 -- .../Extension/RequestBodySearchCollectionExtensionInterface.php | 2 -- src/Elasticsearch/Extension/SortExtension.php | 2 -- src/Elasticsearch/Extension/SortFilterExtension.php | 2 -- src/Elasticsearch/Filter/AbstractFilter.php | 2 -- src/Elasticsearch/Filter/AbstractSearchFilter.php | 2 -- src/Elasticsearch/Filter/ConstantScoreFilterInterface.php | 2 -- src/Elasticsearch/Filter/FilterInterface.php | 2 -- src/Elasticsearch/Filter/OrderFilter.php | 2 -- src/Elasticsearch/Filter/SortFilterInterface.php | 2 -- src/Elasticsearch/Filter/TermFilter.php | 2 -- src/Elasticsearch/Paginator.php | 2 -- src/Elasticsearch/Serializer/DocumentNormalizer.php | 2 -- src/Elasticsearch/Serializer/ItemNormalizer.php | 2 -- .../Serializer/NameConverter/InnerFieldsNameConverter.php | 2 -- src/Elasticsearch/Util/FieldDatatypeTrait.php | 2 -- src/Laravel/State/ParameterValidatorProvider.php | 2 -- src/State/ParameterProvider/IriConverterParameterProvider.php | 2 -- src/State/ParameterProvider/ReadLinkParameterProvider.php | 2 -- src/State/Provider/SecurityParameterProvider.php | 2 -- 24 files changed, 48 deletions(-) diff --git a/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php index 33b9fad001a..80b7eabcb6e 100644 --- a/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php +++ b/src/Doctrine/Common/Filter/PropertyAwareFilterInterface.php @@ -19,8 +19,6 @@ * @author Antoine Bluchet * * @method array|null getProperties() - * - * @experimental */ interface PropertyAwareFilterInterface { diff --git a/src/Elasticsearch/Exception/IndexNotFoundException.php b/src/Elasticsearch/Exception/IndexNotFoundException.php index a92528a0deb..24c4ed9e98d 100644 --- a/src/Elasticsearch/Exception/IndexNotFoundException.php +++ b/src/Elasticsearch/Exception/IndexNotFoundException.php @@ -16,8 +16,6 @@ /** * Index not found exception. * - * @experimental - * * @author Baptiste Meyer */ final class IndexNotFoundException extends \Exception implements ExceptionInterface diff --git a/src/Elasticsearch/Exception/NonUniqueIdentifierException.php b/src/Elasticsearch/Exception/NonUniqueIdentifierException.php index 624ff936c01..9d8d7710e9f 100644 --- a/src/Elasticsearch/Exception/NonUniqueIdentifierException.php +++ b/src/Elasticsearch/Exception/NonUniqueIdentifierException.php @@ -16,8 +16,6 @@ /** * Non unique identifier exception. * - * @experimental - * * @author Baptiste Meyer */ final class NonUniqueIdentifierException extends \Exception implements ExceptionInterface diff --git a/src/Elasticsearch/Extension/AbstractFilterExtension.php b/src/Elasticsearch/Extension/AbstractFilterExtension.php index 13e82800882..ac9ec377685 100644 --- a/src/Elasticsearch/Extension/AbstractFilterExtension.php +++ b/src/Elasticsearch/Extension/AbstractFilterExtension.php @@ -19,8 +19,6 @@ /** * Abstract class for easing the implementation of a filter extension. * - * @experimental - * * @author Baptiste Meyer */ abstract class AbstractFilterExtension implements RequestBodySearchCollectionExtensionInterface diff --git a/src/Elasticsearch/Extension/ConstantScoreFilterExtension.php b/src/Elasticsearch/Extension/ConstantScoreFilterExtension.php index d04eeb156ab..1736ec0e3b2 100644 --- a/src/Elasticsearch/Extension/ConstantScoreFilterExtension.php +++ b/src/Elasticsearch/Extension/ConstantScoreFilterExtension.php @@ -20,8 +20,6 @@ * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-constant-score-query.html * - * @experimental - * * @author Baptiste Meyer */ final class ConstantScoreFilterExtension extends AbstractFilterExtension diff --git a/src/Elasticsearch/Extension/RequestBodySearchCollectionExtensionInterface.php b/src/Elasticsearch/Extension/RequestBodySearchCollectionExtensionInterface.php index 5556a16ca98..0752938e9d7 100644 --- a/src/Elasticsearch/Extension/RequestBodySearchCollectionExtensionInterface.php +++ b/src/Elasticsearch/Extension/RequestBodySearchCollectionExtensionInterface.php @@ -20,8 +20,6 @@ * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-body.html * - * @experimental - * * @author Baptiste Meyer */ interface RequestBodySearchCollectionExtensionInterface diff --git a/src/Elasticsearch/Extension/SortExtension.php b/src/Elasticsearch/Extension/SortExtension.php index e327f7908f4..84da66a136e 100644 --- a/src/Elasticsearch/Extension/SortExtension.php +++ b/src/Elasticsearch/Extension/SortExtension.php @@ -25,8 +25,6 @@ * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html * - * @experimental - * * @author Baptiste Meyer */ final class SortExtension implements RequestBodySearchCollectionExtensionInterface diff --git a/src/Elasticsearch/Extension/SortFilterExtension.php b/src/Elasticsearch/Extension/SortFilterExtension.php index 84aec9efe6c..d6ef1c1a46f 100644 --- a/src/Elasticsearch/Extension/SortFilterExtension.php +++ b/src/Elasticsearch/Extension/SortFilterExtension.php @@ -20,8 +20,6 @@ * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html * - * @experimental - * * @author Baptiste Meyer */ final class SortFilterExtension extends AbstractFilterExtension diff --git a/src/Elasticsearch/Filter/AbstractFilter.php b/src/Elasticsearch/Filter/AbstractFilter.php index a305a57e03b..3083a42ebe1 100644 --- a/src/Elasticsearch/Filter/AbstractFilter.php +++ b/src/Elasticsearch/Filter/AbstractFilter.php @@ -31,8 +31,6 @@ /** * Abstract class with helpers for easing the implementation of a filter. * - * @experimental - * * @author Baptiste Meyer */ abstract class AbstractFilter implements FilterInterface diff --git a/src/Elasticsearch/Filter/AbstractSearchFilter.php b/src/Elasticsearch/Filter/AbstractSearchFilter.php index a20fe911f97..075ce9a55fd 100644 --- a/src/Elasticsearch/Filter/AbstractSearchFilter.php +++ b/src/Elasticsearch/Filter/AbstractSearchFilter.php @@ -30,8 +30,6 @@ /** * Abstract class with helpers for easing the implementation of a search filter like a term filter or a match filter. * - * @experimental - * * @internal * * @author Baptiste Meyer diff --git a/src/Elasticsearch/Filter/ConstantScoreFilterInterface.php b/src/Elasticsearch/Filter/ConstantScoreFilterInterface.php index 638be2d10a8..0c390414aa6 100644 --- a/src/Elasticsearch/Filter/ConstantScoreFilterInterface.php +++ b/src/Elasticsearch/Filter/ConstantScoreFilterInterface.php @@ -16,8 +16,6 @@ /** * Elasticsearch filter interface for a constant score query. * - * @experimental - * * @author Baptiste Meyer */ interface ConstantScoreFilterInterface extends FilterInterface diff --git a/src/Elasticsearch/Filter/FilterInterface.php b/src/Elasticsearch/Filter/FilterInterface.php index 13d4df2a0b1..bf2bdd35ee3 100644 --- a/src/Elasticsearch/Filter/FilterInterface.php +++ b/src/Elasticsearch/Filter/FilterInterface.php @@ -19,8 +19,6 @@ /** * Elasticsearch filter interface. * - * @experimental - * * @author Baptiste Meyer */ interface FilterInterface extends BaseFilterInterface diff --git a/src/Elasticsearch/Filter/OrderFilter.php b/src/Elasticsearch/Filter/OrderFilter.php index 481de5a1fd0..d0c1a7fc0ff 100644 --- a/src/Elasticsearch/Filter/OrderFilter.php +++ b/src/Elasticsearch/Filter/OrderFilter.php @@ -105,8 +105,6 @@ * * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-sort.html * - * @experimental - * * @author Baptiste Meyer */ final class OrderFilter extends AbstractFilter implements SortFilterInterface diff --git a/src/Elasticsearch/Filter/SortFilterInterface.php b/src/Elasticsearch/Filter/SortFilterInterface.php index 0434889c3ae..b94f6080683 100644 --- a/src/Elasticsearch/Filter/SortFilterInterface.php +++ b/src/Elasticsearch/Filter/SortFilterInterface.php @@ -16,8 +16,6 @@ /** * Elasticsearch filter interface for sorting. * - * @experimental - * * @author Baptiste Meyer */ interface SortFilterInterface extends FilterInterface diff --git a/src/Elasticsearch/Filter/TermFilter.php b/src/Elasticsearch/Filter/TermFilter.php index fba2c549c64..ff86bb0cf00 100644 --- a/src/Elasticsearch/Filter/TermFilter.php +++ b/src/Elasticsearch/Filter/TermFilter.php @@ -98,8 +98,6 @@ * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-term-query.html * @see https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-terms-query.html * - * @experimental - * * @author Baptiste Meyer */ final class TermFilter extends AbstractSearchFilter diff --git a/src/Elasticsearch/Paginator.php b/src/Elasticsearch/Paginator.php index 2a1c6edc70e..9b6e7ee46e8 100644 --- a/src/Elasticsearch/Paginator.php +++ b/src/Elasticsearch/Paginator.php @@ -21,8 +21,6 @@ /** * Paginator for Elasticsearch. * - * @experimental - * * @author Baptiste Meyer */ final class Paginator implements \IteratorAggregate, PaginatorInterface diff --git a/src/Elasticsearch/Serializer/DocumentNormalizer.php b/src/Elasticsearch/Serializer/DocumentNormalizer.php index 189561f800b..6188b15606f 100644 --- a/src/Elasticsearch/Serializer/DocumentNormalizer.php +++ b/src/Elasticsearch/Serializer/DocumentNormalizer.php @@ -32,8 +32,6 @@ /** * Document denormalizer for Elasticsearch. * - * @experimental - * * @author Baptiste Meyer */ final class DocumentNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface diff --git a/src/Elasticsearch/Serializer/ItemNormalizer.php b/src/Elasticsearch/Serializer/ItemNormalizer.php index e3cece34f23..10a53d5af28 100644 --- a/src/Elasticsearch/Serializer/ItemNormalizer.php +++ b/src/Elasticsearch/Serializer/ItemNormalizer.php @@ -22,8 +22,6 @@ /** * Item normalizer decorator that prevents {@see \ApiPlatform\Serializer\ItemNormalizer} * from taking over for the {@see DocumentNormalizer::FORMAT} format because of priorities. - * - * @experimental */ final class ItemNormalizer implements NormalizerInterface, DenormalizerInterface, SerializerAwareInterface { diff --git a/src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php b/src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php index dbf5b306e61..6ad041fa238 100644 --- a/src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php +++ b/src/Elasticsearch/Serializer/NameConverter/InnerFieldsNameConverter.php @@ -19,8 +19,6 @@ /** * Converts inner fields with a inner name converter. * - * @experimental - * * @author Baptiste Meyer */ final class InnerFieldsNameConverter implements NameConverterInterface diff --git a/src/Elasticsearch/Util/FieldDatatypeTrait.php b/src/Elasticsearch/Util/FieldDatatypeTrait.php index 25a0fe81bc8..6b5cf242ffb 100644 --- a/src/Elasticsearch/Util/FieldDatatypeTrait.php +++ b/src/Elasticsearch/Util/FieldDatatypeTrait.php @@ -27,8 +27,6 @@ * * @internal * - * @experimental - * * @author Baptiste Meyer */ trait FieldDatatypeTrait diff --git a/src/Laravel/State/ParameterValidatorProvider.php b/src/Laravel/State/ParameterValidatorProvider.php index 72276824602..89306e7bf59 100644 --- a/src/Laravel/State/ParameterValidatorProvider.php +++ b/src/Laravel/State/ParameterValidatorProvider.php @@ -25,8 +25,6 @@ * Validates parameters using the Laravel validator. * * @implements ProviderInterface - * - * @experimental */ final class ParameterValidatorProvider implements ProviderInterface { diff --git a/src/State/ParameterProvider/IriConverterParameterProvider.php b/src/State/ParameterProvider/IriConverterParameterProvider.php index 3d28f5be729..e8147041d0a 100644 --- a/src/State/ParameterProvider/IriConverterParameterProvider.php +++ b/src/State/ParameterProvider/IriConverterParameterProvider.php @@ -23,8 +23,6 @@ use Psr\Log\LoggerInterface; /** - * @experimental - * * @author Vincent Amstoutz */ final readonly class IriConverterParameterProvider implements ParameterProviderInterface diff --git a/src/State/ParameterProvider/ReadLinkParameterProvider.php b/src/State/ParameterProvider/ReadLinkParameterProvider.php index 906eb0ac9d1..4a43f6c3c20 100644 --- a/src/State/ParameterProvider/ReadLinkParameterProvider.php +++ b/src/State/ParameterProvider/ReadLinkParameterProvider.php @@ -26,8 +26,6 @@ /** * Checks if the linked resources have security attributes and prepares them for access checking. - * - * @experimental */ final class ReadLinkParameterProvider implements ParameterProviderInterface { diff --git a/src/State/Provider/SecurityParameterProvider.php b/src/State/Provider/SecurityParameterProvider.php index 301c2d5cb36..9b84c76d423 100644 --- a/src/State/Provider/SecurityParameterProvider.php +++ b/src/State/Provider/SecurityParameterProvider.php @@ -30,8 +30,6 @@ * Loops over parameters to check parameter security. * Throws an exception if security is not granted. * - * @experimental - * * @implements ProviderInterface */ final class SecurityParameterProvider implements ProviderInterface From 2d104d0ee8f26810720e186c67859511064a66d4 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 12 Jul 2026 09:36:01 +0200 Subject: [PATCH 61/84] chore: require Symfony ^7.4 across all components (#8397) Symfony 7.4 is the new LTS; bump every symfony/* floor to ^7.4 (|| ^8.0) across all components and drop 6.4 / 7.0-7.3 support. Also bump the extra.symfony.require declaration in each composer.json to ^7.4 || ^8.0 to stay consistent with the new floor (read by flex in CI). Cleanups the floor unblocks: - ParameterValidatorProvider: drop the getConstraint()/getCause() method_exists shims (guaranteed on ConstraintViolationInterface >= 7.2) - OperationRequestInitiatorTrait: drop a stale TODO --- composer.json | 70 +++++++++---------- src/Doctrine/Common/composer.json | 4 +- src/Doctrine/Odm/composer.json | 20 +++--- src/Doctrine/Orm/composer.json | 20 +++--- src/Documentation/composer.json | 2 +- src/Elasticsearch/composer.json | 16 ++--- src/GraphQl/composer.json | 10 +-- src/Hal/composer.json | 4 +- src/HttpCache/composer.json | 10 +-- src/Hydra/composer.json | 6 +- src/JsonApi/composer.json | 10 +-- src/JsonLd/composer.json | 4 +- src/JsonSchema/composer.json | 12 ++-- src/Laravel/composer.json | 4 +- src/Mcp/composer.json | 2 +- src/Metadata/composer.json | 18 ++--- src/OpenApi/composer.json | 14 ++-- src/RamseyUuid/composer.json | 6 +- src/Serializer/composer.json | 16 ++--- .../Util/OperationRequestInitiatorTrait.php | 3 - src/State/composer.json | 10 +-- .../State/ParameterValidatorProvider.php | 5 +- src/Symfony/composer.json | 28 ++++---- src/Validator/composer.json | 12 ++-- 24 files changed, 151 insertions(+), 155 deletions(-) diff --git a/composer.json b/composer.json index a91224ff2ce..c7c4272b392 100644 --- a/composer.json +++ b/composer.json @@ -53,7 +53,7 @@ "dev-main": "4.4.x-dev" }, "symfony": { - "require": "^6.4 || ^7.1 || ^8.0" + "require": "^7.4 || ^8.0" }, "pmu": { "projects": [ @@ -113,15 +113,15 @@ "psr/cache": "^1.0 || ^2.0 || ^3.0", "psr/container": "^1.0 || ^2.0", "symfony/deprecation-contracts": "^3.1", - "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", - "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/serializer": "^6.4.37 || ^7.4.9 || ^8.0.9", + "symfony/http-foundation": "^7.4 || ^8.0", + "symfony/http-kernel": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/serializer": "^7.4.9 || ^8.0.9", "symfony/translation-contracts": "^3.3", "symfony/type-info": "^7.4 || ^8.0", - "symfony/validator": "^6.4.11 || ^7.1 || ^8.0", - "symfony/web-link": "^6.4 || ^7.1 || ^8.0", + "symfony/validator": "^7.4 || ^8.0", + "symfony/web-link": "^7.4 || ^8.0", "willdurand/negotiation": "^3.1" }, "require-dev": { @@ -157,39 +157,39 @@ "ramsey/uuid-doctrine": "^2.0", "soyuka/pmu": "^0.2.0", "soyuka/stubs-mongodb": "^1.0", - "symfony/asset": "^6.4 || ^7.0 || ^8.0", - "symfony/browser-kit": "^6.4 || ^7.0 || ^8.0", - "symfony/cache": "^6.4 || ^7.0 || ^8.0", - "symfony/config": "^6.4 || ^7.0 || ^8.0", - "symfony/console": "^6.4 || ^7.0 || ^8.0", - "symfony/css-selector": "^6.4 || ^7.0 || ^8.0", - "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", - "symfony/doctrine-bridge": "^6.4.2 || ^7.1 || ^8.0", - "symfony/dom-crawler": "^6.4 || ^7.0 || ^8.0", - "symfony/error-handler": "^6.4 || ^7.0 || ^8.0", - "symfony/event-dispatcher": "^6.4 || ^7.0 || ^8.0", - "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", - "symfony/finder": "^6.4 || ^7.0 || ^8.0", - "symfony/form": "^6.4 || ^7.0 || ^8.0", - "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", - "symfony/http-client": "^6.4 || ^7.0 || ^8.0", - "symfony/intl": "^6.4 || ^7.0 || ^8.0", + "symfony/asset": "^7.4 || ^8.0", + "symfony/browser-kit": "^7.4 || ^8.0", + "symfony/cache": "^7.4 || ^8.0", + "symfony/config": "^7.4 || ^8.0", + "symfony/console": "^7.4 || ^8.0", + "symfony/css-selector": "^7.4 || ^8.0", + "symfony/dependency-injection": "^7.4 || ^8.0", + "symfony/doctrine-bridge": "^7.4 || ^8.0", + "symfony/dom-crawler": "^7.4 || ^8.0", + "symfony/error-handler": "^7.4 || ^8.0", + "symfony/event-dispatcher": "^7.4 || ^8.0", + "symfony/expression-language": "^7.4 || ^8.0", + "symfony/finder": "^7.4 || ^8.0", + "symfony/form": "^7.4 || ^8.0", + "symfony/framework-bundle": "^7.4 || ^8.0", + "symfony/http-client": "^7.4 || ^8.0", + "symfony/intl": "^7.4 || ^8.0", "symfony/json-streamer": "^7.4 || ^8.0", "symfony/maker-bundle": "^1.24", "symfony/mcp-bundle": "dev-main", "symfony/mercure-bundle": "*", - "symfony/messenger": "^6.4 || ^7.0 || ^8.0", + "symfony/messenger": "^7.4 || ^8.0", "symfony/object-mapper": "^7.4 || ^8.0", - "symfony/routing": "^6.4 || ^7.0 || ^8.0", - "symfony/security-bundle": "^6.4 || ^7.0 || ^8.0", - "symfony/security-core": "^6.4 || ^7.0 || ^8.0", - "symfony/stopwatch": "^6.4 || ^7.0 || ^8.0", - "symfony/string": "^6.4 || ^7.0 || ^8.0", - "symfony/twig-bundle": "^6.4 || ^7.0 || ^8.0", - "symfony/uid": "^6.4 || ^7.0 || ^8.0", + "symfony/routing": "^7.4 || ^8.0", + "symfony/security-bundle": "^7.4 || ^8.0", + "symfony/security-core": "^7.4 || ^8.0", + "symfony/stopwatch": "^7.4 || ^8.0", + "symfony/string": "^7.4 || ^8.0", + "symfony/twig-bundle": "^7.4 || ^8.0", + "symfony/uid": "^7.4 || ^8.0", "symfony/var-exporter": "^7.4 || ^8.0", - "symfony/web-profiler-bundle": "^6.4 || ^7.0 || ^8.0", - "symfony/yaml": "^6.4 || ^7.0 || ^8.0", + "symfony/web-profiler-bundle": "^7.4 || ^8.0", + "symfony/yaml": "^7.4 || ^8.0", "twig/twig": "^1.42.3 || ^2.12 || ^3.0", "webonyx/graphql-php": "^15.0" }, diff --git a/src/Doctrine/Common/composer.json b/src/Doctrine/Common/composer.json index b4f06cf68c7..cd6ab991a6c 100644 --- a/src/Doctrine/Common/composer.json +++ b/src/Doctrine/Common/composer.json @@ -35,7 +35,7 @@ "doctrine/orm": "^2.17 || ^3.0", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/type-info": "^7.4 || ^8.0" }, "conflict": { "doctrine/persistence": "<1.3" @@ -67,7 +67,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Doctrine/Odm/composer.json b/src/Doctrine/Odm/composer.json index e90a260c2ec..ff89d0b9fb0 100644 --- a/src/Doctrine/Odm/composer.json +++ b/src/Doctrine/Odm/composer.json @@ -30,21 +30,21 @@ "api-platform/serializer": "^4.4@alpha", "api-platform/state": "^4.4@alpha", "doctrine/mongodb-odm": "^2.10", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/property-info": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "require-dev": { "doctrine/doctrine-bundle": "^2.11 || ^3.1", "doctrine/mongodb-odm-bundle": "^5.0", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/cache": "^6.4 || ^7.0 || ^8.0", - "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", - "symfony/uid": "^6.4 || ^7.0 || ^8.0", - "symfony/validator": "^6.4.11 || ^7.0 || ^8.0", - "symfony/yaml": "^6.4 || ^7.0 || ^8.0" + "symfony/cache": "^7.4 || ^8.0", + "symfony/framework-bundle": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/uid": "^7.4 || ^8.0", + "symfony/validator": "^7.4 || ^8.0", + "symfony/yaml": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -68,7 +68,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Doctrine/Orm/composer.json b/src/Doctrine/Orm/composer.json index 53ac6225bd2..c03c5e0f721 100644 --- a/src/Doctrine/Orm/composer.json +++ b/src/Doctrine/Orm/composer.json @@ -37,15 +37,15 @@ "phpunit/phpunit": "^11.5 || ^12.2", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", - "symfony/cache": "^6.4 || ^7.0 || ^8.0", - "symfony/framework-bundle": "^6.4 || ^7.0 || ^8.0", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/uid": "^6.4 || ^7.0 || ^8.0", - "symfony/validator": "^6.4.11 || ^7.0 || ^8.0", - "symfony/yaml": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/cache": "^7.4 || ^8.0", + "symfony/framework-bundle": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/uid": "^7.4 || ^8.0", + "symfony/validator": "^7.4 || ^8.0", + "symfony/yaml": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -69,7 +69,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Documentation/composer.json b/src/Documentation/composer.json index cb0346846a2..70cf509e52a 100644 --- a/src/Documentation/composer.json +++ b/src/Documentation/composer.json @@ -31,7 +31,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Elasticsearch/composer.json b/src/Elasticsearch/composer.json index a587c37b5e0..d9a0e73e8f8 100644 --- a/src/Elasticsearch/composer.json +++ b/src/Elasticsearch/composer.json @@ -28,13 +28,13 @@ "api-platform/serializer": "^4.4@alpha", "api-platform/state": "^4.4@alpha", "elasticsearch/elasticsearch": "^7.17 || ^8.4 || ^9.0", - "symfony/cache": "^6.4 || ^7.0 || ^8.0", - "symfony/console": "^6.4 || ^7.0 || ^8.0", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0", - "symfony/uid": "^6.4 || ^7.0 || ^8.0" + "symfony/cache": "^7.4 || ^8.0", + "symfony/console": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0", + "symfony/uid": "^7.4 || ^8.0" }, "suggest": { "opensearch-project/opensearch-php": "Required to use OpenSearch instead of Elasticsearch (^2.5)" @@ -70,7 +70,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/GraphQl/composer.json b/src/GraphQl/composer.json index f355ce252f6..2bdba60c19d 100644 --- a/src/GraphQl/composer.json +++ b/src/GraphQl/composer.json @@ -24,9 +24,9 @@ "api-platform/metadata": "^4.4@alpha", "api-platform/state": "^4.4@alpha", "api-platform/serializer": "^4.4@alpha", - "symfony/property-info": "^7.1 || ^8.0", - "symfony/serializer": "^6.4 || ^7.1 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0", "webonyx/graphql-php": "^15.0", "willdurand/negotiation": "^3.1" }, @@ -35,7 +35,7 @@ "api-platform/validator": "^4.4@alpha", "twig/twig": "^1.42.3 || ^2.12 || ^3.0", "symfony/mercure-bundle": "*", - "symfony/routing": "^6.4 || ^7.0 || ^8.0", + "symfony/routing": "^7.4 || ^8.0", "phpunit/phpunit": "^11.5 || ^12.2" }, "autoload": { @@ -70,7 +70,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Hal/composer.json b/src/Hal/composer.json index 1d06e9fb96b..8f8e8cc6345 100644 --- a/src/Hal/composer.json +++ b/src/Hal/composer.json @@ -26,7 +26,7 @@ "api-platform/metadata": "^4.4@alpha", "api-platform/documentation": "^4.4@alpha", "api-platform/serializer": "^4.4@alpha", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/type-info": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -54,7 +54,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/HttpCache/composer.json b/src/HttpCache/composer.json index 210f560e526..34a97d153b2 100644 --- a/src/HttpCache/composer.json +++ b/src/HttpCache/composer.json @@ -25,14 +25,14 @@ "php": ">=8.2", "api-platform/metadata": "^4.4@alpha", "api-platform/state": "^4.4@alpha", - "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0" + "symfony/http-foundation": "^7.4 || ^8.0" }, "require-dev": { "guzzlehttp/guzzle": "^6.0 || ^7.0 || ^8.0", - "symfony/dependency-injection": "^6.4 || ^7.0 || ^8.0", + "symfony/dependency-injection": "^7.4 || ^8.0", "phpspec/prophecy-phpunit": "^2.2", - "symfony/http-client": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0", + "symfony/http-client": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0", "phpunit/phpunit": "^11.5 || ^12.2" }, "autoload": { @@ -61,7 +61,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Hydra/composer.json b/src/Hydra/composer.json index 82ec7d60508..1d0bbb95df5 100644 --- a/src/Hydra/composer.json +++ b/src/Hydra/composer.json @@ -31,8 +31,8 @@ "api-platform/jsonld": "^4.4@alpha", "api-platform/json-schema": "^4.4@alpha", "api-platform/serializer": "^4.4@alpha", - "symfony/web-link": "^6.4 || ^7.1 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/web-link": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "require-dev": { "api-platform/doctrine-odm": "^4.4@alpha", @@ -68,7 +68,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/JsonApi/composer.json b/src/JsonApi/composer.json index 4b619712e72..74f0aac257b 100644 --- a/src/JsonApi/composer.json +++ b/src/JsonApi/composer.json @@ -27,15 +27,15 @@ "api-platform/metadata": "^4.4@alpha", "api-platform/serializer": "^4.4@alpha", "api-platform/state": "^4.4@alpha", - "symfony/error-handler": "^6.4 || ^7.0 || ^8.0", - "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/error-handler": "^7.4 || ^8.0", + "symfony/http-foundation": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "require-dev": { "phpspec/prophecy": "^1.19", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/type-info": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -63,7 +63,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/JsonLd/composer.json b/src/JsonLd/composer.json index ba8457bcf31..518537662b3 100644 --- a/src/JsonLd/composer.json +++ b/src/JsonLd/composer.json @@ -57,7 +57,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", @@ -68,7 +68,7 @@ "test": "./vendor/bin/phpunit" }, "require-dev": { - "symfony/type-info": "^7.3 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0", "phpunit/phpunit": "^11.5 || ^12.2" }, "minimum-stability": "beta", diff --git a/src/JsonSchema/composer.json b/src/JsonSchema/composer.json index 14cd0d6a859..a9aedbe9899 100644 --- a/src/JsonSchema/composer.json +++ b/src/JsonSchema/composer.json @@ -26,11 +26,11 @@ "require": { "php": ">=8.2", "api-platform/metadata": "^4.4@alpha", - "symfony/console": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0", - "symfony/uid": "^6.4 || ^7.0 || ^8.0" + "symfony/console": "^7.4 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0", + "symfony/uid": "^7.4 || ^8.0" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", @@ -62,7 +62,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Laravel/composer.json b/src/Laravel/composer.json index fc00e3684c4..2bb64434d95 100644 --- a/src/Laravel/composer.json +++ b/src/Laravel/composer.json @@ -49,7 +49,7 @@ "laravel/framework": "^11.0 || ^12.0 || ^13.0", "symfony/deprecation-contracts": "^3.6", "symfony/type-info": "^7.4 || ^8.0", - "symfony/web-link": "^6.4 || ^7.4 || ^8.0", + "symfony/web-link": "^7.4 || ^8.0", "willdurand/negotiation": "^3.1" }, "require-dev": { @@ -104,7 +104,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.4 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Mcp/composer.json b/src/Mcp/composer.json index 9b8f8593105..971bc40f0e6 100644 --- a/src/Mcp/composer.json +++ b/src/Mcp/composer.json @@ -53,7 +53,7 @@ "dev-main": "4.4.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Metadata/composer.json b/src/Metadata/composer.json index 792a203f048..c7026b5600f 100644 --- a/src/Metadata/composer.json +++ b/src/Metadata/composer.json @@ -31,9 +31,9 @@ "doctrine/inflector": "^2.0", "psr/cache": "^1.0 || ^2.0 || ^3.0", "psr/log": "^1.0 || ^2.0 || ^3.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/string": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/property-info": "^7.4 || ^8.0", + "symfony/string": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "require-dev": { "api-platform/json-schema": "^4.4@alpha", @@ -42,11 +42,11 @@ "phpspec/prophecy-phpunit": "^2.2", "phpstan/phpdoc-parser": "^1.29 || ^2.0", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/config": "^6.4 || ^7.0 || ^8.0", - "symfony/routing": "^6.4 || ^7.0 || ^8.0", - "symfony/var-dumper": "^6.4 || ^7.0 || ^8.0", - "symfony/web-link": "^6.4 || ^7.1 || ^8.0", - "symfony/yaml": "^6.4 || ^7.0 || ^8.0" + "symfony/config": "^7.4 || ^8.0", + "symfony/routing": "^7.4 || ^8.0", + "symfony/var-dumper": "^7.4 || ^8.0", + "symfony/web-link": "^7.4 || ^8.0", + "symfony/yaml": "^7.4 || ^8.0" }, "suggest": { "phpstan/phpdoc-parser": "For PHP documentation support.", @@ -79,7 +79,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/OpenApi/composer.json b/src/OpenApi/composer.json index d0bd2f8a0b9..be00fa29034 100644 --- a/src/OpenApi/composer.json +++ b/src/OpenApi/composer.json @@ -31,11 +31,11 @@ "api-platform/json-schema": "^4.4@alpha", "api-platform/metadata": "^4.4@alpha", "api-platform/state": "^4.4@alpha", - "symfony/console": "^6.4 || ^7.0 || ^8.0", - "symfony/filesystem": "^6.4 || ^7.0 || ^8.0", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/console": "^7.4 || ^8.0", + "symfony/filesystem": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", @@ -44,7 +44,7 @@ "api-platform/doctrine-orm": "^4.4@alpha", "api-platform/doctrine-odm": "^4.4@alpha", "api-platform/serializer": "^4.4@alpha", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/type-info": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -72,7 +72,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/RamseyUuid/composer.json b/src/RamseyUuid/composer.json index beacdeea745..9a6d4368189 100644 --- a/src/RamseyUuid/composer.json +++ b/src/RamseyUuid/composer.json @@ -24,14 +24,14 @@ "require": { "php": ">=8.2", "api-platform/metadata": "^4.4@alpha", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0" + "symfony/serializer": "^7.4 || ^8.0" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", "ramsey/uuid": "^4.7", "ramsey/uuid-doctrine": "^2.0", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/type-info": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -56,7 +56,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Serializer/composer.json b/src/Serializer/composer.json index edb2ddc652b..bd45d61a482 100644 --- a/src/Serializer/composer.json +++ b/src/Serializer/composer.json @@ -25,10 +25,10 @@ "php": ">=8.2", "api-platform/metadata": "^4.4@alpha", "api-platform/state": "^4.4@alpha", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.1 || ^8.0", - "symfony/serializer": "^6.4.37 || ^7.4.9 || ^8.0.9", - "symfony/validator": "^6.4.11 || ^7.0 || ^8.0" + "symfony/property-access": "^7.4 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/serializer": "^7.4.9 || ^8.0.9", + "symfony/validator": "^7.4 || ^8.0" }, "require-dev": { "api-platform/doctrine-common": "^4.4@alpha", @@ -41,9 +41,9 @@ "phpunit/phpunit": "^11.5 || ^12.2", "sebastian/exporter": "^6.3.2 || ^7.0.2", "symfony/mercure-bundle": "*", - "symfony/var-dumper": "^6.4 || ^7.0 || ^8.0", - "symfony/yaml": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0" + "symfony/var-dumper": "^7.4 || ^8.0", + "symfony/yaml": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0" }, "suggest": { "api-platform/doctrine-orm": "To support Doctrine ORM state options.", @@ -75,7 +75,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/State/Util/OperationRequestInitiatorTrait.php b/src/State/Util/OperationRequestInitiatorTrait.php index 4261ece85d3..11a8bd479fd 100644 --- a/src/State/Util/OperationRequestInitiatorTrait.php +++ b/src/State/Util/OperationRequestInitiatorTrait.php @@ -24,9 +24,6 @@ trait OperationRequestInitiatorTrait { private ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null; - /** - * TODO: Kernel terminate remove the _api_operation attribute? - */ private function initializeOperation(Request $request): ?HttpOperation { if ($request->attributes->get('_api_operation')) { diff --git a/src/State/composer.json b/src/State/composer.json index 189311bd2b5..ac27bd2b2a7 100644 --- a/src/State/composer.json +++ b/src/State/composer.json @@ -30,8 +30,8 @@ "php": ">=8.2", "api-platform/metadata": "^4.4@alpha", "psr/container": "^1.0 || ^2.0", - "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", + "symfony/http-kernel": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", "symfony/translation-contracts": "^3.0", "symfony/deprecation-contracts": "^3.1" }, @@ -39,10 +39,10 @@ "api-platform/serializer": "^4.4@alpha", "api-platform/validator": "^4.4@alpha", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/http-foundation": "^6.4.14 || ^7.0 || ^8.0", + "symfony/http-foundation": "^7.4 || ^8.0", "symfony/object-mapper": "^7.4 || ^8.0", "symfony/type-info": "^7.4 || ^8.0", - "symfony/web-link": "^6.4 || ^7.1 || ^8.0", + "symfony/web-link": "^7.4 || ^8.0", "willdurand/negotiation": "^3.1" }, "conflicts": { @@ -74,7 +74,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Symfony/Validator/State/ParameterValidatorProvider.php b/src/Symfony/Validator/State/ParameterValidatorProvider.php index b814f378dd8..7ce17b5f605 100644 --- a/src/Symfony/Validator/State/ParameterValidatorProvider.php +++ b/src/Symfony/Validator/State/ParameterValidatorProvider.php @@ -88,9 +88,8 @@ public function provide(Operation $operation, array $uriVariables = [], array $c $violation->getInvalidValue(), $violation->getPlural(), $violation->getCode(), - // TODO: remove these with symfony ^7 - method_exists($violation, 'getConstraint') ? $violation->getConstraint() : null, // @phpstan-ignore-line symfony/validator 6.4 is still allowed and this may be true - method_exists($violation, 'getCause') ? $violation->getCause() : null // @phpstan-ignore-line symfony/validator 6.4 is still allowed and this may be true + $violation->getConstraint(), + $violation->getCause() )); } } diff --git a/src/Symfony/composer.json b/src/Symfony/composer.json index 11eff52ca35..55e1122228f 100644 --- a/src/Symfony/composer.json +++ b/src/Symfony/composer.json @@ -39,13 +39,13 @@ "api-platform/state": "^4.4@alpha", "api-platform/validator": "^4.4@alpha", "api-platform/openapi": "^4.4@alpha", - "symfony/asset": "^6.4 || ^7.0 || ^8.0", - "symfony/finder": "^6.4 || ^7.0 || ^8.0", - "symfony/http-kernel": "^6.4.13 || ^7.0 || ^8.0", - "symfony/property-info": "^6.4 || ^7.0 || ^8.0", - "symfony/property-access": "^6.4 || ^7.0 || ^8.0", - "symfony/serializer": "^6.4 || ^7.0 || ^8.0", - "symfony/security-core": "^6.4 || ^7.0 || ^8.0", + "symfony/asset": "^7.4 || ^8.0", + "symfony/finder": "^7.4 || ^8.0", + "symfony/http-kernel": "^7.4 || ^8.0", + "symfony/property-info": "^7.4 || ^8.0", + "symfony/property-access": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/security-core": "^7.4 || ^8.0", "willdurand/negotiation": "^3.1" }, "require-dev": { @@ -58,13 +58,13 @@ "api-platform/json-api": "^4.4@alpha", "phpspec/prophecy-phpunit": "^2.2", "phpunit/phpunit": "^11.5 || ^12.2", - "symfony/expression-language": "^6.4 || ^7.0 || ^8.0", - "symfony/intl": "^6.4 || ^7.0 || ^8.0", + "symfony/expression-language": "^7.4 || ^8.0", + "symfony/intl": "^7.4 || ^8.0", "symfony/mercure-bundle": "*", - "symfony/object-mapper": "^7.0 || ^8.0", - "symfony/routing": "^6.4 || ^7.0 || ^8.0", - "symfony/type-info": "^7.3 || ^8.0", - "symfony/validator": "^6.4.11 || ^7.0 || ^8.0", + "symfony/object-mapper": "^7.4 || ^8.0", + "symfony/routing": "^7.4 || ^8.0", + "symfony/type-info": "^7.4 || ^8.0", + "symfony/validator": "^7.4 || ^8.0", "webonyx/graphql-php": "^15.0" }, "suggest": { @@ -112,7 +112,7 @@ "dev-main": "4.4.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", diff --git a/src/Validator/composer.json b/src/Validator/composer.json index bbe41332fbf..b468e78cc4d 100644 --- a/src/Validator/composer.json +++ b/src/Validator/composer.json @@ -25,11 +25,11 @@ "php": ">=8.2", "api-platform/metadata": "^4.4@alpha", "api-platform/state": "^4.4@alpha", - "symfony/type-info": "^7.3 || ^8.0", - "symfony/http-kernel": "^6.4.13 || ^7.1 || ^8.0", - "symfony/serializer": "^6.4 || ^7.1 || ^8.0", - "symfony/validator": "^6.4.11 || ^7.1 || ^8.0", - "symfony/web-link": "^6.4 || ^7.1 || ^8.0" + "symfony/type-info": "^7.4 || ^8.0", + "symfony/http-kernel": "^7.4 || ^8.0", + "symfony/serializer": "^7.4 || ^8.0", + "symfony/validator": "^7.4 || ^8.0", + "symfony/web-link": "^7.4 || ^8.0" }, "require-dev": { "phpspec/prophecy-phpunit": "^2.2", @@ -58,7 +58,7 @@ "dev-4.1": "4.1.x-dev" }, "symfony": { - "require": "^6.4 || ^7.0 || ^8.0" + "require": "^7.4 || ^8.0" }, "thanks": { "name": "api-platform/api-platform", From 88f458a1108ba2fcd052a58d7647f23dad1b186b Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 12 Jul 2026 11:03:18 +0200 Subject: [PATCH 62/84] fix(jsonschema): drop removed getBuiltinTypes path in SchemaPropertyMetadataFactory The 4.4 readable-link refinement used a getType-gated dual path; main (5.0) removed ApiProperty::getBuiltinTypes and property-info's getType, so on the bumped Symfony floor the legacy branch fatally called an undefined method during cache warmup (breaking every PHPUnit job). Keep only the native-type branch, matching main's modern type handling. --- .../Factory/SchemaPropertyMetadataFactory.php | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php index 8abeca0724a..50c2b66de7d 100644 --- a/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php +++ b/src/JsonSchema/Metadata/Property/Factory/SchemaPropertyMetadataFactory.php @@ -73,23 +73,8 @@ public function create(string $resourceClass, string $property, array $options = // on output a non-resource object is serialized by the standard object normalizer, which embeds non-resource properties regardless of readableLink (see AbstractItemNormalizer::supportsNormalization()) // For resource-typed properties however, the circular reference handler (see AbstractItemNormalizer::$defaultContext) may produce an IRI, so isReadableLink should determine the schema if (!$isInput && !$this->isResourceClass($resourceClass)) { - if (method_exists(PropertyInfoExtractor::class, 'getType')) { - if (!$propertyMetadata->getNativeType()?->isSatisfiedBy(fn (Type $t) => $t instanceof ObjectType && $this->resourceClassResolver->isResourceClass($t->getClassName()))) { - $link = true; - } - } else { - $propertyTypeIsResource = false; - foreach ($propertyMetadata->getBuiltinTypes() ?? [] as $builtinType) { - $className = $builtinType->isCollection() ? ($builtinType->getCollectionValueTypes()[0] ?? null)?->getClassName() : $builtinType->getClassName(); - if ($className && $this->resourceClassResolver->isResourceClass($className)) { - $propertyTypeIsResource = true; - break; - } - } - - if (!$propertyTypeIsResource) { - $link = true; - } + if (!$propertyMetadata->getNativeType()?->isSatisfiedBy(fn (Type $t) => $t instanceof ObjectType && $this->resourceClassResolver->isResourceClass($t->getClassName()))) { + $link = true; } } From 8360186eda109c3e3ed2dd22d054b6ce6cb7404a Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 12 Jul 2026 11:20:36 +0200 Subject: [PATCH 63/84] fix(serializer): remove dead native-type guards in AbstractItemNormalizerTest The #8393 nullable denormalization tests guarded on method_exists(PropertyInfoExtractor::class, 'getType') with an unqualified class (resolving to the test namespace), so they were always skipped and PHPStan flagged the call as always-false. With the Symfony ^7.4 floor native types are guaranteed; drop the guards so the tests actually run. --- .../Tests/AbstractItemNormalizerTest.php | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index 6fd17c1ce73..673a415ae8d 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -1193,12 +1193,6 @@ public function testUnionTypeCollectionDenormalizationAcceptsAnyMember(): void public function testDenormalizeNullableCollectionOfBackedEnums(): void { - // Nullable collection value types (NullableType wrapping ObjectType/BackedEnumType) only exist - // in the native TypeInfo system. - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); - } - $data = ['notificationType' => ['email']]; $propertyNameCollectionFactory = $this->createStub(PropertyNameCollectionFactoryInterface::class); @@ -1244,11 +1238,6 @@ public function testDenormalizeNullableCollectionOfBackedEnums(): void public function testDenormalizeWrongTypedValueForNullableObjectPropertyPreservesNormalizerException(): void { - // Nullable object types (NullableType wrapping ObjectType) only exist in the native TypeInfo system. - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); - } - // What Symfony's DateTimeNormalizer throws for a value it cannot parse. $normalizerException = NotNormalizableValueException::createForUnexpectedDataType('The data is either not an string, an empty string, or null; you should pass a string that can be parsed with the passed format or a valid DateTime string.', false, ['string'], 'dummyDate', true); @@ -1267,9 +1256,6 @@ public function testDenormalizeWrongTypedValueForNullableObjectPropertyPreserves public function testDenormalizeWrongTypedValueForNonNullableObjectPropertyPreservesNormalizerException(): void { - if (!method_exists(PropertyInfoExtractor::class, 'getType')) { - $this->markTestSkipped('Requires symfony/property-info >= 7.1 (native types).'); - } $normalizerException = NotNormalizableValueException::createForUnexpectedDataType('The data is either not an string, an empty string, or null; you should pass a string that can be parsed with the passed format or a valid DateTime string.', false, ['string'], 'dummyDate', true); From bc387cd093343adcc20a9001095a0c710ea13432 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 12 Jul 2026 11:31:21 +0200 Subject: [PATCH 64/84] style: cs-fixer --- src/Serializer/Tests/AbstractItemNormalizerTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Serializer/Tests/AbstractItemNormalizerTest.php b/src/Serializer/Tests/AbstractItemNormalizerTest.php index 673a415ae8d..ff54a251511 100644 --- a/src/Serializer/Tests/AbstractItemNormalizerTest.php +++ b/src/Serializer/Tests/AbstractItemNormalizerTest.php @@ -1256,7 +1256,6 @@ public function testDenormalizeWrongTypedValueForNullableObjectPropertyPreserves public function testDenormalizeWrongTypedValueForNonNullableObjectPropertyPreservesNormalizerException(): void { - $normalizerException = NotNormalizableValueException::createForUnexpectedDataType('The data is either not an string, an empty string, or null; you should pass a string that can be parsed with the passed format or a valid DateTime string.', false, ['string'], 'dummyDate', true); $normalizer = $this->createNormalizerForObjectProperty('dummyDate', Type::object(\DateTimeImmutable::class), \DateTimeImmutable::class, $normalizerException); From af57ec692300098445925bf899ae54f2556bb408 Mon Sep 17 00:00:00 2001 From: "Julien \"Nayte\" Robic" <36332481+Nayte91@users.noreply.github.com> Date: Sun, 12 Jul 2026 12:53:53 +0200 Subject: [PATCH 65/84] perf(state): skip response body on HEAD requests (#8348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * perf(state): skip response body on HEAD requests * feat(symfony): add enable_head_request_optimization config flag HEAD responses now omit some observable headers (Content-Length, per-item cache tags); per semver that is a backward- incompatible behavior change, so this commit ships an opt-out flag — set `enable_head_request_optimization: false` to restore the prior GET-equivalent behavior. * fix(state): do not list HEAD in Allow header without GET operation --- src/Hydra/State/JsonStreamerProcessor.php | 11 +++ src/Laravel/ApiPlatformProvider.php | 2 +- src/Laravel/config/api-platform.php | 4 + .../State/JsonStreamerProcessor.php | 11 +++ src/State/Processor/SerializeProcessor.php | 7 ++ .../Processor/SerializeProcessorTest.php | 63 ++++++++++++ src/State/Util/HttpResponseHeadersTrait.php | 5 +- .../ApiPlatformExtension.php | 1 + .../DependencyInjection/Configuration.php | 1 + .../Resources/config/json_streamer/events.php | 2 + .../Resources/config/json_streamer/hydra.php | 1 + .../Resources/config/json_streamer/json.php | 1 + .../Resources/config/state/processor.php | 1 + .../Resources/config/symfony/events.php | 2 + .../ApiPlatformExtensionTest.php | 3 + .../ApiResource/HeadSpyResource.php | 43 +++++++++ .../TestBundle/State/SpyPaginator.php | 59 ++++++++++++ tests/Functional/HeadAllowWithoutGetTest.php | 56 +++++++++++ tests/Functional/HeadRequestTest.php | 95 +++++++++++++++++++ .../HeadRequestWithoutOptimizationTest.php | 79 +++++++++++++++ tests/State/RespondProcessorTest.php | 29 ++++++ .../DependencyInjection/ConfigurationTest.php | 1 + 22 files changed, 474 insertions(+), 3 deletions(-) create mode 100644 src/State/Tests/Processor/SerializeProcessorTest.php create mode 100644 tests/Fixtures/TestBundle/ApiResource/HeadSpyResource.php create mode 100644 tests/Fixtures/TestBundle/State/SpyPaginator.php create mode 100644 tests/Functional/HeadAllowWithoutGetTest.php create mode 100644 tests/Functional/HeadRequestTest.php create mode 100644 tests/Functional/HeadRequestWithoutOptimizationTest.php diff --git a/src/Hydra/State/JsonStreamerProcessor.php b/src/Hydra/State/JsonStreamerProcessor.php index e2b787bd156..6b461c187fc 100644 --- a/src/Hydra/State/JsonStreamerProcessor.php +++ b/src/Hydra/State/JsonStreamerProcessor.php @@ -59,6 +59,7 @@ public function __construct( private readonly string $enabledParameterName = 'pagination', private readonly int $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, + private readonly bool $enableHeadRequestOptimization = true, ) { $this->resourceClassResolver = $resourceClassResolver; $this->iriConverter = $iriConverter; @@ -79,6 +80,16 @@ public function process(mixed $data, Operation $operation, array $uriVariables = return $this->processor?->process($data, $operation, $uriVariables, $context); } + if ($this->enableHeadRequestOptimization && $request->isMethod('HEAD')) { + $response = new Response( + null, + $this->getStatus($request, $operation, $context), + $this->getHeaders($request, $operation, $context) + ); + + return $this->processor ? $this->processor->process($response, $operation, $uriVariables, $context) : $response; + } + if ($operation instanceof CollectionOperationInterface) { $requestUri = $request->getRequestUri() ?? ''; $collection = new Collection(); diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 9a87f5a6fd6..da535596d59 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -553,7 +553,7 @@ public function register(): void }); $this->app->singleton(SerializeProcessor::class, static function (Application $app) { - return new SerializeProcessor($app->make(RespondProcessor::class), $app->make(Serializer::class), $app->make(SerializerContextBuilderInterface::class)); + return new SerializeProcessor($app->make(RespondProcessor::class), $app->make(Serializer::class), $app->make(SerializerContextBuilderInterface::class), $app['config']->get('api-platform.enable_head_request_optimization', true)); }); $this->app->singleton(WriteProcessor::class, static function (Application $app) { diff --git a/src/Laravel/config/api-platform.php b/src/Laravel/config/api-platform.php index 8dea04c4eb3..c89c853be27 100644 --- a/src/Laravel/config/api-platform.php +++ b/src/Laravel/config/api-platform.php @@ -49,6 +49,10 @@ // on PATCH operations, allowing partial updates without requiring all fields. 'partial_patch_validation' => false, + // When true (default), HEAD requests skip response body construction so + // collections are not iterated. Set to false to process HEAD like GET. + 'enable_head_request_optimization' => true, + 'docs_formats' => [ 'jsonld' => ['application/ld+json'], // 'jsonapi' => ['application/vnd.api+json'], diff --git a/src/Serializer/State/JsonStreamerProcessor.php b/src/Serializer/State/JsonStreamerProcessor.php index c91b43bcba2..be1a3d3d201 100644 --- a/src/Serializer/State/JsonStreamerProcessor.php +++ b/src/Serializer/State/JsonStreamerProcessor.php @@ -48,6 +48,7 @@ public function __construct( ?ResourceClassResolverInterface $resourceClassResolver = null, ?OperationMetadataFactoryInterface $operationMetadataFactory = null, ?ResourceMetadataCollectionFactoryInterface $resourceMetadataCollectionFactory = null, + private readonly bool $enableHeadRequestOptimization = true, ) { $this->resourceClassResolver = $resourceClassResolver; $this->iriConverter = $iriConverter; @@ -68,6 +69,16 @@ public function process(mixed $data, Operation $operation, array $uriVariables = return $this->processor?->process($data, $operation, $uriVariables, $context); } + if ($this->enableHeadRequestOptimization && $request->isMethod('HEAD')) { + $response = new Response( + null, + $this->getStatus($request, $operation, $context), + $this->getHeaders($request, $operation, $context) + ); + + return $this->processor ? $this->processor->process($response, $operation, $uriVariables, $context) : $response; + } + if ($operation instanceof CollectionOperationInterface) { $data = $this->jsonStreamer->write( $data, diff --git a/src/State/Processor/SerializeProcessor.php b/src/State/Processor/SerializeProcessor.php index 8047a384899..4e206fb3cc1 100644 --- a/src/State/Processor/SerializeProcessor.php +++ b/src/State/Processor/SerializeProcessor.php @@ -46,6 +46,7 @@ public function __construct( private readonly ?ProcessorInterface $processor, private readonly SerializerInterface $serializer, private readonly SerializerContextBuilderInterface $serializerContextBuilder, + private readonly bool $enableHeadRequestOptimization = true, ) { } @@ -60,6 +61,12 @@ public function process(mixed $data, Operation $operation, array $uriVariables = // @see ApiPlatform\State\Processor\RespondProcessor $context['original_data'] = $data; + if ($this->enableHeadRequestOptimization && $request->isMethod('HEAD')) { + $this->stopwatch?->stop('api_platform.processor.serialize'); + + return $this->processor?->process(null, $operation, $uriVariables, $context); + } + $class = $operation->getClass(); $serializerContext = $this->serializerContextBuilder->createFromRequest($request, true, [ 'resource_class' => $class, diff --git a/src/State/Tests/Processor/SerializeProcessorTest.php b/src/State/Tests/Processor/SerializeProcessorTest.php new file mode 100644 index 00000000000..7fbf6111507 --- /dev/null +++ b/src/State/Tests/Processor/SerializeProcessorTest.php @@ -0,0 +1,63 @@ + + * + * 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\Tests\Processor; + +use ApiPlatform\Metadata\Get; +use ApiPlatform\State\Processor\SerializeProcessor; +use ApiPlatform\State\ProcessorInterface; +use ApiPlatform\State\SerializerContextBuilderInterface; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Serializer\SerializerInterface; + +class SerializeProcessorTest extends TestCase +{ + public function testHeadRequestSkipsSerializationAndForwardsNull(): void + { + $request = Request::create('/foos', 'HEAD'); + + $serializer = $this->createMock(SerializerInterface::class); + $serializer->expects($this->never())->method('serialize'); + + $inner = $this->createMock(ProcessorInterface::class); + $inner->expects($this->once()) + ->method('process') + ->with($this->isNull()) + ->willReturn(null); + + $processor = new SerializeProcessor($inner, $serializer, $this->createStub(SerializerContextBuilderInterface::class)); + $operation = (new Get())->withSerialize(true); + + $this->assertNull($processor->process(new \stdClass(), $operation, [], ['request' => $request])); + } + + public function testHeadRequestSerializesWhenOptimizationDisabled(): void + { + $request = Request::create('/foos', 'HEAD'); + + $serializer = $this->createMock(SerializerInterface::class); + $serializer->expects($this->once())->method('serialize')->willReturn(''); + + $inner = $this->createMock(ProcessorInterface::class); + $inner->method('process')->willReturn('forwarded'); + + $contextBuilder = $this->createStub(SerializerContextBuilderInterface::class); + $contextBuilder->method('createFromRequest')->willReturn([]); + + $processor = new SerializeProcessor($inner, $serializer, $contextBuilder, false); + $operation = (new Get())->withSerialize(true); + + $this->assertSame('forwarded', $processor->process(new \stdClass(), $operation, [], ['request' => $request])); + } +} diff --git a/src/State/Util/HttpResponseHeadersTrait.php b/src/State/Util/HttpResponseHeadersTrait.php index 6b0190c50b6..a608706fa0d 100644 --- a/src/State/Util/HttpResponseHeadersTrait.php +++ b/src/State/Util/HttpResponseHeadersTrait.php @@ -155,7 +155,7 @@ private function addLinkedDataPlatformHeaders(array &$headers, HttpOperation $op } $acceptPost = null; - $allowedMethods = ['OPTIONS', 'HEAD']; + $allowedMethods = []; $resourceCollection = $this->resourceMetadataCollectionFactory->create($operation->getClass()); foreach ($resourceCollection as $resource) { foreach ($resource->getOperations() as $op) { @@ -172,6 +172,7 @@ private function addLinkedDataPlatformHeaders(array &$headers, HttpOperation $op $headers['Accept-Post'] = $acceptPost; } - $headers['Allow'] = implode(', ', $allowedMethods); + $head = \in_array('GET', $allowedMethods, true) ? ['HEAD'] : []; + $headers['Allow'] = implode(', ', array_merge(['OPTIONS'], $head, $allowedMethods)); } } diff --git a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 623f0ede027..111fdd1d9e8 100644 --- a/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -359,6 +359,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.enable_entrypoint', $config['enable_entrypoint']); $container->setParameter('api_platform.enable_docs', $config['enable_docs']); + $container->setParameter('api_platform.enable_head_request_optimization', $config['enable_head_request_optimization']); $container->setParameter('api_platform.title', $config['title']); $container->setParameter('api_platform.description', $config['description']); $container->setParameter('api_platform.version', $config['version']); diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 27f5cfbd2be..ffdbb0ba31a 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -130,6 +130,7 @@ public function getConfigTreeBuilder(): TreeBuilder ->booleanNode('enable_scalar')->defaultValue(class_exists(TwigBundle::class))->info('Enable Scalar API Reference')->end() ->booleanNode('enable_entrypoint')->defaultTrue()->info('Enable the entrypoint')->end() ->booleanNode('enable_docs')->defaultTrue()->info('Enable the docs')->end() + ->booleanNode('enable_head_request_optimization')->defaultTrue()->info('Skip response body construction on HEAD requests so collections are not iterated. Disable to process HEAD identically to GET.')->end() ->booleanNode('enable_profiler')->defaultTrue()->info('Enable the data collector and the WebProfilerBundle integration.')->end() ->booleanNode('enable_phpdoc_parser')->defaultTrue()->info('Enable resource metadata collector using PHPStan PhpDocParser.')->end() ->booleanNode('enable_link_security') diff --git a/src/Symfony/Bundle/Resources/config/json_streamer/events.php b/src/Symfony/Bundle/Resources/config/json_streamer/events.php index e5addb83c23..a1d0b2a7b25 100644 --- a/src/Symfony/Bundle/Resources/config/json_streamer/events.php +++ b/src/Symfony/Bundle/Resources/config/json_streamer/events.php @@ -34,6 +34,7 @@ '%api_platform.collection.pagination.enabled_parameter_name%', '%api_platform.url_generation_strategy%', service('api_platform.metadata.resource.metadata_collection_factory'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.jsonld.state_provider.json_streamer', HydraJsonStreamerProvider::class) @@ -50,6 +51,7 @@ service('api_platform.resource_class_resolver'), service('api_platform.metadata.operation.metadata_factory'), service('api_platform.metadata.resource.metadata_collection_factory'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.state_provider.json_streamer', JsonStreamerProvider::class) diff --git a/src/Symfony/Bundle/Resources/config/json_streamer/hydra.php b/src/Symfony/Bundle/Resources/config/json_streamer/hydra.php index 17fe3e72c08..e5e8a9feeb9 100644 --- a/src/Symfony/Bundle/Resources/config/json_streamer/hydra.php +++ b/src/Symfony/Bundle/Resources/config/json_streamer/hydra.php @@ -31,6 +31,7 @@ '%api_platform.collection.pagination.enabled_parameter_name%', '%api_platform.url_generation_strategy%', service('api_platform.metadata.resource.metadata_collection_factory'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.jsonld.state_provider.json_streamer', JsonStreamerProvider::class) diff --git a/src/Symfony/Bundle/Resources/config/json_streamer/json.php b/src/Symfony/Bundle/Resources/config/json_streamer/json.php index 40831d878fa..59e17a94149 100644 --- a/src/Symfony/Bundle/Resources/config/json_streamer/json.php +++ b/src/Symfony/Bundle/Resources/config/json_streamer/json.php @@ -28,6 +28,7 @@ service('api_platform.resource_class_resolver'), service('api_platform.metadata.operation.metadata_factory'), service('api_platform.metadata.resource.metadata_collection_factory'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.state_provider.json_streamer', JsonStreamerProvider::class) diff --git a/src/Symfony/Bundle/Resources/config/state/processor.php b/src/Symfony/Bundle/Resources/config/state/processor.php index f44dfb20d8f..b07670d45a8 100644 --- a/src/Symfony/Bundle/Resources/config/state/processor.php +++ b/src/Symfony/Bundle/Resources/config/state/processor.php @@ -29,6 +29,7 @@ service('api_platform.state_processor.serialize.inner'), service('api_platform.serializer'), service('api_platform.serializer.context_builder'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.state_processor.write', WriteProcessor::class) diff --git a/src/Symfony/Bundle/Resources/config/symfony/events.php b/src/Symfony/Bundle/Resources/config/symfony/events.php index 22451e0bd6c..ae428bb459d 100644 --- a/src/Symfony/Bundle/Resources/config/symfony/events.php +++ b/src/Symfony/Bundle/Resources/config/symfony/events.php @@ -101,6 +101,7 @@ null, service('api_platform.serializer'), service('api_platform.serializer.context_builder'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.state_processor.write', WriteProcessor::class) @@ -162,6 +163,7 @@ service('api_platform.state_processor.documentation.serialize.inner'), service('api_platform.serializer'), service('api_platform.serializer.context_builder'), + '%api_platform.enable_head_request_optimization%', ]); $services->set('api_platform.state_processor.documentation.write', WriteProcessor::class) diff --git a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 854a06f2515..d8a2499a628 100644 --- a/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/src/Symfony/Tests/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -263,6 +263,9 @@ public function testCommonConfiguration(): void foreach ($services as $service) { $this->assertNotContainerHasService($service); } + + $this->assertTrue($this->container->hasParameter('api_platform.enable_head_request_optimization')); + $this->assertTrue($this->container->getParameter('api_platform.enable_head_request_optimization')); } public function testSwaggerUiDisabledConfiguration(): void diff --git a/tests/Fixtures/TestBundle/ApiResource/HeadSpyResource.php b/tests/Fixtures/TestBundle/ApiResource/HeadSpyResource.php new file mode 100644 index 00000000000..6c63337130f --- /dev/null +++ b/tests/Fixtures/TestBundle/ApiResource/HeadSpyResource.php @@ -0,0 +1,43 @@ + + * + * 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; + +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Operation; +use ApiPlatform\Tests\Fixtures\TestBundle\State\SpyPaginator; + +#[ApiResource( + shortName: 'HeadSpyResource', + operations: [ + new GetCollection( + uriTemplate: '/head_spy_resources', + provider: [self::class, 'provide'], + ), + new GetCollection( + uriTemplate: '/head_spy_stream_resources', + provider: [self::class, 'provide'], + jsonStream: true, + ), + ], +)] +final class HeadSpyResource +{ + public string $id = ''; + + public static function provide(Operation $operation, array $uriVariables = [], array $context = []): SpyPaginator + { + return new SpyPaginator(); + } +} diff --git a/tests/Fixtures/TestBundle/State/SpyPaginator.php b/tests/Fixtures/TestBundle/State/SpyPaginator.php new file mode 100644 index 00000000000..0cb05130953 --- /dev/null +++ b/tests/Fixtures/TestBundle/State/SpyPaginator.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\Tests\Fixtures\TestBundle\State; + +use ApiPlatform\State\Pagination\PaginatorInterface; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\HttpException; + +/** + * A paginator whose scalar metadata is canned but whose rows are never meant to be + * read: getIterator() and count() throw. A HEAD request must return without iterating, + * proving no row SELECT was issued. + * + * @implements PaginatorInterface + * @implements \IteratorAggregate + */ +final class SpyPaginator implements PaginatorInterface, \IteratorAggregate +{ + public function getCurrentPage(): float + { + return 1.; + } + + public function getItemsPerPage(): float + { + return 30.; + } + + public function getLastPage(): float + { + return 1.; + } + + public function getTotalItems(): float + { + return 42.; + } + + public function count(): int + { + throw new HttpException(Response::HTTP_I_AM_A_TEAPOT, 'iterated on HEAD'); + } + + public function getIterator(): \Iterator + { + throw new HttpException(Response::HTTP_I_AM_A_TEAPOT, 'iterated on HEAD'); + } +} diff --git a/tests/Functional/HeadAllowWithoutGetTest.php b/tests/Functional/HeadAllowWithoutGetTest.php new file mode 100644 index 00000000000..09e9112ad53 --- /dev/null +++ b/tests/Functional/HeadAllowWithoutGetTest.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\JsonLd\PostNoOutputResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; + +/** + * RFC 9110 §10.2.1: the Allow header must advertise only methods that are actually + * valid for the target resource. HEAD is defined as GET-without-body (§9.3.2), so a + * resource that declares no GET operation does not support HEAD — a real HEAD request + * returns 405. The advertised Allow header must therefore not claim HEAD either. + */ +final class HeadAllowWithoutGetTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [PostNoOutputResource::class]; + } + + public function testHeadIsNotAdvertisedWithoutGetOperation(): void + { + $client = self::createClient(); + + $client->request('HEAD', '/jsonld_post_no_output', ['headers' => ['Accept' => 'application/ld+json']]); + $this->assertResponseStatusCodeSame(405); + + $response = $client->request('POST', '/jsonld_post_no_output', [ + 'headers' => ['Content-Type' => 'application/ld+json'], + 'json' => ['lorem' => 'x'], + ]); + + $headers = array_change_key_case($response->getHeaders(false)); + $this->assertArrayHasKey('allow', $headers); + $this->assertStringNotContainsString('HEAD', $headers['allow'][0]); + } +} diff --git a/tests/Functional/HeadRequestTest.php b/tests/Functional/HeadRequestTest.php new file mode 100644 index 00000000000..1ec42d0e22f --- /dev/null +++ b/tests/Functional/HeadRequestTest.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\HeadSpyResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Bundle\FrameworkBundle\Controller\ControllerHelper; +use Symfony\Component\JsonStreamer\JsonStreamWriter; + +/** + * On a HEAD request, API Platform must skip body construction so that the (lazy) + * collection is never iterated: zero row SELECT. The spy paginator throws on + * getIterator()/count(); a HEAD that does not throw proves no iteration occurred. + */ +final class HeadRequestTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [HeadSpyResource::class]; + } + + public function testHeadDoesNotIterateCollection(): void + { + $response = self::createClient()->request('HEAD', '/head_spy_resources', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertEmpty($response->getContent(false)); + + $headers = array_change_key_case($response->getHeaders(false)); + $this->assertArrayHasKey('content-type', $headers); + $this->assertStringStartsWith('application/ld+json', $headers['content-type'][0]); + $this->assertArrayHasKey('vary', $headers); + $this->assertStringContainsString('Accept', $headers['vary'][0]); + } + + public function testGetIteratesCollection(): void + { + self::createClient()->request('GET', '/head_spy_resources', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(418); + } + + public function testOptionsIsUnaffected(): void + { + $response = self::createClient()->request('OPTIONS', '/head_spy_resources', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $headers = array_change_key_case($response->getHeaders(false)); + $this->assertArrayHasKey('allow', $headers); + $this->assertStringContainsString('GET', $headers['allow'][0]); + } + + public function testHeadDoesNotIterateJsonStreamCollection(): void + { + if (false === (class_exists(ControllerHelper::class) && class_exists(JsonStreamWriter::class))) { + $this->markTestSkipped('JsonStreamer component not installed.'); + } + + $response = self::createClient()->request('HEAD', '/head_spy_stream_resources', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(200); + $this->assertEmpty($response->getContent(false)); + + $headers = array_change_key_case($response->getHeaders(false)); + $this->assertArrayHasKey('content-type', $headers); + $this->assertArrayHasKey('vary', $headers); + $this->assertStringContainsString('Accept', $headers['vary'][0]); + } +} diff --git a/tests/Functional/HeadRequestWithoutOptimizationTest.php b/tests/Functional/HeadRequestWithoutOptimizationTest.php new file mode 100644 index 00000000000..5c1ad9de14d --- /dev/null +++ b/tests/Functional/HeadRequestWithoutOptimizationTest.php @@ -0,0 +1,79 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Tests\Functional; + +use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; +use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\HeadSpyResource; +use ApiPlatform\Tests\SetupClassResourcesTrait; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +class HeadRequestWithoutOptimizationAppKernel extends \AppKernel +{ + public function getCacheDir(): string + { + return parent::getCacheDir().'/head_no_opt'; + } + + public function getLogDir(): string + { + return parent::getLogDir().'/head_no_opt'; + } + + protected function configureContainer(ContainerBuilder $c, LoaderInterface $loader): void + { + parent::configureContainer($c, $loader); + + $loader->load(static function (ContainerBuilder $container): void { + $container->loadFromExtension('api_platform', [ + 'enable_head_request_optimization' => false, + ]); + }); + } +} + +/** + * Opt-out: with enable_head_request_optimization disabled, a HEAD request must + * behave like GET again — the body is built, so the (lazy) collection IS iterated. + * The spy paginator throws a fixed 418 on iteration; seeing it proves the flag + * restores the previous GET-equivalent behavior. + */ +final class HeadRequestWithoutOptimizationTest extends ApiTestCase +{ + use SetupClassResourcesTrait; + + protected static ?bool $alwaysBootKernel = false; + + /** + * @return class-string[] + */ + public static function getResources(): array + { + return [HeadSpyResource::class]; + } + + protected static function getKernelClass(): string + { + return HeadRequestWithoutOptimizationAppKernel::class; + } + + public function testHeadIteratesCollectionWhenOptimizationDisabled(): void + { + self::createClient()->request('HEAD', '/head_spy_resources', [ + 'headers' => ['Accept' => 'application/ld+json'], + ]); + + $this->assertResponseStatusCodeSame(418); + } +} diff --git a/tests/State/RespondProcessorTest.php b/tests/State/RespondProcessorTest.php index 9e417221a76..c9ea58073af 100644 --- a/tests/State/RespondProcessorTest.php +++ b/tests/State/RespondProcessorTest.php @@ -163,6 +163,35 @@ public function testAddsLinkedDataPlatformHeaders(): void $this->assertSame('application/ld+json', $response->headers->get('Accept-Post')); } + public function testDoesNotAdvertiseHeadWithoutGetOperation(): void + { + $postOperation = new Post(uriTemplate: '/employees', class: Employee::class); + + $resourceClassResolver = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolver->isResourceClass(Employee::class)->willReturn(true); + + $resourceMetadataCollectionFactory = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); + $resourceMetadataCollectionFactory->create(Employee::class)->willReturn(new ResourceMetadataCollection(Employee::class, [ + new ApiResource(operations: [ + 'post' => $postOperation, + ]), + ])); + + $respondProcessor = new RespondProcessor( + null, + $resourceClassResolver->reveal(), + null, + $resourceMetadataCollectionFactory->reveal() + ); + + $response = $respondProcessor->process('content', $postOperation, context: [ + 'request' => new Request(), + ]); + + $this->assertNotNull($response->headers->get('Allow')); + $this->assertStringNotContainsString('HEAD', $response->headers->get('Allow')); + } + public function testDynamicResponseStatusFromRequestAttribute(): void { $operation = new Post(class: Employee::class); diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index a31121d8a19..98da39bfeab 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -255,6 +255,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'allow_client_generated_id' => false, ], 'enable_scalar' => true, + 'enable_head_request_optimization' => true, ], $config); } From f8c217283659dc80985e85c5570e113e1eba6482 Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sun, 12 Jul 2026 14:39:54 +0200 Subject: [PATCH 66/84] fix(state): correct composer "conflicts" key to "conflict" (#8400) --- src/State/composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/State/composer.json b/src/State/composer.json index ac27bd2b2a7..8b3dafdcdc0 100644 --- a/src/State/composer.json +++ b/src/State/composer.json @@ -45,7 +45,7 @@ "symfony/web-link": "^7.4 || ^8.0", "willdurand/negotiation": "^3.1" }, - "conflicts": { + "conflict": { "symfony/object-mapper": "<7.3.4" }, "autoload": { From f41921be0db7176544f9bb7317d1fcc327eb9e64 Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 12 Jul 2026 14:52:30 +0200 Subject: [PATCH 67/84] doc: changelog 4.4.0-alpha.3 --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 227cd508a6a..aa60f3344d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v4.4.0-alpha.3 + +### Bug fixes + +* [f8c217283](https://github.com/api-platform/core/commit/f8c217283659dc80985e85c5570e113e1eba6482) fix(state): correct composer "conflicts" key to "conflict" (#8400) + +### Dependencies + +* Require `symfony/*` `^7.4 || ^8.0` across all components; drop support for Symfony 6.4 and 7.0–7.3 (#8397) + ## v4.4.0-alpha.2 ### Bug fixes From 1515eb77e3c351fe70c323dfe692ddc66117b3bc Mon Sep 17 00:00:00 2001 From: soyuka Date: Sun, 12 Jul 2026 15:19:48 +0200 Subject: [PATCH 68/84] doc: changelog 5.0.0-alpha.2 --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 227cd508a6a..06dd758e2fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## v5.0.0-alpha.2 + +### Breaking changes + +* [e22e74464](https://github.com/api-platform/core/commit/e22e74464e49d0dc0bd86e7407f1e19b6c5db9ca) feat!: remove deprecated APIs scheduled for 5.0 (#8367) +* [4a9a14507](https://github.com/api-platform/core/commit/4a9a14507e5ca97c85fcf8dd1e240008f000c525) feat!: remove the legacy PropertyInfo Type system, use symfony/type-info (#8364) +* [1e6d13ae1](https://github.com/api-platform/core/commit/1e6d13ae117471dd6e549cd1cc5dc8816d5804fd) feat!: core 5.0 cleanups — PropertyAwareFilterInterface::getProperties(), JSON:API status as string (#8366) + +### Features + +* [d37a75379](https://github.com/api-platform/core/commit/d37a753790f8a8e1481a118b6a8fc9a08f57e962) feat(doctrine): standalone Date/Exists filters, ComparisonFilter [between], deprecate RangeFilter (#8351) + +### Bug fixes + +* [88f458a11](https://github.com/api-platform/core/commit/88f458a1108ba2fcd052a58d7647f23dad1b186b) fix(jsonschema): drop removed getBuiltinTypes path in SchemaPropertyMetadataFactory + +### Dependencies + +* Require `symfony/*` `^7.4 || ^8.0` across all components; drop support for Symfony 6.4 and 7.0–7.3 (#8397) +* Stabilize formerly `@experimental` APIs (Elasticsearch, State parameter providers, PropertyAwareFilterInterface, Laravel); `@experimental` kept only on MCP (#8365) + ## v4.4.0-alpha.2 ### Bug fixes From dd00d0fa2862d06736fe9db66ac16fa382360e0f Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Mon, 20 Jul 2026 08:07:05 +0200 Subject: [PATCH 69/84] chore: resolve stale SerializerContextBuilder / ContextAction 5.0 TODOs (#8402) --- src/JsonLd/Action/ContextAction.php | 10 ---------- src/Serializer/SerializerContextBuilder.php | 9 --------- tests/Functional/JsonLd/ContextTest.php | 18 ++++++++++++++++++ tests/JsonLd/Action/ContextActionTest.php | 11 ----------- 4 files changed, 18 insertions(+), 30 deletions(-) diff --git a/src/JsonLd/Action/ContextAction.php b/src/JsonLd/Action/ContextAction.php index 74c144ed81b..faa3b0b67f2 100644 --- a/src/JsonLd/Action/ContextAction.php +++ b/src/JsonLd/Action/ContextAction.php @@ -32,11 +32,6 @@ */ final class ContextAction { - public const RESERVED_SHORT_NAMES = [ - 'ConstraintViolationList' => true, - 'Error' => true, - ]; - public function __construct( private readonly ContextBuilderInterface $contextBuilder, private readonly ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, @@ -90,11 +85,6 @@ private function getContext(string $shortName): ?array return ['@context' => $this->contextBuilder->getEntrypointContext()]; } - // TODO: remove this, exceptions are resources since 3.2 - if (isset(self::RESERVED_SHORT_NAMES[$shortName])) { - return ['@context' => $this->contextBuilder->getBaseContext()]; - } - foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { $resourceMetadataCollection = $this->resourceMetadataCollectionFactory->create($resourceClass); diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index 63ee797f426..b259e6015ea 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -76,15 +76,6 @@ public function createFromRequest(Request $request, bool $normalization, ?array $context['types'] = $types; } - // TODO: remove this as uri variables are available in the SerializerProcessor but correctly parsed - if ($operation->getUriVariables()) { - $context['uri_variables'] = []; - - foreach (array_keys($operation->getUriVariables()) as $parameterName) { - $context['uri_variables'][$parameterName] = $request->attributes->get($parameterName); - } - } - if (null === $context['output'] && $this->getStateOptionsClass($operation)) { $context['force_resource_class'] = $operation->getClass(); } diff --git a/tests/Functional/JsonLd/ContextTest.php b/tests/Functional/JsonLd/ContextTest.php index 6906f979db2..873e9d43a39 100644 --- a/tests/Functional/JsonLd/ContextTest.php +++ b/tests/Functional/JsonLd/ContextTest.php @@ -121,4 +121,22 @@ public function testResourceLevelJsonLdContextAddsNamespacePrefixes(): void $this->assertSame('http://purl.org/dc/terms/', $body['@context']['dct']); $this->assertSame('dct:title', $body['@context']['title']); } + + public function testErrorContextIsResolvedThroughItsResource(): void + { + $response = self::createClient()->request('GET', '/contexts/Error'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertArrayHasKey('@context', $body); + $this->assertSame('http://www.w3.org/ns/hydra/core#', $body['@context']['hydra']); + } + + public function testConstraintViolationContextIsResolvedThroughItsResource(): void + { + $response = self::createClient()->request('GET', '/contexts/ConstraintViolation'); + $this->assertResponseIsSuccessful(); + $body = $response->toArray(); + $this->assertArrayHasKey('@context', $body); + $this->assertSame('http://www.w3.org/ns/hydra/core#', $body['@context']['hydra']); + } } diff --git a/tests/JsonLd/Action/ContextActionTest.php b/tests/JsonLd/Action/ContextActionTest.php index 52683ad81b2..62117051935 100644 --- a/tests/JsonLd/Action/ContextActionTest.php +++ b/tests/JsonLd/Action/ContextActionTest.php @@ -52,17 +52,6 @@ public function testContextActionWithEntrypoint(): void $this->assertEquals(['@context' => ['/entrypoints']], $contextAction('Entrypoint')); } - public function testContextActionWithContexts(): void - { - $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); - $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); - $resourceMetadataCollectionFactoryProphecy = $this->prophesize(ResourceMetadataCollectionFactoryInterface::class); - $contextBuilderProphecy->getBaseContext()->willReturn(['/contexts']); - $contextAction = new ContextAction($contextBuilderProphecy->reveal(), $resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataCollectionFactoryProphecy->reveal()); - - $this->assertEquals(['@context' => ['/contexts']], $contextAction('ConstraintViolationList')); - } - public function testContextActionWithResourceClass(): void { $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); From e1c900fc4da6fa7d9e2032e5c706755c18dd930c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 09:59:00 +0200 Subject: [PATCH 70/84] chore(deps): bump actions/setup-node from 6 to 7 (#8437) Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index db8cdbb5700..a05d09e6cf5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -268,7 +268,7 @@ jobs: composer global config allow-plugins.soyuka/pmu true --no-interaction composer global link . - name: Setup Node.js - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '20' - name: Install Redocly CLI @@ -1106,7 +1106,7 @@ jobs: extensions: intl, bcmath, curl, openssl, mbstring, pdo_sqlite ini-values: memory_limit=-1 - name: Setup node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: '22' - name: Get composer cache directory From 78151b7d19da1dfba9a37362bb23a9cb4ff4acd1 Mon Sep 17 00:00:00 2001 From: Ben Younes Date: Sun, 16 Aug 2026 17:57:59 +0200 Subject: [PATCH 71/84] fix(mcp): return resource read results for resources (#8436) Signed-off-by: Ousama Ben Younes --- composer.json | 2 +- src/Laravel/ApiPlatformProvider.php | 4 +- src/Mcp/State/StructuredContentProcessor.php | 10 +++++ .../State/StructuredContentProcessorTest.php | 42 +++++++++++++++++++ src/Mcp/composer.json | 2 +- 5 files changed, 57 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index e1b841d1530..4772ec8fc31 100644 --- a/composer.json +++ b/composer.json @@ -142,7 +142,7 @@ "jangregor/phpstan-prophecy": "^2.1.11", "justinrainbow/json-schema": "^6.5.2", "laravel/framework": "^11.0 || ^12.0 || ^13.0", - "mcp/sdk": "^0.6", + "mcp/sdk": "^0.7", "orchestra/testbench": "^10.9 || ^11.0", "phpspec/prophecy-phpunit": "^2.2", "phpstan/extension-installer": "^1.1", diff --git a/src/Laravel/ApiPlatformProvider.php b/src/Laravel/ApiPlatformProvider.php index 4cc6fd7aa1f..aaca8987c0f 100644 --- a/src/Laravel/ApiPlatformProvider.php +++ b/src/Laravel/ApiPlatformProvider.php @@ -193,6 +193,7 @@ use PHPStan\PhpDocParser\Parser\PhpDocParser; use Psr\Log\LoggerInterface; use Symfony\AI\McpBundle\Controller\McpController; +use Symfony\AI\McpBundle\Http\MiddlewareFactory; use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; use Symfony\Component\HttpFoundation\RequestStack; @@ -1342,7 +1343,8 @@ private function registerMcp(): void $psrHttpFactory, $httpFoundationFactory, $psr17Factory, - $psr17Factory + $psr17Factory, + new MiddlewareFactory() ); }); } diff --git a/src/Mcp/State/StructuredContentProcessor.php b/src/Mcp/State/StructuredContentProcessor.php index 1a92b43b51a..842304c9952 100644 --- a/src/Mcp/State/StructuredContentProcessor.php +++ b/src/Mcp/State/StructuredContentProcessor.php @@ -19,6 +19,7 @@ use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\SerializerContextBuilderInterface; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Result\CallToolResult; use Mcp\Schema\Result\ReadResourceResult; @@ -71,6 +72,15 @@ public function process(mixed $data, Operation $operation, array $uriVariables = } } + if ($operation instanceof McpResource) { + return new Response( + $context['mcp_request']->getId(), + new ReadResourceResult([ + new TextResourceContents($operation->getUri(), $operation->getMimeType() ?? 'application/json', $result), + ]), + ); + } + return new Response( $context['mcp_request']->getId(), new CallToolResult( diff --git a/src/Mcp/Tests/State/StructuredContentProcessorTest.php b/src/Mcp/Tests/State/StructuredContentProcessorTest.php index 305a8f718f5..390bfc0b988 100644 --- a/src/Mcp/Tests/State/StructuredContentProcessorTest.php +++ b/src/Mcp/Tests/State/StructuredContentProcessorTest.php @@ -14,13 +14,16 @@ namespace ApiPlatform\Mcp\Tests\State; use ApiPlatform\Mcp\State\StructuredContentProcessor; +use ApiPlatform\Metadata\McpResource; use ApiPlatform\Metadata\McpTool; use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\SerializerContextBuilderInterface; use Mcp\Schema\Content\TextContent; +use Mcp\Schema\Content\TextResourceContents; use Mcp\Schema\JsonRpc\Request; use Mcp\Schema\JsonRpc\Response; use Mcp\Schema\Result\CallToolResult; +use Mcp\Schema\Result\ReadResourceResult; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request as HttpRequest; use Symfony\Component\Serializer\Encoder\EncoderInterface; @@ -81,6 +84,45 @@ public function testTextContentIsPopulatedWhenStructuredContentIsDisabled(): voi $this->assertNotSame('{}', $textContent->text); $this->assertSame($expectedJson, $textContent->text); } + + public function testMcpResourceReturnsReadResourceResult(): void + { + $expectedJson = '{"name":"foo"}'; + $resourceUri = 'app://dummy'; + $resourceMimeType = 'application/json'; + + $decorated = $this->createMock(ProcessorInterface::class); + $decorated->method('process')->willReturn(new \stdClass()); + + $serializer = $this->createMock(SerializerEncoderNormalizer::class); + $serializer->method('normalize')->willReturn(['name' => 'foo']); + $serializer->method('encode')->willReturn($expectedJson); + + $contextBuilder = $this->createMock(SerializerContextBuilderInterface::class); + $contextBuilder->method('createFromRequest')->willReturn([]); + + $processor = new StructuredContentProcessor($serializer, $contextBuilder, $decorated); + + $operation = (new McpResource(uri: $resourceUri, mimeType: $resourceMimeType))->withClass(\stdClass::class); + + $mcpRequest = $this->createMock(Request::class); + $mcpRequest->method('getId')->willReturn('req-1'); + + /** @var Response $response */ + $response = $processor->process([], $operation, [], [ + 'mcp_request' => $mcpRequest, + 'request' => new HttpRequest(), + ]); + + $result = $response->result; + $this->assertInstanceOf(ReadResourceResult::class, $result); + + $resourceContents = $result->contents[0]; + $this->assertInstanceOf(TextResourceContents::class, $resourceContents); + $this->assertSame($resourceUri, $resourceContents->uri); + $this->assertSame($resourceMimeType, $resourceContents->mimeType); + $this->assertSame($expectedJson, $resourceContents->text); + } } /** diff --git a/src/Mcp/composer.json b/src/Mcp/composer.json index a824d20b63d..608d3f2425b 100644 --- a/src/Mcp/composer.json +++ b/src/Mcp/composer.json @@ -30,7 +30,7 @@ "php": ">=8.2", "api-platform/metadata": "^5.0@alpha", "api-platform/json-schema": "^5.0@alpha", - "mcp/sdk": "^0.6", + "mcp/sdk": "^0.7", "symfony/object-mapper": "^7.4 || ^8.0", "symfony/polyfill-php85": "^1.32" }, From d3935bf26b6785487945b9154d5036435ef48207 Mon Sep 17 00:00:00 2001 From: Ben Younes Date: Tue, 1 Sep 2026 10:05:05 +0200 Subject: [PATCH 72/84] ci: restore the PMU_VERSION pin that the merge up dropped (#8474) --- .github/workflows/ci.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79cb690e4c5..2524ecf0660 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,9 @@ permissions: env: COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} COMPOSER_ROOT_VERSION: "5.0.x-dev" + # Pinned build tooling: these are installed and executed on the runner, so an + # unconstrained version would run whatever the registry serves that day. + PMU_VERSION: "0.3.0" jobs: architecture: @@ -156,7 +159,7 @@ jobs: restore-keys: ${{ runner.os }}-composer- - name: Update project dependencies run: | - composer global require soyuka/pmu + composer global require "soyuka/pmu:$PMU_VERSION" composer global config allow-plugins.soyuka/pmu true --no-interaction composer global link . - name: Codemod unit tests From a16cb28319790e4ba5b9c9f680976443f76d1f25 Mon Sep 17 00:00:00 2001 From: Ben Younes Date: Tue, 1 Sep 2026 10:05:40 +0200 Subject: [PATCH 73/84] ci: raise inter-component floors so the PHP 8.5 lowest jobs pass (#8445) --- src/Doctrine/Odm/composer.json | 2 +- src/Doctrine/Orm/composer.json | 2 +- src/JsonApi/composer.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Doctrine/Odm/composer.json b/src/Doctrine/Odm/composer.json index 0e483af782d..e7cd30933a0 100644 --- a/src/Doctrine/Odm/composer.json +++ b/src/Doctrine/Odm/composer.json @@ -25,7 +25,7 @@ ], "require": { "php": ">=8.2", - "api-platform/doctrine-common": "^5.0@alpha", + "api-platform/doctrine-common": "^5.0.0-alpha.2", "api-platform/metadata": "^5.0@alpha", "api-platform/serializer": "^5.0@alpha", "api-platform/state": "^5.0@alpha", diff --git a/src/Doctrine/Orm/composer.json b/src/Doctrine/Orm/composer.json index 581008adeea..0974ce4697a 100644 --- a/src/Doctrine/Orm/composer.json +++ b/src/Doctrine/Orm/composer.json @@ -24,7 +24,7 @@ ], "require": { "php": ">=8.2", - "api-platform/doctrine-common": "^5.0@alpha", + "api-platform/doctrine-common": "^5.0.0-alpha.2", "api-platform/metadata": "^5.0@alpha", "api-platform/serializer": "^5.0@alpha", "api-platform/state": "^5.0@alpha", diff --git a/src/JsonApi/composer.json b/src/JsonApi/composer.json index 5f310df4f79..63ffd5f9f9e 100644 --- a/src/JsonApi/composer.json +++ b/src/JsonApi/composer.json @@ -25,7 +25,7 @@ "api-platform/documentation": "^5.0@alpha", "api-platform/json-schema": "^5.0@alpha", "api-platform/metadata": "^5.0@alpha", - "api-platform/serializer": "^5.0@alpha", + "api-platform/serializer": "^5.0.0-alpha.2", "api-platform/state": "^5.0@alpha", "symfony/error-handler": "^7.4 || ^8.0", "symfony/http-foundation": "^7.4 || ^8.0", From 20c2490908a3e776b4a3a71b4e873b95209d3ece Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 1 Sep 2026 11:01:43 +0200 Subject: [PATCH 74/84] test: drop DoctrineTest methods revived by the merge The 4.3 -> 4.4 merge 7085b6da7 re-added 121 lines to DoctrineTest.php that #8344 had removed when SearchFilterParameter moved under Legacy/. The three methods reference a fixture class and a route that only exist on 4.3, breaking every PHPUnit job and PHPStan. Legacy/SearchFilterParameterLegacyTest.php already covers them. --- tests/Functional/Parameters/DoctrineTest.php | 83 -------------------- 1 file changed, 83 deletions(-) diff --git a/tests/Functional/Parameters/DoctrineTest.php b/tests/Functional/Parameters/DoctrineTest.php index f062b98e218..d81e92f23c3 100644 --- a/tests/Functional/Parameters/DoctrineTest.php +++ b/tests/Functional/Parameters/DoctrineTest.php @@ -16,7 +16,6 @@ use ApiPlatform\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\FilterWithStateOptions; use ApiPlatform\Tests\Fixtures\TestBundle\ApiResource\FilterWithStateOptionsAndNoApiFilter; -use ApiPlatform\Tests\Fixtures\TestBundle\Document\SearchFilterParameter as SearchFilterParameterDocument; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\ExactAndComparisonParameter; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilterWithStateOptionsAndNoApiFilterEntity; use ApiPlatform\Tests\Fixtures\TestBundle\Entity\FilterWithStateOptionsEntity; @@ -81,88 +80,6 @@ public function testExactFilterIgnoresOperatorMap(): void $this->assertSame([5, 8], $quantities); } - public function testDoctrineEntitySearchFilter(): void - { - $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; - $this->recreateSchema([$resource]); - $this->loadFixtures($resource); - $route = 'search_filter_parameter'; - $response = self::createClient()->request('GET', $route.'?foo=bar'); - $a = $response->toArray(); - $this->assertCount(2, $a['hydra:member']); - $this->assertEquals('bar', $a['hydra:member'][0]['foo']); - $this->assertEquals('bar', $a['hydra:member'][1]['foo']); - - $this->assertArraySubset(['hydra:search' => [ - 'hydra:template' => \sprintf('/%s{?foo,fooAlias,q,order[id],order[foo],searchPartial[foo],searchExact[foo],searchOnTextAndDate[foo],searchOnTextAndDate[createdAt][before],searchOnTextAndDate[createdAt][strictly_before],searchOnTextAndDate[createdAt][after],searchOnTextAndDate[createdAt][strictly_after],search[foo],search[createdAt],id,createdAt}', $route), - ]], $a); - - $this->assertArraySubset(['@type' => 'IriTemplateMapping', 'variable' => 'fooAlias', 'property' => 'foo'], $a['hydra:search']['hydra:mapping'][1]); - - $response = self::createClient()->request('GET', $route.'?fooAlias=baz'); - $a = $response->toArray(); - $this->assertCount(1, $a['hydra:member']); - $this->assertEquals('baz', $a['hydra:member'][0]['foo']); - - $response = self::createClient()->request('GET', $route.'?order[foo]=asc'); - $this->assertEquals($response->toArray()['hydra:member'][0]['foo'], 'bar'); - $response = self::createClient()->request('GET', $route.'?order[foo]=desc'); - $this->assertEquals($response->toArray()['hydra:member'][0]['foo'], 'foo'); - - $response = self::createClient()->request('GET', $route.'?searchPartial[foo]=az'); - $members = $response->toArray()['hydra:member']; - $this->assertCount(1, $members); - $this->assertArraySubset(['foo' => 'baz'], $members[0]); - - $response = self::createClient()->request('GET', $route.'?searchOnTextAndDate[foo]=bar&searchOnTextAndDate[createdAt][before]=2024-01-21'); - $members = $response->toArray()['hydra:member']; - $this->assertCount(1, $members); - $this->assertArraySubset(['foo' => 'bar', 'createdAt' => '2024-01-21T00:00:00+00:00'], $members[0]); - } - - public function testGraphQl(): void - { - if ($_SERVER['EVENT_LISTENERS_BACKWARD_COMPATIBILITY_LAYER'] ?? false) { - $this->markTestSkipped('Parameters are not supported in BC mode.'); - } - - $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; - $this->recreateSchema([$resource]); - $this->loadFixtures($resource); - $object = 'searchFilterParameters'; - $response = self::createClient()->request('POST', '/graphql', ['json' => [ - 'query' => \sprintf('{ %s(foo: "bar") { edges { node { id foo createdAt } } } }', $object), - ]]); - $this->assertEquals('bar', $response->toArray()['data'][$object]['edges'][0]['node']['foo']); - - $response = self::createClient()->request('POST', '/graphql', ['json' => [ - 'query' => \sprintf('{ %s(searchPartial: {foo: "az"}) { edges { node { id foo createdAt } } } }', $object), - ]]); - $this->assertEquals('baz', $response->toArray()['data'][$object]['edges'][0]['node']['foo']); - - $response = self::createClient()->request('POST', '/graphql', ['json' => [ - 'query' => \sprintf('{ %s(searchExact: {foo: "baz"}) { edges { node { id foo createdAt } } } }', $object), - ]]); - $this->assertEquals('baz', $response->toArray()['data'][$object]['edges'][0]['node']['foo']); - - $response = self::createClient()->request('POST', '/graphql', ['json' => [ - 'query' => \sprintf('{ %s(searchOnTextAndDate: {foo: "bar", createdAt: {before: "2024-01-21"}}) { edges { node { id foo createdAt } } } }', $object), - ]]); - $this->assertArraySubset(['foo' => 'bar', 'createdAt' => '2024-01-21T00:00:00+00:00'], $response->toArray()['data'][$object]['edges'][0]['node']); - } - - public function testPropertyPlaceholderFilter(): void - { - static::bootKernel(); - $resource = $this->isMongoDB() ? SearchFilterParameterDocument::class : SearchFilterParameter::class; - $this->recreateSchema([$resource]); - $this->loadFixtures($resource); - $route = 'search_filter_parameter'; - $response = self::createClient()->request('GET', $route.'?foo=baz'); - $a = $response->toArray(); - $this->assertEquals($a['hydra:member'][0]['foo'], 'baz'); - } - public function testStateOptions(): void { if ($this->isMongoDB()) { From 88e71ef8664ccc06f54803dabc041101e0af8fcb Mon Sep 17 00:00:00 2001 From: soyuka Date: Tue, 1 Sep 2026 11:01:43 +0200 Subject: [PATCH 75/84] ci(jsonapi): pin the serializer floor to alpha.3 api-platform/serializer 4.4.0-alpha.1 predates #8397, so --prefer-lowest pulled symfony/serializer 6.4, which keys circular references by spl_object_hash and broke testNormalizeCircularReference. Also drop the EventStreamResponse probe: it tested http-foundation to guess a serializer behaviour, and Symfony >= 7.3 is the only supported range. --- src/JsonApi/Tests/Serializer/ItemNormalizerTest.php | 5 +---- src/JsonApi/composer.json | 2 +- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php index 744aefc2f42..4472b372915 100644 --- a/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php +++ b/src/JsonApi/Tests/Serializer/ItemNormalizerTest.php @@ -38,7 +38,6 @@ use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; -use Symfony\Component\HttpFoundation\EventStreamResponse; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; @@ -218,11 +217,9 @@ public function testNormalizeCircularReference(): void $normalizer->setSerializer($this->prophesize(SerializerInterface::class)->reveal()); - // Symfony >= 7.3 - $splObject = class_exists(EventStreamResponse::class) ? spl_object_id($circularReferenceEntity) : spl_object_hash($circularReferenceEntity); $context = [ 'circular_reference_limit' => 2, - 'circular_reference_limit_counters' => [$splObject => 2], + 'circular_reference_limit_counters' => [spl_object_id($circularReferenceEntity) => 2], 'cache_error' => static function (): void {}, ]; diff --git a/src/JsonApi/composer.json b/src/JsonApi/composer.json index 74f0aac257b..a9518aaab0f 100644 --- a/src/JsonApi/composer.json +++ b/src/JsonApi/composer.json @@ -25,7 +25,7 @@ "api-platform/documentation": "^4.4@alpha", "api-platform/json-schema": "^4.4@alpha", "api-platform/metadata": "^4.4@alpha", - "api-platform/serializer": "^4.4@alpha", + "api-platform/serializer": "^4.4.0-alpha.3", "api-platform/state": "^4.4@alpha", "symfony/error-handler": "^7.4 || ^8.0", "symfony/http-foundation": "^7.4 || ^8.0", From 672d48e2510ca08aed92958677c8f861cb36f959 Mon Sep 17 00:00:00 2001 From: Pascal CESCON Date: Tue, 1 Sep 2026 16:08:58 +0200 Subject: [PATCH 76/84] feat(symfony): map UniqueConstraintViolationException to 422 by default (#8478) --- src/Symfony/Bundle/DependencyInjection/Configuration.php | 2 ++ tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/DependencyInjection/Configuration.php index 012f0f1aa23..fb070df0506 100644 --- a/src/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/DependencyInjection/Configuration.php @@ -23,6 +23,7 @@ use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use Doctrine\Bundle\MongoDBBundle\DoctrineMongoDBBundle; use Doctrine\ORM\EntityManagerInterface; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\OptimisticLockException; use GraphQL\GraphQL; use Symfony\Bundle\FrameworkBundle\Controller\ControllerHelper; @@ -592,6 +593,7 @@ private function addExceptionToStatusSection(ArrayNodeDefinition $rootNode): voi SerializerExceptionInterface::class => Response::HTTP_BAD_REQUEST, InvalidArgumentException::class => Response::HTTP_BAD_REQUEST, OptimisticLockException::class => Response::HTTP_CONFLICT, + UniqueConstraintViolationException::class => Response::HTTP_UNPROCESSABLE_ENTITY, ]) ->info('The list of exceptions mapped to their HTTP status code.') ->normalizeKeys(false) diff --git a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index fced5378b78..b1d0a51c448 100644 --- a/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -15,6 +15,7 @@ use ApiPlatform\Metadata\Exception\InvalidArgumentException; use ApiPlatform\Symfony\Bundle\DependencyInjection\Configuration; +use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\ORM\OptimisticLockException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -101,6 +102,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm ExceptionInterface::class => Response::HTTP_BAD_REQUEST, InvalidArgumentException::class => Response::HTTP_BAD_REQUEST, OptimisticLockException::class => Response::HTTP_CONFLICT, + UniqueConstraintViolationException::class => Response::HTTP_UNPROCESSABLE_ENTITY, ], 'path_segment_name_generator' => 'api_platform.metadata.path_segment_name_generator.underscore', 'inflector' => 'api_platform.metadata.inflector', From 854c9218efd907b87c096a2a6e3e44c7f8c36f1a Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Tue, 1 Sep 2026 16:23:40 +0200 Subject: [PATCH 77/84] fix(metadata): stop alerting on inline unwired filters at warmup (#8490) Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Alexis Lefebvre Closes #7361 --- .../UnwiredLegacyFilterParameterTest.php | 94 +++++++++++++++++++ ...meterResourceMetadataCollectionFactory.php | 9 +- 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 src/Doctrine/Orm/Tests/Metadata/Resource/UnwiredLegacyFilterParameterTest.php diff --git a/src/Doctrine/Orm/Tests/Metadata/Resource/UnwiredLegacyFilterParameterTest.php b/src/Doctrine/Orm/Tests/Metadata/Resource/UnwiredLegacyFilterParameterTest.php new file mode 100644 index 00000000000..51b8ab17294 --- /dev/null +++ b/src/Doctrine/Orm/Tests/Metadata/Resource/UnwiredLegacyFilterParameterTest.php @@ -0,0 +1,94 @@ + + * + * 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\Doctrine\Orm\Tests\Metadata\Resource; + +use ApiPlatform\Doctrine\Orm\Filter\DateFilter; +use ApiPlatform\Metadata\ApiProperty; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Exception\RuntimeException; +use ApiPlatform\Metadata\FilterInterface; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Metadata\QueryParameter; +use ApiPlatform\Metadata\Resource\Factory\AttributesResourceMetadataCollectionFactory; +use ApiPlatform\Metadata\Resource\Factory\ParameterResourceMetadataCollectionFactory; +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; + +final class UnwiredLegacyFilterParameterTest extends TestCase +{ + public function testUnwiredRegistryAwareFilterIsLoggedAtDebug(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->never())->method('alert'); + $logger->expects($this->once())->method('debug'); + + $this->createFactory($logger)->create(ResourceWithInlineDateFilter::class); + } + + public function testFilterFailureUnrelatedToTheManagerRegistryStillAlerts(): void + { + $logger = $this->createMock(LoggerInterface::class); + $logger->expects($this->once())->method('alert'); + $logger->expects($this->never())->method('debug'); + + $this->createFactory($logger)->create(ResourceWithThrowingFilter::class); + } + + private function createFactory(LoggerInterface $logger): ParameterResourceMetadataCollectionFactory + { + $propertyNameCollectionFactory = $this->createStub(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->method('create')->willReturn(new PropertyNameCollection(['id', 'updatedAt'])); + + $propertyMetadataFactory = $this->createStub(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->method('create')->willReturn(new ApiProperty(readable: true)); + + return new ParameterResourceMetadataCollectionFactory( + $propertyNameCollectionFactory, + $propertyMetadataFactory, + new AttributesResourceMetadataCollectionFactory(), + null, + null, + $logger, + ); + } +} + +final class ThrowingFilter implements FilterInterface +{ + public function getDescription(string $resourceClass): array + { + throw new RuntimeException('Something unexpected happened.'); + } +} + +#[ApiResource(operations: [ + new GetCollection(parameters: ['updatedAt' => new QueryParameter(filter: new DateFilter())]), +])] +final class ResourceWithInlineDateFilter +{ + public $id; + public $updatedAt; +} + +#[ApiResource(operations: [ + new GetCollection(parameters: ['updatedAt' => new QueryParameter(filter: new ThrowingFilter())]), +])] +final class ResourceWithThrowingFilter +{ + public $id; + public $updatedAt; +} diff --git a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php index 3972b1e8978..6fb17b9972b 100644 --- a/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php +++ b/src/Metadata/Resource/Factory/ParameterResourceMetadataCollectionFactory.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Metadata\Resource\Factory; +use ApiPlatform\Doctrine\Common\Filter\ManagerRegistryAwareInterface; use ApiPlatform\Doctrine\Common\Filter\PropertyAwareFilterInterface; use ApiPlatform\Metadata\ApiProperty; use ApiPlatform\Metadata\Exception\RuntimeException; @@ -422,7 +423,13 @@ private function setDefaults(string $key, Parameter $parameter, ?object $filter, try { return $this->getLegacyFilterMetadata($parameter, $operation, $filter); } catch (RuntimeException $exception) { - $this->logger?->alert($exception->getMessage(), ['exception' => $exception]); + // An inline filter instance never gets a ManagerRegistry, unlike one resolved as a service + // through the filter locator: failing to describe it is expected, not an alert-worthy event. + if ($filter instanceof ManagerRegistryAwareInterface && !$filter->hasManagerRegistry()) { + $this->logger?->debug($exception->getMessage(), ['exception' => $exception]); + } else { + $this->logger?->alert($exception->getMessage(), ['exception' => $exception]); + } return $parameter; } From 87dae7fc8b39e5366fe3a96af1f2c038cf0f3b2e Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 4 Sep 2026 16:06:00 +0200 Subject: [PATCH 78/84] chore: bump metadata floor to alpha.4 The Doctrine (ORM, ODM, Common) and JSON:API components ship filters implementing OpenApiParameterFilterInterface that instantiate ApiPlatform\OpenApi\Model\Parameter, and none of them depends on api-platform/openapi. ParameterResourceMetadataCollectionFactory only guards that call with class_exists() as of v4.4.0-alpha.4, so an older metadata makes them fatal on an install without the OpenApi component. --- src/Doctrine/Common/composer.json | 2 +- src/Doctrine/Odm/composer.json | 2 +- src/Doctrine/Orm/composer.json | 2 +- src/JsonApi/composer.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Doctrine/Common/composer.json b/src/Doctrine/Common/composer.json index cd6ab991a6c..e925d5d6e1a 100644 --- a/src/Doctrine/Common/composer.json +++ b/src/Doctrine/Common/composer.json @@ -24,7 +24,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^4.4@alpha", + "api-platform/metadata": "^4.4.0-alpha.4", "api-platform/state": "^4.4@alpha", "doctrine/collections": "^2.1 || ^3.0", "doctrine/common": "^3.2.2", diff --git a/src/Doctrine/Odm/composer.json b/src/Doctrine/Odm/composer.json index ff89d0b9fb0..1b3fa7b50b8 100644 --- a/src/Doctrine/Odm/composer.json +++ b/src/Doctrine/Odm/composer.json @@ -26,7 +26,7 @@ "require": { "php": ">=8.2", "api-platform/doctrine-common": "^4.4@alpha", - "api-platform/metadata": "^4.4@alpha", + "api-platform/metadata": "^4.4.0-alpha.4", "api-platform/serializer": "^4.4@alpha", "api-platform/state": "^4.4@alpha", "doctrine/mongodb-odm": "^2.10", diff --git a/src/Doctrine/Orm/composer.json b/src/Doctrine/Orm/composer.json index c03c5e0f721..3f8c0f1d0c7 100644 --- a/src/Doctrine/Orm/composer.json +++ b/src/Doctrine/Orm/composer.json @@ -25,7 +25,7 @@ "require": { "php": ">=8.2", "api-platform/doctrine-common": "^4.4@alpha", - "api-platform/metadata": "^4.4@alpha", + "api-platform/metadata": "^4.4.0-alpha.4", "api-platform/serializer": "^4.4@alpha", "api-platform/state": "^4.4@alpha", "composer/semver": "^3.4", diff --git a/src/JsonApi/composer.json b/src/JsonApi/composer.json index a9518aaab0f..10da0d2d464 100644 --- a/src/JsonApi/composer.json +++ b/src/JsonApi/composer.json @@ -24,7 +24,7 @@ "php": ">=8.2", "api-platform/documentation": "^4.4@alpha", "api-platform/json-schema": "^4.4@alpha", - "api-platform/metadata": "^4.4@alpha", + "api-platform/metadata": "^4.4.0-alpha.4", "api-platform/serializer": "^4.4.0-alpha.3", "api-platform/state": "^4.4@alpha", "symfony/error-handler": "^7.4 || ^8.0", From c6b048a0c9c78893a5c96c8cf0b4f75fdc9898a8 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 4 Sep 2026 16:06:01 +0200 Subject: [PATCH 79/84] doc: changelog 4.4.0-alpha.4 --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23ef8b6f9ea..a1eb16f5657 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## v4.4.0-alpha.4 + +### Bug fixes + +* [854c9218e](https://github.com/api-platform/core/commit/854c9218efd907b87c096a2a6e3e44c7f8c36f1a) fix(metadata): stop alerting on inline unwired filters at warmup (#8490) + +### Dependencies + +* The Doctrine (ORM, ODM, Common) and JSON:API components now require `api-platform/metadata` `^4.4.0-alpha.4`. Their filters instantiate `ApiPlatform\OpenApi\Model\Parameter`, which the metadata component only guards behind a `class_exists()` check from that version on, so an older metadata makes them fatal on an install without `api-platform/openapi`. + +Also contains [v4.3.18 changes](#v4318). + ## v4.4.0-alpha.3 ### Bug fixes From 233880fa6c4fa737c05a86d6520b7147909d1cae Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 4 Sep 2026 16:10:00 +0200 Subject: [PATCH 80/84] chore: bump metadata floor to 5.0.0-alpha.3 The Doctrine (ORM, ODM, Common) and JSON:API components ship filters implementing OpenApiParameterFilterInterface that instantiate ApiPlatform\OpenApi\Model\Parameter, and none of them depends on api-platform/openapi. ParameterResourceMetadataCollectionFactory only guards that call with class_exists() as of v5.0.0-alpha.3, so an older metadata makes them fatal on an install without the OpenApi component. --- src/Doctrine/Common/composer.json | 2 +- src/Doctrine/Odm/composer.json | 2 +- src/Doctrine/Orm/composer.json | 2 +- src/JsonApi/composer.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Doctrine/Common/composer.json b/src/Doctrine/Common/composer.json index 7a250a44ded..15ab1faa141 100644 --- a/src/Doctrine/Common/composer.json +++ b/src/Doctrine/Common/composer.json @@ -24,7 +24,7 @@ ], "require": { "php": ">=8.2", - "api-platform/metadata": "^5.0@alpha", + "api-platform/metadata": "^5.0.0-alpha.3", "api-platform/state": "^5.0@alpha", "doctrine/collections": "^2.1 || ^3.0", "doctrine/common": "^3.2.2", diff --git a/src/Doctrine/Odm/composer.json b/src/Doctrine/Odm/composer.json index e7cd30933a0..a633f30de8c 100644 --- a/src/Doctrine/Odm/composer.json +++ b/src/Doctrine/Odm/composer.json @@ -26,7 +26,7 @@ "require": { "php": ">=8.2", "api-platform/doctrine-common": "^5.0.0-alpha.2", - "api-platform/metadata": "^5.0@alpha", + "api-platform/metadata": "^5.0.0-alpha.3", "api-platform/serializer": "^5.0@alpha", "api-platform/state": "^5.0@alpha", "doctrine/mongodb-odm": "^2.10", diff --git a/src/Doctrine/Orm/composer.json b/src/Doctrine/Orm/composer.json index 0974ce4697a..bb768304b74 100644 --- a/src/Doctrine/Orm/composer.json +++ b/src/Doctrine/Orm/composer.json @@ -25,7 +25,7 @@ "require": { "php": ">=8.2", "api-platform/doctrine-common": "^5.0.0-alpha.2", - "api-platform/metadata": "^5.0@alpha", + "api-platform/metadata": "^5.0.0-alpha.3", "api-platform/serializer": "^5.0@alpha", "api-platform/state": "^5.0@alpha", "composer/semver": "^3.4", diff --git a/src/JsonApi/composer.json b/src/JsonApi/composer.json index 63ffd5f9f9e..ee99a11f556 100644 --- a/src/JsonApi/composer.json +++ b/src/JsonApi/composer.json @@ -24,7 +24,7 @@ "php": ">=8.2", "api-platform/documentation": "^5.0@alpha", "api-platform/json-schema": "^5.0@alpha", - "api-platform/metadata": "^5.0@alpha", + "api-platform/metadata": "^5.0.0-alpha.3", "api-platform/serializer": "^5.0.0-alpha.2", "api-platform/state": "^5.0@alpha", "symfony/error-handler": "^7.4 || ^8.0", From f631000982e04ba088935215334828dc645fcc46 Mon Sep 17 00:00:00 2001 From: soyuka Date: Fri, 4 Sep 2026 16:10:10 +0200 Subject: [PATCH 81/84] doc: changelog 5.0.0-alpha.3 --- CHANGELOG.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49b7640f4f7..d16b38ed4b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## v5.0.0-alpha.3 + +### Features + +* [672d48e25](https://github.com/api-platform/core/commit/672d48e2510ca08aed92958677c8f861cb36f959) feat(symfony): map UniqueConstraintViolationException to 422 by default (#8478) + +### Bug fixes + +* [78151b7d1](https://github.com/api-platform/core/commit/78151b7d19da1dfba9a37362bb23a9cb4ff4acd1) fix(mcp): return resource read results for resources (#8436) + +### Dependencies + +* The Doctrine (ORM, ODM, Common) and JSON:API components now require `api-platform/metadata` `^5.0.0-alpha.3`. Their filters instantiate `ApiPlatform\OpenApi\Model\Parameter`, which the metadata component only guards behind a `class_exists()` check from that version on, so an older metadata makes them fatal on an install without `api-platform/openapi`. + +### Notes + +* JSON-LD: `/contexts/Error` and `/contexts/ConstraintViolationList` are no longer special-cased to the base context; they are built like any other resource context, since exceptions have been resources since 3.2 (#8402). +* `SerializerContextBuilder` no longer injects `uri_variables` into the serialization context — URI variables are parsed by the serializer processor instead (#8402). + +Also contains [v4.4.0-alpha.4 changes](#v440-alpha4). + ## v5.0.0-alpha.2 ### Breaking changes From 74bef1969a982f0378d38fb5fe1a074df79140fa Mon Sep 17 00:00:00 2001 From: Antoine Bluchet Date: Sat, 5 Sep 2026 08:19:15 +0200 Subject: [PATCH 82/84] test(doctrine): adapt unwired filter test to 5.0 (#8501) --- .../Metadata/Resource/UnwiredLegacyFilterParameterTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Doctrine/Orm/Tests/Metadata/Resource/UnwiredLegacyFilterParameterTest.php b/src/Doctrine/Orm/Tests/Metadata/Resource/UnwiredLegacyFilterParameterTest.php index 51b8ab17294..d6bf62583b3 100644 --- a/src/Doctrine/Orm/Tests/Metadata/Resource/UnwiredLegacyFilterParameterTest.php +++ b/src/Doctrine/Orm/Tests/Metadata/Resource/UnwiredLegacyFilterParameterTest.php @@ -30,11 +30,11 @@ final class UnwiredLegacyFilterParameterTest extends TestCase { - public function testUnwiredRegistryAwareFilterIsLoggedAtDebug(): void + public function testUnwiredRegistryAwareFilterIsNotLogged(): void { $logger = $this->createMock(LoggerInterface::class); $logger->expects($this->never())->method('alert'); - $logger->expects($this->once())->method('debug'); + $logger->expects($this->never())->method('debug'); $this->createFactory($logger)->create(ResourceWithInlineDateFilter::class); } From 6ce161d47db287b71c0c550c0e43125f969ad6bc Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Tue, 8 Sep 2026 12:43:50 +0200 Subject: [PATCH 83/84] fix(mcp): declare missing dependencies and run the component in CI --- .github/workflows/ci.yml | 1 + src/Mcp/composer.json | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2524ecf0660..04acb811585 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -375,6 +375,7 @@ jobs: - api-platform/openapi - api-platform/graphql - api-platform/http-cache + - api-platform/mcp - api-platform/ramsey-uuid - api-platform/serializer - api-platform/state diff --git a/src/Mcp/composer.json b/src/Mcp/composer.json index 22fc743f322..24bab2d78cc 100644 --- a/src/Mcp/composer.json +++ b/src/Mcp/composer.json @@ -30,12 +30,17 @@ "php": ">=8.2", "api-platform/metadata": "^5.0@alpha", "api-platform/json-schema": "^5.0@alpha", + "api-platform/state": "^5.0@alpha", "mcp/sdk": "^0.8", + "psr/log": "^1.0 || ^2.0 || ^3.0", + "symfony/http-foundation": "^7.4 || ^8.0", "symfony/object-mapper": "^7.4 || ^8.0", - "symfony/polyfill-php85": "^1.32" + "symfony/polyfill-php85": "^1.32", + "symfony/serializer": "^7.4 || ^8.0" }, "require-dev": { - "phpunit/phpunit": "^11.5 || ^12.2" + "phpunit/phpunit": "^11.5 || ^12.2", + "symfony/expression-language": "^7.4 || ^8.0" }, "autoload": { "psr-4": { @@ -60,6 +65,12 @@ "url": "https://github.com/api-platform/api-platform" } }, + "suggest": { + "symfony/expression-language": "To use the operation-level \"security\" expressions." + }, + "scripts": { + "test": "./vendor/bin/phpunit" + }, "minimum-stability": "beta", "prefer-stable": true } From ec36afaa5474316f824e3a0602389c3ccaf57280 Mon Sep 17 00:00:00 2001 From: Maxcastel Date: Tue, 8 Sep 2026 14:00:02 +0200 Subject: [PATCH 84/84] fix(mcp): skip ToolProvider mapping when the request carries no tool payload --- src/Mcp/State/ToolProvider.php | 2 +- src/Mcp/Tests/State/ToolProviderTest.php | 42 ++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 src/Mcp/Tests/State/ToolProviderTest.php diff --git a/src/Mcp/State/ToolProvider.php b/src/Mcp/State/ToolProvider.php index dff2e8874eb..8ed7f761d17 100644 --- a/src/Mcp/State/ToolProvider.php +++ b/src/Mcp/State/ToolProvider.php @@ -30,7 +30,7 @@ public function __construct(private readonly ObjectMapperInterface $objectMapper public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null { - if (!isset($context['mcp_request'])) { + if (!isset($context['mcp_request'], $context['mcp_data'])) { return null; } diff --git a/src/Mcp/Tests/State/ToolProviderTest.php b/src/Mcp/Tests/State/ToolProviderTest.php new file mode 100644 index 00000000000..15308a6d1e9 --- /dev/null +++ b/src/Mcp/Tests/State/ToolProviderTest.php @@ -0,0 +1,42 @@ + + * + * 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\Mcp\Tests\State; + +use ApiPlatform\Mcp\State\ToolProvider; +use ApiPlatform\Metadata\McpResource; +use Mcp\Schema\Request\ReadResourceRequest; +use PHPUnit\Framework\TestCase; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; + +class ToolProviderTest extends TestCase +{ + /** + * The handler installs this provider on every MCP operation that declares none, + * MCP resources included, but it only fills `mcp_data` for a tool call: reading + * a resource must not be mapped from a payload that does not exist. + */ + public function testProvideReturnsNullWhenTheRequestCarriesNoToolPayload(): void + { + $objectMapper = $this->createMock(ObjectMapperInterface::class); + $objectMapper->expects($this->never())->method('map'); + + $provider = new ToolProvider($objectMapper); + + $operation = new McpResource(uri: 'dummy://docs', name: 'docs', class: \stdClass::class); + + $this->assertNull($provider->provide($operation, [], [ + 'mcp_request' => new ReadResourceRequest('dummy://docs'), + ])); + } +}