diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06df63ea290..d110e5a730c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -384,7 +384,7 @@ jobs: - 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 + run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction --tags '~@!lowest' postgresql: name: Behat (PHP ${{ matrix.php }}) (PostgreSQL) @@ -599,7 +599,7 @@ jobs: - '7.4' fail-fast: false env: - SYMFONY_DEPRECATIONS_HELPER: max[total]=8 + SYMFONY_DEPRECATIONS_HELPER: max[total]=0 steps: - name: Checkout uses: actions/checkout@v2 @@ -638,17 +638,19 @@ jobs: strategy: matrix: php: - - '7.4' + - '8.0' symfony: - - '5.2' + - '5.3' fail-fast: false + env: + SYMFONY_DEPRECATIONS_HELPER: max[direct]=0 steps: - name: Checkout uses: actions/checkout@v2 - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '7.4' + php-version: '8.0' tools: pecl, composer extensions: intl, bcmath, curl, openssl, mbstring coverage: none @@ -698,9 +700,9 @@ jobs: strategy: matrix: php: - - '7.4' + - '8.0' symfony: - - '5.2' + - '5.3' fail-fast: false steps: - name: Checkout @@ -708,7 +710,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '7.4' + php-version: '8.0' tools: pecl, composer extensions: intl, bcmath, curl, openssl, mbstring coverage: none @@ -719,8 +721,7 @@ jobs: id: composercache run: echo "::set-output name=dir::$(composer config cache-files-dir)" - name: Allow unstable project dependencies - run: | - jq '. + {"minimum-stability": "dev"}' composer.json | sponge composer.json + run: composer config minimum-stability dev - name: Cache dependencies uses: actions/cache@v2 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index f2ee9d43f2d..81f1944990e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,17 @@ ## 2.6.3 +* Identifiers: Re-allow `POST` operations even if no identifier is defined (#4052) +* Hydra: Fix partial pagination which no longer returns the `hydra:next` property (#4015) +* Security: Use a `NullToken` when using the new authenticator manager in the resource access checker (#4067) * Mercure: Do not use data in options when deleting (#4056) +* Doctrine: Support for foreign identifiers (#4042) +* Doctrine: Support for binary UUID in search filter (#3774) +* JSON Schema: Allow generating documentation when property and method start from "is" (property `isActive` and method `isActive`) (#4064) +* OpenAPI: Fix missing 422 responses in the documentation (#4086) +* OpenAPI: Fix error when schema is empty (#4051) +* OpenAPI: Do not set scheme to oauth2 when generating securitySchemes (#4073) +* OpenAPI: Fix missing `$ref` when no `type` is used in context (#4076) ## 2.6.2 @@ -78,7 +88,7 @@ * Tests: adds a method to retrieve the CookieJar in the test Client `getCookieJar` * Tests: Fix the registration of the `test.api_platform.client` service when the `FrameworkBundle` bundle is registered after the `ApiPlatformBundle` bundle (#3928) * Validator: Add the violation code to the violation properties (#3857) -* Validator: Allow customizing the validation error status code (#3808) +* Validator: Allow customizing the validation error status code. **BC** Status code for validation errors is now 422, use `exception_to_status` to fallback to 400 if needed (#3808) * Validator: Autoconfiguration of validation groups generator via `ApiPlatform\Core\Validator\ValidationGroupsGeneratorInterface` * Validator: Deprecate using a validation groups generator service not implementing `ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface` (#3346) * Validator: Property validation through OpenAPI (#33329) @@ -235,7 +245,8 @@ For compatibility reasons with Symfony 5.2 and PHP 8, we do not test anymore the ## 2.5.0 beta 1 * Add an HTTP client dedicated to functional API testing (#2608) -* Add PATCH support (#2895) +* Add PATCH support (#2895) + Note: with JSON Merge Patch, responses will skip null values. As this may break on some endpoints, you need to manually [add the `merge-patch+json` format](https://api-platform.com/docs/core/content-negotiation/#configuring-patch-formats) to enable PATCH support. This will be the default behavior in API Platform 3. * Add a command to generate json schemas `api:json-schema:generate` (#2996) * Add infrastructure to generate a JSON Schema from a Resource `ApiPlatform\Core\JsonSchema\SchemaFactoryInterface` (#2983) * Replaces `access_control` by `security` and adds a `security_post_denormalize` attribute (#2992) diff --git a/behat.yml.dist b/behat.yml.dist index c3af2501185..6625645655c 100644 --- a/behat.yml.dist +++ b/behat.yml.dist @@ -50,7 +50,7 @@ postgres: - 'Behat\MinkExtension\Context\MinkContext' - 'behatch:context:rest' filters: - tags: '~@sqlite&&~@mongodb&&~@elasticsearch' + tags: '~@sqlite&&~@mongodb&&~@elasticsearch&&~@!postgres' mongodb: suites: diff --git a/features/doctrine/search_filter.feature b/features/doctrine/search_filter.feature index c396159bf77..ad4b30c0a10 100644 --- a/features/doctrine/search_filter.feature +++ b/features/doctrine/search_filter.feature @@ -539,6 +539,52 @@ Feature: Search filter on collections } """ + @!postgres + @!mongodb + @!lowest + Scenario: Search collection by binary UUID (Ramsey) + Given there is a ramsey identified resource with binary uuid "c19900a9-d2b2-45bf-b040-05c72d321282" + And there is a ramsey identified resource with binary uuid "a96cb2ed-e3dc-4449-9842-830e770cdecc" + When I send a "GET" request to "/ramsey_uuid_binary_dummies?id=c19900a9-d2b2-45bf-b040-05c72d321282" + Then the response status code should be 200 + And 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:totalItems" should be equal to "1" + + @!postgres + @!mongodb + @!lowest + Scenario: Search collection by binary UUID (Ramsey) (multiple values) + Given there is a ramsey identified resource with binary uuid "f71a6469-1bfc-4945-bad1-d6092f09a8c3" + When I send a "GET" request to "/ramsey_uuid_binary_dummies?id[]=c19900a9-d2b2-45bf-b040-05c72d321282&id[]=f71a6469-1bfc-4945-bad1-d6092f09a8c3" + Then the response status code should be 200 + And 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:totalItems" should be equal to "2" + + @!postgres + @!mongodb + @!lowest + Scenario: Search collection by related binary UUID (Ramsey) + Given there is a ramsey identified resource with binary uuid "56fa36c3-2b5e-4813-9e3a-b0bbe2ab5553" having a related resource with binary uuid "02227dc6-a371-4b8b-a34c-bbbf921b8ebd" + And there is a ramsey identified resource with binary uuid "4d796212-4b26-4e19-b092-a32d990b1e7e" having a related resource with binary uuid "31f64c33-6061-4fc1-b0e8-f4711b607c7d" + When I send a "GET" request to "/ramsey_uuid_binary_dummies?relateds=02227dc6-a371-4b8b-a34c-bbbf921b8ebd" + Then the response status code should be 200 + And 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:totalItems" should be equal to "1" + + @!postgres + @!mongodb + @!lowest + Scenario: Search collection by related binary UUID (Ramsey) (multiple values) + Given there is a ramsey identified resource with binary uuid "3248c908-a89d-483a-b75f-25888730d391" having a related resource with binary uuid "d7b2e909-37b0-411e-814c-74e044afbccb" + When I send a "GET" request to "/ramsey_uuid_binary_dummies?relateds[]=02227dc6-a371-4b8b-a34c-bbbf921b8ebd&relateds[]=d7b2e909-37b0-411e-814c-74e044afbccb" + Then the response status code should be 200 + And 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:totalItems" should be equal to "2" + Scenario: Search for entities within an impossible range When I send a "GET" request to "/dummies?name=MuYm" Then the response status code should be 200 diff --git a/features/main/overridden_operation.feature b/features/main/overridden_operation.feature index a7dec71e024..4d57a700a03 100644 --- a/features/main/overridden_operation.feature +++ b/features/main/overridden_operation.feature @@ -130,3 +130,27 @@ Feature: Create-Retrieve-Update-Delete with a Overridden Operation context 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 "RPC" diff --git a/features/openapi/docs.feature b/features/openapi/docs.feature index 95e07d38a18..af0da57fbe6 100644 --- a/features/openapi/docs.feature +++ b/features/openapi/docs.feature @@ -27,6 +27,7 @@ Feature: Documentation support And the OpenAPI class "CustomNormalizedDummy-output" exists And the OpenAPI class "CustomWritableIdentifierDummy" exists And the OpenAPI class "Dummy" exists + And the OpenAPI class "DummyBoolean" exists And the OpenAPI class "RelatedDummy" exists And the OpenAPI class "DummyTableInheritance" exists And the OpenAPI class "DummyTableInheritanceChild" exists @@ -53,8 +54,11 @@ Feature: Documentation support And the JSON node "paths./api/custom-call/{id}.get" should exist And the JSON node "paths./api/custom-call/{id}.put" should exist # Properties - And "id" property exists for the OpenAPI class "Dummy" - And "name" property is required for OpenAPI class "Dummy" + And the "id" property exists for the OpenAPI class "Dummy" + And the "name" property is required for the OpenAPI class "Dummy" + # Enable these tests when SF 4.4 / PHP 7.1 support is dropped + #And the "isDummyBoolean" property exists for the OpenAPI class "DummyBoolean" + #And the "isDummyBoolean" property is not read only for the OpenAPI class "DummyBoolean" # Filters And the JSON node "paths./dummies.get.parameters[3].name" should be equal to "dummyBoolean" And the JSON node "paths./dummies.get.parameters[3].in" should be equal to "query" @@ -129,6 +133,7 @@ Feature: Documentation support And the Swagger class "CustomNormalizedDummy-output" exists And the Swagger class "CustomWritableIdentifierDummy" exists And the Swagger class "Dummy" exists + And the Swagger class "DummyBoolean" exists And the Swagger class "RelatedDummy" exists And the Swagger class "DummyTableInheritance" exists And the Swagger class "DummyTableInheritanceChild" exists @@ -155,8 +160,11 @@ Feature: Documentation support And the JSON node "paths./api/custom-call/{id}.get" should exist And the JSON node "paths./api/custom-call/{id}.put" should exist # Properties - And "id" property exists for the Swagger class "Dummy" - And "name" property is required for Swagger class "Dummy" + And the "id" property exists for the Swagger class "Dummy" + And the "name" property is required for the Swagger class "Dummy" + # Enable these tests when SF 4.4 / PHP 7.1 support is dropped + #And the "isDummyBoolean" property exists for the Swagger class "DummyBoolean" + #And the "isDummyBoolean" property is not read only for the Swagger class "DummyBoolean" # Filters And the JSON node "paths./dummies.get.parameters[0].name" should be equal to "dummyBoolean" And the JSON node "paths./dummies.get.parameters[0].in" should be equal to "query" diff --git a/features/security/strong_typing.feature b/features/security/strong_typing.feature index ef630a2c27a..3d4b7599a62 100644 --- a/features/security/strong_typing.feature +++ b/features/security/strong_typing.feature @@ -33,8 +33,8 @@ Feature: Handle properly invalid data submitted to the API "jsonData": [], "arrayData": [], "name_converted": null, - "relatedOwnedDummy": null, - "relatedOwningDummy": null, + "relatedOwnedDummy": null, + "relatedOwningDummy": null, "id": 1, "name": "Not existing", "alias": null, diff --git a/phpstan.neon.dist b/phpstan.neon.dist index c4e2f5fc0fd..744d2b0d3f4 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -89,6 +89,9 @@ parameters: message: '#Property Doctrine\\ORM\\Mapping\\ClassMetadataInfo::\$fieldMappings \(array.*\)>\) does not accept array\(.*\)\.#' path: tests/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactoryTest.php + # Expected, Symfony 5.3 + - '#getCollectionValueT|getCollectionKeyTyp#' + # Expected, due to PHP 8 attributes - '#ReflectionProperty::getAttributes\(\)#' - '#ReflectionMethod::getAttributes\(\)#' diff --git a/src/Api/IdentifiersExtractor.php b/src/Api/IdentifiersExtractor.php index d2712a65fe2..55927239b0d 100644 --- a/src/Api/IdentifiersExtractor.php +++ b/src/Api/IdentifiersExtractor.php @@ -62,7 +62,7 @@ public function getIdentifiersFromResourceClass(string $resourceClass): array return ['id']; } - throw new RuntimeException(sprintf('No identifier defined "%s". You should add #[\ApiPlatform\Core\Annotation\ApiProperty(identifier: true)]" on the property identifying the resource."', $resourceClass)); + throw new RuntimeException(sprintf('No identifier defined in "%s". You should add #[\ApiPlatform\Core\Annotation\ApiProperty(identifier: true)]" on the property identifying the resource."', $resourceClass)); } return $identifiers; diff --git a/src/Bridge/Doctrine/Orm/Extension/FilterEagerLoadingExtension.php b/src/Bridge/Doctrine/Orm/Extension/FilterEagerLoadingExtension.php index e2b67f60d0d..9901e35d310 100644 --- a/src/Bridge/Doctrine/Orm/Extension/FilterEagerLoadingExtension.php +++ b/src/Bridge/Doctrine/Orm/Extension/FilterEagerLoadingExtension.php @@ -76,8 +76,16 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator if (!$classMetadata->isIdentifierComposite) { $replacementAlias = $queryNameGenerator->generateJoinAlias($originAlias); $in = $this->getQueryBuilderWithNewAliases($queryBuilder, $queryNameGenerator, $originAlias, $replacementAlias); - $in->select($replacementAlias); - $queryBuilderClone->andWhere($queryBuilderClone->expr()->in($originAlias, $in->getDQL())); + + if ($classMetadata->containsForeignIdentifier) { + $identifier = current($classMetadata->getIdentifier()); + $in->select("IDENTITY($replacementAlias.$identifier)"); + $queryBuilderClone->andWhere($queryBuilderClone->expr()->in("$originAlias.$identifier", $in->getDQL())); + } else { + $in->select($replacementAlias); + $queryBuilderClone->andWhere($queryBuilderClone->expr()->in($originAlias, $in->getDQL())); + } + $changedWhereClause = true; } else { // Because Doctrine doesn't support WHERE ( foo, bar ) IN () (https://github.com/doctrine/doctrine2/issues/5238), we are building as many subqueries as they are identifiers diff --git a/src/Bridge/Doctrine/Orm/Extension/OrderExtension.php b/src/Bridge/Doctrine/Orm/Extension/OrderExtension.php index bcebbec9879..a149d752740 100644 --- a/src/Bridge/Doctrine/Orm/Extension/OrderExtension.php +++ b/src/Bridge/Doctrine/Orm/Extension/OrderExtension.php @@ -80,6 +80,11 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator } if (null !== $this->order) { + // A foreign identifier cannot be used for ordering. + if ($classMetaData->containsForeignIdentifier) { + return; + } + foreach ($identifiers as $identifier) { $queryBuilder->addOrderBy("{$rootAlias}.{$identifier}", $this->order); } diff --git a/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php b/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php index d56ecc5046f..ad26c178b54 100644 --- a/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php @@ -21,8 +21,10 @@ use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; use Doctrine\DBAL\Types\Type as DBALType; +use Doctrine\ORM\Query\Parameter; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\Mapping\ClassMetadata; use Psr\Log\LoggerInterface; use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\PropertyAccess\PropertyAccess; @@ -113,7 +115,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB $caseSensitive = false; } - $this->addWhereByStrategy($strategy, $queryBuilder, $queryNameGenerator, $alias, $field, $values, $caseSensitive); + $this->addWhereByStrategy($strategy, $queryBuilder, $queryNameGenerator, $alias, $field, $values, $caseSensitive, $metadata); return; } @@ -149,15 +151,28 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB $associationField = $associationFieldIdentifier; } + $type = $metadata->getTypeOfField($associationField); + if (1 === \count($values)) { $queryBuilder ->andWhere($queryBuilder->expr()->eq($associationAlias.'.'.$associationField, ':'.$valueParameter)) - ->setParameter($valueParameter, $values[0]); - } else { - $queryBuilder - ->andWhere($queryBuilder->expr()->in($associationAlias.'.'.$associationField, ':'.$valueParameter)) - ->setParameter($valueParameter, $values); + ->setParameter($valueParameter, $values[0], $type); + + return; + } + + $parameters = $queryBuilder->getParameters(); + $inQuery = []; + + foreach ($values as $val) { + $inQuery[] = ':'.$valueParameter; + $parameters->add(new Parameter($valueParameter, $val, $type)); + $valueParameter = $queryNameGenerator->generateParameterName($associationField); } + + $queryBuilder + ->andWhere($associationAlias.'.'.$associationField.' IN ('.implode(', ', $inQuery).')') + ->setParameters($parameters); } /** @@ -165,8 +180,15 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB * * @throws InvalidArgumentException If strategy does not exist */ - protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, $values, bool $caseSensitive) + protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, $values, bool $caseSensitive/*, ClassMetadata $metadata*/) { + if (\func_num_args() > 7 && ($metadata = func_get_arg(7)) instanceof ClassMetadata) { + $type = $metadata->getTypeOfField($field); + } else { + @trigger_error(sprintf('Method %s() will have a 8th argument `$metadata` in version API Platform 3.0.', __FUNCTION__), \E_USER_DEPRECATED); + $type = null; + } + if (!\is_array($values)) { $values = [$values]; } @@ -175,18 +197,26 @@ protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuild $valueParameter = ':'.$queryNameGenerator->generateParameterName($field); $aliasedField = sprintf('%s.%s', $alias, $field); - if (null == $strategy || self::STRATEGY_EXACT == $strategy) { - if (1 == \count($values)) { + if (self::STRATEGY_EXACT === $strategy) { + if (1 === \count($values)) { $queryBuilder ->andWhere($queryBuilder->expr()->eq($wrapCase($aliasedField), $wrapCase($valueParameter))) - ->setParameter($valueParameter, $values[0]); + ->setParameter($valueParameter, $values[0], $type); return; } + $parameters = $queryBuilder->getParameters(); + $inQuery = []; + foreach ($values as $value) { + $inQuery[] = $valueParameter; + $parameters->add(new Parameter($valueParameter, $caseSensitive ? $value : strtolower($value), $type)); + $valueParameter = ':'.$queryNameGenerator->generateParameterName($field); + } + $queryBuilder - ->andWhere($queryBuilder->expr()->in($wrapCase($aliasedField), $valueParameter)) - ->setParameter($valueParameter, $caseSensitive ? $values : array_map('strtolower', $values)); + ->andWhere($wrapCase($aliasedField).' IN ('.implode(', ', $inQuery).')') + ->setParameters($parameters); return; } @@ -228,7 +258,7 @@ protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuild } $queryBuilder->andWhere($queryBuilder->expr()->orX(...$ors)); - array_walk($parameters, [$queryBuilder, 'setParameter']); + array_walk($parameters, [$queryBuilder, 'setParameter'], $type); } /** diff --git a/src/Bridge/Elasticsearch/DataProvider/Filter/AbstractFilter.php b/src/Bridge/Elasticsearch/DataProvider/Filter/AbstractFilter.php index bdd041936ff..4be367ca6e0 100644 --- a/src/Bridge/Elasticsearch/DataProvider/Filter/AbstractFilter.php +++ b/src/Bridge/Elasticsearch/DataProvider/Filter/AbstractFilter.php @@ -115,7 +115,7 @@ protected function getMetadata(string $resourceClass, string $property): array return $noop; } - if ($type->isCollection() && null === $type = $type->getCollectionValueType()) { + if ($type->isCollection() && null === $type = method_exists(Type::class, 'getCollectionValueTypes') ? ($type->getCollectionValueTypes()[0] ?? null) : $type->getCollectionValueType()) { return $noop; } diff --git a/src/Bridge/Elasticsearch/Util/FieldDatatypeTrait.php b/src/Bridge/Elasticsearch/Util/FieldDatatypeTrait.php index fd7d2c81ead..fc541ee0887 100644 --- a/src/Bridge/Elasticsearch/Util/FieldDatatypeTrait.php +++ b/src/Bridge/Elasticsearch/Util/FieldDatatypeTrait.php @@ -80,7 +80,7 @@ private function getNestedFieldPath(string $resourceClass, string $property): ?s } if ( - null !== ($type = $type->getCollectionValueType()) + null !== ($type = method_exists(Type::class, 'getCollectionValueTypes') ? ($type->getCollectionValueTypes()[0] ?? null) : $type->getCollectionValueType()) && Type::BUILTIN_TYPE_OBJECT === $type->getBuiltinType() && null !== ($className = $type->getClassName()) && $this->resourceClassResolver->isResourceClass($className) diff --git a/src/Bridge/NelmioApiDoc/Parser/ApiPlatformParser.php b/src/Bridge/NelmioApiDoc/Parser/ApiPlatformParser.php index 44d8c52c289..702bc07eb4a 100644 --- a/src/Bridge/NelmioApiDoc/Parser/ApiPlatformParser.php +++ b/src/Bridge/NelmioApiDoc/Parser/ApiPlatformParser.php @@ -213,7 +213,7 @@ private function parseProperty(ResourceMetadata $resourceMetadata, PropertyMetad if ($type->isCollection()) { $data['actualType'] = DataTypes::COLLECTION; - if ($collectionType = $type->getCollectionValueType()) { + if ($collectionType = method_exists(Type::class, 'getCollectionValueTypes') ? ($type->getCollectionValueTypes()[0] ?? null) : $type->getCollectionValueType()) { $subProperty = $this->parseProperty($resourceMetadata, $propertyMetadata, $io, $collectionType, $visited); if (self::TYPE_IRI === $subProperty['dataType']) { $data['dataType'] = 'array of IRIs'; diff --git a/src/Bridge/Symfony/Bundle/ApiPlatformBundle.php b/src/Bridge/Symfony/Bundle/ApiPlatformBundle.php index 560b61d80c8..da038d8324b 100644 --- a/src/Bridge/Symfony/Bundle/ApiPlatformBundle.php +++ b/src/Bridge/Symfony/Bundle/ApiPlatformBundle.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Bridge\Symfony\Bundle; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\AnnotationFilterPass; +use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\AuthenticatorManagerPass; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\DataProviderPass; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\DeprecateMercurePublisherPass; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\ElasticsearchClientPass; @@ -53,5 +54,6 @@ public function build(ContainerBuilder $container) $container->addCompilerPass(new DeprecateMercurePublisherPass()); $container->addCompilerPass(new MetadataAwareNameConverterPass()); $container->addCompilerPass(new TestClientPass()); + $container->addCompilerPass(new AuthenticatorManagerPass()); } } diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Compiler/AuthenticatorManagerPass.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Compiler/AuthenticatorManagerPass.php new file mode 100644 index 00000000000..6df6b0cde60 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Compiler/AuthenticatorManagerPass.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\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * Checks if the new authenticator manager exists. + * + * @internal + * + * @author Alan Poulain + */ +final class AuthenticatorManagerPass implements CompilerPassInterface +{ + /** + * {@inheritdoc} + */ + public function process(ContainerBuilder $container): void + { + if ($container->has('security.authenticator.manager')) { + $container->getDefinition('api_platform.security.resource_access_checker')->setArgument(5, false); + } + } +} diff --git a/src/Bridge/Symfony/Routing/ApiLoader.php b/src/Bridge/Symfony/Routing/ApiLoader.php index 86fbe8620ac..d7390fd7bd8 100644 --- a/src/Bridge/Symfony/Routing/ApiLoader.php +++ b/src/Bridge/Symfony/Routing/ApiLoader.php @@ -227,7 +227,12 @@ private function addRoute(RouteCollection $routeCollection, string $resourceClas } } - $operation['identifiers'] = (array) ($operation['identifiers'] ?? $resourceMetadata->getAttribute('identifiers', $this->identifiersExtractor ? $this->identifiersExtractor->getIdentifiersFromResourceClass($resourceClass) : ['id'])); + if ($resourceMetadata->getItemOperations()) { + $operation['identifiers'] = (array) ($operation['identifiers'] ?? $resourceMetadata->getAttribute('identifiers', $this->identifiersExtractor ? $this->identifiersExtractor->getIdentifiersFromResourceClass($resourceClass) : ['id'])); + } else { + $operation['identifiers'] = $operation['identifiers'] ?? []; + } + $operation['has_composite_identifier'] = \count($operation['identifiers']) > 1 ? $resourceMetadata->getAttribute('composite_identifier', true) : false; $path = trim(trim($resourceMetadata->getAttribute('route_prefix', '')), '/'); $path .= $this->operationPathResolver->resolveOperationPath($resourceShortName, $operation, $operationType, $operationName); diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index e4913177df7..aa462454c73 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -263,7 +263,14 @@ public function resolveResourceArgs(array $args, string $operationName, string $ private function getResourceFieldConfiguration(?string $property, ?string $fieldDescription, ?string $deprecationReason, Type $type, string $rootResource, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, int $depth = 0): ?array { try { - $resourceClass = $this->typeBuilder->isCollection($type) && ($collectionValueType = $type->getCollectionValueType()) ? $collectionValueType->getClassName() : $type->getClassName(); + if ( + $this->typeBuilder->isCollection($type) && + $collectionValueType = method_exists(Type::class, 'getCollectionValueTypes') ? ($type->getCollectionValueTypes()[0] ?? null) : $type->getCollectionValueType() + ) { + $resourceClass = $collectionValueType->getClassName(); + } else { + $resourceClass = $type->getClassName(); + } if (null === $graphqlType = $this->convertType($type, $input, $queryName, $mutationName, $subscriptionName, $resourceClass ?? '', $rootResource, $property, $depth)) { return null; diff --git a/src/GraphQl/Type/TypeBuilder.php b/src/GraphQl/Type/TypeBuilder.php index 28c5b0d49ca..8d34b383965 100644 --- a/src/GraphQl/Type/TypeBuilder.php +++ b/src/GraphQl/Type/TypeBuilder.php @@ -217,7 +217,7 @@ public function getResourcePaginatedCollectionType(GraphQLType $resourceType, st */ public function isCollection(Type $type): bool { - return $type->isCollection() && ($collectionValueType = $type->getCollectionValueType()) && null !== $collectionValueType->getClassName(); + return $type->isCollection() && ($collectionValueType = method_exists(Type::class, 'getCollectionValueTypes') ? ($type->getCollectionValueTypes()[0] ?? null) : $type->getCollectionValueType()) && null !== $collectionValueType->getClassName(); } private function getCursorBasedPaginationFields(GraphQLType $resourceType): array diff --git a/src/GraphQl/Type/TypeConverter.php b/src/GraphQl/Type/TypeConverter.php index d25736b2993..ca68f16d622 100644 --- a/src/GraphQl/Type/TypeConverter.php +++ b/src/GraphQl/Type/TypeConverter.php @@ -102,7 +102,15 @@ public function resolveType(string $type): ?GraphQLType private function getResourceType(Type $type, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, int $depth): ?GraphQLType { - $resourceClass = $this->typeBuilder->isCollection($type) && ($collectionValueType = $type->getCollectionValueType()) ? $collectionValueType->getClassName() : $type->getClassName(); + if ( + $this->typeBuilder->isCollection($type) && + $collectionValueType = method_exists(Type::class, 'getCollectionValueTypes') ? ($type->getCollectionValueTypes()[0] ?? null) : $type->getCollectionValueType() + ) { + $resourceClass = $collectionValueType->getClassName(); + } else { + $resourceClass = $type->getClassName(); + } + if (null === $resourceClass) { return null; } diff --git a/src/Hal/Serializer/ItemNormalizer.php b/src/Hal/Serializer/ItemNormalizer.php index e61c44d24bc..a6e485d1780 100644 --- a/src/Hal/Serializer/ItemNormalizer.php +++ b/src/Hal/Serializer/ItemNormalizer.php @@ -17,6 +17,7 @@ use ApiPlatform\Core\Serializer\CacheKeyTrait; use ApiPlatform\Core\Serializer\ContextTrait; use ApiPlatform\Core\Util\ClassInfoTrait; +use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\UnexpectedValueException; use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; @@ -140,7 +141,7 @@ private function getComponents($object, ?string $format, array $context): array if (null !== $type) { if ($type->isCollection()) { - $valueType = $type->getCollectionValueType(); + $valueType = method_exists(Type::class, 'getCollectionValueTypes') ? ($type->getCollectionValueTypes()[0] ?? null) : $type->getCollectionValueType(); $isMany = null !== $valueType && ($className = $valueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className); } else { $className = $type->getClassName(); diff --git a/src/Hydra/Serializer/DocumentationNormalizer.php b/src/Hydra/Serializer/DocumentationNormalizer.php index 372b53df7cb..d5dcc96ca60 100644 --- a/src/Hydra/Serializer/DocumentationNormalizer.php +++ b/src/Hydra/Serializer/DocumentationNormalizer.php @@ -347,7 +347,7 @@ private function getRange(PropertyMetadata $propertyMetadata): ?string return null; } - if ($type->isCollection() && null !== $collectionType = $type->getCollectionValueType()) { + if ($type->isCollection() && null !== $collectionType = method_exists(Type::class, 'getCollectionValueTypes') ? ($type->getCollectionValueTypes()[0] ?? null) : $type->getCollectionValueType()) { $type = $collectionType; } diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index 7e024d59fab..2beb8cab51a 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -26,6 +26,7 @@ use ApiPlatform\Core\Serializer\ContextTrait; use ApiPlatform\Core\Util\ClassInfoTrait; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Exception\LogicException; use Symfony\Component\Serializer\Exception\NotNormalizableValueException; use Symfony\Component\Serializer\Exception\RuntimeException; @@ -275,7 +276,8 @@ private function getComponents($object, ?string $format, array $context): array if (null !== $type) { if ($type->isCollection()) { - $isMany = ($type->getCollectionValueType() && $className = $type->getCollectionValueType()->getClassName()) ? $this->resourceClassResolver->isResourceClass($className) : false; + $collectionValueType = method_exists(Type::class, 'getCollectionValueTypes') ? ($type->getCollectionValueTypes()[0] ?? null) : $type->getCollectionValueType(); + $isMany = ($collectionValueType && $className = $collectionValueType->getClassName()) ? $this->resourceClassResolver->isResourceClass($className) : false; } else { $isOne = ($className = $type->getClassName()) ? $this->resourceClassResolver->isResourceClass($className) : false; } diff --git a/src/JsonLd/Serializer/ObjectNormalizer.php b/src/JsonLd/Serializer/ObjectNormalizer.php index 308cda9035e..af038d1f4d0 100644 --- a/src/JsonLd/Serializer/ObjectNormalizer.php +++ b/src/JsonLd/Serializer/ObjectNormalizer.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\JsonLd\Serializer; use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\JsonLd\AnonymousContextBuilderInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; @@ -84,7 +85,11 @@ public function normalize($object, $format = null, array $context = []) } if (isset($originalResource)) { - $context['output']['iri'] = $this->iriConverter->getIriFromItem($originalResource); + try { + $context['output']['iri'] = $this->iriConverter->getIriFromItem($originalResource); + } catch (InvalidArgumentException $e) { + // The original resource has no identifiers + } $context['api_resource'] = $originalResource; } diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index 2093ef60b93..a1ab3610e0b 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -214,8 +214,13 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $valueSchema = []; if (null !== $type = $propertyMetadata->getType()) { - $isCollection = $type->isCollection(); - if (null === $valueType = $isCollection ? $type->getCollectionValueType() : $type) { + if ($isCollection = $type->isCollection()) { + $valueType = method_exists(Type::class, 'getCollectionValueTypes') ? ($type->getCollectionValueTypes()[0] ?? null) : $type->getCollectionValueType(); + } else { + $valueType = $type; + } + + if (null === $valueType) { $builtinType = 'string'; $className = null; } else { @@ -226,7 +231,7 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $valueSchema = $this->typeFactory->getType(new Type($builtinType, $type->isNullable(), $className, $isCollection), $format, $propertyMetadata->isReadableLink(), $serializerContext, $schema); } - if (\count($propertySchema) > 0 && \array_key_exists('$ref', $valueSchema)) { + if (\array_key_exists('type', $propertySchema) && \array_key_exists('$ref', $valueSchema)) { $propertySchema = new \ArrayObject($propertySchema); } else { $propertySchema = new \ArrayObject($propertySchema + $valueSchema); @@ -325,7 +330,10 @@ private function getValidationGroups(ResourceMetadata $resourceMetadata, ?string */ private function getFactoryOptions(array $serializerContext, array $validationGroups, ?string $operationType, ?string $operationName): array { - $options = []; + $options = [ + /* @see https://github.com/symfony/symfony/blob/v5.1.0/src/Symfony/Component/PropertyInfo/Extractor/ReflectionExtractor.php */ + 'enable_getter_setter_extraction' => true, + ]; if (isset($serializerContext[AbstractNormalizer::GROUPS])) { /* @see https://github.com/symfony/symfony/blob/v4.2.6/src/Symfony/Component/PropertyInfo/Extractor/SerializerExtractor.php */ diff --git a/src/JsonSchema/TypeFactory.php b/src/JsonSchema/TypeFactory.php index 85fb2f31311..04e71d4e60f 100644 --- a/src/JsonSchema/TypeFactory.php +++ b/src/JsonSchema/TypeFactory.php @@ -52,8 +52,8 @@ public function setSchemaFactory(SchemaFactoryInterface $schemaFactory): void public function getType(Type $type, string $format = 'json', ?bool $readableLink = null, ?array $serializerContext = null, Schema $schema = null): array { if ($type->isCollection()) { - $keyType = $type->getCollectionKeyType(); - $subType = $type->getCollectionValueType() ?? new Type($type->getBuiltinType(), false, $type->getClassName(), false); + $keyType = method_exists(Type::class, 'getCollectionKeyTypes') ? ($type->getCollectionKeyTypes()[0] ?? null) : $type->getCollectionKeyType(); + $subType = (method_exists(Type::class, 'getCollectionValueTypes') ? ($type->getCollectionValueTypes()[0] ?? null) : $type->getCollectionValueType()) ?? new Type($type->getBuiltinType(), false, $type->getClassName(), false); if (null !== $keyType && Type::BUILTIN_TYPE_STRING === $keyType->getBuiltinType()) { return $this->addNullabilityToTypeDefinition([ diff --git a/src/Metadata/Property/Factory/AnnotationSubresourceMetadataFactory.php b/src/Metadata/Property/Factory/AnnotationSubresourceMetadataFactory.php index 953c630a72a..878ce30f072 100644 --- a/src/Metadata/Property/Factory/AnnotationSubresourceMetadataFactory.php +++ b/src/Metadata/Property/Factory/AnnotationSubresourceMetadataFactory.php @@ -19,6 +19,7 @@ use ApiPlatform\Core\Metadata\Property\SubresourceMetadata; use ApiPlatform\Core\Util\Reflection; use Doctrine\Common\Annotations\Reader; +use Symfony\Component\PropertyInfo\Type; /** * Adds subresources to the properties metadata from {@see ApiResource} annotations. @@ -92,7 +93,16 @@ private function updateMetadata(ApiSubresource $annotation, PropertyMetadata $pr throw new InvalidResourceException(sprintf('Property "%s" on resource "%s" is declared as a subresource, but its type could not be determined.', $propertyName, $originResourceClass)); } $isCollection = $type->isCollection(); - $resourceClass = $isCollection && ($collectionValueType = $type->getCollectionValueType()) ? $collectionValueType->getClassName() : $type->getClassName(); + + if ( + $isCollection && + $collectionValueType = method_exists(Type::class, 'getCollectionValueTypes') ? ($type->getCollectionValueTypes()[0] ?? null) : $type->getCollectionValueType() + ) { + $resourceClass = $collectionValueType->getClassName(); + } else { + $resourceClass = $type->getClassName(); + } + $maxDepth = $annotation->maxDepth; // @ApiSubresource is on the class identifier (/collection/{id}/subcollection/{subcollectionId}) if (null === $resourceClass) { diff --git a/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php b/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php index ecf31999fd8..89f86c69cb6 100644 --- a/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/ExtractorPropertyMetadataFactory.php @@ -17,6 +17,7 @@ use ApiPlatform\Core\Metadata\Extractor\ExtractorInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Property\SubresourceMetadata; +use Symfony\Component\PropertyInfo\Type; /** * Creates properties's metadata using an extractor. @@ -139,7 +140,14 @@ private function createSubresourceMetadata($subresource, PropertyMetadata $prope if (null !== $type) { $isCollection = $type->isCollection(); - $resourceClass = $isCollection && ($collectionValueType = $type->getCollectionValueType()) ? $collectionValueType->getClassName() : $type->getClassName(); + if ( + $isCollection && + $collectionValueType = method_exists(Type::class, 'getCollectionValueTypes') ? ($type->getCollectionValueTypes()[0] ?? null) : $type->getCollectionValueType() + ) { + $resourceClass = $collectionValueType->getClassName(); + } else { + $resourceClass = $type->getClassName(); + } } elseif (\is_array($subresource) && isset($subresource['resourceClass'])) { $resourceClass = $subresource['resourceClass']; $isCollection = $subresource['collection'] ?? true; diff --git a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php index c02050cd5a0..5df7fdb3420 100644 --- a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php @@ -18,6 +18,7 @@ use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Util\ResourceClassInfoTrait; +use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Mapping\AttributeMetadataInterface; use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface as SerializerClassMetadataFactoryInterface; @@ -113,7 +114,14 @@ private function transformLinkStatus(PropertyMetadata $propertyMetadata, array $ return $propertyMetadata; } - $relatedClass = $type->isCollection() && ($collectionValueType = $type->getCollectionValueType()) ? $collectionValueType->getClassName() : $type->getClassName(); + if ( + $type->isCollection() && + $collectionValueType = method_exists(Type::class, 'getCollectionValueTypes') ? ($type->getCollectionValueTypes()[0] ?? null) : $type->getCollectionValueType() + ) { + $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)) { diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php index 7eb1f175cde..d76d7d7b65c 100644 --- a/src/OpenApi/Factory/OpenApiFactory.php +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -138,7 +138,11 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour $rootResourceClass = $resourceClass; foreach ($operations as $operationName => $operation) { - $identifiers = (array) ($operation['identifiers'] ?? $resourceMetadata->getAttribute('identifiers', null === $this->identifiersExtractor ? ['id'] : $this->identifiersExtractor->getIdentifiersFromResourceClass($resourceClass))); + if (OperationType::COLLECTION === $operationType && !$resourceMetadata->getItemOperations()) { + $identifiers = []; + } else { + $identifiers = (array) ($operation['identifiers'] ?? $resourceMetadata->getAttribute('identifiers', null === $this->identifiersExtractor ? ['id'] : $this->identifiersExtractor->getIdentifiersFromResourceClass($resourceClass))); + } if (\count($identifiers) > 1 ? $resourceMetadata->getAttribute('composite_identifier', true) : false) { $identifiers = ['id']; } @@ -200,6 +204,7 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '201'); $responses[$successStatus] = new Model\Response(sprintf('%s resource created', $resourceShortName), $responseContent, null, $responseLinks); $responses['400'] = new Model\Response('Invalid input'); + $responses['422'] = new Model\Response('Unprocessable entity'); break; case 'PATCH': case 'PUT': @@ -208,6 +213,7 @@ private function collectPaths(ResourceMetadata $resourceMetadata, string $resour $responseContent = $this->buildContent($responseMimeTypes, $operationOutputSchemas); $responses[$successStatus] = new Model\Response(sprintf('%s resource updated', $resourceShortName), $responseContent, null, $responseLinks); $responses['400'] = new Model\Response('Invalid input'); + $responses['422'] = new Model\Response('Unprocessable entity'); break; case 'DELETE': $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '204'); @@ -456,7 +462,7 @@ private function getOauthSecurityScheme(): Model\SecurityScheme throw new \LogicException('OAuth flow must be one of: implicit, password, clientCredentials, authorizationCode'); } - return new Model\SecurityScheme($this->openApiOptions->getOAuthType(), $description, null, null, 'oauth2', null, new Model\OAuthFlows($implicit, $password, $clientCredentials, $authorizationCode), null); + return new Model\SecurityScheme($this->openApiOptions->getOAuthType(), $description, null, null, null, null, new Model\OAuthFlows($implicit, $password, $clientCredentials, $authorizationCode), null); } private function getSecuritySchemes(): array diff --git a/src/Security/ResourceAccessChecker.php b/src/Security/ResourceAccessChecker.php index 67d8fad1299..23862b49a1a 100644 --- a/src/Security/ResourceAccessChecker.php +++ b/src/Security/ResourceAccessChecker.php @@ -15,6 +15,7 @@ use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface; +use Symfony\Component\Security\Core\Authentication\Token\NullToken; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; @@ -33,14 +34,16 @@ final class ResourceAccessChecker implements ResourceAccessCheckerInterface private $roleHierarchy; private $tokenStorage; private $authorizationChecker; + private $exceptionOnNoToken; - public function __construct(ExpressionLanguage $expressionLanguage = null, AuthenticationTrustResolverInterface $authenticationTrustResolver = null, RoleHierarchyInterface $roleHierarchy = null, TokenStorageInterface $tokenStorage = null, AuthorizationCheckerInterface $authorizationChecker = null) + public function __construct(ExpressionLanguage $expressionLanguage = null, AuthenticationTrustResolverInterface $authenticationTrustResolver = null, RoleHierarchyInterface $roleHierarchy = null, TokenStorageInterface $tokenStorage = null, AuthorizationCheckerInterface $authorizationChecker = null, bool $exceptionOnNoToken = true) { $this->expressionLanguage = $expressionLanguage; $this->authenticationTrustResolver = $authenticationTrustResolver; $this->roleHierarchy = $roleHierarchy; $this->tokenStorage = $tokenStorage; $this->authorizationChecker = $authorizationChecker; + $this->exceptionOnNoToken = $exceptionOnNoToken; } public function isGranted(string $resourceClass, string $expression, array $extraVariables = []): bool @@ -48,6 +51,15 @@ public function isGranted(string $resourceClass, string $expression, array $extr if (null === $this->tokenStorage || null === $this->authenticationTrustResolver) { throw new \LogicException('The "symfony/security" library must be installed to use the "security" attribute.'); } + if (null === $token = $this->tokenStorage->getToken()) { + if ($this->exceptionOnNoToken) { + throw new \LogicException('The current token must be set to use the "security" attribute (is the URL behind a firewall?).'); + } + + if (class_exists(NullToken::class)) { + $token = new NullToken(); + } + } if (null === $this->expressionLanguage) { throw new \LogicException('The "symfony/expression-language" library must be installed to use the "security" attribute.'); } @@ -57,7 +69,7 @@ public function isGranted(string $resourceClass, string $expression, array $extr 'auth_checker' => $this->authorizationChecker, // needed for the is_granted expression function ]); - if ($token = $this->tokenStorage->getToken()) { + if ($token) { $variables = array_merge($variables, $this->getVariables($token)); } diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index b8c5eb8551c..075ce28c951 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -433,7 +433,7 @@ protected function denormalizeCollection(string $attribute, PropertyMetadata $pr throw new InvalidArgumentException(sprintf('The type of the "%s" attribute must be "array", "%s" given.', $attribute, \gettype($value))); } - $collectionKeyType = $type->getCollectionKeyType(); + $collectionKeyType = method_exists(Type::class, 'getCollectionKeyTypes') ? ($type->getCollectionKeyTypes()[0] ?? null) : $type->getCollectionKeyType(); $collectionKeyBuiltinType = null === $collectionKeyType ? null : $collectionKeyType->getBuiltinType(); $values = []; @@ -578,7 +578,7 @@ protected function getAttributeValue($object, $attribute, $format = null, array if ( $type && $type->isCollection() && - ($collectionValueType = $type->getCollectionValueType()) && + ($collectionValueType = method_exists(Type::class, 'getCollectionValueTypes') ? ($type->getCollectionValueTypes()[0] ?? null) : $type->getCollectionValueType()) && ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className) ) { @@ -727,7 +727,7 @@ private function createAttributeValue($attribute, $value, $format = null, array if ( $type->isCollection() && - null !== ($collectionValueType = $type->getCollectionValueType()) && + null !== ($collectionValueType = method_exists(Type::class, 'getCollectionValueTypes') ? ($type->getCollectionValueTypes()[0] ?? null) : $type->getCollectionValueType()) && null !== ($className = $collectionValueType->getClassName()) && $this->resourceClassResolver->isResourceClass($className) ) { @@ -750,7 +750,7 @@ private function createAttributeValue($attribute, $value, $format = null, array if ( $type->isCollection() && - null !== ($collectionValueType = $type->getCollectionValueType()) && + null !== ($collectionValueType = method_exists(Type::class, 'getCollectionValueTypes') ? ($type->getCollectionValueTypes()[0] ?? null) : $type->getCollectionValueType()) && null !== ($className = $collectionValueType->getClassName()) ) { if (!$this->serializer instanceof DenormalizerInterface) { diff --git a/src/Swagger/Serializer/DocumentationNormalizer.php b/src/Swagger/Serializer/DocumentationNormalizer.php index 62aba70ec07..e405251228d 100644 --- a/src/Swagger/Serializer/DocumentationNormalizer.php +++ b/src/Swagger/Serializer/DocumentationNormalizer.php @@ -197,7 +197,12 @@ public function normalize($object, $format = null, array $context = []) foreach ($object->getResourceNameCollection() as $resourceClass) { $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); if ($this->identifiersExtractor) { - $resourceMetadata = $resourceMetadata->withAttributes(($resourceMetadata->getAttributes() ?: []) + ['identifiers' => $this->identifiersExtractor->getIdentifiersFromResourceClass($resourceClass)]); + $identifiers = []; + if ($resourceMetadata->getItemOperations()) { + $identifiers = $this->identifiersExtractor->getIdentifiersFromResourceClass($resourceClass); + } + + $resourceMetadata = $resourceMetadata->withAttributes(($resourceMetadata->getAttributes() ?: []) + ['identifiers' => $identifiers]); } $resourceShortName = $resourceMetadata->getShortName(); @@ -521,6 +526,7 @@ private function updatePostOperation(bool $v3, \ArrayObject $pathOperation, arra (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '201') => $successResponse, '400' => ['description' => 'Invalid input'], '404' => ['description' => 'Resource not found'], + '422' => ['description' => 'Unprocessable entity'], ]; return $this->addRequestBody($v3, $pathOperation, $definitions, $resourceClass, $resourceShortName, $operationType, $operationName, $requestMimeTypes); @@ -544,6 +550,7 @@ private function updatePutOperation(bool $v3, \ArrayObject $pathOperation, array (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '200') => $successResponse, '400' => ['description' => 'Invalid input'], '404' => ['description' => 'Resource not found'], + '422' => ['description' => 'Unprocessable entity'], ]; return $this->addRequestBody($v3, $pathOperation, $definitions, $resourceClass, $resourceShortName, $operationType, $operationName, $requestMimeTypes, true); diff --git a/src/Test/DoctrineOrmFilterTestCase.php b/src/Test/DoctrineOrmFilterTestCase.php index a27d0f73738..32430b74414 100644 --- a/src/Test/DoctrineOrmFilterTestCase.php +++ b/src/Test/DoctrineOrmFilterTestCase.php @@ -18,7 +18,6 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use Doctrine\ORM\EntityRepository; use Doctrine\Persistence\ManagerRegistry; -use Symfony\Bridge\Doctrine\Test\DoctrineTestHelper; use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\RequestStack; @@ -59,9 +58,8 @@ protected function setUp(): void { self::bootKernel(); - $manager = DoctrineTestHelper::createTestEntityManager(); $this->managerRegistry = self::$kernel->getContainer()->get('doctrine'); - $this->repository = $manager->getRepository(Dummy::class); + $this->repository = $this->managerRegistry->getManagerForClass(Dummy::class)->getRepository(Dummy::class); } /** diff --git a/tests/Api/IdentifiersExtractorTest.php b/tests/Api/IdentifiersExtractorTest.php index 6cefc317203..c45eeccb21f 100644 --- a/tests/Api/IdentifiersExtractorTest.php +++ b/tests/Api/IdentifiersExtractorTest.php @@ -205,7 +205,7 @@ public function testGetsIdentifiersFromCorrectResourceClass(): void public function testNoIdentifiers(): void { $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('No identifier defined "ApiPlatform\\Core\\Tests\\Fixtures\\TestBundle\\Entity\\Dummy". You should add #[\ApiPlatform\Core\Annotation\ApiProperty(identifier: true)]" on the property identifying the resource.'); + $this->expectExceptionMessage('No identifier defined in "ApiPlatform\\Core\\Tests\\Fixtures\\TestBundle\\Entity\\Dummy". You should add #[\ApiPlatform\Core\Annotation\ApiProperty(identifier: true)]" on the property identifying the resource.'); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactoryProphecy->create(Dummy::class)->willReturn(new PropertyNameCollection(['foo'])); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index db2405e5daa..c57510af27d 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -140,6 +140,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Pet; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Product; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Question; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RamseyUuidBinaryDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RamseyUuidDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedOwnedDummy; @@ -1307,6 +1308,35 @@ public function thereIsARamseyIdentifiedResource(string $uuid) $this->manager->flush(); } + /** + * @Given there is a ramsey identified resource with binary uuid :uuid + */ + public function thereIsARamseyIdentifiedResourceWithBinaryUuid(string $uuid) + { + $dummy = new RamseyUuidBinaryDummy(); + $dummy->setId($uuid); + + $this->manager->persist($dummy); + $this->manager->flush(); + } + + /** + * @Given there is a ramsey identified resource with binary uuid :uuid having a related resource with binary uuid :uuid_related + */ + public function thereIsARamseyIdentifiedResourceWithBinaryUuidHavingARelatedResourceWithBinaryUuid(string $uuid, string $uuidRelated) + { + $related = new RamseyUuidBinaryDummy(); + $related->setId($uuidRelated); + + $dummy = new RamseyUuidBinaryDummy(); + $dummy->setId($uuid); + $dummy->addRelated($related); + + $this->manager->persist($related); + $this->manager->persist($dummy); + $this->manager->flush(); + } + /** * @Given there is a dummy object with a fourth level relation */ diff --git a/tests/Behat/OpenApiContext.php b/tests/Behat/OpenApiContext.php index 19d06caa9ad..2ddc5ed1875 100644 --- a/tests/Behat/OpenApiContext.php +++ b/tests/Behat/OpenApiContext.php @@ -109,33 +109,33 @@ public function assertThePathExist(string $path) } /** - * @Then :prop property exists for the Swagger class :class + * @Then the :prop property exists for the Swagger class :class */ - public function assertPropertyExistForTheSwaggerClass(string $propertyName, string $className) + public function assertThePropertyExistForTheSwaggerClass(string $propertyName, string $className) { try { $this->getPropertyInfo($propertyName, $className); } catch (\InvalidArgumentException $e) { - throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" exists.', $propertyName, $className), null, $e); + throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" doesn\'t exist.', $propertyName, $className), null, $e); } } /** - * @Then :prop property exists for the OpenAPI class :class + * @Then the :prop property exists for the OpenAPI class :class */ - public function assertPropertyExistForTheOpenApiClass(string $propertyName, string $className) + public function assertThePropertyExistForTheOpenApiClass(string $propertyName, string $className) { try { $this->getPropertyInfo($propertyName, $className, 3); } catch (\InvalidArgumentException $e) { - throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" exists.', $propertyName, $className), null, $e); + throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" doesn\'t exist.', $propertyName, $className), null, $e); } } /** - * @Then :prop property is required for Swagger class :class + * @Then the :prop property is required for the Swagger class :class */ - public function assertPropertyIsRequiredForSwagger(string $propertyName, string $className) + public function assertThePropertyIsRequiredForTheSwaggerClass(string $propertyName, string $className) { if (!\in_array($propertyName, $this->getClassInfo($className)->required, true)) { throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" should be required', $propertyName, $className)); @@ -143,15 +143,37 @@ public function assertPropertyIsRequiredForSwagger(string $propertyName, string } /** - * @Then :prop property is required for OpenAPi class :class + * @Then the :prop property is required for the OpenAPI class :class */ - public function assertPropertyIsRequiredForOpenAPi(string $propertyName, string $className) + public function assertThePropertyIsRequiredForTheOpenAPIClass(string $propertyName, string $className) { if (!\in_array($propertyName, $this->getClassInfo($className, 3)->required, true)) { throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" should be required', $propertyName, $className)); } } + /** + * @Then the :prop property is not read only for the Swagger class :class + */ + public function assertThePropertyIsNotReadOnlyForTheSwaggerClass(string $propertyName, string $className) + { + $propertyInfo = $this->getPropertyInfo($propertyName, $className); + if (property_exists($propertyInfo, 'readOnly') && $propertyInfo->readOnly) { + throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" should not be read only', $propertyName, $className)); + } + } + + /** + * @Then the :prop property is not read only for the OpenAPI class :class + */ + public function assertThePropertyIsNotReadOnlyForTheOpenAPIClass(string $propertyName, string $className) + { + $propertyInfo = $this->getPropertyInfo($propertyName, $className, 3); + if (property_exists($propertyInfo, 'readOnly') && $propertyInfo->readOnly) { + throw new ExpectationFailedException(sprintf('Property "%s" of class "%s" should not be read only', $propertyName, $className)); + } + } + /** * Gets information about a property. * diff --git a/tests/Bridge/Doctrine/Orm/Extension/FilterEagerLoadingExtensionTest.php b/tests/Bridge/Doctrine/Orm/Extension/FilterEagerLoadingExtensionTest.php index 99221bfda8f..810632a670a 100644 --- a/tests/Bridge/Doctrine/Orm/Extension/FilterEagerLoadingExtensionTest.php +++ b/tests/Bridge/Doctrine/Orm/Extension/FilterEagerLoadingExtensionTest.php @@ -559,6 +559,48 @@ public function testCompositeIdentifiersWithoutAssociation() $this->assertEquals($this->toDQLString($expected), $qb->getDQL()); } + public function testCompositeIdentifiersWithForeignIdentifiers() + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(DummyCar::class)->willReturn(new ResourceMetadata(DummyCar::class)); + + $classMetadata = new ClassMetadataInfo(DummyCar::class); + $classMetadata->setIdentifier(['id']); + $classMetadata->containsForeignIdentifier = true; + + $em = $this->prophesize(EntityManager::class); + $em->getExpressionBuilder()->shouldBeCalled()->willReturn(new Expr()); + $em->getClassMetadata(DummyCar::class)->shouldBeCalled()->willReturn($classMetadata); + + $qb = new QueryBuilder($em->reveal()); + + $qb->select('o') + ->from(DummyCar::class, 'o') + ->leftJoin('o.colors', 'colors') + ->where('o.colors = :foo') + ->setParameter('foo', 1); + + $queryNameGenerator = $this->prophesize(QueryNameGeneratorInterface::class); + $queryNameGenerator->generateJoinAlias('colors')->shouldBeCalled()->willReturn('colors_2'); + $queryNameGenerator->generateJoinAlias('o')->shouldBeCalled()->willReturn('o_2'); + + $filterEagerLoadingExtension = new FilterEagerLoadingExtension($resourceMetadataFactoryProphecy->reveal(), true); + $filterEagerLoadingExtension->applyToCollection($qb, $queryNameGenerator->reveal(), DummyCar::class, 'get'); + + $expected = <<assertEquals($this->toDQLString($expected), $qb->getDQL()); + } + private function toDQLString(string $dql): string { return preg_replace(['/\s+/', '/\(\s/', '/\s\)/'], [' ', '(', ')'], $dql); diff --git a/tests/Bridge/Doctrine/Orm/Filter/SearchFilterTest.php b/tests/Bridge/Doctrine/Orm/Filter/SearchFilterTest.php index 1dbb23905dd..c202e3c5475 100644 --- a/tests/Bridge/Doctrine/Orm/Filter/SearchFilterTest.php +++ b/tests/Bridge/Doctrine/Orm/Filter/SearchFilterTest.php @@ -390,22 +390,18 @@ public function provideApplyTestData(): array $filterFactory, ], 'exact (multiple values)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name IN(:name_p1)', $this->alias, Dummy::class), + sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name IN (:name_p1, :name_p2)', $this->alias, Dummy::class), [ - 'name_p1' => [ - 'CaSE', - 'SENSitive', - ], + 'name_p1' => 'CaSE', + 'name_p2' => 'SENSitive', ], $filterFactory, ], 'exact (multiple values; case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) IN(:name_p1)', $this->alias, Dummy::class), + sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) IN (:name_p1, :name_p2)', $this->alias, Dummy::class), [ - 'name_p1' => [ - 'case', - 'insensitive', - ], + 'name_p1' => 'case', + 'name_p2' => 'insensitive', ], $filterFactory, ], @@ -547,10 +543,11 @@ public function provideApplyTestData(): array $filterFactory, ], 'mixed IRI and entity ID values for relations' => [ - sprintf('SELECT %s FROM %s %1$s INNER JOIN %1$s.relatedDummies relatedDummies_a1 WHERE %1$s.relatedDummy IN(:relatedDummy_p1) AND relatedDummies_a1.id = :relatedDummies_p2', $this->alias, Dummy::class), + sprintf('SELECT %s FROM %s %1$s INNER JOIN %1$s.relatedDummies relatedDummies_a1 WHERE %1$s.relatedDummy IN (:relatedDummy_p1, :relatedDummy_p2) AND relatedDummies_a1.id = :relatedDummies_p4', $this->alias, Dummy::class), [ - 'relatedDummy_p1' => [1, 2], - 'relatedDummies_p2' => 1, + 'relatedDummy_p1' => 1, + 'relatedDummy_p2' => 2, + 'relatedDummies_p4' => 1, ], $filterFactory, ], diff --git a/tests/Bridge/Symfony/Bundle/ApiPlatformBundleTest.php b/tests/Bridge/Symfony/Bundle/ApiPlatformBundleTest.php index 5ea6458148c..ac043f594ba 100644 --- a/tests/Bridge/Symfony/Bundle/ApiPlatformBundleTest.php +++ b/tests/Bridge/Symfony/Bundle/ApiPlatformBundleTest.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\Bridge\Symfony\Bundle\ApiPlatformBundle; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\AnnotationFilterPass; +use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\AuthenticatorManagerPass; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\DataProviderPass; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\DeprecateMercurePublisherPass; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\ElasticsearchClientPass; @@ -50,6 +51,7 @@ public function testBuild() $containerProphecy->addCompilerPass(Argument::type(DeprecateMercurePublisherPass::class))->shouldBeCalled(); $containerProphecy->addCompilerPass(Argument::type(MetadataAwareNameConverterPass::class))->shouldBeCalled(); $containerProphecy->addCompilerPass(Argument::type(TestClientPass::class))->shouldBeCalled(); + $containerProphecy->addCompilerPass(Argument::type(AuthenticatorManagerPass::class))->shouldBeCalled(); $bundle = new ApiPlatformBundle(); $bundle->build($containerProphecy->reveal()); diff --git a/tests/Bridge/Symfony/Bundle/DataPersister/TraceableChainDataPersisterTest.php b/tests/Bridge/Symfony/Bundle/DataPersister/TraceableChainDataPersisterTest.php index f723a501ea5..79e28babcd4 100644 --- a/tests/Bridge/Symfony/Bundle/DataPersister/TraceableChainDataPersisterTest.php +++ b/tests/Bridge/Symfony/Bundle/DataPersister/TraceableChainDataPersisterTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Core\DataPersister\ChainDataPersister; use ApiPlatform\Core\DataPersister\DataPersisterInterface; use ApiPlatform\Core\DataPersister\ResumableDataPersisterInterface; +use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; /** @@ -24,6 +25,8 @@ */ class TraceableChainDataPersisterTest extends TestCase { + use ProphecyTrait; + /** @dataProvider dataPersisterProvider */ public function testPersist($persister, $expected) { diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/Compiler/AuthenticatorManagerPassTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/Compiler/AuthenticatorManagerPassTest.php new file mode 100644 index 00000000000..409a7a73363 --- /dev/null +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/Compiler/AuthenticatorManagerPassTest.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\Core\Tests\Bridge\Symfony\Bundle\DependencyInjection\Compiler; + +use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\AuthenticatorManagerPass; +use ApiPlatform\Core\Tests\ProphecyTrait; +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Definition; + +final class AuthenticatorManagerPassTest extends TestCase +{ + use ProphecyTrait; + + private $containerBuilderProphecy; + private $authenticatorManagerPass; + + protected function setUp(): void + { + $this->containerBuilderProphecy = $this->prophesize(ContainerBuilder::class); + $this->authenticatorManagerPass = new AuthenticatorManagerPass(); + } + + public function testConstruct(): void + { + self::assertInstanceOf(CompilerPassInterface::class, $this->authenticatorManagerPass); + } + + public function testProcessWithoutAuthenticatorManager(): void + { + $this->containerBuilderProphecy->has('security.authenticator.manager')->willReturn(false); + $this->containerBuilderProphecy->getDefinition('api_platform.security.resource_access_checker')->shouldNotBeCalled(); + + $this->authenticatorManagerPass->process($this->containerBuilderProphecy->reveal()); + } + + public function testProcess(): void + { + $this->containerBuilderProphecy->has('security.authenticator.manager')->willReturn(true); + $authenticatorManagerDefinitionProphecy = $this->prophesize(Definition::class); + $this->containerBuilderProphecy->getDefinition('api_platform.security.resource_access_checker')->willReturn($authenticatorManagerDefinitionProphecy->reveal()); + $authenticatorManagerDefinitionProphecy->setArgument(5, false)->shouldBeCalledOnce(); + + $this->authenticatorManagerPass->process($this->containerBuilderProphecy->reveal()); + } +} diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/Compiler/TestClientPassTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/Compiler/TestClientPassTest.php index 02d4d7147f4..fec961a4637 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/Compiler/TestClientPassTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/Compiler/TestClientPassTest.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\TestClientPass; use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\Client; +use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; @@ -23,6 +24,8 @@ final class TestClientPassTest extends TestCase { + use ProphecyTrait; + private $containerBuilderProphecy; private $testClientPass; diff --git a/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php b/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php index f75315d9712..126a3b8bc10 100644 --- a/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php +++ b/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php @@ -23,6 +23,7 @@ use ApiPlatform\Core\OpenApi\Model\Paths; use ApiPlatform\Core\OpenApi\OpenApi; use ApiPlatform\Core\OpenApi\Options; +use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Symfony\Component\HttpFoundation\Request; @@ -35,6 +36,8 @@ */ class SwaggerUiActionTest extends TestCase { + use ProphecyTrait; + public const SPEC = [ 'paths' => [ '/fs' => ['get' => ['operationId' => 'getFCollection']], diff --git a/tests/Filter/QueryParameterValidatorTest.php b/tests/Filter/QueryParameterValidatorTest.php index 1c9e60f06f6..e2a34ebf6c4 100644 --- a/tests/Filter/QueryParameterValidatorTest.php +++ b/tests/Filter/QueryParameterValidatorTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Core\Exception\FilterValidationException; use ApiPlatform\Core\Filter\QueryParameterValidator; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; @@ -27,6 +28,8 @@ */ class QueryParameterValidatorTest extends TestCase { + use ProphecyTrait; + private $testedInstance; private $filterLocatorProphecy; diff --git a/tests/Fixtures/TestBundle/DataTransformer/RPCOutputDataTransformer.php b/tests/Fixtures/TestBundle/DataTransformer/RPCOutputDataTransformer.php new file mode 100644 index 00000000000..54940515aa2 --- /dev/null +++ b/tests/Fixtures/TestBundle/DataTransformer/RPCOutputDataTransformer.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\Core\Tests\Fixtures\TestBundle\DataTransformer; + +use ApiPlatform\Core\DataTransformer\DataTransformerInterface; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\RPC as RPCDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\RPCOutput; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RPC; + +final class RPCOutputDataTransformer implements DataTransformerInterface +{ + /** + * {@inheritdoc} + */ + public function transform($object, string $to, array $context = []) + { + return new RPCOutput(); + } + + /** + * {@inheritdoc} + */ + public function supportsTransformation($object, string $to, array $context = []): bool + { + return ($object instanceof RPC || $object instanceof RPCDocument) && RPCOutput::class === $to; + } +} diff --git a/tests/Fixtures/TestBundle/Document/DummyBoolean.php b/tests/Fixtures/TestBundle/Document/DummyBoolean.php new file mode 100644 index 00000000000..66476ca029e --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/DummyBoolean.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\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * @ApiResource + * @ODM\Document + */ +class DummyBoolean +{ + /** + * @var int + * + * @ODM\Id(strategy="INCREMENT", type="int", nullable=true) + */ + private $id; + + /** + * @var bool + * + * @ODM\Field(type="bool", nullable=true) + */ + private $isDummyBoolean; + + public function __construct(bool $isDummyBoolean) + { + $this->isDummyBoolean = $isDummyBoolean; + } + + public function getId() + { + return $this->id; + } + + public function isDummyBoolean(): bool + { + return $this->isDummyBoolean; + } +} diff --git a/tests/Fixtures/TestBundle/Document/RPC.php b/tests/Fixtures/TestBundle/Document/RPC.php new file mode 100644 index 00000000000..35b458efd94 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/RPC.php @@ -0,0 +1,36 @@ + + * + * For 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\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\RPCOutput; + +/** + * RPC-like resource. + * + * @ApiResource( + * itemOperations={}, + * collectionOperations={ + * "post"={"status"=202, "messenger"=true, "path"="rpc", "output"=false}, + * "post_output"={"method"="POST", "status"=200, "path"="rpc_output", "output"=RPCOutput::class} + * }, + * ) + */ +class RPC +{ + /** + * @var string + */ + public $value; +} diff --git a/tests/Fixtures/TestBundle/Dto/RPCOutput.php b/tests/Fixtures/TestBundle/Dto/RPCOutput.php new file mode 100644 index 00000000000..6111878933f --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/RPCOutput.php @@ -0,0 +1,19 @@ + + * + * For 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\Fixtures\TestBundle\Dto; + +class RPCOutput +{ + public $success = 'YES'; +} diff --git a/tests/Fixtures/TestBundle/Entity/DummyBoolean.php b/tests/Fixtures/TestBundle/Entity/DummyBoolean.php new file mode 100644 index 00000000000..389d878cf07 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyBoolean.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\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ApiResource + * @ORM\Entity + */ +class DummyBoolean +{ + /** + * @var int + * + * @ORM\Column(type="integer", nullable=true) + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + */ + private $id; + + /** + * @var bool + * + * @ORM\Column(type="boolean", nullable=true) + */ + private $isDummyBoolean; + + public function __construct(bool $isDummyBoolean) + { + $this->isDummyBoolean = $isDummyBoolean; + } + + public function getId() + { + return $this->id; + } + + public function isDummyBoolean(): bool + { + return $this->isDummyBoolean; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/DummyCar.php b/tests/Fixtures/TestBundle/Entity/DummyCar.php index ad9241e0174..9fa15c8e634 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyCar.php +++ b/tests/Fixtures/TestBundle/Entity/DummyCar.php @@ -42,11 +42,10 @@ class DummyCar { /** - * @var int The entity Id + * @var DummyCarIdentifier The entity Id * * @ORM\Id - * @ORM\GeneratedValue - * @ORM\Column(type="integer") + * @ORM\OneToOne(targetEntity="DummyCarIdentifier", cascade="persist") */ private $id; @@ -85,7 +84,7 @@ class DummyCar * * @ORM\ManyToMany(targetEntity="UuidIdentifierDummy", indexBy="uuid") * * @ORM\JoinTable(name="uuid_cars", - * joinColumns={@ORM\JoinColumn(name="car_id", referencedColumnName="id")}, + * joinColumns={@ORM\JoinColumn(name="car_id", referencedColumnName="id_id")}, * inverseJoinColumns={@ORM\JoinColumn(name="uuid_uuid", referencedColumnName="uuid")} * ) * @Serializer\Groups({"colors"}) @@ -127,6 +126,7 @@ class DummyCar public function __construct() { + $this->id = new DummyCarIdentifier(); $this->colors = new ArrayCollection(); } diff --git a/tests/Fixtures/TestBundle/Entity/DummyCarColor.php b/tests/Fixtures/TestBundle/Entity/DummyCarColor.php index a3e1f08cee7..a9eaef13ded 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyCarColor.php +++ b/tests/Fixtures/TestBundle/Entity/DummyCarColor.php @@ -39,7 +39,7 @@ class DummyCarColor * @var DummyCar * * @ORM\ManyToOne(targetEntity="DummyCar", inversedBy="colors") - * @ORM\JoinColumn(nullable=false, onDelete="CASCADE") + * @ORM\JoinColumn(nullable=false, onDelete="CASCADE", referencedColumnName="id_id") * @Assert\NotBlank */ private $car; diff --git a/tests/Fixtures/TestBundle/Entity/DummyCarIdentifier.php b/tests/Fixtures/TestBundle/Entity/DummyCarIdentifier.php new file mode 100644 index 00000000000..481d76eb0d4 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyCarIdentifier.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use Doctrine\ORM\Mapping as ORM; + +/** + * @ORM\Entity + */ +class DummyCarIdentifier +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + public function __toString() + { + return (string) $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/DummyTravel.php b/tests/Fixtures/TestBundle/Entity/DummyTravel.php index d877a71edee..958023ede3f 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyTravel.php +++ b/tests/Fixtures/TestBundle/Entity/DummyTravel.php @@ -31,7 +31,7 @@ class DummyTravel /** * @ORM\ManyToOne(targetEntity="DummyCar") - * @ORM\JoinColumn(name="car_id", referencedColumnName="id") + * @ORM\JoinColumn(name="car_id", referencedColumnName="id_id") */ public $car; diff --git a/tests/Fixtures/TestBundle/Entity/RPC.php b/tests/Fixtures/TestBundle/Entity/RPC.php new file mode 100644 index 00000000000..a029e54b515 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/RPC.php @@ -0,0 +1,36 @@ + + * + * For 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\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\RPCOutput; + +/** + * RPC-like resource. + * + * @ApiResource( + * itemOperations={}, + * collectionOperations={ + * "post"={"status"=202, "messenger"=true, "path"="rpc", "output"=false}, + * "post_output"={"method"="POST", "status"=200, "path"="rpc_output", "output"=RPCOutput::class} + * }, + * ) + */ +class RPC +{ + /** + * @var string + */ + public $value; +} diff --git a/tests/Fixtures/TestBundle/Entity/RamseyUuidBinaryDummy.php b/tests/Fixtures/TestBundle/Entity/RamseyUuidBinaryDummy.php new file mode 100644 index 00000000000..5cede9f62b3 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/RamseyUuidBinaryDummy.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\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiFilter; +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\SearchFilter; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Doctrine\ORM\Mapping as ORM; +use Ramsey\Uuid\Uuid; +use Ramsey\Uuid\UuidInterface; + +/** + * @ORM\Entity + * @ApiResource + * @ApiFilter(SearchFilter::class, properties={"id"="exact", "relateds"="exact"}) + */ +class RamseyUuidBinaryDummy +{ + /** + * @var UuidInterface + * + * @ORM\Id + * @ORM\Column(type="uuid_binary", unique=true) + */ + private $id; + + /** + * @var Collection + * + * @ORM\OneToMany(targetEntity="RamseyUuidBinaryDummy", mappedBy="relatedParent") + */ + private $relateds; + + /** + * @var ?RamseyUuidBinaryDummy + * + * @ORM\ManyToOne(targetEntity="RamseyUuidBinaryDummy", inversedBy="relateds") + */ + private $relatedParent; + + public function __construct() + { + $this->relateds = new ArrayCollection(); + } + + public function getId(): UuidInterface + { + return $this->id; + } + + public function setId(string $uuid): void + { + $this->id = Uuid::fromString($uuid); + } + + public function getRelateds(): Collection + { + return $this->relateds; + } + + public function addRelated(self $dummy): void + { + $this->relateds->add($dummy); + $dummy->setRelatedParent($this); + } + + public function getRelatedParent(): ?self + { + return $this->relatedParent; + } + + public function setRelatedParent(self $dummy): void + { + $this->relatedParent = $dummy; + } +} diff --git a/tests/Fixtures/app/AppKernel.php b/tests/Fixtures/app/AppKernel.php index af4e67daf87..128d952fd6a 100644 --- a/tests/Fixtures/app/AppKernel.php +++ b/tests/Fixtures/app/AppKernel.php @@ -29,10 +29,11 @@ use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\ErrorHandler\ErrorRenderer\ErrorRendererInterface; +use Symfony\Component\HttpFoundation\Session\SessionFactory; use Symfony\Component\HttpKernel\Kernel; +use Symfony\Component\PasswordHasher\Hasher\NativePasswordHasher; use Symfony\Component\Routing\Loader\Configurator\RoutingConfigurator; use Symfony\Component\Routing\RouteCollectionBuilder; -use Symfony\Component\Security\Core\Encoder\NativePasswordEncoder; use Symfony\Component\Security\Core\User\UserInterface; /** @@ -109,7 +110,26 @@ protected function configureContainer(ContainerBuilder $c, LoaderInterface $load $loader->load(__DIR__."/config/config_{$this->getEnvironment()}.yml"); - $alg = class_exists(NativePasswordEncoder::class) ? 'auto' : 'bcrypt'; + $c->prependExtensionConfig('framework', [ + 'secret' => 'dunglas.fr', + 'validation' => ['enable_annotations' => true], + 'serializer' => ['enable_annotations' => true], + 'test' => null, + 'session' => class_exists(SessionFactory::class) ? ['storage_factory_id' => 'session.storage.factory.mock_file'] : ['storage_id' => 'session.storage.mock_file'], + 'profiler' => [ + 'enabled' => true, + 'collect' => false, + ], + 'messenger' => [ + 'default_bus' => 'messenger.bus.default', + 'buses' => [ + 'messenger.bus.default' => ['default_middleware' => 'allow_no_handlers'], + ], + ], + 'router' => ['utf8' => true], + ]); + + $alg = class_exists(NativePasswordHasher::class) || class_exists('Symfony\Component\Security\Core\Encoder\NativePasswordEncoder') ? 'auto' : 'bcrypt'; $securityConfig = [ 'encoders' => [ User::class => $alg, diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index 060dde81d0c..69db4cfb002 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -1,23 +1,3 @@ -framework: - secret: 'dunglas.fr' - validation: - enable_annotations: true - serializer: - enable_annotations: true - test: ~ - session: - storage_id: 'session.storage.mock_file' - profiler: - enabled: true - collect: false - messenger: - default_bus: messenger.bus.default - buses: - messenger.bus.default: - default_middleware: allow_no_handlers - router: - utf8: true - web_profiler: toolbar: true intercept_redirects: false @@ -28,7 +8,8 @@ doctrine: path: '%kernel.cache_dir%/db.sqlite' charset: 'UTF8' types: - uuid: Ramsey\Uuid\Doctrine\UuidType + uuid: Ramsey\Uuid\Doctrine\UuidType + uuid_binary: Ramsey\Uuid\Doctrine\UuidBinaryType orm: auto_generate_proxy_classes: '%kernel.debug%' @@ -371,3 +352,10 @@ services: decorates: 'api_platform.graphql.type_converter' arguments: ['@app.graphql.type_converter.inner'] public: false + + app.data_transformer.rpc_output: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer\RPCOutputDataTransformer' + public: false + tags: + - { name: 'api_platform.data_transformer' } + diff --git a/tests/Fixtures/app/config/config_mysql.yml b/tests/Fixtures/app/config/config_mysql.yml index d83e9650176..e69bf6b474a 100644 --- a/tests/Fixtures/app/config/config_mysql.yml +++ b/tests/Fixtures/app/config/config_mysql.yml @@ -13,3 +13,4 @@ doctrine: server_version: '%env(MYSQL_VERSION)%' types: uuid: Ramsey\Uuid\Doctrine\UuidType + uuid_binary: Ramsey\Uuid\Doctrine\UuidBinaryType diff --git a/tests/Fixtures/app/config/config_postgres.yml b/tests/Fixtures/app/config/config_postgres.yml index 20c21168dd2..54928efaa0f 100644 --- a/tests/Fixtures/app/config/config_postgres.yml +++ b/tests/Fixtures/app/config/config_postgres.yml @@ -13,3 +13,4 @@ doctrine: server_version: '%env(POSTGRES_VERSION)%' types: uuid: Ramsey\Uuid\Doctrine\UuidType + uuid_binary: Ramsey\Uuid\Doctrine\UuidBinaryType diff --git a/tests/Fixtures/app/config/config_sqlite.yml b/tests/Fixtures/app/config/config_sqlite.yml index 1407de645d4..55707b7c681 100644 --- a/tests/Fixtures/app/config/config_sqlite.yml +++ b/tests/Fixtures/app/config/config_sqlite.yml @@ -10,3 +10,4 @@ doctrine: url: '%env(resolve:DATABASE_URL)%' types: uuid: Ramsey\Uuid\Doctrine\UuidType + uuid_binary: Ramsey\Uuid\Doctrine\UuidBinaryType diff --git a/tests/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactoryTest.php b/tests/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactoryTest.php index 2d6240366a3..c0384d54d85 100644 --- a/tests/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactoryTest.php +++ b/tests/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactoryTest.php @@ -21,6 +21,7 @@ use ApiPlatform\Core\GraphQl\Subscription\SubscriptionManagerInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Tests\ProphecyTrait; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -30,6 +31,8 @@ */ class ItemSubscriptionResolverFactoryTest extends TestCase { + use ProphecyTrait; + private $itemSubscriptionResolverFactory; private $readStageProphecy; private $securityStageProphecy; diff --git a/tests/GraphQl/Subscription/SubscriptionManagerTest.php b/tests/GraphQl/Subscription/SubscriptionManagerTest.php index 476152ae12c..bcead175c47 100644 --- a/tests/GraphQl/Subscription/SubscriptionManagerTest.php +++ b/tests/GraphQl/Subscription/SubscriptionManagerTest.php @@ -18,6 +18,7 @@ use ApiPlatform\Core\GraphQl\Subscription\SubscriptionIdentifierGeneratorInterface; use ApiPlatform\Core\GraphQl\Subscription\SubscriptionManager; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\ProphecyTrait; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Psr\Cache\CacheItemInterface; @@ -28,6 +29,8 @@ */ class SubscriptionManagerTest extends TestCase { + use ProphecyTrait; + private $subscriptionsCacheProphecy; private $subscriptionIdentifierGeneratorProphecy; private $serializeStageProphecy; diff --git a/tests/Hydra/JsonSchema/SchemaFactoryTest.php b/tests/Hydra/JsonSchema/SchemaFactoryTest.php index f80caf4dcb9..5c29c60ae6b 100644 --- a/tests/Hydra/JsonSchema/SchemaFactoryTest.php +++ b/tests/Hydra/JsonSchema/SchemaFactoryTest.php @@ -39,7 +39,7 @@ protected function setUp(): void $resourceMetadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); $resourceMetadataFactory->create(Dummy::class)->willReturn(new ResourceMetadata(Dummy::class)); $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); - $propertyNameCollectionFactory->create(Dummy::class, [])->willReturn(new PropertyNameCollection()); + $propertyNameCollectionFactory->create(Dummy::class, ['enable_getter_setter_extraction' => true])->willReturn(new PropertyNameCollection()); $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); $baseSchemaFactory = new BaseSchemaFactory( diff --git a/tests/Metadata/Property/Factory/DefaultPropertyMetadataFactoryTest.php b/tests/Metadata/Property/Factory/DefaultPropertyMetadataFactoryTest.php index 4ad92c7a59f..08746004939 100644 --- a/tests/Metadata/Property/Factory/DefaultPropertyMetadataFactoryTest.php +++ b/tests/Metadata/Property/Factory/DefaultPropertyMetadataFactoryTest.php @@ -18,10 +18,13 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyPropertyWithDefaultValue; +use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; class DefaultPropertyMetadataFactoryTest extends TestCase { + use ProphecyTrait; + public function testCreate() { $factory = new DefaultPropertyMetadataFactory(); diff --git a/tests/OpenApi/Factory/OpenApiFactoryTest.php b/tests/OpenApi/Factory/OpenApiFactoryTest.php index 069008f0866..42e74f73e05 100644 --- a/tests/OpenApi/Factory/OpenApiFactoryTest.php +++ b/tests/OpenApi/Factory/OpenApiFactoryTest.php @@ -42,6 +42,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Answer; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Question; +use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Psr\Container\ContainerInterface; @@ -56,6 +57,8 @@ class OpenApiFactoryTest extends TestCase { + use ProphecyTrait; + private const OPERATION_FORMATS = [ 'input_formats' => ['jsonld' => ['application/ld+json']], 'output_formats' => ['jsonld' => ['application/ld+json']], @@ -253,7 +256,7 @@ public function testInvoke(): void $this->assertEquals($components->getSchemas(), new \ArrayObject(['Dummy' => $dummySchema->getDefinitions()])); $this->assertEquals($components->getSecuritySchemes(), new \ArrayObject([ - 'oauth' => new Model\SecurityScheme('oauth2', 'OAuth 2.0 authorization code Grant', null, null, 'oauth2', null, new Model\OAuthFlows(null, null, null, new Model\OAuthFlow('/oauth/v2/auth', '/oauth/v2/token', '/oauth/v2/refresh', new \ArrayObject(['scope param'])))), + 'oauth' => new Model\SecurityScheme('oauth2', 'OAuth 2.0 authorization code Grant', null, null, null, null, new Model\OAuthFlows(null, null, null, new Model\OAuthFlow('/oauth/v2/auth', '/oauth/v2/token', '/oauth/v2/refresh', new \ArrayObject(['scope param'])))), 'header' => new Model\SecurityScheme('apiKey', 'Value for the Authorization header parameter.', 'Authorization', 'header'), 'query' => new Model\SecurityScheme('apiKey', 'Value for the key query parameter.', 'key', 'query'), ])); @@ -314,6 +317,7 @@ public function testInvoke(): void new \ArrayObject(['GetDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), [], 'The `id` value returned in the response can be used as the `id` parameter in `GET /dummies/{id}`.')]) ), '400' => new Model\Response('Invalid input'), + '422' => new Model\Response('Unprocessable entity'), ], 'Creates a Dummy resource.', 'Creates a Dummy resource.', @@ -365,6 +369,7 @@ public function testInvoke(): void new \ArrayObject(['GetDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), [], 'The `id` value returned in the response can be used as the `id` parameter in `GET /dummies/{id}`.')]) ), '400' => new Model\Response('Invalid input'), + '422' => new Model\Response('Unprocessable entity'), '404' => new Model\Response('Resource not found'), ], 'Replaces the Dummy resource.', @@ -434,6 +439,7 @@ public function testInvoke(): void new \ArrayObject(['GetDummyItem' => new Model\Link('getDummyItem', new \ArrayObject(['id' => '$response.body#/id']), [], 'The `id` value returned in the response can be used as the `id` parameter in `GET /dummies/{id}`.')]) ), '400' => new Model\Response('Invalid input'), + '422' => new Model\Response('Unprocessable entity'), '404' => new Model\Response('Resource not found'), ], 'Replaces the Dummy resource.', diff --git a/tests/OpenApi/Serializer/OpenApiNormalizerTest.php b/tests/OpenApi/Serializer/OpenApiNormalizerTest.php index 821afb71e42..5e90b76b2c5 100644 --- a/tests/OpenApi/Serializer/OpenApiNormalizerTest.php +++ b/tests/OpenApi/Serializer/OpenApiNormalizerTest.php @@ -35,6 +35,7 @@ use ApiPlatform\Core\PathResolver\CustomOperationPathResolver; use ApiPlatform\Core\PathResolver\OperationPathResolver; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; use Prophecy\Argument; use Psr\Container\ContainerInterface; @@ -46,6 +47,8 @@ class OpenApiNormalizerTest extends TestCase { + use ProphecyTrait; + private const OPERATION_FORMATS = [ 'input_formats' => ['jsonld' => ['application/ld+json']], 'output_formats' => ['jsonld' => ['application/ld+json']], diff --git a/tests/Security/ResourceAccessCheckerTest.php b/tests/Security/ResourceAccessCheckerTest.php index b22314290cc..6aeaa41df47 100644 --- a/tests/Security/ResourceAccessCheckerTest.php +++ b/tests/Security/ResourceAccessCheckerTest.php @@ -23,6 +23,7 @@ use Symfony\Component\Security\Core\Authentication\AuthenticationTrustResolverInterface; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; +use Symfony\Component\Security\Core\Authorization\AuthorizationCheckerInterface; /** * @author Kévin Dunglas @@ -85,4 +86,31 @@ public function testExpressionLanguageNotInstalled() $checker = new ResourceAccessChecker(null, $authenticationTrustResolverProphecy->reveal(), null, $tokenStorageProphecy->reveal()); $checker->isGranted(Dummy::class, 'is_granted("ROLE_ADMIN")'); } + + public function testNotBehindAFirewall() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('The current token must be set to use the "security" attribute (is the URL behind a firewall?).'); + + $authenticationTrustResolverProphecy = $this->prophesize(AuthenticationTrustResolverInterface::class); + $tokenStorageProphecy = $this->prophesize(TokenStorageInterface::class); + + $checker = new ResourceAccessChecker(null, $authenticationTrustResolverProphecy->reveal(), null, $tokenStorageProphecy->reveal()); + $checker->isGranted(Dummy::class, 'is_granted("ROLE_ADMIN")'); + } + + public function testWithoutAuthenticationTokenAndExceptionOnNoTokenIsFalse() + { + $expressionLanguageProphecy = $this->prophesize(ExpressionLanguage::class); + $expressionLanguageProphecy->evaluate('is_granted("ROLE_ADMIN")', Argument::type('array'))->willReturn(true)->shouldBeCalled(); + + $authenticationTrustResolverProphecy = $this->prophesize(AuthenticationTrustResolverInterface::class); + $authorizationCheckerProphecy = $this->prophesize(AuthorizationCheckerInterface::class); + $tokenStorageProphecy = $this->prophesize(TokenStorageInterface::class); + + $tokenStorageProphecy->getToken()->willReturn(null); + + $checker = new ResourceAccessChecker($expressionLanguageProphecy->reveal(), $authenticationTrustResolverProphecy->reveal(), null, $tokenStorageProphecy->reveal(), $authorizationCheckerProphecy->reveal(), false); + self::assertTrue($checker->isGranted(Dummy::class, 'is_granted("ROLE_ADMIN")')); + } } diff --git a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php index ba53a2d7979..6afe6864eed 100644 --- a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php +++ b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php @@ -241,6 +241,7 @@ private function doTestNormalize(OperationMethodResolverInterface $operationMeth ], 400 => ['description' => 'Invalid input'], 404 => ['description' => 'Resource not found'], + 422 => ['description' => 'Unprocessable entity'], ], ]), ], @@ -293,6 +294,7 @@ private function doTestNormalize(OperationMethodResolverInterface $operationMeth ], 400 => ['description' => 'Invalid input'], 404 => ['description' => 'Resource not found'], + 422 => ['description' => 'Unprocessable entity'], ], ]), ], @@ -349,6 +351,7 @@ private function doTestNormalize(OperationMethodResolverInterface $operationMeth ], 400 => ['description' => 'Invalid input'], 404 => ['description' => 'Resource not found'], + 422 => ['description' => 'Unprocessable entity'], ], ]), ], @@ -791,6 +794,7 @@ public function testNormalizeWithOnlyNormalizationGroups(): void ], 400 => ['description' => 'Invalid input'], 404 => ['description' => 'Resource not found'], + 422 => ['description' => 'Unprocessable entity'], ], ]), ], @@ -841,6 +845,7 @@ public function testNormalizeWithOnlyNormalizationGroups(): void ], 400 => ['description' => 'Invalid input'], 404 => ['description' => 'Resource not found'], + 422 => ['description' => 'Unprocessable entity'], ], ]), ], @@ -1013,6 +1018,7 @@ public function testNormalizeNotAddExtraBodyParameters(): void ], 400 => ['description' => 'Invalid input'], 404 => ['description' => 'Resource not found'], + 422 => ['description' => 'Unprocessable entity'], ], ]), ], @@ -1063,6 +1069,7 @@ public function testNormalizeNotAddExtraBodyParameters(): void ], 400 => ['description' => 'Invalid input'], 404 => ['description' => 'Resource not found'], + 422 => ['description' => 'Unprocessable entity'], ], ]), ], @@ -1333,6 +1340,7 @@ public function testNormalizeWithOnlyDenormalizationGroups(): void ], 400 => ['description' => 'Invalid input'], 404 => ['description' => 'Resource not found'], + 422 => ['description' => 'Unprocessable entity'], ], ]), ], @@ -1383,6 +1391,7 @@ public function testNormalizeWithOnlyDenormalizationGroups(): void ], 400 => ['description' => 'Invalid input'], 404 => ['description' => 'Resource not found'], + 422 => ['description' => 'Unprocessable entity'], ], ]), ], @@ -1543,6 +1552,7 @@ public function testNormalizeWithNormalizationAndDenormalizationGroups(): void ], 400 => ['description' => 'Invalid input'], 404 => ['description' => 'Resource not found'], + 422 => ['description' => 'Unprocessable entity'], ], ]), ], @@ -1593,6 +1603,7 @@ public function testNormalizeWithNormalizationAndDenormalizationGroups(): void ], 400 => ['description' => 'Invalid input'], 404 => ['description' => 'Resource not found'], + 422 => ['description' => 'Unprocessable entity'], ], ]), ], @@ -1752,6 +1763,7 @@ public function testNormalizeSkipsNotReadableAndNotWritableProperties(): void ], 400 => ['description' => 'Invalid input'], 404 => ['description' => 'Resource not found'], + 422 => ['description' => 'Unprocessable entity'], ], ]), ], @@ -1804,6 +1816,7 @@ public function testNormalizeSkipsNotReadableAndNotWritableProperties(): void ], 400 => ['description' => 'Invalid input'], 404 => ['description' => 'Resource not found'], + 422 => ['description' => 'Unprocessable entity'], ], ]), ], @@ -2133,7 +2146,7 @@ public function testNormalizeWithNestedNormalizationGroups(): void $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::allOf( Argument::type('array'), Argument::withEntry('serializer_groups', $groups) - ))->willReturn(new PropertyNameCollection(['name', 'relatedDummy'])); + ))->willReturn(new PropertyNameCollection(['name', 'relatedDummy', 'relatedDummyWithCustomOpenApiContextType'])); $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::type('array'))->willReturn(new PropertyNameCollection(['name'])); $propertyNameCollectionFactoryProphecy->create(RelatedDummy::class, Argument::allOf( Argument::type('array'), @@ -2170,6 +2183,7 @@ public function testNormalizeWithNestedNormalizationGroups(): void $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::type('array'))->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is a name.', true, true, true, true, false, false, null, null, [])); $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummy', Argument::type('array'))->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, true, RelatedDummy::class), 'This is a related dummy \o/.', true, true, true, true, false, false, null, null, [])); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'relatedDummyWithCustomOpenApiContextType', Argument::type('array'))->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, true, RelatedDummy::class), 'This is a related dummy with type string \o/.', true, true, true, true, false, false, null, null, ['swagger_context' => ['type' => 'string']])); $propertyMetadataFactoryProphecy->create(RelatedDummy::class, 'name', Argument::type('array'))->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is a name.', true, true, true, true, false, false, null, null, [])); $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); @@ -2262,6 +2276,7 @@ public function testNormalizeWithNestedNormalizationGroups(): void ], 400 => ['description' => 'Invalid input'], 404 => ['description' => 'Resource not found'], + 422 => ['description' => 'Unprocessable entity'], ], ]), ], @@ -2312,6 +2327,7 @@ public function testNormalizeWithNestedNormalizationGroups(): void ], 400 => ['description' => 'Invalid input'], 404 => ['description' => 'Resource not found'], + 422 => ['description' => 'Unprocessable entity'], ], ]), ], @@ -2339,6 +2355,11 @@ public function testNormalizeWithNestedNormalizationGroups(): void ]), 'relatedDummy' => new \ArrayObject([ 'description' => 'This is a related dummy \o/.', + '$ref' => '#/definitions/'.$relatedDummyRef, + ]), + 'relatedDummyWithCustomOpenApiContextType' => new \ArrayObject([ + 'description' => 'This is a related dummy with type string \o/.', + 'type' => 'string', ]), ], ]), @@ -3032,6 +3053,7 @@ private function doTestNormalizeWithCustomFormatsDefinedAtOperationLevel(Operati ], 400 => ['description' => 'Invalid input'], 404 => ['description' => 'Resource not found'], + 422 => ['description' => 'Unprocessable entity'], ], ]), ], @@ -3084,6 +3106,7 @@ private function doTestNormalizeWithCustomFormatsDefinedAtOperationLevel(Operati ], 400 => ['description' => 'Invalid input'], 404 => ['description' => 'Resource not found'], + 422 => ['description' => 'Unprocessable entity'], ], ]), ], @@ -3242,6 +3265,9 @@ private function doTestNormalizeWithInputAndOutputClass(): void 404 => [ 'description' => 'Resource not found', ], + 422 => [ + 'description' => 'Unprocessable entity', + ], ], 'parameters' => [ [ @@ -3316,6 +3342,9 @@ private function doTestNormalizeWithInputAndOutputClass(): void 404 => [ 'description' => 'Resource not found', ], + 422 => [ + 'description' => 'Unprocessable entity', + ], ], ]), ], diff --git a/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php b/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php index 6d7de03e455..3ea56e07583 100644 --- a/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php +++ b/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php @@ -238,6 +238,7 @@ private function doTestNormalize(OperationMethodResolverInterface $operationMeth ], '400' => ['description' => 'Invalid input'], '404' => ['description' => 'Resource not found'], + '422' => ['description' => 'Unprocessable entity'], ], ]), ], @@ -297,6 +298,7 @@ private function doTestNormalize(OperationMethodResolverInterface $operationMeth ], '400' => ['description' => 'Invalid input'], '404' => ['description' => 'Resource not found'], + '422' => ['description' => 'Unprocessable entity'], ], ]), ], @@ -372,6 +374,7 @@ private function doTestNormalize(OperationMethodResolverInterface $operationMeth ], '400' => ['description' => 'Invalid input'], '404' => ['description' => 'Resource not found'], + '422' => ['description' => 'Unprocessable entity'], ], ]), ], @@ -867,6 +870,7 @@ public function testNormalizeWithOnlyNormalizationGroups(): void ], '400' => ['description' => 'Invalid input'], '404' => ['description' => 'Resource not found'], + '422' => ['description' => 'Unprocessable entity'], ], ]), ], @@ -926,6 +930,7 @@ public function testNormalizeWithOnlyNormalizationGroups(): void ], '400' => ['description' => 'Invalid input'], '404' => ['description' => 'Resource not found'], + '422' => ['description' => 'Unprocessable entity'], ], ]), ], @@ -1212,6 +1217,7 @@ public function testNormalizeWithOnlyDenormalizationGroups(): void ], '400' => ['description' => 'Invalid input'], '404' => ['description' => 'Resource not found'], + '422' => ['description' => 'Unprocessable entity'], ], ]), ], @@ -1269,6 +1275,7 @@ public function testNormalizeWithOnlyDenormalizationGroups(): void ], '400' => ['description' => 'Invalid input'], '404' => ['description' => 'Resource not found'], + '422' => ['description' => 'Unprocessable entity'], ], ]), ], @@ -1440,6 +1447,7 @@ public function testNormalizeWithNormalizationAndDenormalizationGroups(): void ], '400' => ['description' => 'Invalid input'], '404' => ['description' => 'Resource not found'], + '422' => ['description' => 'Unprocessable entity'], ], ]), ], @@ -1497,6 +1505,7 @@ public function testNormalizeWithNormalizationAndDenormalizationGroups(): void ], '400' => ['description' => 'Invalid input'], '404' => ['description' => 'Resource not found'], + '422' => ['description' => 'Unprocessable entity'], ], ]), ], @@ -1990,6 +1999,7 @@ public function testNormalizeWithNestedNormalizationGroups(): void ], '400' => ['description' => 'Invalid input'], '404' => ['description' => 'Resource not found'], + '422' => ['description' => 'Unprocessable entity'], ], ]), ], @@ -2047,6 +2057,7 @@ public function testNormalizeWithNestedNormalizationGroups(): void ], '400' => ['description' => 'Invalid input'], '404' => ['description' => 'Resource not found'], + '422' => ['description' => 'Unprocessable entity'], ], ]), ], @@ -3119,6 +3130,7 @@ private function doTestNormalizeWithCustomFormatsDefinedAtOperationLevel(Operati ], 400 => ['description' => 'Invalid input'], 404 => ['description' => 'Resource not found'], + 422 => ['description' => 'Unprocessable entity'], ], ]), ], @@ -3184,6 +3196,7 @@ private function doTestNormalizeWithCustomFormatsDefinedAtOperationLevel(Operati ], 400 => ['description' => 'Invalid input'], 404 => ['description' => 'Resource not found'], + 422 => ['description' => 'Unprocessable entity'], ], ]), ],