diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab21915d65f..06df63ea290 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -522,8 +522,8 @@ jobs: - name: Update project dependencies run: | composer update --no-interaction --no-progress --ansi - # - name: Require Symfony Uid - # run: composer require symfony/uid --dev --no-interaction --no-progress --ansi + - name: Require Symfony Uid + run: composer require symfony/uid --dev --no-interaction --no-progress --ansi - name: Install PHPUnit run: vendor/bin/simple-phpunit --version - name: Clear test app cache @@ -762,6 +762,7 @@ jobs: matrix: php: - '7.4' + - '8.0' fail-fast: false env: APP_ENV: sqlite @@ -786,10 +787,14 @@ jobs: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} restore-keys: ${{ runner.os }}-composer- + - name: Set Composer platform config + if: (startsWith(matrix.php, '8.0')) + run: | + composer config platform.php 7.4.99 - name: Update project dependencies - run: composer update --no-interaction --no-progress --ansi --ignore-platform-reqs + run: composer update --no-interaction --no-progress --ansi - name: Require Symfony Uid - run: composer require symfony/uid --dev --no-interaction --no-progress --ansi --ignore-platform-reqs + run: composer require symfony/uid --dev --no-interaction --no-progress --ansi - name: Install phpunit run: vendor/bin/simple-phpunit --version - name: Clear test app cache @@ -805,6 +810,7 @@ jobs: matrix: php: - '7.4' + - '8.0' fail-fast: false env: APP_ENV: sqlite @@ -829,10 +835,14 @@ jobs: path: ${{ steps.composercache.outputs.dir }} key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.json') }} restore-keys: ${{ runner.os }}-composer- + - name: Set Composer platform config + if: (startsWith(matrix.php, '8.0')) + run: | + composer config platform.php 7.4.99 - name: Update project dependencies - run: composer update --no-interaction --no-progress --ansi --ignore-platform-reqs + run: composer update --no-interaction --no-progress --ansi - name: Require Symfony Uid - run: composer require symfony/uid --dev --no-interaction --no-progress --ansi --ignore-platform-reqs + run: composer require symfony/uid --dev --no-interaction --no-progress --ansi - name: Install phpunit run: vendor/bin/simple-phpunit --version - name: Clear test app cache diff --git a/CHANGELOG.md b/CHANGELOG.md index 5145a3c46e2..0714fcb2e39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ * MongoDB: `date_immutable` support (#3940) +## 2.6.3 + +* Mercure: Do not use data in options when deleting (#4056) + +## 2.6.2 + +* Validation: properties regex pattern is now compliant with ECMA 262 (#4027) +* OpenApi: normalizer is now backward compatible (#4016), fix the name converter issue changing OpenApi properties (#4019) +* Identifiers: Break after transforming the identifier (#3985), use the identifiers context to transform with multiple classes (#4029) +* JsonSchema: Revert `ALLOW_EXTRA_ATTRIBUTE=false` as it is a BC break and will be done in 3.0 instead see #3881 (#4007) +* Subresource: fix ApiSubresource maxDepth option (#3986), recursive issue in the profiler (#4023) +* OpenApi: Allow `requestBody` and `parameters` via the `openapi_context` (#4001), make `openapi_context` work on subresources (#4004), sort paths (#4013) +* Config: Allow disabling OpenAPI and Swagger UI without loosing the schema (#3968 and #4018), fix pagination defaults (#4011) +* DataPersister: context propagation fix (#3983) + ## 2.6.1 * Fix defaults when using attributes (#3978) diff --git a/features/hydra/collection.feature b/features/hydra/collection.feature index ee92c4002e7..aebca7165d7 100644 --- a/features/hydra/collection.feature +++ b/features/hydra/collection.feature @@ -203,9 +203,13 @@ Feature: Collections support "hydra:next": {"pattern": "^/dummies\\?partial=1&page=8$"}, "hydra:previous": {"pattern": "^/dummies\\?partial=1&page=6$"} }, - "additionalProperties": false + "required": ["@id", "@type", "hydra:next", "hydra:previous"], + "additionalProperties": false, + "maxProperties": 4 } - } + }, + "required": ["@context", "@id", "@type", "hydra:member", "hydra:view", "hydra:search"], + "maxProperties": 6 } """ @@ -275,16 +279,16 @@ Feature: Collections support And the response 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" - } - """ + """ + { + "@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 diff --git a/features/jsonapi/related-resouces-inclusion.feature b/features/jsonapi/related-resouces-inclusion.feature index df339df10db..d2850d7ea49 100644 --- a/features/jsonapi/related-resouces-inclusion.feature +++ b/features/jsonapi/related-resouces-inclusion.feature @@ -602,7 +602,7 @@ Feature: JSON API Inclusion of Related Resources 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: + And the JSON should be a superset of: """ { "links": { diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 35e32f8b377..c4e2f5fc0fd 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -92,7 +92,7 @@ parameters: # Expected, due to PHP 8 attributes - '#ReflectionProperty::getAttributes\(\)#' - '#ReflectionMethod::getAttributes\(\)#' - - '#ReflectionClass::getAttributes\(\)#' + - '#ReflectionClass::getAttributes\(\)#' - '#Constructor of class ApiPlatform\\Core\\Annotation\\ApiResource has an unused parameter#' - '#Constructor of class ApiPlatform\\Core\\Annotation\\ApiProperty has an unused parameter#' @@ -118,9 +118,3 @@ parameters: - message: "#Call to function method_exists\\(\\) with ApiPlatform\\\\Core\\\\JsonApi\\\\Serializer\\\\ItemNormalizer and 'setCircularReferenc…' will always evaluate to false\\.#" path: tests/JsonApi/Serializer/ItemNormalizerTest.php - # Waiting to be fixed by https://github.com/Roave/BetterReflection/issues/663 - - - message: '#Call to private method getNestedFieldPath\(\) of class ApiPlatform\\Core\\Bridge\\Elasticsearch\\DataProvider\\Filter\\AbstractFilter\.#' - paths: - - src/Bridge/Elasticsearch/DataProvider/Filter/OrderFilter.php - - src/Bridge/Elasticsearch/DataProvider/Filter/AbstractSearchFilter.php diff --git a/src/Annotation/ApiResource.php b/src/Annotation/ApiResource.php index 1bee3858161..a591557e10b 100644 --- a/src/Annotation/ApiResource.php +++ b/src/Annotation/ApiResource.php @@ -148,7 +148,7 @@ final class ApiResource * @param bool|array $mercure https://api-platform.com/docs/core/mercure * @param bool $messenger https://api-platform.com/docs/core/messenger/#dispatching-a-resource-through-the-message-bus * @param array $normalizationContext https://api-platform.com/docs/core/serialization/#using-serialization-groups - * @param array $openapiContext https://api-platform.com/docs/core/swagger/#using-the-openapi-and-swagger-contexts + * @param array $openapiContext https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts * @param array $order https://api-platform.com/docs/core/default-order/#overriding-default-order * @param string|false $output https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation * @param bool $paginationClientEnabled https://api-platform.com/docs/core/pagination/#for-a-specific-resource-1 @@ -167,7 +167,7 @@ final class ApiResource * @param string $securityPostDenormalizeMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message * @param bool $stateless * @param string $sunset https://api-platform.com/docs/core/deprecations/#setting-the-sunset-http-header-to-indicate-when-a-resource-or-an-operation-will-be-removed - * @param array $swaggerContext https://api-platform.com/docs/core/swagger/#using-the-openapi-and-swagger-contexts + * @param array $swaggerContext https://api-platform.com/docs/core/openapi/#using-the-openapi-and-swagger-contexts * @param array $validationGroups https://api-platform.com/docs/core/validation/#using-validation-groups * @param int $urlGenerationStrategy * diff --git a/src/Annotation/ApiSubresource.php b/src/Annotation/ApiSubresource.php index 701c4596b44..40eeb3d8bb4 100644 --- a/src/Annotation/ApiSubresource.php +++ b/src/Annotation/ApiSubresource.php @@ -20,6 +20,9 @@ * * @Annotation * @Target({"METHOD", "PROPERTY"}) + * @Attributes( + * @Attribute("maxDepth", type="int"), + * ) */ #[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD)] final class ApiSubresource @@ -36,6 +39,8 @@ public function __construct($maxDepth = null) { if (!\is_array($maxDepth)) { // @phpstan-ignore-line $this->maxDepth = $maxDepth; + } else { + $this->maxDepth = $maxDepth['maxDepth'] ?? null; } } } diff --git a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php index 3579c21843d..65860a20985 100644 --- a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php +++ b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -213,7 +213,7 @@ private function publishUpdate($object, array $options, string $type): void // and I'm not a fond of this approach. $iri = $options['topics'] ?? $object->iri; /** @var string $data */ - $data = $options['data'] ?? json_encode(['@id' => $object->id]); + $data = json_encode(['@id' => $object->id]); } else { $resourceClass = $this->getObjectClass($object); $context = $options['normalization_context'] ?? $this->resourceMetadataFactory->create($resourceClass)->getAttribute('normalization_context', []); diff --git a/src/Bridge/Symfony/Bundle/DataCollector/RequestDataCollector.php b/src/Bridge/Symfony/Bundle/DataCollector/RequestDataCollector.php index a5c8db17701..2b7c993cf5d 100644 --- a/src/Bridge/Symfony/Bundle/DataCollector/RequestDataCollector.php +++ b/src/Bridge/Symfony/Bundle/DataCollector/RequestDataCollector.php @@ -72,15 +72,20 @@ public function collect(Request $request, Response $response, \Throwable $except ++$counters['ignored_filters']; } + $requestAttributes = RequestAttributesExtractor::extractAttributes($request); + if (isset($requestAttributes['previous_data'])) { + $requestAttributes['previous_data'] = $this->cloneVar($requestAttributes['previous_data']); + } + $this->data = [ 'resource_class' => $resourceClass, 'resource_metadata' => $resourceMetadata ? $this->cloneVar($resourceMetadata) : null, 'acceptable_content_types' => $request->getAcceptableContentTypes(), - 'request_attributes' => RequestAttributesExtractor::extractAttributes($request), 'filters' => $filters, 'counters' => $counters, 'dataProviders' => [], 'dataPersisters' => ['responses' => []], + 'request_attributes' => $requestAttributes, ]; if ($this->collectionDataProvider instanceof TraceableChainCollectionDataProvider) { diff --git a/src/Bridge/Symfony/Bundle/DataPersister/TraceableChainDataPersister.php b/src/Bridge/Symfony/Bundle/DataPersister/TraceableChainDataPersister.php index 801d12e82d7..a85a79f2219 100644 --- a/src/Bridge/Symfony/Bundle/DataPersister/TraceableChainDataPersister.php +++ b/src/Bridge/Symfony/Bundle/DataPersister/TraceableChainDataPersister.php @@ -73,7 +73,7 @@ private function tracePersisters($data, array $context = []) $found = false; foreach ($this->persisters as $persister) { if ( - ($this->persistersResponse[\get_class($persister)] = $found ? false : $persister->supports($data)) + ($this->persistersResponse[\get_class($persister)] = $found ? false : $persister->supports($data, $context)) && !($persister instanceof ResumableDataPersisterInterface && $persister->resumable()) && !$found ) { diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 8110b5bb07c..c330c90905f 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -185,7 +185,7 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.collection.exists_parameter_name', $config['collection']['exists_parameter_name']); $container->setParameter('api_platform.collection.order', $config['defaults']['order'] ?? $config['collection']['order']); $container->setParameter('api_platform.collection.order_parameter_name', $config['collection']['order_parameter_name']); - $container->setParameter('api_platform.collection.pagination.enabled', $this->isConfigEnabled($container, $config['defaults']['pagination_enabled'] ?? $config['collection']['pagination'])); + $container->setParameter('api_platform.collection.pagination.enabled', $config['defaults']['pagination_enabled'] ?? $this->isConfigEnabled($container, $config['collection']['pagination'])); $container->setParameter('api_platform.collection.pagination.partial', $config['defaults']['pagination_partial'] ?? $config['collection']['pagination']['partial']); $container->setParameter('api_platform.collection.pagination.client_enabled', $config['defaults']['pagination_client_enabled'] ?? $config['collection']['pagination']['client_enabled']); $container->setParameter('api_platform.collection.pagination.client_items_per_page', $config['defaults']['pagination_client_items_per_page'] ?? $config['collection']['pagination']['client_items_per_page']); @@ -397,6 +397,10 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array $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.swagger.api_keys', $config['swagger']['api_keys']); + + if (true === $config['openapi']['backward_compatibility_layer']) { + $container->getDefinition('api_platform.swagger.normalizer.documentation')->addArgument($container->getDefinition('api_platform.openapi.normalizer')); + } } private function registerJsonApiConfiguration(array $formats, XmlFileLoader $loader): void diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index b0dfe68cf3e..de9a01c80a8 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -483,6 +483,7 @@ private function addOpenApiSection(ArrayNodeDefinition $rootNode): void ->scalarNode('email')->defaultNull()->info('The email address of the contact person/organization. MUST be in the format of an email address.')->end() ->end() ->end() + ->booleanNode('backward_compatibility_layer')->defaultTrue()->info('Enable this to decorate the "api_platform.swagger.normalizer.documentation" instead of decorating the OpenAPI factory.')->end() ->scalarNode('termsOfService')->defaultNull()->info('A URL to the Terms of Service for the API. MUST be in the format of a URL.')->end() ->arrayNode('license') ->addDefaultsIfNotSet() diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index 4742de28199..cb4bf137755 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -275,11 +275,11 @@ - + - + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml index f512cfd7f0b..ec63780de2c 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml @@ -6,8 +6,26 @@ - - + + + + + + null + null + + + + + + + + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/ramsey_uuid.xml b/src/Bridge/Symfony/Bundle/Resources/config/ramsey_uuid.xml index bbf81f232fa..e094bafdd1d 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/ramsey_uuid.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/ramsey_uuid.xml @@ -6,7 +6,7 @@ - + diff --git a/src/Bridge/Symfony/Bundle/Test/ApiTestAssertionsTrait.php b/src/Bridge/Symfony/Bundle/Test/ApiTestAssertionsTrait.php index a3f828a0340..bd246318424 100644 --- a/src/Bridge/Symfony/Bundle/Test/ApiTestAssertionsTrait.php +++ b/src/Bridge/Symfony/Bundle/Test/ApiTestAssertionsTrait.php @@ -20,7 +20,6 @@ use ApiPlatform\Core\JsonSchema\SchemaFactoryInterface; use PHPUnit\Framework\ExpectationFailedException; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; -use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; use Symfony\Contracts\HttpClient\ResponseInterface; /** @@ -112,14 +111,14 @@ public static function assertMatchesJsonSchema($jsonSchema, ?int $checkMode = nu public static function assertMatchesResourceCollectionJsonSchema(string $resourceClass, ?string $operationName = null, string $format = 'jsonld'): void { - $schema = self::getSchemaFactory()->buildSchema($resourceClass, $format, Schema::TYPE_OUTPUT, OperationType::COLLECTION, $operationName, null, [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false]); + $schema = self::getSchemaFactory()->buildSchema($resourceClass, $format, Schema::TYPE_OUTPUT, OperationType::COLLECTION, $operationName, null); static::assertMatchesJsonSchema($schema->getArrayCopy()); } public static function assertMatchesResourceItemJsonSchema(string $resourceClass, ?string $operationName = null, string $format = 'jsonld'): void { - $schema = self::getSchemaFactory()->buildSchema($resourceClass, $format, Schema::TYPE_OUTPUT, OperationType::ITEM, $operationName, null, [AbstractNormalizer::ALLOW_EXTRA_ATTRIBUTES => false]); + $schema = self::getSchemaFactory()->buildSchema($resourceClass, $format, Schema::TYPE_OUTPUT, OperationType::ITEM, $operationName, null); static::assertMatchesJsonSchema($schema->getArrayCopy()); } diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRegexRestriction.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRegexRestriction.php index dccc81e9fbd..167152748c8 100644 --- a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRegexRestriction.php +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRegexRestriction.php @@ -29,7 +29,7 @@ class PropertySchemaRegexRestriction implements PropertySchemaRestrictionMetadat */ public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array { - return isset($constraint->pattern) ? ['pattern' => $constraint->pattern] : []; + return $constraint instanceof Regex && $constraint->getHtmlPattern() ? ['pattern' => $constraint->getHtmlPattern()] : []; } /** diff --git a/src/DataProvider/OperationDataProviderTrait.php b/src/DataProvider/OperationDataProviderTrait.php index 7e0de21fbcb..7e08d768b4d 100644 --- a/src/DataProvider/OperationDataProviderTrait.php +++ b/src/DataProvider/OperationDataProviderTrait.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Exception\InvalidIdentifierException; use ApiPlatform\Core\Exception\RuntimeException; use ApiPlatform\Core\Identifier\CompositeIdentifierParser; +use ApiPlatform\Core\Identifier\ContextAwareIdentifierConverterInterface; use ApiPlatform\Core\Identifier\IdentifierConverterInterface; /** @@ -106,7 +107,7 @@ private function extractIdentifiers(array $parameters, array $attributes) throw new InvalidIdentifierException(sprintf('Expected %d identifiers, got %d', $identifiersNumber, $currentIdentifiersNumber)); } - return $this->identifierConverter->convert($identifiers, $attributes['resource_class']); + return $this->identifierConverter->convert($identifiers, $identifiedBy[0]); } // TODO: Subresources tuple may have a third item representing if it is a "collection", this behavior will be removed in 3.0 @@ -120,6 +121,10 @@ private function extractIdentifiers(array $parameters, array $attributes) $identifiers[$parameterName] = $parameters[$parameterName]; } + if ($this->identifierConverter instanceof ContextAwareIdentifierConverterInterface) { + return $this->identifierConverter->convert($identifiers, $attributes['resource_class'], ['identifiers' => $identifiersKeys]); + } + return $this->identifierConverter->convert($identifiers, $attributes['resource_class']); } } diff --git a/src/Hydra/Serializer/PartialCollectionViewNormalizer.php b/src/Hydra/Serializer/PartialCollectionViewNormalizer.php index 3e0bae6eb08..f6a18586b22 100644 --- a/src/Hydra/Serializer/PartialCollectionViewNormalizer.php +++ b/src/Hydra/Serializer/PartialCollectionViewNormalizer.php @@ -62,7 +62,7 @@ public function normalize($object, $format = null, array $context = []) } $currentPage = $lastPage = $itemsPerPage = $pageTotalItems = null; - if ($paginated = $object instanceof PartialPaginatorInterface) { + if ($paginated = ($object instanceof PartialPaginatorInterface)) { if ($object instanceof PaginatorInterface) { $paginated = 1. !== $lastPage = $object->getLastPage(); } else { @@ -81,41 +81,20 @@ public function normalize($object, $format = null, array $context = []) return $data; } + $cursorPaginationAttribute = null; $metadata = isset($context['resource_class']) && null !== $this->resourceMetadataFactory ? $this->resourceMetadataFactory->create($context['resource_class']) : null; $isPaginatedWithCursor = $paginated && null !== $metadata && null !== $cursorPaginationAttribute = $metadata->getCollectionOperationAttribute($context['collection_operation_name'] ?? $context['subresource_operation_name'], 'pagination_via_cursor', null, true); - $data['hydra:view'] = [ - '@id' => IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated && !$isPaginatedWithCursor ? $currentPage : null), - '@type' => 'hydra:PartialCollectionView', - ]; + $data['hydra:view'] = ['@id' => null, '@type' => 'hydra:PartialCollectionView']; if ($isPaginatedWithCursor) { - $objects = iterator_to_array($object); - $firstObject = current($objects); - $lastObject = end($objects); - - $data['hydra:view']['@id'] = IriHelper::createIri($parsed['parts'], $parsed['parameters']); - - if (false !== $lastObject && isset($cursorPaginationAttribute)) { - $data['hydra:view']['hydra:next'] = IriHelper::createIri($parsed['parts'], array_merge($parsed['parameters'], $this->cursorPaginationFields($cursorPaginationAttribute, 1, $lastObject))); - } - - if (false !== $firstObject && isset($cursorPaginationAttribute)) { - $data['hydra:view']['hydra:previous'] = IriHelper::createIri($parsed['parts'], array_merge($parsed['parameters'], $this->cursorPaginationFields($cursorPaginationAttribute, -1, $firstObject))); - } - } elseif ($paginated) { - if (null !== $lastPage) { - $data['hydra:view']['hydra:first'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1.); - $data['hydra:view']['hydra:last'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage); - } + return $this->populateDataWithCursorBasedPagination($data, $parsed, $object, $cursorPaginationAttribute); + } - if (1. !== $currentPage) { - $data['hydra:view']['hydra:previous'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1.); - } + $data['hydra:view']['@id'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null); - if (null !== $lastPage && $currentPage < $lastPage || null === $lastPage && $pageTotalItems > $itemsPerPage) { - $data['hydra:view']['hydra:next'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1.); - } + if ($paginated) { + return $this->populateDataWithPagination($data, $parsed, $currentPage, $lastPage, $itemsPerPage, $pageTotalItems); } return $data; @@ -164,4 +143,41 @@ private function cursorPaginationFields(array $fields, int $direction, $object) return $paginationFilters; } + + private function populateDataWithCursorBasedPagination(array $data, array $parsed, \Traversable $object, $cursorPaginationAttribute): array + { + $objects = iterator_to_array($object); + $firstObject = current($objects); + $lastObject = end($objects); + + $data['hydra:view']['@id'] = IriHelper::createIri($parsed['parts'], $parsed['parameters']); + + if (false !== $lastObject && \is_array($cursorPaginationAttribute)) { + $data['hydra:view']['hydra:next'] = IriHelper::createIri($parsed['parts'], array_merge($parsed['parameters'], $this->cursorPaginationFields($cursorPaginationAttribute, 1, $lastObject))); + } + + if (false !== $firstObject && \is_array($cursorPaginationAttribute)) { + $data['hydra:view']['hydra:previous'] = IriHelper::createIri($parsed['parts'], array_merge($parsed['parameters'], $this->cursorPaginationFields($cursorPaginationAttribute, -1, $firstObject))); + } + + return $data; + } + + private function populateDataWithPagination(array $data, array $parsed, ?float $currentPage, ?float $lastPage, ?float $itemsPerPage, ?float $pageTotalItems): array + { + if (null !== $lastPage) { + $data['hydra:view']['hydra:first'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1.); + $data['hydra:view']['hydra:last'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage); + } + + if (1. !== $currentPage) { + $data['hydra:view']['hydra:previous'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1.); + } + + if ((null !== $lastPage && $currentPage < $lastPage) || (null === $lastPage && $pageTotalItems >= $itemsPerPage)) { + $data['hydra:view']['hydra:next'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1.); + } + + return $data; + } } diff --git a/src/Identifier/IdentifierConverter.php b/src/Identifier/IdentifierConverter.php index 714d3de7c21..5c62ab9fe58 100644 --- a/src/Identifier/IdentifierConverter.php +++ b/src/Identifier/IdentifierConverter.php @@ -56,8 +56,9 @@ public function convert($data, string $class, array $context = []): array } $identifiers = $data; - foreach ($data as $identifier => $value) { - if (null === $type = $this->getIdentifierType($class, $identifier)) { + + foreach ($data as $parameter => $value) { + if (null === $type = $this->getIdentifierType($context['identifiers'][$parameter][0] ?? $class, $context['identifiers'][$parameter][1] ?? $parameter)) { continue; } @@ -68,9 +69,10 @@ public function convert($data, string $class, array $context = []): array } try { - $identifiers[$identifier] = $identifierTransformer->denormalize($value, $type); + $identifiers[$parameter] = $identifierTransformer->denormalize($value, $type); + break; } catch (InvalidIdentifierException $e) { - throw new InvalidIdentifierException(sprintf('Identifier "%s" could not be denormalized.', $identifier), $e->getCode(), $e); + throw new InvalidIdentifierException(sprintf('Identifier "%s" could not be denormalized.', $parameter), $e->getCode(), $e); } } } diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index f65231a2dfb..7eb1f175cde 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -162,6 +162,12 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour $parameters = []; $responses = []; + if ($operation['openapi_context']['parameters'] ?? false) { + foreach ($operation['openapi_context']['parameters'] as $parameter) { + $parameters[] = new Model\Parameter($parameter['name'], $parameter['in'], $parameter['description'] ?? '', $parameter['required'] ?? false, $parameter['deprecated'] ?? false, $parameter['allowEmptyValue'] ?? false, $parameter['schema'] ?? [], $parameter['style'] ?? null, $parameter['explode'] ?? false, $parameter['allowReserved '] ?? false, $parameter['example'] ?? null, isset($parameter['examples']) ? new \ArrayObject($parameter['examples']) : null, isset($parameter['content']) ? new \ArrayObject($parameter['content']) : null); + } + } + // Set up parameters if (OperationType::ITEM === $operationType) { foreach ($identifiers as $parameterName => $identifier) { @@ -218,7 +224,9 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour } $requestBody = null; - if ('PUT' === $method || 'POST' === $method || 'PATCH' === $method) { + if ($contextRequestBody = $operation['openapi_context']['requestBody'] ?? false) { + $requestBody = new Model\RequestBody($contextRequestBody['description'] ?? '', new \ArrayObject($contextRequestBody['content']), $contextRequestBody['required'] ?? false); + } elseif ('PUT' === $method || 'POST' === $method || 'PATCH' === $method) { $operationInputSchemas = []; foreach ($requestMimeTypes as $operationFormat) { $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operationType, $operationName, new Schema('openapi'), null, $forceSchemaCollection); diff --git a/src/OpenApi/Model/Parameter.php b/src/OpenApi/Model/Parameter.php index f68f0cbd231..a44ab7ee8d1 100644 --- a/src/OpenApi/Model/Parameter.php +++ b/src/OpenApi/Model/Parameter.php @@ -56,11 +56,13 @@ public function __construct(string $name, string $in, string $description = '', } } + // TODO: string not ?string public function getName(): ?string { return $this->name; } + // TODO: string not ?string public function getIn(): ?string { return $this->in; diff --git a/src/OpenApi/Serializer/OpenApiNormalizer.php b/src/OpenApi/Serializer/OpenApiNormalizer.php index 381b2599574..ba6a80abfb9 100644 --- a/src/OpenApi/Serializer/OpenApiNormalizer.php +++ b/src/OpenApi/Serializer/OpenApiNormalizer.php @@ -54,9 +54,16 @@ private function recursiveClean($data): array continue; } + if ('schemas' === $key && \is_array($value)) { + ksort($value); + } + // Side effect of using getPaths(): Paths which itself contains the array if ('paths' === $key) { $value = $data['paths'] = $data['paths']['paths']; + if ($value) { + ksort($value); + } unset($data['paths']['paths']); } diff --git a/src/Operation/Factory/SubresourceOperationFactory.php b/src/Operation/Factory/SubresourceOperationFactory.php index e5a19bedeaf..73e14471d22 100644 --- a/src/Operation/Factory/SubresourceOperationFactory.php +++ b/src/Operation/Factory/SubresourceOperationFactory.php @@ -184,6 +184,10 @@ private function computeSubresourceOperations(string $resourceClass, array &$tre } } + if (isset($subresourceOperation['openapi_context'])) { + $operation['openapi_context'] = $subresourceOperation['openapi_context']; + } + foreach (self::ROUTE_OPTIONS as $routeOption => $defaultValue) { $operation[$routeOption] = $subresourceOperation[$routeOption] ?? $defaultValue; } diff --git a/src/Swagger/Serializer/DocumentationNormalizer.php b/src/Swagger/Serializer/DocumentationNormalizer.php index 33d5fa23f1c..62aba70ec07 100644 --- a/src/Swagger/Serializer/DocumentationNormalizer.php +++ b/src/Swagger/Serializer/DocumentationNormalizer.php @@ -33,6 +33,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\OpenApi\OpenApi; use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactoryInterface; use ApiPlatform\Core\PathResolver\OperationPathResolverInterface; use Psr\Container\ContainerInterface; @@ -102,6 +103,8 @@ final class DocumentationNormalizer implements NormalizerInterface, CacheableSup private $identifiersExtractor; + private $openApiNormalizer; + /** * @param SchemaFactoryInterface|ResourceClassResolverInterface|null $jsonSchemaFactory * @param ContainerInterface|FilterCollection|null $filterLocator @@ -109,7 +112,7 @@ final class DocumentationNormalizer implements NormalizerInterface, CacheableSup * @param mixed|null $jsonSchemaTypeFactory * @param int[] $swaggerVersions */ - public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, $jsonSchemaFactory = null, $jsonSchemaTypeFactory = null, OperationPathResolverInterface $operationPathResolver = null, UrlGeneratorInterface $urlGenerator = null, $filterLocator = null, NameConverterInterface $nameConverter = null, bool $oauthEnabled = false, string $oauthType = '', string $oauthFlow = '', string $oauthTokenUrl = '', string $oauthAuthorizationUrl = '', array $oauthScopes = [], array $apiKeys = [], SubresourceOperationFactoryInterface $subresourceOperationFactory = null, bool $paginationEnabled = true, string $paginationPageParameterName = 'page', bool $clientItemsPerPage = false, string $itemsPerPageParameterName = 'itemsPerPage', $formats = [], bool $paginationClientEnabled = false, string $paginationClientEnabledParameterName = 'pagination', array $defaultContext = [], array $swaggerVersions = [2, 3], IdentifiersExtractorInterface $identifiersExtractor = null) + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, $jsonSchemaFactory = null, $jsonSchemaTypeFactory = null, OperationPathResolverInterface $operationPathResolver = null, UrlGeneratorInterface $urlGenerator = null, $filterLocator = null, NameConverterInterface $nameConverter = null, bool $oauthEnabled = false, string $oauthType = '', string $oauthFlow = '', string $oauthTokenUrl = '', string $oauthAuthorizationUrl = '', array $oauthScopes = [], array $apiKeys = [], SubresourceOperationFactoryInterface $subresourceOperationFactory = null, bool $paginationEnabled = true, string $paginationPageParameterName = 'page', bool $clientItemsPerPage = false, string $itemsPerPageParameterName = 'itemsPerPage', $formats = [], bool $paginationClientEnabled = false, string $paginationClientEnabledParameterName = 'pagination', array $defaultContext = [], array $swaggerVersions = [2, 3], IdentifiersExtractorInterface $identifiersExtractor = null, NormalizerInterface $openApiNormalizer = null) { if ($jsonSchemaTypeFactory instanceof OperationMethodResolverInterface) { @trigger_error(sprintf('Passing an instance of %s to %s() is deprecated since version 2.5 and will be removed in 3.0.', OperationMethodResolverInterface::class, __METHOD__), \E_USER_DEPRECATED); @@ -171,6 +174,7 @@ public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFa $this->defaultContext = array_merge($this->defaultContext, $defaultContext); $this->identifiersExtractor = $identifiersExtractor; + $this->openApiNormalizer = $openApiNormalizer; } /** @@ -178,6 +182,12 @@ public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFa */ public function normalize($object, $format = null, array $context = []) { + if ($object instanceof OpenApi) { + @trigger_error('Using the swagger DocumentationNormalizer is deprecated in favor of decorating the OpenApiFactory, use the "openapi.backward_compatibility_layer" configuration to change this behavior.', \E_USER_DEPRECATED); + + return $this->openApiNormalizer->normalize($object, $format, $context); + } + $v3 = 3 === ($context['spec_version'] ?? $this->defaultContext['spec_version']) && !($context['api_gateway'] ?? $this->defaultContext['api_gateway']); $definitions = new \ArrayObject(); @@ -779,7 +789,7 @@ private function getFiltersParameters(bool $v3, string $resourceClass, string $o */ public function supportsNormalization($data, $format = null): bool { - return self::FORMAT === $format && $data instanceof Documentation; + return self::FORMAT === $format && ($data instanceof Documentation || $this->openApiNormalizer && $data instanceof OpenApi); } /** diff --git a/tests/Annotation/ApiSubresourceTest.php b/tests/Annotation/ApiSubresourceTest.php new file mode 100644 index 00000000000..fc1ed117299 --- /dev/null +++ b/tests/Annotation/ApiSubresourceTest.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\Core\Tests\Annotation; + +use ApiPlatform\Core\Annotation\ApiSubresource; +use PHPUnit\Framework\TestCase; + +/** + * @author Cody Banman + */ +class ApiSubresourceTest extends TestCase +{ + public function testAssignation() + { + $property = new ApiSubresource(); + $property->maxDepth = 1; + + $this->assertEquals(1, $property->maxDepth); + } + + public function testConstruct() + { + $property = new ApiSubresource([ // @phpstan-ignore-line + 'maxDepth' => 1, + ]); + $this->assertEquals(1, $property->maxDepth); + } + + /** + * @requires PHP 8.0 + */ + public function testConstructAttribute() + { + $property = eval(<<<'PHP' +return new \ApiPlatform\Core\Annotation\ApiSubresource( + maxDepth: 1 +); +PHP + ); + $this->assertEquals(1, $property->maxDepth); + } +} diff --git a/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php b/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php index 7c3a070c473..73df66ea95d 100644 --- a/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php +++ b/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php @@ -146,6 +146,7 @@ public function testPublishUpdate(): void $toDelete->setId(3); $toDeleteExpressionLanguage = new DummyFriend(); $toDeleteExpressionLanguage->setId(4); + $toDeleteMercureOptions = new DummyOffer(); $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class); @@ -167,12 +168,14 @@ public function testPublishUpdate(): void $iriConverterProphecy->getIriFromItem($toDelete)->willReturn('/dummies/3')->shouldBeCalled(); $iriConverterProphecy->getIriFromItem($toDeleteExpressionLanguage)->willReturn('/dummy_friends/4')->shouldBeCalled(); $iriConverterProphecy->getIriFromItem($toDeleteExpressionLanguage, UrlGeneratorInterface::ABS_URL)->willReturn('http://example.com/dummy_friends/4')->shouldBeCalled(); + $iriConverterProphecy->getIriFromItem($toDeleteMercureOptions)->willReturn('/dummy_offers/5')->shouldBeCalled(); + $iriConverterProphecy->getIriFromItem($toDeleteMercureOptions, UrlGeneratorInterface::ABS_URL)->willReturn('http://example.com/dummy_offers/5')->shouldBeCalled(); $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['mercure' => true, 'normalization_context' => ['groups' => ['foo', 'bar']]])); $resourceMetadataFactoryProphecy->create(DummyCar::class)->willReturn(new ResourceMetadata()); $resourceMetadataFactoryProphecy->create(DummyFriend::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['mercure' => ['private' => true, 'retry' => 10]])); - $resourceMetadataFactoryProphecy->create(DummyOffer::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['mercure' => ['topics' => 'http://example.com/custom_topics/1', 'normalization_context' => ['groups' => ['baz']]]])); + $resourceMetadataFactoryProphecy->create(DummyOffer::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['mercure' => ['topics' => 'http://example.com/custom_topics/1', 'data' => 'mercure_custom_data', 'normalization_context' => ['groups' => ['baz']]]])); $resourceMetadataFactoryProphecy->create(DummyMercure::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['mercure' => ['topics' => ['/dummies/1', '/users/3'], 'normalization_context' => ['groups' => ['baz']]]])); $serializerProphecy = $this->prophesize(SerializerInterface::class); @@ -186,10 +189,12 @@ public function testPublishUpdate(): void $topics = []; $private = []; $retry = []; - $publisher = function (Update $update) use (&$topics, &$private, &$retry): string { + $data = []; + $publisher = function (Update $update) use (&$topics, &$private, &$retry, &$data): string { $topics = array_merge($topics, $update->getTopics()); $private[] = $update->isPrivate(); $retry[] = $update->getRetry(); + $data[] = $update->getData(); return 'id'; }; @@ -207,7 +212,7 @@ public function testPublishUpdate(): void $uowProphecy = $this->prophesize(UnitOfWork::class); $uowProphecy->getScheduledEntityInsertions()->willReturn([$toInsert, $toInsertNotResource])->shouldBeCalled(); $uowProphecy->getScheduledEntityUpdates()->willReturn([$toUpdate, $toUpdateNoMercureAttribute, $toUpdateMercureOptions, $toUpdateMercureTopicOptions])->shouldBeCalled(); - $uowProphecy->getScheduledEntityDeletions()->willReturn([$toDelete, $toDeleteExpressionLanguage])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([$toDelete, $toDeleteExpressionLanguage, $toDeleteMercureOptions])->shouldBeCalled(); $emProphecy = $this->prophesize(EntityManagerInterface::class); $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); @@ -216,9 +221,10 @@ public function testPublishUpdate(): void $listener->onFlush($eventArgs); $listener->postFlush(); - $this->assertSame(['http://example.com/dummies/1', 'http://example.com/dummies/2', 'http://example.com/custom_topics/1', '/dummies/1', '/users/3', 'http://example.com/dummies/3', 'http://example.com/dummy_friends/4'], $topics); - $this->assertSame([false, false, false, false, false, true], $private); - $this->assertSame([null, null, null, null, null, 10], $retry); + $this->assertSame(['1', '2', 'mercure_custom_data', 'mercure_options', '{"@id":"\/dummies\/3"}', '{"@id":"\/dummy_friends\/4"}', '{"@id":"\/dummy_offers\/5"}'], $data); + $this->assertSame(['http://example.com/dummies/1', 'http://example.com/dummies/2', 'http://example.com/custom_topics/1', '/dummies/1', '/users/3', 'http://example.com/dummies/3', 'http://example.com/dummy_friends/4', 'http://example.com/custom_topics/1'], $topics); + $this->assertSame([false, false, false, false, false, true, false], $private); + $this->assertSame([null, null, null, null, null, 10, null], $retry); } public function testPublishGraphQlUpdates(): void diff --git a/tests/Bridge/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php b/tests/Bridge/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php index 980add90322..6e865a5b3fd 100644 --- a/tests/Bridge/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php +++ b/tests/Bridge/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php @@ -237,6 +237,36 @@ public function testVersionCollection() $this->assertSame(null !== $dataCollector->getVersion(), class_exists(Versions::class)); } + public function testWithPreviousData() + { + $data = new \stdClass(); + $data->a = $data; + + $this->apiResourceClassWillReturn(DummyEntity::class, ['_api_item_operation_name' => 'get', '_api_receive' => true, 'previous_data' => $data]); + $this->request->attributes = $this->attributes->reveal(); + + $this->filterLocator->has('foo')->willReturn(false); + $this->filterLocator->has('a_filter')->willReturn(true); + $this->filterLocator->get('a_filter')->willReturn(new \stdClass()); + + $dataCollector = new RequestDataCollector( + $this->metadataFactory->reveal(), + $this->filterLocator->reveal(), + new ChainCollectionDataProvider([]), + new ChainItemDataProvider([]), + new ChainSubresourceDataProvider([]), + new ChainDataPersister([]) + ); + + $dataCollector->collect( + $this->request->reveal(), + $this->response + ); + + $this->assertArrayHasKey('previous_data', $requestAttributes = $dataCollector->getRequestAttributes()); + $this->assertNotSame($requestAttributes['previous_data']->data, $requestAttributes['previous_data']); + } + private function apiResourceClassWillReturn($data, $context = []) { $this->attributes->get('_api_resource_class')->shouldBeCalled()->willReturn($data); diff --git a/tests/Bridge/Symfony/Bundle/DataPersister/TraceableChainDataPersisterTest.php b/tests/Bridge/Symfony/Bundle/DataPersister/TraceableChainDataPersisterTest.php index 4a231cd2fea..f723a501ea5 100644 --- a/tests/Bridge/Symfony/Bundle/DataPersister/TraceableChainDataPersisterTest.php +++ b/tests/Bridge/Symfony/Bundle/DataPersister/TraceableChainDataPersisterTest.php @@ -60,6 +60,17 @@ public function testRemove($persister, $expected) $this->assertSame($expected, array_values($result)); } + public function testSupports() + { + $context = ['ok' => true]; + $persister = $this->prophesize(DataPersisterInterface::class); + $persister->supports('', $context)->willReturn(true)->shouldBeCalled(); + $chain = new ChainDataPersister([$persister->reveal()]); + $persister->persist('', $context)->shouldBeCalled(); + $dataPersister = new TraceableChainDataPersister($chain); + $dataPersister->persist('', $context); + } + public function dataPersisterProvider(): iterable { yield [ diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index aea8afcb566..38f379dd726 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -786,6 +786,26 @@ public function testDisabledSwagger() $this->extension->load($config, $containerBuilder); } + public function testDisabledPaginationViaDefaults() + { + $config = self::DEFAULT_CONFIG; + $config['api_platform']['defaults'] = [ + 'pagination_enabled' => false, + ]; + + $containerBuilderProphecy = $this->getBaseContainerBuilderProphecy(); + $containerBuilderProphecy->setParameter('api_platform.collection.pagination', [ + 'enabled' => false, 'partial' => false, 'client_enabled' => false, 'client_items_per_page' => false, 'client_partial' => false, 'items_per_page' => 30, 'maximum_items_per_page' => null, 'page_parameter_name' => 'page', 'enabled_parameter_name' => 'pagination', 'items_per_page_parameter_name' => 'itemsPerPage', 'partial_parameter_name' => 'partial', ])->shouldBeCalled(); + $containerBuilderProphecy->setParameter('api_platform.collection.pagination', [ + 'enabled' => true, 'partial' => false, 'client_enabled' => false, 'client_items_per_page' => false, 'client_partial' => false, 'items_per_page' => 30, 'maximum_items_per_page' => null, 'page_parameter_name' => 'page', 'enabled_parameter_name' => 'pagination', 'items_per_page_parameter_name' => 'itemsPerPage', 'partial_parameter_name' => 'partial', ])->shouldNotBeCalled(); + $containerBuilderProphecy->setParameter('api_platform.collection.pagination.enabled', false)->shouldBeCalled(); + $containerBuilderProphecy->setParameter('api_platform.collection.pagination.enabled', true)->shouldNotBeCalled(); + $containerBuilderProphecy->setParameter('api_platform.defaults', ['attributes' => ['pagination_enabled' => false]])->shouldBeCalled(); + $containerBuilderProphecy->setParameter('api_platform.defaults', ['attributes' => []])->shouldNotBeCalled(); + $containerBuilder = $containerBuilderProphecy->reveal(); + $this->extension->load($config, $containerBuilder); + } + private function getPartialContainerBuilderProphecy($configuration = null) { $parameterBag = new EnvPlaceholderParameterBag(); @@ -1342,6 +1362,13 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo $definitions[] = 'api_platform.jsonld.normalizer.object'; } + // Ignore inlined services + $containerBuilderProphecy->setDefinition(Argument::that(static function (string $arg) { + return 0 === strpos($arg, '.'); + }), Argument::type(Definition::class))->should(function () { + return true; + }); + foreach ($definitions as $definition) { $containerBuilderProphecy->setDefinition($definition, Argument::type(Definition::class))->shouldBeCalled(); } diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index 737b5df99cf..1df2208bb80 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -218,6 +218,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'name' => null, 'url' => null, ], + 'backward_compatibility_layer' => true, ], ], $config); } diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php index 181b9ff14df..819493540f3 100644 --- a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php @@ -300,7 +300,7 @@ public function testCreateWithPropertyRegexRestriction(): void $schema = $validationPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummy')->getSchema(); $this->assertNotNull($schema); $this->assertArrayHasKey('pattern', $schema); - $this->assertEquals('^dummy$', $schema['pattern']); + $this->assertEquals('dummy', $schema['pattern']); } public function testCreateWithPropertyFormatRestriction(): void diff --git a/tests/Fixtures/DummyValidatedEntity.php b/tests/Fixtures/DummyValidatedEntity.php index 81d3e4b852d..3668c7ff51b 100644 --- a/tests/Fixtures/DummyValidatedEntity.php +++ b/tests/Fixtures/DummyValidatedEntity.php @@ -32,7 +32,7 @@ class DummyValidatedEntity * * @Assert\NotBlank * @Assert\Length(max="4", min="10") - * @Assert\Regex(pattern="^dummy$") + * @Assert\Regex(pattern="/^dummy$/") */ public $dummy; diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 4bf5207f6b7..060dde81d0c 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -73,6 +73,8 @@ api_platform: http_cache: invalidation: enabled: true + openapi: + backward_compatibility_layer: false defaults: pagination_client_enabled: true pagination_client_items_per_page: true diff --git a/tests/Hydra/Serializer/PartialCollectionViewNormalizerTest.php b/tests/Hydra/Serializer/PartialCollectionViewNormalizerTest.php index 94d9cea5852..d6ebe2b4da6 100644 --- a/tests/Hydra/Serializer/PartialCollectionViewNormalizerTest.php +++ b/tests/Hydra/Serializer/PartialCollectionViewNormalizerTest.php @@ -17,6 +17,8 @@ use ApiPlatform\Core\DataProvider\PartialPaginatorInterface; use ApiPlatform\Core\Hydra\Serializer\PartialCollectionViewNormalizer; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\SoMany; use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -79,13 +81,31 @@ public function testNormalizePartialPaginator() '@id' => '/?_page=3', '@type' => 'hydra:PartialCollectionView', 'hydra:previous' => '/?_page=2', + 'hydra:next' => '/?_page=4', ], ], $this->normalizePaginator(true) ); } - private function normalizePaginator($partial = false) + public function testNormalizeWithCursorBasedPagination(): void + { + self::assertEquals( + [ + 'foo' => 'bar', + 'hydra:totalItems' => 40, + 'hydra:view' => [ + '@id' => '/', + '@type' => 'hydra:PartialCollectionView', + 'hydra:previous' => '/?id%5Bgt%5D=1', + 'hydra:next' => '/?id%5Blt%5D=2', + ], + ], + $this->normalizePaginator(false, true) + ); + } + + private function normalizePaginator(bool $partial = false, bool $cursor = false) { $paginatorProphecy = $this->prophesize($partial ? PartialPaginatorInterface::class : PaginatorInterface::class); $paginatorProphecy->getCurrentPage()->willReturn(3)->shouldBeCalled(); @@ -102,11 +122,33 @@ private function normalizePaginator($partial = false) $decoratedNormalizerProphecy = $this->prophesize(NormalizerInterface::class); $decoratedNormalizerProphecy->normalize(Argument::type($partial ? PartialPaginatorInterface::class : PaginatorInterface::class), null, Argument::type('array'))->willReturn($decoratedNormalize)->shouldBeCalled(); - $resourceMetadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); - $normalizer = new PartialCollectionViewNormalizer($decoratedNormalizerProphecy->reveal(), '_page', 'pagination', $resourceMetadataFactory->reveal()); + $resourceMetadataFactoryProphecy = null; + + if ($cursor) { + $firstSoMany = new SoMany(); + $firstSoMany->id = 1; + $firstSoMany->content = 'SoMany #1'; + + $lastSoMany = new SoMany(); + $lastSoMany->id = 2; + $lastSoMany->content = 'SoMany #2'; + + $paginatorProphecy->rewind()->willReturn()->shouldBeCalledOnce(); + $paginatorProphecy->valid()->willReturn(true, true, false)->shouldBeCalledTimes(3); + $paginatorProphecy->key()->willReturn(1, 2)->shouldBeCalledTimes(2); + $paginatorProphecy->current()->willReturn($firstSoMany, $lastSoMany)->shouldBeCalledTimes(2); + $paginatorProphecy->next()->willReturn()->shouldBeCalledTimes(2); + + $soManyMetadata = new ResourceMetadata(null, null, null, null, ['get' => ['pagination_via_cursor' => [['field' => 'id', 'direction' => 'desc']]]]); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(SoMany::class)->willReturn($soManyMetadata)->shouldBeCalledOnce(); + } + + $normalizer = new PartialCollectionViewNormalizer($decoratedNormalizerProphecy->reveal(), '_page', 'pagination', $resourceMetadataFactoryProphecy ? $resourceMetadataFactoryProphecy->reveal() : null); - return $normalizer->normalize($paginatorProphecy->reveal()); + return $normalizer->normalize($paginatorProphecy->reveal(), null, ['resource_class' => SoMany::class, 'collection_operation_name' => 'get']); } public function testSupportsNormalization() diff --git a/tests/Identifier/IdentifierConverterTest.php b/tests/Identifier/IdentifierConverterTest.php index ee30a54520e..c2a39234f6d 100644 --- a/tests/Identifier/IdentifierConverterTest.php +++ b/tests/Identifier/IdentifierConverterTest.php @@ -22,6 +22,7 @@ use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; /** * @author Antoine Bluchet @@ -98,4 +99,49 @@ public function testIntegerIdentifier() $this->assertSame(['id' => 42], $identifierDenormalizer->convert($identifier, $class)); } + + public function testShouldBreakAfterTransforming() + { + $identifier = ['id' => '42']; + $class = 'Dummy'; + + $integerIdentifierPropertyMetadata = (new PropertyMetadata())->withIdentifier(true)->withType(new Type(Type::BUILTIN_TYPE_INT)); + + $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->create($class, 'id')->shouldBeCalled()->willReturn($integerIdentifierPropertyMetadata); + + $identifiersExtractor = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractor->getIdentifiersFromResourceClass($class)->willReturn(['id']); + + $shouldNotBeCalled = $this->prophesize(DenormalizerInterface::class); + $shouldNotBeCalled->supportsDenormalization()->shouldNotBeCalled(); + + $identifierDenormalizers = [new IntegerDenormalizer(), $shouldNotBeCalled->reveal()]; + $identifierDenormalizer = new IdentifierConverter($identifiersExtractor->reveal(), $propertyMetadataFactory->reveal(), $identifierDenormalizers); + + $this->assertSame(['id' => 42], $identifierDenormalizer->convert($identifier, $class)); + } + + public function testWithContextAndMultipleIdentifiers() + { + $identifier = ['id' => '42', 'book' => '21']; + + $integerIdentifierPropertyMetadata = (new PropertyMetadata())->withIdentifier(true)->withType(new Type(Type::BUILTIN_TYPE_INT)); + + $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->create('Author', 'id')->shouldBeCalled()->willReturn($integerIdentifierPropertyMetadata); + $propertyMetadataFactory->create('Book', 'id')->shouldBeCalled()->willReturn($integerIdentifierPropertyMetadata); + + $identifiersExtractor = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractor->getIdentifiersFromResourceClass('Book')->willReturn(['id']); + $identifiersExtractor->getIdentifiersFromResourceClass('Author')->willReturn(['id']); + + $shouldNotBeCalled = $this->prophesize(DenormalizerInterface::class); + $shouldNotBeCalled->supportsDenormalization()->shouldNotBeCalled(); + + $identifierDenormalizers = [new IntegerDenormalizer(), $shouldNotBeCalled->reveal()]; + $identifierDenormalizer = new IdentifierConverter($identifiersExtractor->reveal(), $propertyMetadataFactory->reveal(), $identifierDenormalizers); + + $this->assertSame(['id' => 42, 'book' => 21], $identifierDenormalizer->convert($identifier, 'Book', ['identifiers' => ['id' => ['Author', 'id'], 'book' => ['Book', 'id']]])); + } } diff --git a/tests/OpenApi/Factory/OpenApiFactoryTest.php b/tests/OpenApi/Factory/OpenApiFactoryTest.php index 52cabc750e4..069008f0866 100644 --- a/tests/OpenApi/Factory/OpenApiFactoryTest.php +++ b/tests/OpenApi/Factory/OpenApiFactoryTest.php @@ -32,6 +32,7 @@ use ApiPlatform\Core\OpenApi\Model; use ApiPlatform\Core\OpenApi\OpenApi; use ApiPlatform\Core\OpenApi\Options; +use ApiPlatform\Core\OpenApi\Serializer\OpenApiNormalizer; use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactory; use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactoryInterface; use ApiPlatform\Core\Operation\UnderscorePathSegmentNameGenerator; @@ -48,7 +49,10 @@ use Symfony\Component\Routing\Route; use Symfony\Component\Routing\RouteCollection; use Symfony\Component\Routing\RouterInterface; +use Symfony\Component\Serializer\Encoder\JsonEncoder; use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; class OpenApiFactoryTest extends TestCase { @@ -67,7 +71,29 @@ public function testInvoke(): void 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, 'put' => ['method' => 'PUT'] + self::OPERATION_FORMATS, 'delete' => ['method' => 'DELETE'] + self::OPERATION_FORMATS, - 'custom' => ['method' => 'HEAD', 'path' => '/foo/{id}', 'openapi_context' => ['description' => 'Custom description']] + self::OPERATION_FORMATS, + 'custom' => ['method' => 'HEAD', 'path' => '/foo/{id}', 'openapi_context' => [ + 'description' => 'Custom description', + 'parameters' => [ + ['description' => 'Test parameter', 'name' => 'param', 'in' => 'path', 'type' => 'string', 'required' => true, 'default' => 'BOTH'], + ], + 'requestBody' => [ + 'required' => true, + 'description' => 'Custom request body', + 'content' => [ + 'multipart/form-data' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'file' => [ + 'type' => 'string', + 'format' => 'binary', + ], + ], + ], + ], + ], + ], + ]] + self::OPERATION_FORMATS, 'formats' => ['method' => 'PUT', 'path' => '/formatted/{id}', 'output_formats' => ['json' => ['application/json'], 'csv' => ['text/csv']], 'input_formats' => ['json' => ['application/json'], 'csv' => ['text/csv']]], ], [ @@ -377,7 +403,20 @@ public function testInvoke(): void 'Dummy', 'Custom description', null, - [new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])] + [new Model\Parameter('param', 'path', 'Test parameter', true), new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])], + new Model\RequestBody('Custom request body', new \ArrayObject([ + 'multipart/form-data' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'file' => [ + 'type' => 'string', + 'format' => 'binary', + ], + ], + ], + ], + ]), true) )); $formattedPath = $paths->getPath('/formatted/{id}'); @@ -689,5 +728,15 @@ public function testSubresourceDocumentation() null, [new Model\Parameter('id', 'path', 'Question identifier', true, false, false, ['type' => 'string'])] )); + + $encoders = [new JsonEncoder()]; + $normalizers = [new ObjectNormalizer()]; + + $serializer = new Serializer($normalizers, $encoders); + $normalizers[0]->setSerializer($serializer); + + // Call the normalizer to see if everything is smooth + $normalizer = new OpenApiNormalizer($normalizers[0]); + $normalizer->normalize($openApi); } } diff --git a/tests/OpenApi/Serializer/OpenApiNormalizerTest.php b/tests/OpenApi/Serializer/OpenApiNormalizerTest.php index 8ba832a5007..821afb71e42 100644 --- a/tests/OpenApi/Serializer/OpenApiNormalizerTest.php +++ b/tests/OpenApi/Serializer/OpenApiNormalizerTest.php @@ -27,6 +27,7 @@ use ApiPlatform\Core\Metadata\Resource\ResourceNameCollection; use ApiPlatform\Core\OpenApi\Factory\OpenApiFactory; use ApiPlatform\Core\OpenApi\Model; +use ApiPlatform\Core\OpenApi\OpenApi; use ApiPlatform\Core\OpenApi\Options; use ApiPlatform\Core\OpenApi\Serializer\OpenApiNormalizer; use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactoryInterface; @@ -53,10 +54,11 @@ class OpenApiNormalizerTest extends TestCase public function testNormalize() { $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); - $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class, 'Zorro'])); $defaultContext = ['base_url' => '/app_dev.php/']; $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate'])); + $propertyNameCollectionFactoryProphecy->create('Zorro', Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id'])); $dummyMetadata = new ResourceMetadata( 'Dummy', @@ -74,11 +76,25 @@ public function testNormalize() [] ); + $zorroMetadata = new ResourceMetadata( + 'Zorro', + 'This is zorro.', + 'http://schema.example.com/Zorro', + [ + 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, + ], + [ + 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, + ], + [] + ); + $subresourceOperationFactoryProphecy = $this->prophesize(SubresourceOperationFactoryInterface::class); $subresourceOperationFactoryProphecy->create(Argument::any(), Argument::any(), Argument::any())->willReturn([]); $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); + $resourceMetadataFactoryProphecy->create('Zorro')->shouldBeCalled()->willReturn($zorroMetadata); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT), 'This is an id.', true, false, null, null, null, true, null, null, null, null, null, null, null, ['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$'])); @@ -86,6 +102,8 @@ public function testNormalize() $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is an initializable but not writable property.', true, false, true, true, false, false, null, null, [], null, true)); $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class), 'This is a \DateTimeInterface object.', true, true, true, true, false, false, null, null, [])); + $propertyMetadataFactoryProphecy->create('Zorro', 'id', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT), 'This is an id.', true, false, null, null, null, true)); + $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); @@ -165,5 +183,41 @@ public function testNormalize() // Security can be disabled per-operation using an empty array $this->assertEquals([], $openApiAsArray['paths']['/dummies']['post']['security']); $this->assertEquals(['url' => '/test'], $openApiAsArray['paths']['/dummies']['post']['servers']); + + // Make sure things are sorted + $this->assertEquals(array_keys($openApiAsArray['paths']), ['/dummies', '/dummies/{id}', '/zorros', '/zorros/{id}']); + // Test name converter doesn't rename this property + $this->assertArrayHasKey('requestBody', $openApiAsArray['paths']['/dummies']['post']); + } + + public function testNormalizeWithSchemas() + { + $openApi = new OpenApi(new Model\Info('My API', '1.0.0', 'An amazing API'), [new Model\Server('https://example.com')], new Model\Paths(), new Model\Components(new \ArrayObject(['z' => [], 'b' => []]))); + $encoders = [new JsonEncoder()]; + $normalizers = [new ObjectNormalizer()]; + + $serializer = new Serializer($normalizers, $encoders); + $normalizers[0]->setSerializer($serializer); + + $normalizer = new OpenApiNormalizer($normalizers[0]); + + $array = $normalizer->normalize($openApi); + + $this->assertEquals(array_keys($array['components']['schemas']), ['b', 'z']); + } + + public function testNormalizeWithEmptySchemas() + { + $openApi = new OpenApi(new Model\Info('My API', '1.0.0', 'An amazing API'), [new Model\Server('https://example.com')], new Model\Paths(), new Model\Components(new \ArrayObject())); + $encoders = [new JsonEncoder()]; + $normalizers = [new ObjectNormalizer()]; + + $serializer = new Serializer($normalizers, $encoders); + $normalizers[0]->setSerializer($serializer); + + $normalizer = new OpenApiNormalizer($normalizers[0]); + + $array = $normalizer->normalize($openApi); + $this->assertCount(0, $array['components']['schemas']); } } diff --git a/tests/Operation/Factory/SubresourceOperationFactoryTest.php b/tests/Operation/Factory/SubresourceOperationFactoryTest.php index 59e644a14d1..c9ea4b2dc84 100644 --- a/tests/Operation/Factory/SubresourceOperationFactoryTest.php +++ b/tests/Operation/Factory/SubresourceOperationFactoryTest.php @@ -837,4 +837,61 @@ public function testCreateSelfReferencingSubresourcesWithSubresources() ] + SubresourceOperationFactory::ROUTE_OPTIONS, ], $subresourceOperationFactory->create(DummyEntity::class)); } + + public function testCreateWithOpenapiContext() + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(RelatedDummyEntity::class)->shouldBeCalled()->willReturn(new ResourceMetadata('relatedDummyEntity')); + $resourceMetadataFactoryProphecy->create(DummyEntity::class)->shouldBeCalled()->willReturn((new ResourceMetadata('dummyEntity'))->withSubresourceOperations([ + 'subresource_get_subresource' => [ + 'openapi_context' => [ + 'summary' => 'Get related dummy entities', + 'tags' => ['Dummy'], + ], + ], + ])); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(DummyEntity::class)->shouldBeCalled()->willReturn(new PropertyNameCollection(['subresource'])); + $propertyNameCollectionFactoryProphecy->create(RelatedDummyEntity::class)->shouldBeCalled()->willReturn(new PropertyNameCollection([])); + + $subresourceMetadata = (new PropertyMetadata())->withSubresource(new SubresourceMetadata(RelatedDummyEntity::class, false)); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(DummyEntity::class, 'subresource')->shouldBeCalled()->willReturn($subresourceMetadata); + + $pathSegmentNameGeneratorProphecy = $this->prophesize(PathSegmentNameGeneratorInterface::class); + $pathSegmentNameGeneratorProphecy->getSegmentName('dummyEntity')->shouldBeCalled()->willReturn('dummy_entities'); + $pathSegmentNameGeneratorProphecy->getSegmentName('subresource', false)->shouldBeCalled()->willReturn('subresources'); + + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + $identifiersExtractorProphecy->getIdentifiersFromResourceClass(Argument::type('string'))->willReturn(['id']); + + $subresourceOperationFactory = new SubresourceOperationFactory( + $resourceMetadataFactoryProphecy->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $pathSegmentNameGeneratorProphecy->reveal(), + $identifiersExtractorProphecy->reveal() + ); + + $this->assertEquals([ + 'api_dummy_entities_subresource_get_subresource' => [ + 'property' => 'subresource', + 'collection' => false, + 'resource_class' => RelatedDummyEntity::class, + 'shortNames' => ['relatedDummyEntity', 'dummyEntity'], + 'identifiers' => [ + 'id' => [DummyEntity::class, 'id', true], + ], + 'route_name' => 'api_dummy_entities_subresource_get_subresource', + 'path' => '/dummy_entities/{id}/subresources.{_format}', + 'operation_name' => 'subresource_get_subresource', + 'openapi_context' => [ + 'summary' => 'Get related dummy entities', + 'tags' => ['Dummy'], + ], + ] + SubresourceOperationFactory::ROUTE_OPTIONS, + ], $subresourceOperationFactory->create(DummyEntity::class)); + } } diff --git a/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php b/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php index 407c28f6c75..6d7de03e455 100644 --- a/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php +++ b/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php @@ -18,6 +18,7 @@ use ApiPlatform\Core\Api\OperationAwareFormatsProviderInterface; use ApiPlatform\Core\Api\OperationMethodResolverInterface; use ApiPlatform\Core\Api\OperationType; +use ApiPlatform\Core\Api\ResourceClassResolverInterface; use ApiPlatform\Core\Bridge\Symfony\Routing\RouterOperationPathResolver; use ApiPlatform\Core\Documentation\Documentation; use ApiPlatform\Core\Exception\InvalidArgumentException; @@ -33,6 +34,8 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\Core\OpenApi\Model; +use ApiPlatform\Core\OpenApi\OpenApi; use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactory; use ApiPlatform\Core\Operation\UnderscorePathSegmentNameGenerator; use ApiPlatform\Core\PathResolver\CustomOperationPathResolver; @@ -54,6 +57,7 @@ use Symfony\Component\Routing\RouterInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; /** * @author Amrouche Hamza @@ -3208,4 +3212,55 @@ private function doTestNormalizeWithCustomFormatsDefinedAtOperationLevel(Operati $this->assertEquals($expected, $normalizer->normalize($documentation, DocumentationNormalizer::FORMAT, ['base_url' => '/'])); } + + /** + * @group legacy + * @expectedDeprecation Using the swagger DocumentationNormalizer is deprecated in favor of decorating the OpenApiFactory, use the "openapi.backward_compatibility_layer" configuration to change this behavior. + */ + public function testNormalizeOpenApi() + { + $openapi = new OpenApi(new Model\Info('api', 'v1'), [], new Model\Paths()); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $operationPathResolver = new OperationPathResolver(new UnderscorePathSegmentNameGenerator()); + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + + $openApiNormalizerProphecy = $this->prophesize(NormalizerInterface::class); + $openApiNormalizerProphecy->normalize($openapi, null, [])->willReturn([])->shouldBeCalled(); + + $normalizer = new DocumentationNormalizer( + $resourceMetadataFactoryProphecy->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + null, + null, + $operationPathResolver, + null, + null, + null, false, + '', + '', + '', + '', + [], + [], + null, + false, + 'page', + false, + 'itemsPerPage', + $formatsProvider ?? [], + false, + 'pagination', + ['spec_version' => 3], + [2, 3], + $identifiersExtractorProphecy->reveal(), + $openApiNormalizerProphecy->reveal() + ); + + $this->assertTrue($normalizer->supportsNormalization($openapi, 'json')); + $this->assertEquals([], $normalizer->normalize($openapi)); + } }