diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 00000000000..99822f99fc1 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +tidelift: "packagist/api-platform/core" diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index eb455a2e2a8..00000000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,13 +0,0 @@ - -| Q | A -| ------------- | --- -| Bug fix? | yes/no -| New feature? | yes/no -| BC breaks? | no -| Deprecations? | no -| Tests pass? | yes -| Fixed tickets | #1234, #5678 -| License | MIT -| Doc PR | api-platform/doc#1234 - - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 820b3af825f..6eafc631a32 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -596,7 +596,7 @@ jobs: - '7.4' fail-fast: false env: - SYMFONY_DEPRECATIONS_HELPER: max[total]=7 + SYMFONY_DEPRECATIONS_HELPER: max[total]=8 steps: - name: Checkout uses: actions/checkout@v2 @@ -836,3 +836,4 @@ jobs: run: tests/Fixtures/app/console cache:clear --ansi - name: Run Behat tests run: vendor/bin/behat --out=std --format=progress --profile=default --no-interaction + diff --git a/.gitignore b/.gitignore index 8c62c7fc8fa..1d12eccbda9 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ /tests/Fixtures/app/var/ /tests/Fixtures/app/public/bundles/ /vendor/ +/Dockerfile diff --git a/.php_cs.dist b/.php_cs.dist index cbd76a6fc83..bdd5ffa42c0 100644 --- a/.php_cs.dist +++ b/.php_cs.dist @@ -29,6 +29,7 @@ return PhpCsFixer\Config::create() '@PHPUnit60Migration:risky' => true, '@Symfony' => true, '@Symfony:risky' => true, + 'single_line_comment_style' => false, // Temporary fix for compatibility with PHP 8 attributes, see https://github.com/FriendsOfPHP/PHP-CS-Fixer/pull/5284 'align_multiline_comment' => [ 'comment_type' => 'phpdocs_like', ], diff --git a/CHANGELOG.md b/CHANGELOG.md index 7125fd0221c..cb9ddbc0804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,25 @@ # Changelog +## 2.6.0 + +* Display the API Platform's version in the debug-bar +* MongoDB: Possibility to add execute options (aggregate command fields) for a resource, like `allowDiskUse` (#3144) +* MongoDB: Mercure support (#3290) +* GraphQL: Subscription support with Mercure (#3321) +* GraphQL: Allow to format GraphQL errors based on exceptions (#3063) +* GraphQL: Add page-based pagination (#3175, #3517) +* GraphQL: Errors thrown from the GraphQL library can now be handled (#3632) +* GraphQL: Possibility to add a custom description for queries, mutations and subscriptions (#3477, #3514) +* GraphQL: Support for field name conversion (serialized name) (#3455, #3516) +* GraphQL: **BC** `operation` is now `operationName` to follow the standard (#3568) +* GraphQL: **BC** New syntax for the filters' arguments to preserve the order: `order: [{foo: 'asc'}, {bar: 'desc'}]` (#3468) +* OpenAPI: Add PHP default values to the documentation (#2386) +* Deprecate using a validation groups generator service not implementing `ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface` (#3346) +* Subresources: subresource resourceClass can now be defined as a container parameter in XML and Yaml definitions +* IriConverter: Fix IRI url double encoding - may cause breaking change as some characters no longer encoded in output (#3552) +* OpenAPI: **BC** Replace all characters other than `[a-zA-Z0-9\.\-_]` to `.` in definition names to be compliant with OpenAPI 3.0 (#3669) +* Add stateless ApiResource attribute + ## 2.5.8 For compatibility reasons with Symfony 5.2 and PHP 8, we do not test these legacy packages nor their integration anymore: @@ -130,7 +150,7 @@ For compatibility reasons with Symfony 5.2 and PHP 8, we do not test these legac * Allow to not declare GET item operation * Add support for the Accept-Patch header -* Make the `maximum_items_per_page` attribute consistent with other attributes controlling pagination +* Make the `maximum_items_per_page` attribute consistent with other attributes controlling pagination * Allow to use a string instead of an array for serializer groups * Test: Add a helper method to find the IRI of a resource * Test: Add assertions for testing response against JSON Schema from API resource diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cc6e77496e0..81abefc21f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,11 +18,6 @@ Then, if it appears that it's a real bug, you may report it using GitHub by foll > _NOTE:_ Don't hesitate giving as much information as you can (OS, PHP version extensions...) -### Security Issues - -If you find a security issue, send a mail to Kévin Dunglas . **Please do not report security problems -publicly**. We will disclose details of the issue and credit you after having released a new version including a fix. - ## Pull Requests ### Writing a Pull Request @@ -50,7 +45,7 @@ Alternatively, you can also work with the test application we provide: ### Matching Coding Standards The API Platform project follows [Symfony coding standards](https://symfony.com/doc/current/contributing/code/standards.html). -But don't worry, you can fix CS issues automatically using the [PHP CS Fixer](http://cs.sensiolabs.org/) tool: +But don't worry, you can fix CS issues automatically using the [PHP CS Fixer](https://cs.sensiolabs.org/) tool: php-cs-fixer.phar fix @@ -68,8 +63,6 @@ When you send a PR, just make sure that: that you did not make in your PR, you're doing it wrong. * Also don't forget to add a comment when you update a PR with a ping to [the maintainers](https://github.com/orgs/api-platform/people), so he/she will get a notification. -All Pull Requests must include [this header](.github/PULL_REQUEST_TEMPLATE.md). - ### Tests On `api-platform/core` there are two kinds of tests: unit (`phpunit` through `simple-phpunit`) and integration tests (`behat`). diff --git a/behat.yml.dist b/behat.yml.dist index a68daa902fe..75db6117f57 100644 --- a/behat.yml.dist +++ b/behat.yml.dist @@ -132,3 +132,4 @@ elasticsearch-coverage: - 'ApiPlatform\Core\Tests\Behat\CoverageContext' - 'Behat\MinkExtension\Context\MinkContext' - 'behatch:context:rest' + diff --git a/composer.json b/composer.json index d3827fd7ace..00990f8314f 100644 --- a/composer.json +++ b/composer.json @@ -93,6 +93,7 @@ "doctrine/mongodb-odm-bundle": "To support MongoDB. Only versions 4.0 and later are supported.", "elasticsearch/elasticsearch": "To support Elasticsearch.", "guzzlehttp/guzzle": "To use the HTTP cache invalidation system.", + "ocramius/package-versions": "To display the API Platform's version in the debug bar.", "phpdocumentor/reflection-docblock": "To support extracting metadata from PHPDoc.", "psr/cache-implementation": "To use metadata caching.", "ramsey/uuid": "To support Ramsey's UUID identifiers.", @@ -101,6 +102,7 @@ "symfony/expression-language": "To use authorization features.", "symfony/security": "To use authorization features.", "symfony/twig-bundle": "To use the Swagger UI integration.", + "symfony/uid": "To support Symfony UUID/ULID identifiers.", "symfony/web-profiler-bundle": "To use the data collector.", "webonyx/graphql-php": "To support GraphQL." }, @@ -122,7 +124,7 @@ }, "extra": { "branch-alias": { - "dev-master": "2.5.x-dev" + "dev-master": "2.6.x-dev" }, "symfony": { "require": "^3.4 || ^4.4 || ^5.1" diff --git a/docs/adr/0000-subresources-definition.md b/docs/adr/0000-subresources-definition.md new file mode 100644 index 00000000000..72cccb17e03 --- /dev/null +++ b/docs/adr/0000-subresources-definition.md @@ -0,0 +1,157 @@ +# Subresource Definition + +* Status: proposed +* Deciders: @dunglas, @vincentchalamon, @soyuka, @GregoireHebert, @Deuchnord + +## Context and Problem Statement + +Subresources introduced in 2017 ([#904][pull/904]) the `ApiSubresource` annotation. This definition came along with its own set of issues ([#2706][issue/2706]) and needs a refreshment. On top of that, write support on subresources is a wanted feature and it is hard to implement currently ([#2598][pull/2598]) (See [ADR-0001-subresource-write-support](./0001-subresource-write-support.md)). How can we revamp the Subresource definition to improve the developer experience and reduce the complexity? + +## Considered Options + +* Fix the current `ApiSubresource` annotation +* Use multiple `ApiResource` to declare subresources and deprecate `ApiSubresource` +* Deprecate subresources + +## Decision Outcome + +We choose to use multiple `ApiResource` annotations to declare subresources on a given Model class: + +* Subresource declaration is an important feature and removing it would harm the software. +* The `ApiSubresource` annotation is declared on a Model's properties, which was identified as the root of several issues. For example, finding what class it is defined on ([#3458][issue/3458]). Having multiple `ApiResource` would improve a lot the declaration of our internal metadata and would cause less confusion for developers. +* The `path` of these multiple `ApiResource` needs to be explicitly described. +* An `ApiResource` is always defined on the Resource it represents: `/companies/1/users` outputs Users and should be defined on the `User` model. +* PropertyInfo and Doctrine metadata can be used to define how is the Resource identified according to the given path. + +### Examples + +Get Users belonging to the company on (`/companies/1/users`); + +```php +/** + * @ApiResource(path="/users") + * @ApiResource(path="/companies/{companyId}/users") + */ +class User { + /** @ApiProperty(identifier=true) */ + public int $id; + + /** @var Company[] */ + public array $companies = []; +} +``` + +With explicit identifiers, the tuple is explained in [ADR-0002-identifiers](./0002-identifiers) `{parameterName: {Class, property}}`: + +```php +/** + * @ApiResource(path="/users", identifiers={"id": {User::class, "id"}}) + * @ApiResource(path="/companies/{companyId}/users", identifiers={"companyId": {Company::class, "id"}, "id": {User::class, "id"}}) + */ +class User { + /** @ApiProperty(identifier=true) */ + public int $id; + + /** @var Company[] */ + public array $companies = []; +} +``` + +Two-level subresource to get the Users belonging to the Company #1 located in France `/countries/fr/companies/1/users`: + +```php +/** + * @ApiResource(path="/users") + * @ApiResource(path="/countries/{countryId}/companies/{companyId}/users") + */ +class User { + /** @ApiProperty(identifier=true) */ + public int $id; + + /** @var Company[] */ + public array $companies = []; +} + +class Company { + /** @ApiProperty(identifier=true) */ + public int $id; + + /** @var Country[] **/ + public array $countries = []; +} + +class Country { + /** @ApiProperty(identifier=true) */ + public string $shortName; +} +``` + +With explicit identifiers: + +```php +/** + * @ApiResource(path="/users", identifiers={"id": {User::class, "id"}}) + * @ApiResource(path="/countries/{countryId}/companies/{companyId}/users", identifiers={"companyId": {Company::class, "id"}, "countryId": {Country::class, "shortName"}, "id": {User::class, "id"}}) + */ +class User { + /** @ApiProperty(identifier=true) */ + public int $id; + + /** @var Company[] */ + public array $companies = []; +} + +class Company { + /** @ApiProperty(identifier=true) */ + public int $id; + + /** @var Country[] **/ + public array $countries = []; +} + +class Country { + /** @ApiProperty(identifier=true) */ + public string $shortName; +} +``` + +Get the company employees or administrators `/companies/1/administrators`: + +```php +/** + * @ApiResource(path="/users") + * @ApiResource(path="/companies/{companyId}/administrators") + * @ApiResource(path="/companies/{companyId}/employees") + */ +class User { + /** @ApiProperty(identifier=true) */ + public int $id; + + /** @var Company[] */ + public array $companies = []; +} + +class Company { + /** @ApiProperty(identifier=true) */ + public int $id; + + /** @var User[] **/ + public array $employees; + + /** @var User[] **/ + public array $administrators; +} +``` + +This example will require a custom DataProvider as the discriminator needs to be explicit. + +## Links + +* [Subresource refactor][pull/3689] + + +[pull/904]: https://github.com/api-platform/core/pull/904 "Subresource feature" +[issue/2706]: https://github.com/api-platform/core/issues/2706 "Subresource RFC" +[pull/2598]: https://github.com/api-platform/core/pull/2598 "Subresource write support" +[issue/3458]: https://github.com/api-platform/core/pull/3458 "Subresource poor DX" +[pull/3689]: https://github.com/api-platform/core/pull/3689 "Revamp subresource" diff --git a/docs/adr/0001-resource-identifiers.md b/docs/adr/0001-resource-identifiers.md new file mode 100644 index 00000000000..4e5ac69b6c8 --- /dev/null +++ b/docs/adr/0001-resource-identifiers.md @@ -0,0 +1,155 @@ +# Resource Identifiers + +* Status: proposed +* Deciders: @dunglas @alanpoulain @soyuka + +Technical Story: [#2126][pull/2126] +Implementation: [#3825][pull/3825] + +## Context and Problem Statement + +In API Platform, a resource is identified by [IRIs][rfc/IRI], for example `/books/1`. Internally, this is also known as a route with an identifier parameter named `id`: `/books/{id}`. This `id` parameter is then matched to the resource identifiers, known by the `ApiProperty` metadata when `identifier` is true. When multiple identifiers are found, composite identifiers map the value of `id` to the resource identifiers (eg: `keya=value1;keyb=value2`, where `keya` and `keyb` are identifiers of the resource). This behavior is suggested by the [URI RFC][rfc/URI]. +Subresources IRIs have multiple parts, for example: `/books/{id}/author/{authorId}`. The router needs to know that `id` matches the `Book` resource, and `authorId` the `Author` resource. To do so, a Tuple representing the class and the property matching each parameter is linked to the route, for example: `id: [Book, id], authorId: [User, id]`. +By normalizing the shape of (sub-)resources (see [0000-subresources-definition][0000-subresources-definition]), we need to normalize the resource identifiers. + +## Decision Outcome + +Declare explicit resource `identifiers` that will default to `id: [id, Resource]` with composite identifiers. Allow composite identifiers to be disabled if needed. + +### Examples + +Define a route `/users/{id}`: + +```php +/** + * @ApiResource + */ + class User { + /** @ApiProperty(identifier=true) */ + public int $id; + } +``` + +Or + +```php +/** + * @ApiResource(identifiers={"id": {User::class, "id"}}) + */ + class User { + /** @ApiProperty(identifier=true) */ + public int $id; + } +``` + +Define a route `/users/{username}` that uses the username identifier: + +```php +/** + * @ApiResource(identifiers={"username"}) + */ + class User { + /** @ApiProperty(identifier=true) */ + public string $username; + } +``` + +Or + +```php +/** + * @ApiResource(identifiers={"username": {User::class, "username"}}) + */ + class User { + /** @ApiProperty(identifier=true) */ + public string $username; + } +``` + +Define a route `/users/{username}` that uses the property shortName: + +```php +/** + * @ApiResource(identifiers={"username"={User::class, "shortName"}}) + */ + class User { + /** @ApiProperty(identifier=true) */ + public string $shortName; + } +``` + +Define a route `/users/{composite}` that uses composite identifiers `/users/keya=value1;keyb=value2`: + +```php +/** + * @ApiResource(identifiers={"composite"}) + */ + class User { + /** @ApiProperty(identifier=true) */ + public string $keya; + /** @ApiProperty(identifier=true) */ + public string $keyb; + } +``` + +Define a route `/users/{keya}/{keyb}`: + +```php +/** + * @ApiResource(identifiers={"keya", "keyb"}, compositeIdentifier=false) + */ + class User { + /** @ApiProperty(identifier=true) */ + public string $keya; + /** @ApiProperty(identifier=true) */ + public string $keyb; + } +``` + +Complex version: + +```php +/** + * @ApiResource(identifiers={"keya"={User::class, "keya"}, "keyb"={User::class, "keyb"}}, compositeIdentifier=false) + */ + class User { + /** @ApiProperty(identifier=true) */ + public string $keya; + /** @ApiProperty(identifier=true) */ + public string $keyb; + } +``` + +Define a subresource `/companies/{companyId}/users/{id}`: + +```php +/** + * @ApiResource(path="/users", identifiers={"id": {User::class, "id"}}) + * @ApiResource(path="/companies/{companyId}/users", identifiers={"companyId": {Company::class, "id"}, "id": {User::class, "id"}}) + */ +class User { + /** @ApiProperty(identifier=true) */ + public int $id; + + /** @var Company[] */ + public array $companies = []; +} + +class Company { + /** @ApiProperty(identifier=true) */ + public int $id; + + /** @var User[] */ + public $users; +} +``` + +## Links + +* Adds up to the [0000-subresources-definition][0000-subresources-definition] rework. + +[0000-subresources-definition]: ./0000-subresources-definition "Subresources definition" +[pull/2126]: https://github.com/api-platform/core/pull/2126 "Ability to specify identifier property of custom item operations" +[pull/3825]: https://github.com/api-platform/core/pull/3825 "Rework to improve and simplify identifiers management" +[rfc/IRI]: https://tools.ietf.org/html/rfc3987 "RFC3987" +[rfc/URI]: https://tools.ietf.org/html/rfc3986#section-3.3 "RFC 3986" diff --git a/features/authorization/deny.feature b/features/authorization/deny.feature index 404a8c45438..54d63181f1f 100644 --- a/features/authorization/deny.feature +++ b/features/authorization/deny.feature @@ -59,7 +59,8 @@ Feature: Authorization checking { "title": "Special Title", "description": "Description", - "owner": "dunglas" + "owner": "dunglas", + "adminOnlyProperty": "secret" } """ Then the response status code should be 201 @@ -100,3 +101,53 @@ Feature: Authorization checking } """ Then the response status code should be 200 + + Scenario: An admin retrieves a resource with an admin only viewable property + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" + And I send a "GET" request to "/secured_dummies" + Then the response status code should be 200 + And the response should contain "adminOnlyProperty" + + Scenario: A user retrieves a resource with an admin only viewable property + When I add "Accept" header equal to "application/ld+json" + And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send a "GET" request to "/secured_dummies" + Then the response status code should be 200 + And the response should not contain "adminOnlyProperty" + + Scenario: An admin can create a secured resource with a secured Property + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" + And I send a "POST" request to "/secured_dummies" with body: + """ + { + "title": "Common Title", + "description": "Description", + "owner": "dunglas", + "adminOnlyProperty": "Is it safe?" + } + """ + Then the response status code should be 201 + And the response should contain "adminOnlyProperty" + And the JSON node "adminOnlyProperty" should be equal to the string "Is it safe?" + + Scenario: A user cannot update a secured property + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I add "Authorization" header equal to "Basic ZHVuZ2xhczprZXZpbg==" + And I send a "PUT" request to "/secured_dummies/3" with body: + """ + { + "adminOnlyProperty": "Yes it is!" + } + """ + Then the response status code should be 200 + And the response should not contain "adminOnlyProperty" + And I add "Authorization" header equal to "Basic YWRtaW46a2l0dGVu" + And I send a "GET" request to "/secured_dummies" + Then the response status code should be 200 + And the response should contain "adminOnlyProperty" + And the JSON node "hydra:member[2].adminOnlyProperty" should be equal to the string "Is it safe?" diff --git a/features/doctrine/messenger.feature b/features/doctrine/messenger.feature new file mode 100644 index 00000000000..7abecfce529 --- /dev/null +++ b/features/doctrine/messenger.feature @@ -0,0 +1,51 @@ +Feature: Combine messenger with doctrine + In order to persist and send a resource + As a client software developer + I need to configure the messenger ApiResource attribute properly + + Background: + Given I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + + @createSchema + Scenario: Using messenger="persist" should combine doctrine and messenger + When I send a "POST" request to "/messenger_with_persists" with body: + """ + { + "name": "test" + } + """ + Then the response status code should be 201 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/MessengerWithPersist", + "@id": "/messenger_with_persists/1", + "@type": "MessengerWithPersist", + "id": 1, + "name": "test" + } + """ + + Scenario: Using messenger={"persist", "input"} should combine doctrine, messenger and input DTO + When I send a "POST" request to "/messenger_with_arrays" with body: + """ + { + "var": "test" + } + """ + Then the response status code should be 201 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/MessengerWithArray", + "@id": "/messenger_with_arrays/1", + "@type": "MessengerWithArray", + "id": 1, + "name": "test" + } + """ diff --git a/features/doctrine/search_filter.feature b/features/doctrine/search_filter.feature index 857e0c70718..0ed91ebf66d 100644 --- a/features/doctrine/search_filter.feature +++ b/features/doctrine/search_filter.feature @@ -65,7 +65,8 @@ Feature: Search filter on collections "prop": "blue" } ], - "uuid": [] + "uuid": [], + "carBrand": "DummyBrand" } ], "hydra:totalItems": 1, @@ -271,6 +272,47 @@ Feature: Search filter on collections } """ + Scenario: Search collection by name (partial multiple values) + Given there are 30 dummy objects + When I send a "GET" request to "/dummies?name[]=2&name[]=3" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And print last JSON response + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@context": {"pattern": "^/contexts/Dummy$"}, + "@id": {"pattern": "^/dummies$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@id": { + "oneOf": [ + {"pattern": "^/dummies/2$"}, + {"pattern": "^/dummies/3$"}, + {"pattern": "^/dummies/12$"} + ] + } + } + } + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": {"pattern": "^/dummies\\?name%5B%5D=2&name%5B%5D=3"}, + "@type": {"pattern": "^hydra:PartialCollectionView$"} + } + } + } + } + """ + Scenario: Search collection by name (partial case insensitive) When I send a "GET" request to "/dummies?dummy=somedummytest1" Then the response status code should be 200 @@ -338,6 +380,45 @@ Feature: Search filter on collections } """ + Scenario: Search collection by alias (start multiple values) + When I send a "GET" request to "/dummies?description[]=Sma&description[]=Not" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@context": {"pattern": "^/contexts/Dummy$"}, + "@id": {"pattern": "^/dummies$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@id": { + "oneOf": [ + {"pattern": "^/dummies/1$"}, + {"pattern": "^/dummies/2$"}, + {"pattern": "^/dummies/3$"} + ] + } + } + } + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": {"pattern": "^/dummies\\?description%5B%5D=Sma&description%5B%5D=Not"}, + "@type": {"pattern": "^hydra:PartialCollectionView$"} + } + } + } + } + """ + @sqlite Scenario: Search collection by description (word_start) When I send a "GET" request to "/dummies?description=smart" @@ -378,6 +459,46 @@ Feature: Search filter on collections } """ + @sqlite + Scenario: Search collection by description (word_start multiple values) + When I send a "GET" request to "/dummies?description[]=smart&description[]=so" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "properties": { + "@context": {"pattern": "^/contexts/Dummy$"}, + "@id": {"pattern": "^/dummies$"}, + "@type": {"pattern": "^hydra:Collection$"}, + "hydra:member": { + "type": "array", + "items": { + "type": "object", + "properties": { + "@id": { + "oneOf": [ + {"pattern": "^/dummies/1$"}, + {"pattern": "^/dummies/2$"}, + {"pattern": "^/dummies/3$"} + ] + } + } + } + }, + "hydra:view": { + "type": "object", + "properties": { + "@id": {"pattern": "^/dummies\\?description%5B%5D=smart&description%5B%5D=so"}, + "@type": {"pattern": "^hydra:PartialCollectionView$"} + } + } + } + } + """ + # note on Postgres compared to sqlite the LIKE clause is case sensitive @postgres Scenario: Search collection by description (word_start) @@ -804,7 +925,6 @@ Feature: Search filter on collections When I send a "GET" request to "/converted_owners?name_converted.name_converted=Converted 3" Then the response status code should be 200 And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" - Then print last JSON response And the JSON should be valid according to this schema: """ { diff --git a/features/filter/filter_validation.feature b/features/filter/filter_validation.feature index ab85b45dd5d..23b63bcff09 100644 --- a/features/filter/filter_validation.feature +++ b/features/filter/filter_validation.feature @@ -2,16 +2,18 @@ Feature: Validate filters based upon filter description @createSchema Scenario: Required filter should not throw an error if set - When I am on "/filter_validators?required=foo" + When I am on "/filter_validators?required=foo&required-allow-empty=&arrayRequired[foo]=" Then the response status code should be 200 - When I am on "/filter_validators?required=" - Then the response status code should be 200 + Scenario: Required filter that does not allow empty value should throw an error if empty + When I am on "/filter_validators?required=&required-allow-empty=&arrayRequired[foo]=" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "required" does not allow empty value' Scenario: Required filter should throw an error if not set When I am on "/filter_validators" Then the response status code should be 400 - And the JSON node "detail" should be equal to 'Query parameter "required" is required' + Then the JSON node "detail" should match '/^Query parameter "required" is required\nQuery parameter "required-allow-empty" is required$/' Scenario: Required filter should not throw an error if set When I am on "/array_filter_validators?arrayRequired[]=foo&indexedArrayRequired[foo]=foo" @@ -37,3 +39,129 @@ Feature: Validate filters based upon filter description When I am on "/array_filter_validators?arrayRequired[]=foo&indexedArrayRequired[bar]=bar" Then the response status code should be 400 And the JSON node "detail" should be equal to 'Query parameter "indexedArrayRequired[foo]" is required' + + Scenario: Test filter bounds: maximum + When I am on "/filter_validators?required=foo&required-allow-empty&maximum=10" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&maximum=11" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "maximum" must be less than or equal to 10' + + Scenario: Test filter bounds: exclusiveMaximum + When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMaximum=9" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMaximum=10" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "exclusiveMaximum" must be less than 10' + + Scenario: Test filter bounds: minimum + When I am on "/filter_validators?required=foo&required-allow-empty&minimum=5" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&minimum=0" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "minimum" must be greater than or equal to 5' + + Scenario: Test filter bounds: exclusiveMinimum + When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMinimum=6" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&exclusiveMinimum=5" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "exclusiveMinimum" must be greater than 5' + + Scenario: Test filter bounds: max length + When I am on "/filter_validators?required=foo&required-allow-empty&max-length-3=123" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&max-length-3=1234" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "max-length-3" length must be lower than or equal to 3' + + Scenario: Do not throw an error if value is not an array + When I am on "/filter_validators?required=foo&required-allow-empty&max-length-3[]=12345" + Then the response status code should be 200 + + Scenario: Test filter bounds: min length + When I am on "/filter_validators?required=foo&required-allow-empty&min-length-3=123" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&min-length-3=12" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "min-length-3" length must be greater than or equal to 3' + + Scenario: Test filter pattern + When I am on "/filter_validators?required=foo&required-allow-empty&pattern=pattern" + When I am on "/filter_validators?required=foo&required-allow-empty&pattern=nrettap" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&pattern=not-pattern" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "pattern" must match pattern /^(pattern|nrettap)$/' + + Scenario: Test filter enum + When I am on "/filter_validators?required=foo&required-allow-empty&enum=in-enum" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&enum=not-in-enum" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "enum" must be one of "in-enum, mune-ni"' + + Scenario: Test filter multipleOf + When I am on "/filter_validators?required=foo&required-allow-empty&multiple-of=4" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&multiple-of=3" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "multiple-of" must multiple of 2' + + Scenario: Test filter array items csv format minItems + When I am on "/filter_validators?required=foo&required-allow-empty&csv-min-2=a,b" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&csv-min-2=a" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "csv-min-2" must contain more than 2 values' + + Scenario: Test filter array items csv format maxItems + When I am on "/filter_validators?required=foo&required-allow-empty&csv-max-3=a,b,c" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&csv-max-3=a,b,c,d" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "csv-max-3" must contain less than 3 values' + + Scenario: Test filter array items tsv format minItems + When I am on "/filter_validators?required=foo&required-allow-empty&tsv-min-2=a\tb" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&tsv-min-2=a,b" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "tsv-min-2" must contain more than 2 values' + + Scenario: Test filter array items pipes format minItems + When I am on "/filter_validators?required=foo&required-allow-empty&pipes-min-2=a|b" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&pipes-min-2=a,b" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "pipes-min-2" must contain more than 2 values' + + Scenario: Test filter array items ssv format minItems + When I am on "/filter_validators?required=foo&required-allow-empty&ssv-min-2=a b" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&ssv-min-2=a,b" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "ssv-min-2" must contain more than 2 values' + + @dropSchema + Scenario: Test filter array items unique items + When I am on "/filter_validators?required=foo&required-allow-empty&csv-uniques=a,b" + Then the response status code should be 200 + + When I am on "/filter_validators?required=foo&required-allow-empty&csv-uniques=a,a" + Then the response status code should be 400 + And the JSON node "detail" should be equal to 'Query parameter "csv-uniques" must contain unique values' diff --git a/features/graphql/authorization.feature b/features/graphql/authorization.feature index 7be5fb24499..165b0131c20 100644 --- a/features/graphql/authorization.feature +++ b/features/graphql/authorization.feature @@ -18,6 +18,8 @@ Feature: Authorization checking Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/json" + And the JSON node "errors[0].extensions.status" should be equal to 403 + And the JSON node "errors[0].extensions.category" should be equal to user And the JSON node "errors[0].message" should be equal to "Access Denied." Scenario: An anonymous user tries to retrieve a secured collection @@ -38,6 +40,8 @@ Feature: Authorization checking Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/json" + And the JSON node "errors[0].extensions.status" should be equal to 403 + And the JSON node "errors[0].extensions.category" should be equal to user And the JSON node "errors[0].message" should be equal to "Access Denied." Scenario: An admin can retrieve a secured collection @@ -79,13 +83,15 @@ Feature: Authorization checking And the response should be in JSON And the header "Content-Type" should be equal to "application/json" And the JSON node "data.securedDummies" should be null + And the JSON node "errors[0].extensions.status" should be equal to 403 + And the JSON node "errors[0].extensions.category" should be equal to user And the JSON node "errors[0].message" should be equal to "Access Denied." Scenario: An anonymous user tries to create a resource they are not allowed to When I send the following GraphQL request: """ mutation { - createSecuredDummy(input: {owner: "me", title: "Hi", description: "Desc", clientMutationId: "auth"}) { + createSecuredDummy(input: {owner: "me", title: "Hi", description: "Desc", adminOnlyProperty: "secret", clientMutationId: "auth"}) { securedDummy { title owner @@ -96,6 +102,8 @@ Feature: Authorization checking Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/json" + And the JSON node "errors[0].extensions.status" should be equal to 403 + And the JSON node "errors[0].extensions.category" should be equal to user And the JSON node "errors[0].message" should be equal to "Only admins can create a secured dummy." @createSchema @@ -104,7 +112,7 @@ Feature: Authorization checking And I send the following GraphQL request: """ mutation { - createSecuredDummy(input: {owner: "someone", title: "Hi", description: "Desc"}) { + createSecuredDummy(input: {owner: "someone", title: "Hi", description: "Desc", adminOnlyProperty: "secret"}) { securedDummy { id title @@ -123,7 +131,7 @@ Feature: Authorization checking And I send the following GraphQL request: """ mutation { - createSecuredDummy(input: {owner: "dunglas", title: "Hi", description: "Desc"}) { + createSecuredDummy(input: {owner: "dunglas", title: "Hi", description: "Desc", adminOnlyProperty: "secret"}) { securedDummy { id title @@ -151,6 +159,8 @@ Feature: Authorization checking Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/json" + And the JSON node "errors[0].extensions.status" should be equal to 403 + And the JSON node "errors[0].extensions.category" should be equal to user And the JSON node "errors[0].message" should be equal to "Access Denied." Scenario: A user can retrieve an item they owns @@ -186,6 +196,8 @@ Feature: Authorization checking Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/json" + And the JSON node "errors[0].extensions.status" should be equal to 403 + And the JSON node "errors[0].extensions.category" should be equal to user And the JSON node "errors[0].message" should be equal to "Access Denied." Scenario: A user can update an item they owns and transfer it diff --git a/features/graphql/collection.feature b/features/graphql/collection.feature index eb581455912..b6cdd7ef027 100644 --- a/features/graphql/collection.feature +++ b/features/graphql/collection.feature @@ -680,3 +680,113 @@ Feature: GraphQL collection support And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[0].node.title" should not exist And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[1].node.title" should not exist And the JSON node "data.dummyDifferentGraphQlSerializationGroups.edges[2].node.title" should not exist + + @createSchema + Scenario: Retrieve a paginated collection using page-based pagination + Given there are 5 fooDummy objects with fake names + When I send the following GraphQL request: + """ + { + fooDummies(page: 1) { + collection { + id + name + } + paginationInfo { + itemsPerPage + lastPage + totalCount + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.fooDummies.collection" should have 3 elements + And the JSON node "data.fooDummies.collection[0].id" should exist + And the JSON node "data.fooDummies.collection[0].name" should exist + And the JSON node "data.fooDummies.collection[1].id" should exist + And the JSON node "data.fooDummies.collection[1].name" should exist + And the JSON node "data.fooDummies.collection[2].id" should exist + And the JSON node "data.fooDummies.collection[2].name" should exist + And the JSON node "data.fooDummies.paginationInfo.itemsPerPage" should be equal to the number 3 + And the JSON node "data.fooDummies.paginationInfo.lastPage" should be equal to the number 2 + And the JSON node "data.fooDummies.paginationInfo.totalCount" should be equal to the number 5 + When I send the following GraphQL request: + """ + { + fooDummies(page: 2) { + collection { + id + name + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.fooDummies.collection" should have 2 elements + When I send the following GraphQL request: + """ + { + fooDummies(page: 3) { + collection { + id + name + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.fooDummies.collection" should have 0 elements + + @createSchema + Scenario: Retrieve a paginated collection using page-based pagination and client-defined limit + Given there are 5 fooDummy objects with fake names + When I send the following GraphQL request: + """ + { + fooDummies(page: 1, itemsPerPage: 2) { + collection { + id + name + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.fooDummies.collection" should have 2 elements + And the JSON node "data.fooDummies.collection[0].id" should exist + And the JSON node "data.fooDummies.collection[0].name" should exist + And the JSON node "data.fooDummies.collection[1].id" should exist + And the JSON node "data.fooDummies.collection[1].name" should exist + When I send the following GraphQL request: + """ + { + fooDummies(page: 2, itemsPerPage: 2) { + collection { + id + name + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.fooDummies.collection" should have 2 elements + When I send the following GraphQL request: + """ + { + fooDummies(page: 3, itemsPerPage: 2) { + collection { + id + name + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON node "data.fooDummies.collection" should have 1 element diff --git a/features/graphql/filters.feature b/features/graphql/filters.feature index 54254b20258..b094006013d 100644 --- a/features/graphql/filters.feature +++ b/features/graphql/filters.feature @@ -30,7 +30,7 @@ Feature: Collections filtering When I send the following GraphQL request: """ { - dummies(exists: {relatedDummy: true}) { + dummies(exists: [{relatedDummy: true}]) { edges { node { id @@ -52,7 +52,7 @@ Feature: Collections filtering When I send the following GraphQL request: """ { - dummies(dummyDate: {after: "2015-04-02"}) { + dummies(dummyDate: [{after: "2015-04-02"}]) { edges { node { id @@ -204,7 +204,7 @@ Feature: Collections filtering When I send the following GraphQL request: """ { - dummies(order: {relatedDummy__name: "DESC"}) { + dummies(order: [{relatedDummy__name: "DESC"}]) { edges { node { name @@ -222,6 +222,30 @@ Feature: Collections filtering And the JSON node "data.dummies.edges[0].node.name" should be equal to "Dummy #2" And the JSON node "data.dummies.edges[1].node.name" should be equal to "Dummy #1" + @createSchema + Scenario: Retrieve a collection ordered correctly given the order of the argument + Given there are dummies with similar properties + When I send the following GraphQL request: + """ + { + dummies(order: [{description: "ASC"}, {name: "ASC"}]) { + edges { + node { + id + name + description + } + } + } + } + """ + Then the response status code should be 200 + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.dummies.edges[0].node.name" should be equal to "baz" + And the JSON node "data.dummies.edges[0].node.description" should be equal to "bar" + And the JSON node "data.dummies.edges[1].node.name" should be equal to "foo" + And the JSON node "data.dummies.edges[1].node.description" should be equal to "bar" + @createSchema Scenario: Retrieve a collection filtered using the related search filter with two values and exact strategy Given there are 3 dummy objects with relatedDummy diff --git a/features/graphql/introspection.feature b/features/graphql/introspection.feature index 03d1e36389d..1867b588c7c 100644 --- a/features/graphql/introspection.feature +++ b/features/graphql/introspection.feature @@ -3,9 +3,11 @@ Feature: GraphQL introspection support @createSchema Scenario: Execute an empty GraphQL query When I send a "GET" request to "/graphql" - Then the response status code should be 400 + Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/json" + And the JSON node "errors[0].extensions.status" should be equal to 400 + And the JSON node "errors[0].extensions.category" should be equal to user And the JSON node "errors[0].message" should be equal to "GraphQL query is not valid." Scenario: Introspect the GraphQL schema diff --git a/features/graphql/mutation.feature b/features/graphql/mutation.feature index ac8a8f36520..a624047ce62 100644 --- a/features/graphql/mutation.feature +++ b/features/graphql/mutation.feature @@ -674,7 +674,11 @@ Feature: GraphQL mutation support Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/json" + And the JSON node "errors[0].extensions.status" should be equal to "422" And the JSON node "errors[0].message" should be equal to "name: This value should not be blank." + And the JSON node "errors[0].extensions.violations" should exist + And the JSON node "errors[0].extensions.violations[0].path" should be equal to "name" + And the JSON node "errors[0].extensions.violations[0].message" should be equal to "This value should not be blank." Scenario: Execute a custom mutation Given there are 1 dummyCustomMutation objects diff --git a/features/graphql/query.feature b/features/graphql/query.feature index 8fd4058ca3e..149d39eaef1 100644 --- a/features/graphql/query.feature +++ b/features/graphql/query.feature @@ -102,13 +102,13 @@ Feature: GraphQL query support } } """ - And I send the GraphQL request with operation "DummyWithId2" + And I send the GraphQL request with operationName "DummyWithId2" Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/json" And the JSON node "data.dummyItem.id" should be equal to "/dummies/2" And the JSON node "data.dummyItem.name" should be equal to "Dummy #2" - And I send the GraphQL request with operation "DummyWithId1" + And I send the GraphQL request with operationName "DummyWithId1" And the JSON node "data.dummyItem.name" should be equal to "Dummy #1" Scenario: Use serialization groups @@ -126,6 +126,18 @@ Feature: GraphQL query support And the header "Content-Type" should be equal to "application/json" And the JSON node "data.dummyGroup.foo" should be equal to "Foo #1" + Scenario: Query a serialized name + Given there is a DummyCar entity with related colors + When I send the following GraphQL request: + """ + { + dummyCar(id: "/dummy_cars/1") { + carBrand + } + } + """ + Then the JSON node "data.dummyCar.carBrand" should be equal to "DummyBrand" + Scenario: Fetch only the internal id When I send the following GraphQL request: """ diff --git a/features/graphql/subscription.feature b/features/graphql/subscription.feature new file mode 100644 index 00000000000..123cb5b8e52 --- /dev/null +++ b/features/graphql/subscription.feature @@ -0,0 +1,224 @@ +Feature: GraphQL subscription support + + @createSchema + Scenario: Introspect subscription type + When I send the following GraphQL request: + """ + { + __type(name: "Subscription") { + fields { + name + description + type { + name + kind + } + args { + name + type { + name + kind + ofType { + name + kind + } + } + } + } + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON should be valid according to this schema: + """ + { + "type": "object", + "required": [ + "data" + ], + "properties": { + "data": { + "type": "object", + "required": [ + "__type" + ], + "properties": { + "__type": { + "type": "object", + "required": [ + "fields" + ], + "properties": { + "fields": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": [ + "name", + "description", + "type", + "args" + ], + "properties": { + "name": { + "pattern": "^update[A-z0-9]+Subscribe" + }, + "description": { + "pattern": "^Subscribes to the update event of a [A-z0-9]+.$" + }, + "type": { + "type": "object", + "required": [ + "name", + "kind" + ], + "properties": { + "name": { + "pattern": "^update[A-z0-9]+SubscriptionPayload$" + }, + "kind": { + "enum": ["OBJECT"] + } + } + }, + "args": { + "type": "array", + "minItems": 1, + "maxItems": 1, + "items": [ + { + "type": "object", + "required": [ + "name", + "type" + ], + "properties": { + "name": { + "enum": ["input"] + }, + "type": { + "type": "object", + "required": [ + "kind", + "ofType" + ], + "properties": { + "kind": { + "enum": ["NON_NULL"] + }, + "ofType": { + "type": "object", + "required": [ + "name", + "kind" + ], + "properties": { + "name": { + "pattern": "^update[A-z0-9]+SubscriptionInput$" + }, + "kind": { + "enum": ["INPUT_OBJECT"] + } + } + } + } + } + } + } + ] + } + } + } + } + } + } + } + } + } + } + """ + + Scenario: Subscribe to updates + Given there are 2 dummy mercure objects + When I send the following GraphQL request: + """ + subscription { + updateDummyMercureSubscribe(input: {id: "/dummy_mercures/1", clientSubscriptionId: "myId"}) { + dummyMercure { + id + name + relatedDummy { + name + } + } + mercureUrl + clientSubscriptionId + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.updateDummyMercureSubscribe.dummyMercure.id" should be equal to "/dummy_mercures/1" + And the JSON node "data.updateDummyMercureSubscribe.dummyMercure.name" should be equal to "Dummy Mercure #1" + And the JSON node "data.updateDummyMercureSubscribe.mercureUrl" should match "@^https://demo.mercure.rocks/hub\?topic=http://example.com/subscriptions/[a-f0-9]+$@" + And the JSON node "data.updateDummyMercureSubscribe.clientSubscriptionId" should be equal to "myId" + + When I send the following GraphQL request: + """ + subscription { + updateDummyMercureSubscribe(input: {id: "/dummy_mercures/2"}) { + dummyMercure { + id + } + mercureUrl + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json" + And the JSON node "data.updateDummyMercureSubscribe.dummyMercure.id" should be equal to "/dummy_mercures/2" + And the JSON node "data.updateDummyMercureSubscribe.mercureUrl" should match "@^https://demo.mercure.rocks/hub\?topic=http://example.com/subscriptions/[a-f0-9]+$@" + + Scenario: Receive Mercure updates with different payloads from subscriptions + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I send a "PUT" request to "/dummy_mercures/1" with body: + """ + { + "name": "Dummy Mercure #1 updated" + } + """ + Then the following Mercure update with topics "http://example.com/subscriptions/[a-f0-9]+" should have been sent: + """ + { + "dummyMercure": { + "id": 1, + "name": "Dummy Mercure #1 updated", + "relatedDummy": { + "name": "RelatedDummy #1" + } + } + } + """ + + When I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/ld+json" + And I send a "PUT" request to "/dummy_mercures/2" with body: + """ + { + "name": "Dummy Mercure #2 updated" + } + """ + Then the following Mercure update with topics "http://example.com/subscriptions/[a-f0-9]+" should have been sent: + """ + { + "dummyMercure": { + "id": 2 + } + } + """ diff --git a/features/hal/absolute_url.feature b/features/hal/absolute_url.feature new file mode 100644 index 00000000000..394bb425c10 --- /dev/null +++ b/features/hal/absolute_url.feature @@ -0,0 +1,119 @@ +Feature: IRI should contain Absolute URL + In order to add detail to IRIs + Include the absolute url + + @createSchema + Scenario: I should be able to GET a collection of Objects with Absolute Urls + Given there are 1 absoluteUrlDummy objects with a related absoluteUrlRelationDummy + And I add "Accept" header equal to "application/hal+json" + And I send a "GET" request to "/absolute_url_dummies" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "http://example.com/absolute_url_dummies" + }, + "item": [ + { + "href": "http://example.com/absolute_url_dummies/1" + } + ] + }, + "totalItems": 1, + "itemsPerPage": 3, + "_embedded": { + "item": [ + { + "_links": { + "self": { + "href": "http://example.com/absolute_url_dummies/1" + }, + "absoluteUrlRelationDummy": { + "href": "http://example.com/absolute_url_relation_dummies/1" + } + }, + "id": 1 + } + ] + } + } + """ + + Scenario: I should be able to POST an object using an Absolute Url + Given I add "Accept" header equal to "application/hal+json" + And I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/absolute_url_relation_dummies" with body: + """ + { + "absolute_url_dummies": "http://example.com/absolute_url_dummies/1" + } + """ + Then the response status code should be 201 + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "http://example.com/absolute_url_relation_dummies/2" + } + }, + "id": 2 + } + """ + + Scenario: I should be able to GET an Item with Absolute Urls + Given I add "Accept" header equal to "application/hal+json" + And I add "Content-Type" header equal to "application/json" + And I send a "GET" request to "/absolute_url_dummies/1" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "http://example.com/absolute_url_dummies/1" + }, + "absoluteUrlRelationDummy": { + "href": "http://example.com/absolute_url_relation_dummies/1" + } + }, + "id": 1 + } + """ + + Scenario: I should be able to GET subresources with Absolute Urls + Given I add "Accept" header equal to "application/hal+json" + And I add "Content-Type" header equal to "application/json" + And I send a "GET" request to "/absolute_url_relation_dummies/1/absolute_url_dummies" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "http://example.com/absolute_url_relation_dummies/1/absolute_url_dummies" + }, + "item": [ + { + "href": "http://example.com/absolute_url_dummies/1" + } + ] + }, + "totalItems": 1, + "itemsPerPage": 3, + "_embedded": { + "item": [ + { + "_links": { + "self": { + "href": "http://example.com/absolute_url_dummies/1" + }, + "absoluteUrlRelationDummy": { + "href": "http://example.com/absolute_url_relation_dummies/1" + } + }, + "id": 1 + } + ] + } + } + """ diff --git a/features/hal/network_path.feature b/features/hal/network_path.feature new file mode 100644 index 00000000000..5fba0bcbb21 --- /dev/null +++ b/features/hal/network_path.feature @@ -0,0 +1,117 @@ +Feature: IRI should contain network path + In order to add detail to IRIs + Include the network path + + @createSchema + Scenario: I should be able to GET a collection of objects with network paths + Given there are 1 networkPathDummy objects with a related networkPathRelationDummy + And I add "Accept" header equal to "application/hal+json" + And I send a "GET" request to "/network_path_dummies" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "//example.com/network_path_dummies" + }, + "item": [ + { + "href": "//example.com/network_path_dummies/1" + } + ] + }, + "totalItems": 1, + "itemsPerPage": 3, + "_embedded": { + "item": [ + { + "_links": { + "self": { + "href": "//example.com/network_path_dummies/1" + }, + "networkPathRelationDummy": { + "href": "//example.com/network_path_relation_dummies/1" + } + }, + "id": 1 + } + ] + } + } + """ + + Scenario: I should be able to POST an object using a network path + Given I add "Accept" header equal to "application/hal+json" + And I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/network_path_relation_dummies" with body: + """ + { + "network_path_dummies": "//example.com/network_path_dummies/1" + } + """ + Then the response status code should be 201 + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "//example.com/network_path_relation_dummies/2" + } + }, + "id": 2 + } + """ + + Scenario: I should be able to GET an Item with network paths + Given I add "Accept" header equal to "application/hal+json" + And I send a "GET" request to "/network_path_dummies/1" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "//example.com/network_path_dummies/1" + }, + "networkPathRelationDummy": { + "href": "//example.com/network_path_relation_dummies/1" + } + }, + "id": 1 + } + """ + + Scenario: I should be able to GET subresources with network paths + Given I add "Accept" header equal to "application/hal+json" + And I send a "GET" request to "/network_path_relation_dummies/1/network_path_dummies" + And the JSON should be equal to: + """ + { + "_links": { + "self": { + "href": "//example.com/network_path_relation_dummies/1/network_path_dummies" + }, + "item": [ + { + "href": "//example.com/network_path_dummies/1" + } + ] + }, + "totalItems": 1, + "itemsPerPage": 3, + "_embedded": { + "item": [ + { + "_links": { + "self": { + "href": "//example.com/network_path_dummies/1" + }, + "networkPathRelationDummy": { + "href": "//example.com/network_path_relation_dummies/1" + } + }, + "id": 1 + } + ] + } + } + """ diff --git a/features/hal/problem.feature b/features/hal/problem.feature index fe92ff4ba3f..9ee65614a96 100644 --- a/features/hal/problem.feature +++ b/features/hal/problem.feature @@ -10,7 +10,7 @@ Feature: Error handling valid according to RFC 7807 (application/problem+json) """ {} """ - Then the response status code should be 400 + Then the response status code should be 422 And the response should be in JSON And the header "Content-Type" should be equal to "application/problem+json; charset=utf-8" And the JSON should be equal to: @@ -22,7 +22,8 @@ Feature: Error handling valid according to RFC 7807 (application/problem+json) "violations": [ { "propertyPath": "name", - "message": "This value should not be blank." + "message": "This value should not be blank.", + "code": "c1051bb4-d103-4f74-8988-acbcafc7fdc3" } ] } diff --git a/features/hydra/entrypoint.feature b/features/hydra/entrypoint.feature index 7fd1b631137..b4b008583cc 100644 --- a/features/hydra/entrypoint.feature +++ b/features/hydra/entrypoint.feature @@ -8,6 +8,7 @@ Feature: Entrypoint support Then the response status code should be 200 And the response should be in JSON And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be sorted And the JSON node "@context" should be equal to "/contexts/Entrypoint" And the JSON node "@id" should be equal to "/" And the JSON node "@type" should be equal to "Entrypoint" diff --git a/features/hydra/error.feature b/features/hydra/error.feature index 9ec12d469fa..2dafb3ae705 100644 --- a/features/hydra/error.feature +++ b/features/hydra/error.feature @@ -9,7 +9,7 @@ Feature: Error handling """ {} """ - Then the response status code should be 400 + Then the response status code should be 422 And the response should be in JSON And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" And the JSON should be equal to: @@ -22,7 +22,8 @@ Feature: Error handling "violations": [ { "propertyPath": "name", - "message": "This value should not be blank." + "message": "This value should not be blank.", + "code": "c1051bb4-d103-4f74-8988-acbcafc7fdc3" } ] } diff --git a/features/jsonapi/absolute_url.feature b/features/jsonapi/absolute_url.feature new file mode 100644 index 00000000000..2bf4d2f0367 --- /dev/null +++ b/features/jsonapi/absolute_url.feature @@ -0,0 +1,120 @@ +Feature: IRI should contain Absolute URL + In order to add detail to IRIs + Include the absolute url + + @createSchema + Scenario: I should be able to GET a collection of Objects with Absolute Urls + Given there are 1 absoluteUrlDummy objects with a related absoluteUrlRelationDummy + And I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/absolute_url_dummies" + And the JSON should be equal to: + """ + { + "links": { + "self": "http://example.com/absolute_url_dummies" + }, + "meta": { + "totalItems": 1, + "itemsPerPage": 3, + "currentPage": 1 + }, + "data": [ + { + "id": "http://example.com/absolute_url_dummies/1", + "type": "AbsoluteUrlDummy", + "attributes": { + "_id": 1 + }, + "relationships": { + "absoluteUrlRelationDummy": { + "data": { + "type": "AbsoluteUrlRelationDummy", + "id": "http://example.com/absolute_url_relation_dummies/1" + } + } + } + } + ] + } + """ + + Scenario: I should be able to POST an object using an Absolute Url + Given I add "Accept" header equal to "application/vnd.api+json" + And I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/absolute_url_relation_dummies" with body: + """ + { + "absolute_url_dummies": "http://example.com/absolute_url_dummies/1" + } + """ + Then the response status code should be 201 + And the JSON should be equal to: + """ + { + "data": { + "id": "http://example.com/absolute_url_relation_dummies/2", + "type": "AbsoluteUrlRelationDummy", + "attributes": { + "_id": 2 + } + } + } + """ + + Scenario: I should be able to GET an Item with Absolute Urls + Given I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/absolute_url_dummies/1" + And the JSON should be equal to: + """ + { + "data": { + "id": "http://example.com/absolute_url_dummies/1", + "type": "AbsoluteUrlDummy", + "attributes": { + "_id": 1 + }, + "relationships": { + "absoluteUrlRelationDummy": { + "data": { + "type": "AbsoluteUrlRelationDummy", + "id": "http://example.com/absolute_url_relation_dummies/1" + } + } + } + } + } + """ + + Scenario: I should be able to GET subresources with Absolute Urls + Given I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/absolute_url_relation_dummies/1/absolute_url_dummies" + And the JSON should be equal to: + """ + { + "links": { + "self": "http://example.com/absolute_url_relation_dummies/1/absolute_url_dummies" + }, + "meta": { + "totalItems": 1, + "itemsPerPage": 3, + "currentPage": 1 + }, + "data": [ + { + "id": "http://example.com/absolute_url_dummies/1", + "type": "AbsoluteUrlDummy", + "attributes": { + "_id": 1 + }, + "relationships": { + "absoluteUrlRelationDummy": { + "data": { + "type": "AbsoluteUrlRelationDummy", + "id": "http://example.com/absolute_url_relation_dummies/1" + } + } + } + } + ] + } + """ diff --git a/features/jsonapi/errors.feature b/features/jsonapi/errors.feature index 7d37824e052..587ee74a837 100644 --- a/features/jsonapi/errors.feature +++ b/features/jsonapi/errors.feature @@ -18,7 +18,7 @@ Feature: JSON API error handling } } """ - Then the response status code should be 400 + Then the response status code should be 422 And the response should be in JSON And the JSON should be valid according to the JSON API schema And the JSON should be equal to: @@ -49,7 +49,7 @@ Feature: JSON API error handling } } """ - Then the response status code should be 400 + Then the response status code should be 422 And the response should be in JSON And the JSON should be valid according to the JSON API schema And the JSON should be equal to: diff --git a/features/jsonapi/network_path.feature b/features/jsonapi/network_path.feature new file mode 100644 index 00000000000..9837fb065e4 --- /dev/null +++ b/features/jsonapi/network_path.feature @@ -0,0 +1,120 @@ +Feature: IRI should contain network path + In order to add detail to IRIs + Include the network path + + @createSchema + Scenario: I should be able to GET a collection of objects with network paths + Given there are 1 networkPathDummy objects with a related networkPathRelationDummy + And I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/network_path_dummies" + And the JSON should be equal to: + """ + { + "links": { + "self": "//example.com/network_path_dummies" + }, + "meta": { + "totalItems": 1, + "itemsPerPage": 3, + "currentPage": 1 + }, + "data": [ + { + "id": "//example.com/network_path_dummies/1", + "type": "NetworkPathDummy", + "attributes": { + "_id": 1 + }, + "relationships": { + "networkPathRelationDummy": { + "data": { + "type": "NetworkPathRelationDummy", + "id": "//example.com/network_path_relation_dummies/1" + } + } + } + } + ] + } + """ + + Scenario: I should be able to POST an object using a network path + Given I add "Accept" header equal to "application/vnd.api+json" + And I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/network_path_relation_dummies" with body: + """ + { + "network_path_dummies": "//example.com/network_path_dummies/1" + } + """ + Then the response status code should be 201 + And the JSON should be equal to: + """ + { + "data": { + "id": "//example.com/network_path_relation_dummies/2", + "type": "NetworkPathRelationDummy", + "attributes": { + "_id": 2 + } + } + } + """ + + Scenario: I should be able to GET an Item with network paths + Given I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/network_path_dummies/1" + And the JSON should be equal to: + """ + { + "data": { + "id": "//example.com/network_path_dummies/1", + "type": "NetworkPathDummy", + "attributes": { + "_id": 1 + }, + "relationships": { + "networkPathRelationDummy": { + "data": { + "type": "NetworkPathRelationDummy", + "id": "//example.com/network_path_relation_dummies/1" + } + } + } + } + } + """ + + Scenario: I should be able to GET subresources with network paths + Given I add "Accept" header equal to "application/vnd.api+json" + And I send a "GET" request to "/network_path_relation_dummies/1/network_path_dummies" + And the JSON should be equal to: + """ + { + "links": { + "self": "//example.com/network_path_relation_dummies/1/network_path_dummies" + }, + "meta": { + "totalItems": 1, + "itemsPerPage": 3, + "currentPage": 1 + }, + "data": [ + { + "id": "//example.com/network_path_dummies/1", + "type": "NetworkPathDummy", + "attributes": { + "_id": 1 + }, + "relationships": { + "networkPathRelationDummy": { + "data": { + "type": "NetworkPathRelationDummy", + "id": "//example.com/network_path_relation_dummies/1" + } + } + } + } + ] + } + """ diff --git a/features/jsonld/absolute_url.feature b/features/jsonld/absolute_url.feature new file mode 100644 index 00000000000..4421c7702da --- /dev/null +++ b/features/jsonld/absolute_url.feature @@ -0,0 +1,83 @@ +Feature: IRI should contain Absolute URL + In order to add detail to IRIs + Include the absolute url + + @createSchema + Scenario: I should be able to GET a collection of Objects with Absolute Urls + Given there are 1 absoluteUrlDummy objects with a related absoluteUrlRelationDummy + And I add "Accept" header equal to "application/ld+json" + And I send a "GET" request to "/absolute_url_dummies" + And the JSON should be equal to: + """ + { + "@context": "http://example.com/contexts/AbsoluteUrlDummy", + "@id": "http://example.com/absolute_url_dummies", + "@type": "hydra:Collection", + "hydra:member": [ + { + "@id": "http://example.com/absolute_url_dummies/1", + "@type": "AbsoluteUrlDummy", + "absoluteUrlRelationDummy": "http://example.com/absolute_url_relation_dummies/1", + "id": 1 + } + ], + "hydra:totalItems": 1 + } + + """ + + Scenario: I should be able to POST an object using an Absolute Url + Given I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/absolute_url_relation_dummies" with body: + """ + { + "absolute_url_dummies": "http://example.com/absolute_url_dummies/1" + } + """ + Then the response status code should be 201 + And the JSON should be equal to: + """ + { + "@context": "http://example.com/contexts/AbsoluteUrlRelationDummy", + "@id": "http://example.com/absolute_url_relation_dummies/2", + "@type": "AbsoluteUrlRelationDummy", + "absoluteUrlDummies": [], + "id": 2 + } + """ + + Scenario: I should be able to GET an Item with Absolute Urls + Given I add "Accept" header equal to "application/ld+json" + And I send a "GET" request to "/absolute_url_dummies/1" + And the JSON should be equal to: + """ + { + "@context": "http://example.com/contexts/AbsoluteUrlDummy", + "@id": "http://example.com/absolute_url_dummies/1", + "@type": "AbsoluteUrlDummy", + "absoluteUrlRelationDummy": "http://example.com/absolute_url_relation_dummies/1", + "id": 1 + } + """ + + Scenario: I should be able to GET subresources with Absolute Urls + Given I add "Accept" header equal to "application/ld+json" + And I send a "GET" request to "/absolute_url_relation_dummies/1/absolute_url_dummies" + And the JSON should be equal to: + """ + { + "@context": "http://example.com/contexts/AbsoluteUrlDummy", + "@id": "http://example.com/absolute_url_relation_dummies/1/absolute_url_dummies", + "@type": "hydra:Collection", + "hydra:member": [ + { + "@id": "http://example.com/absolute_url_dummies/1", + "@type": "AbsoluteUrlDummy", + "absoluteUrlRelationDummy": "http://example.com/absolute_url_relation_dummies/1", + "id": 1 + } + ], + "hydra:totalItems": 1 + } + """ diff --git a/features/jsonld/input_output.feature b/features/jsonld/input_output.feature index 5ae4b6874eb..361be332ff5 100644 --- a/features/jsonld/input_output.feature +++ b/features/jsonld/input_output.feature @@ -321,6 +321,28 @@ Feature: JSON-LD DTO input and output """ @createSchema + Scenario: Initialize input data with a DataTransformerInitializer + Given there is an InitializeInput object with id 1 + When I send a "PUT" request to "/initialize_inputs/1" with body: + """ + { + "name": "La peste" + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the JSON should be equal to: + """ + { + "@context": "/contexts/InitializeInput", + "@id": "/initialize_inputs/1", + "@type": "InitializeInput", + "id": 1, + "manager": "Orwell", + "name": "La peste" + } + """ + Scenario: Create a resource with a custom Input When I send a "POST" request to "/dummy_dto_customs" with body: """ diff --git a/features/jsonld/iri_only.feature b/features/jsonld/iri_only.feature new file mode 100644 index 00000000000..2e2e3ad5915 --- /dev/null +++ b/features/jsonld/iri_only.feature @@ -0,0 +1,50 @@ +Feature: JSON-LD using iri_only parameter + In order to improve Vulcain support + As a Vulcain user and as a developer + I should be able to only get an IRI list when I ask a resource. + + Scenario: Retrieve Dummy's resource context with iri_only + When I send a "GET" request to "/contexts/IriOnlyDummy" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": { + "@vocab": "http://example.com/docs.jsonld#", + "hydra": "http://www.w3.org/ns/hydra/core#", + "hydra:member": { + "@type": "@id" + } + } + } + """ + + @createSchema + Scenario: Retrieve Dummies with iri_only and jsonld_embed_context + Given there are 3 iriOnlyDummies + When I send a "GET" request to "/iri_only_dummies" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": { + "@vocab": "http://example.com/docs.jsonld#", + "hydra": "http://www.w3.org/ns/hydra/core#", + "hydra:member": { + "@type": "@id" + } + }, + "@id": "/iri_only_dummies", + "@type": "hydra:Collection", + "hydra:member": [ + "/iri_only_dummies/1", + "/iri_only_dummies/2", + "/iri_only_dummies/3" + ], + "hydra:totalItems": 3 + } + """ diff --git a/features/jsonld/network_path.feature b/features/jsonld/network_path.feature new file mode 100644 index 00000000000..2f77e39aa91 --- /dev/null +++ b/features/jsonld/network_path.feature @@ -0,0 +1,86 @@ +Feature: IRI should contain network path + In order to add detail to IRIs + Include the network path + + @createSchema + Scenario: I should be able to GET a collection of objects with network paths + Given there are 1 networkPathDummy objects with a related networkPathRelationDummy + And I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/json" + And I send a "GET" request to "/network_path_dummies" + And the JSON should be equal to: + """ + { + "@context": "//example.com/contexts/NetworkPathDummy", + "@id": "//example.com/network_path_dummies", + "@type": "hydra:Collection", + "hydra:member": [ + { + "@id": "//example.com/network_path_dummies/1", + "@type": "NetworkPathDummy", + "networkPathRelationDummy": "//example.com/network_path_relation_dummies/1", + "id": 1 + } + ], + "hydra:totalItems": 1 + } + + """ + + Scenario: I should be able to POST an object using a network path + Given I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/json" + And I send a "POST" request to "/network_path_relation_dummies" with body: + """ + { + "network_path_dummies": "//example.com/network_path_dummies/1" + } + """ + Then the response status code should be 201 + And the JSON should be equal to: + """ + { + "@context": "//example.com/contexts/NetworkPathRelationDummy", + "@id": "//example.com/network_path_relation_dummies/2", + "@type": "NetworkPathRelationDummy", + "networkPathDummies": [], + "id": 2 + } + """ + + Scenario: I should be able to GET an Item with network paths + Given I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/json" + And I send a "GET" request to "/network_path_dummies/1" + And the JSON should be equal to: + """ + { + "@context": "//example.com/contexts/NetworkPathDummy", + "@id": "//example.com/network_path_dummies/1", + "@type": "NetworkPathDummy", + "networkPathRelationDummy": "//example.com/network_path_relation_dummies/1", + "id": 1 + } + """ + + Scenario: I should be able to GET subresources with network paths + Given I add "Accept" header equal to "application/ld+json" + And I add "Content-Type" header equal to "application/json" + And I send a "GET" request to "/network_path_relation_dummies/1/network_path_dummies" + And the JSON should be equal to: + """ + { + "@context": "//example.com/contexts/NetworkPathDummy", + "@id": "//example.com/network_path_relation_dummies/1/network_path_dummies", + "@type": "hydra:Collection", + "hydra:member": [ + { + "@id": "//example.com/network_path_dummies/1", + "@type": "NetworkPathDummy", + "networkPathRelationDummy": "//example.com/network_path_relation_dummies/1", + "id": 1 + } + ], + "hydra:totalItems": 1 + } + """ diff --git a/features/main/default_order.feature b/features/main/default_order.feature index 6b39736eee2..1bc97ab37c1 100644 --- a/features/main/default_order.feature +++ b/features/main/default_order.feature @@ -117,3 +117,115 @@ Feature: Default order } } """ + + Scenario: Override custom order asc + When I send a "GET" request to "/custom_collection_asc_foos?itemsPerPage=10" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/Foo", + "@id": "/foos", + "@type": "hydra:Collection", + "hydra:member": [ + { + "@id": "/foos/5", + "@type": "Foo", + "id": 5, + "name": "Balbo", + "bar": "Amet" + }, + { + "@id": "/foos/3", + "@type": "Foo", + "id": 3, + "name": "Ephesian", + "bar": "Dolor" + }, + { + "@id": "/foos/1", + "@type": "Foo", + "id": 1, + "name": "Hawsepipe", + "bar": "Lorem" + }, + { + "@id": "/foos/4", + "@type": "Foo", + "id": 4, + "name": "Separativeness", + "bar": "Sit" + }, + { + "@id": "/foos/2", + "@type": "Foo", + "id": 2, + "name": "Sthenelus", + "bar": "Ipsum" + } + ], + "hydra:totalItems": 5, + "hydra:view": { + "@id": "/custom_collection_asc_foos?itemsPerPage=10", + "@type": "hydra:PartialCollectionView" + } + } + """ + + Scenario: Override custom order desc + When I send a "GET" request to "/custom_collection_desc_foos?itemsPerPage=10" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/Foo", + "@id": "/foos", + "@type": "hydra:Collection", + "hydra:member": [ + { + "@id": "/foos/2", + "@type": "Foo", + "id": 2, + "name": "Sthenelus", + "bar": "Ipsum" + }, + { + "@id": "/foos/4", + "@type": "Foo", + "id": 4, + "name": "Separativeness", + "bar": "Sit" + }, + { + "@id": "/foos/1", + "@type": "Foo", + "id": 1, + "name": "Hawsepipe", + "bar": "Lorem" + }, + { + "@id": "/foos/3", + "@type": "Foo", + "id": 3, + "name": "Ephesian", + "bar": "Dolor" + }, + { + "@id": "/foos/5", + "@type": "Foo", + "id": 5, + "name": "Balbo", + "bar": "Amet" + } + ], + "hydra:totalItems": 5, + "hydra:view": { + "@id": "/custom_collection_desc_foos?itemsPerPage=10", + "@type": "hydra:PartialCollectionView" + } + } + """ diff --git a/features/main/patch.feature b/features/main/patch.feature index c2f37f67bb6..eeddbc5a1e0 100644 --- a/features/main/patch.feature +++ b/features/main/patch.feature @@ -28,3 +28,33 @@ Feature: Sending PATCH requets {"name": null} """ Then the JSON node "name" should not exist + + @createSchema + Scenario: Patch the relation + Given there is a PatchDummyRelation + When I add "Content-Type" header equal to "application/merge-patch+json" + And I send a "PATCH" request to "/patch_dummy_relations/1" with body: + """ + { + "related": { + "symfony": "A new name" + } + } + """ + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" + And the JSON should be equal to: + """ + { + "@context": "/contexts/PatchDummyRelation", + "@id": "/patch_dummy_relations/1", + "@type": "PatchDummyRelation", + "related": { + "@id": "/related_dummies/1", + "@type": "https://schema.org/Product", + "id": 1, + "symfony": "A new name" + } + } + """ diff --git a/features/main/table_inheritance.feature b/features/main/table_inheritance.feature index 379e39f49e1..85cf6be152f 100644 --- a/features/main/table_inheritance.feature +++ b/features/main/table_inheritance.feature @@ -538,7 +538,7 @@ Feature: Table inheritance }, "@id": { "type": "string", - "pattern": "^/resource_interfaces/single%2520item$" + "pattern": "^/resource_interfaces/single%20item$" }, "@type": { "type": "string", diff --git a/features/main/url_encoded_id.feature b/features/main/url_encoded_id.feature new file mode 100644 index 00000000000..bf0190d71e7 --- /dev/null +++ b/features/main/url_encoded_id.feature @@ -0,0 +1,26 @@ +Feature: Allowing resource identifiers with characters that should be URL encoded + In order to have a resource with an id with special characters + As a client software developer + I need to be able to set and retrieve these resources with the URL encoded ID + + @createSchema + Scenario Outline: Get a resource whether or not the id is URL encoded + Given there is a UrlEncodedId resource + And I add "Content-Type" header equal to "application/ld+json" + When I send a "GET" request to "" + Then the response status code should be 200 + And the JSON should be equal to: + """ + { + "@context": "/contexts/UrlEncodedId", + "@id": "/url_encoded_ids/%25encode:id", + "@type": "UrlEncodedId", + "id": "%encode:id" + } + """ + Examples: + | url | + | /url_encoded_ids/%encode:id | + | /url_encoded_ids/%25encode%3Aid | + | /url_encoded_ids/%25encode:id | + | /url_encoded_ids/%encode%3Aid | diff --git a/features/main/validation.feature b/features/main/validation.feature index 960f10718be..2d7e0532815 100644 --- a/features/main/validation.feature +++ b/features/main/validation.feature @@ -24,7 +24,7 @@ Feature: Using validations groups "code": "My Dummy" } """ - Then the response status code should be 400 + Then the response status code should be 422 And the response should be in JSON And the JSON should be equal to: """ @@ -36,7 +36,8 @@ Feature: Using validations groups "violations": [ { "propertyPath": "name", - "message": "This value should not be null." + "message": "This value should not be null.", + "code": "ad32d13f-c3d4-423b-909a-857b961eb720" } ] } @@ -52,7 +53,7 @@ Feature: Using validations groups "code": "My Dummy" } """ - Then the response status code should be 400 + Then the response status code should be 422 And the response should be in JSON And the JSON should be equal to: """ @@ -64,7 +65,8 @@ Feature: Using validations groups "violations": [ { "propertyPath": "title", - "message": "This value should not be null." + "message": "This value should not be null.", + "code": "ad32d13f-c3d4-423b-909a-857b961eb720" } ] } diff --git a/features/openapi/docs.feature b/features/openapi/docs.feature index ec8806eaafe..3952c966d30 100644 --- a/features/openapi/docs.feature +++ b/features/openapi/docs.feature @@ -10,7 +10,7 @@ Feature: Documentation support And the response should be in JSON And the header "Content-Type" should be equal to "application/json; charset=utf-8" # Context - And the JSON node "openapi" should be equal to "3.0.2" + And the JSON node "openapi" should be equal to "3.0.3" # Root properties And the JSON node "info.title" should be equal to "My Dummy API" And the JSON node "info.description" should contain "This is a test API." @@ -56,47 +56,135 @@ Feature: Documentation support And "id" property exists for the OpenAPI class "Dummy" And "name" property is required for OpenAPI class "Dummy" # 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" + And the JSON node "paths./dummies.get.parameters[3].required" should be false + And the JSON node "paths./dummies.get.parameters[3].schema.type" should be equal to "boolean" + + # Subcollection - check filter on subResource + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[0].name" should be equal to "id" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[0].in" should be equal to "path" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[0].required" should be true + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[0].schema.type" should be equal to "string" + + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].name" should be equal to "page" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].in" should be equal to "query" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].required" should be false + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].schema.type" should be equal to "integer" + + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[2].name" should be equal to "itemsPerPage" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[2].in" should be equal to "query" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[2].required" should be false + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[2].schema.type" should be equal to "integer" + + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[3].name" should be equal to "pagination" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[3].in" should be equal to "query" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[3].required" should be false + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[3].schema.type" should be equal to "boolean" + + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[4].name" should be equal to "name" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[4].in" should be equal to "query" + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[4].required" should be false + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[4].schema.type" should be equal to "string" + + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters" should have 5 elements + + # Subcollection - check schema + And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.responses.200.content.application/ld+json.schema.properties.hydra:member.items.$ref" should be equal to "#/components/schemas/RelatedToDummyFriend.jsonld-fakemanytomany" + + # Deprecations + And the JSON node "paths./dummies.get.deprecated" should be false + And the JSON node "paths./deprecated_resources.get.deprecated" should be true + And the JSON node "paths./deprecated_resources.post.deprecated" should be true + And the JSON node "paths./deprecated_resources/{id}.get.deprecated" should be true + And the JSON node "paths./deprecated_resources/{id}.delete.deprecated" should be true + And the JSON node "paths./deprecated_resources/{id}.put.deprecated" should be true + And the JSON node "paths./deprecated_resources/{id}.patch.deprecated" should be true + + @createSchema + Scenario: Retrieve the Swagger documentation + Given I send a "GET" request to "/docs.json?spec_version=2" + Then the response status code should be 200 + And the response should be in JSON + And the header "Content-Type" should be equal to "application/json; charset=utf-8" + # Context + And the JSON node "swagger" should be equal to "2.0" + # Root properties + And the JSON node "info.title" should be equal to "My Dummy API" + And the JSON node "info.description" should contain "This is a test API." + And the JSON node "info.description" should contain "Made with love" + # Supported classes + And the Swagger class "AbstractDummy" exists + And the Swagger class "CircularReference" exists + And the Swagger class "CircularReference-circular" exists + And the Swagger class "CompositeItem" exists + And the Swagger class "CompositeLabel" exists + And the Swagger class "ConcreteDummy" exists + And the Swagger class "CustomIdentifierDummy" exists + And the Swagger class "CustomNormalizedDummy-input" exists + And the Swagger class "CustomNormalizedDummy-output" exists + And the Swagger class "CustomWritableIdentifierDummy" exists + And the Swagger class "Dummy" exists + And the Swagger class "RelatedDummy" exists + And the Swagger class "DummyTableInheritance" exists + And the Swagger class "DummyTableInheritanceChild" exists + And the Swagger class "OverriddenOperationDummy-overridden_operation_dummy_get" exists + And the Swagger class "OverriddenOperationDummy-overridden_operation_dummy_put" exists + And the Swagger class "OverriddenOperationDummy-overridden_operation_dummy_read" exists + And the Swagger class "OverriddenOperationDummy-overridden_operation_dummy_write" exists + And the Swagger class "RelatedDummy" exists + And the Swagger class "NoCollectionDummy" exists + And the Swagger class "RelatedToDummyFriend" exists + And the Swagger class "RelatedToDummyFriend-fakemanytomany" exists + And the Swagger class "DummyFriend" exists + And the Swagger class "RelationEmbedder-barcelona" exists + And the Swagger class "RelationEmbedder-chicago" exists + And the Swagger class "User-user_user-read" exists + And the Swagger class "User-user_user-write" exists + And the Swagger class "UuidIdentifierDummy" exists + And the Swagger class "ThirdLevel" exists + And the Swagger class "ParentDummy" doesn't exist + And the Swagger class "UnknownDummy" doesn't exist + And the Swagger path "/relation_embedders/{id}/custom" exists + And the Swagger path "/override/swagger" exists + And the Swagger path "/api/custom-call/{id}" exists + 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" + # 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" And the JSON node "paths./dummies.get.parameters[0].required" should be false - And the JSON node "paths./dummies.get.parameters[0].schema.type" should be equal to "boolean" # Subcollection - check filter on subResource And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[0].name" should be equal to "id" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[0].in" should be equal to "path" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[0].required" should be true - And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[0].schema.type" should be equal to "string" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].name" should be equal to "name" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].in" should be equal to "query" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].required" should be false - And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[1].schema.type" should be equal to "string" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[2].name" should be equal to "description" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[2].in" should be equal to "query" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[2].required" should be false - And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[2].schema.type" should be equal to "string" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[3].name" should be equal to "page" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[3].in" should be equal to "query" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[3].required" should be false - And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[3].schema.type" should be equal to "integer" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[4].name" should be equal to "itemsPerPage" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[4].in" should be equal to "query" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[4].required" should be false - And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[4].schema.type" should be equal to "integer" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[5].name" should be equal to "pagination" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[5].in" should be equal to "query" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[5].required" should be false - And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters[5].schema.type" should be equal to "boolean" And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.parameters" should have 6 elements - # Subcollection - check schema - And the JSON node "paths./related_dummies/{id}/related_to_dummy_friends.get.responses.200.content.application/ld+json.schema.properties.hydra:member.items.$ref" should be equal to "#/components/schemas/RelatedToDummyFriend:jsonld-fakemanytomany" - # Deprecations And the JSON node "paths./dummies.get.deprecated" should not exist And the JSON node "paths./deprecated_resources.get.deprecated" should be true @@ -112,11 +200,9 @@ Feature: Documentation support Then the response status code should be 200 And I should see text matching "My Dummy API" And I should see text matching "openapi" - And I should see text matching "3.0.2" Scenario: OpenAPI UI is enabled for an arbitrary endpoint Given I add "Accept" header equal to "text/html" And I send a "GET" request to "/dummies?spec_version=3" Then the response status code should be 200 And I should see text matching "openapi" - And I should see text matching "3.0.2" diff --git a/features/security/send_security_headers.feature b/features/security/send_security_headers.feature index d4b91f77491..b09afc7c316 100644 --- a/features/security/send_security_headers.feature +++ b/features/security/send_security_headers.feature @@ -27,7 +27,7 @@ Feature: Send security header """ {"name": ""} """ - Then the response status code should be 400 + Then the response status code should be 422 And the header "Content-Type" should be equal to "application/ld+json; charset=utf-8" And the header "X-Content-Type-Options" should be equal to "nosniff" And the header "X-Frame-Options" should be equal to "deny" diff --git a/phpstan.neon.dist b/phpstan.neon.dist index e371c5b3e98..71435d0cf02 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -21,6 +21,9 @@ parameters: - tests/Bridge/NelmioApiDoc/* - src/Bridge/FosUser/* # BC layer + - tests/Annotation/ApiResourceTest.php + - tests/Annotation/ApiPropertyTest.php + - tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php - tests/Fixtures/TestBundle/BrowserKit/Client.php # The Symfony Configuration API isn't good enough to be analysed - src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -30,6 +33,7 @@ parameters: - src/Bridge/Symfony/Bundle/Test/BrowserKitAssertionsTrait.php - tests/Bridge/Symfony/Bundle/Test/WebTestCaseTest.php - tests/ProphecyTrait.php + - tests/Behat/CoverageContext.php earlyTerminatingMethodCalls: PHPUnit\Framework\Constraint\Constraint: - fail @@ -80,18 +84,22 @@ parameters: message: '#Call to method PHPUnit\\Framework\\Assert::assertSame\(\) with 2 and int will always evaluate to false\.#' path: tests/Identifier/Normalizer/IntegerDenormalizerTest.php - - message: '#Service "api_platform\.json_schema\.schema_factory" is private\.#' - path: src/Bridge/Symfony/Bundle/Test/ApiTestAssertionsTrait.php - - - message: '#Service "api_platform\.iri_converter" is private\.#' - path: src/Bridge/Symfony/Bundle/Test/ApiTestCase.php + message: '#Call to method PHPUnit\\Framework\\Assert::assertSame\(\) with array\(.+\) and array\(.+\) will always evaluate to false\.#' + path: tests/Util/SortTraitTest.php # https://github.com/phpstan/phpstan-symfony/issues/76 - - - message: '#Service "api_platform\.graphql\.fields_builder" is private\.#' - path: src/GraphQl/Type/TypeBuilder.php - message: '#Service "test" is not registered in the container\.#' path: tests/GraphQl/Type/TypesContainerTest.php + - + message: '#Property Doctrine\\ORM\\Mapping\\ClassMetadataInfo::\$fieldMappings \(array.*\)>\) does not accept array\(.*\)\.#' + path: tests/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactoryTest.php + + # Expected, due to PHP 8 attributes + - '#ReflectionProperty::getAttributes\(\)#' + - '#ReflectionMethod::getAttributes\(\)#' + - '#ReflectionClass::getAttributes\(\)#' + - '#Constructor of class ApiPlatform\\Core\\Annotation\\ApiResource has an unused parameter#' + - '#Constructor of class ApiPlatform\\Core\\Annotation\\ApiProperty has an unused parameter#' # Expected, due to optional interfaces - '#Method ApiPlatform\\Core\\Bridge\\Doctrine\\Orm\\Extension\\QueryCollectionExtensionInterface::applyToCollection\(\) invoked with 5 parameters, 3-4 required\.#' diff --git a/phpunit.xml.dist b/phpunit.xml.dist index a24d73d3f0f..4518373e404 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -2,7 +2,7 @@ + */ + private static $deprecatedAttributes = []; + /** * @var string */ @@ -77,10 +84,73 @@ final class ApiProperty public $identifier; /** + * @var string|int|float|bool|array|null + */ + public $default; + + /** + * @var string|int|float|bool|array|null + */ + public $example; + + /** + * @param string $description + * @param bool $readable + * @param bool $writable + * @param bool $readableLink + * @param bool $writableLink + * @param bool $required + * @param string $iri + * @param bool $identifier + * @param string|int|float|bool|array $default + * @param string|int|float|bool|array|null $example + * @param string $deprecationReason + * @param bool $fetchable + * @param bool $fetchEager + * @param array $jsonldContext + * @param array $openapiContext + * @param bool $push + * @param string $security + * @param array $swaggerContext + * * @throws InvalidArgumentException */ - public function __construct(array $values = []) - { - $this->hydrateAttributes($values); + public function __construct( + $description = null, + ?bool $readable = null, + ?bool $writable = null, + ?bool $readableLink = null, + ?bool $writableLink = null, + ?bool $required = null, + ?string $iri = null, + ?bool $identifier = null, + $default = null, + $example = null, + + // attributes + ?array $attributes = null, + ?string $deprecationReason = null, + ?bool $fetchable = null, + ?bool $fetchEager = null, + ?array $jsonldContext = null, + ?array $openapiContext = null, + ?bool $push = null, + ?string $security = null, + ?array $swaggerContext = null + ) { + if (!\is_array($description)) { // @phpstan-ignore-line Doctrine annotations support + [$publicProperties, $configurableAttributes] = self::getConfigMetadata(); + + foreach ($publicProperties as $prop => $_) { + $this->{$prop} = ${$prop}; + } + + $description = []; + foreach ($configurableAttributes as $attribute => $_) { + $description[$attribute] = ${$attribute}; + } + } + + $this->hydrateAttributes($description); } } diff --git a/src/Annotation/ApiResource.php b/src/Annotation/ApiResource.php index af64b790bca..1208080d692 100644 --- a/src/Annotation/ApiResource.php +++ b/src/Annotation/ApiResource.php @@ -25,16 +25,22 @@ * @Attributes( * @Attribute("accessControl", type="string"), * @Attribute("accessControlMessage", type="string"), + * @Attribute("attributes", type="array"), * @Attribute("cacheHeaders", type="array"), + * @Attribute("collectionOperations", type="array"), * @Attribute("denormalizationContext", type="array"), * @Attribute("deprecationReason", type="string"), + * @Attribute("description", type="string"), * @Attribute("elasticsearch", type="bool"), * @Attribute("fetchPartial", type="bool"), * @Attribute("forceEager", type="bool"), * @Attribute("formats", type="array"), * @Attribute("filters", type="string[]"), + * @Attribute("graphql", type="array"), * @Attribute("hydraContext", type="array"), * @Attribute("input", type="mixed"), + * @Attribute("iri", type="string"), + * @Attribute("itemOperations", type="array"), * @Attribute("mercure", type="mixed"), * @Attribute("messenger", type="mixed"), * @Attribute("normalizationContext", type="array"), @@ -56,15 +62,29 @@ * @Attribute("securityMessage", type="string"), * @Attribute("securityPostDenormalize", type="string"), * @Attribute("securityPostDenormalizeMessage", type="string"), + * @Attribute("shortName", type="string"), + * @Attribute("stateless", type="bool"), + * @Attribute("subresourceOperations", type="array"), * @Attribute("sunset", type="string"), * @Attribute("swaggerContext", type="array"), - * @Attribute("validationGroups", type="mixed") + * @Attribute("urlGenerationStrategy", type="int"), + * @Attribute("validationGroups", type="mixed"), * ) */ +#[\Attribute(\Attribute::TARGET_CLASS)] final class ApiResource { use AttributesHydratorTrait; + /** + * @var array + */ + private static $deprecatedAttributes = [ + 'accessControl' => ['security', '2.5'], + 'accessControlMessage' => ['securityMessage', '2.5'], + 'maximumItemsPerPage' => ['paginationMaximumItemsPerPage', '2.6'], + ]; + /** * @see https://api-platform.com/docs/core/operations * @@ -109,10 +129,109 @@ final class ApiResource public $subresourceOperations; /** + * @param string $description + * @param array $collectionOperations https://api-platform.com/docs/core/operations + * @param array $graphql https://api-platform.com/docs/core/graphql + * @param array $itemOperations https://api-platform.com/docs/core/operations + * @param array $subresourceOperations https://api-platform.com/docs/core/subresources + * @param array $cacheHeaders https://api-platform.com/docs/core/performance/#setting-custom-http-cache-headers + * @param array $denormalizationContext https://api-platform.com/docs/core/serialization/#using-serialization-groups + * @param string $deprecationReason https://api-platform.com/docs/core/deprecations/#deprecating-resource-classes-operations-and-properties + * @param bool $elasticsearch https://api-platform.com/docs/core/elasticsearch/ + * @param bool $fetchPartial https://api-platform.com/docs/core/performance/#fetch-partial + * @param bool $forceEager https://api-platform.com/docs/core/performance/#force-eager + * @param array $formats https://api-platform.com/docs/core/content-negotiation/#configuring-formats-for-a-specific-resource-or-operation + * @param string[] $filters https://api-platform.com/docs/core/filters/#doctrine-orm-and-mongodb-odm-filters + * @param string[] $hydraContext https://api-platform.com/docs/core/extending-jsonld-context/#hydra + * @param string|false $input https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation + * @param bool|array $mercure https://api-platform.com/docs/core/mercure + * @param bool $messenger https://api-platform.com/docs/core/messenger/#dispatching-a-resource-through-the-message-bus + * @param array $normalizationContext https://api-platform.com/docs/core/serialization/#using-serialization-groups + * @param array $openapiContext https://api-platform.com/docs/core/swagger/#using-the-openapi-and-swagger-contexts + * @param array $order https://api-platform.com/docs/core/default-order/#overriding-default-order + * @param string|false $output https://api-platform.com/docs/core/dto/#specifying-an-input-or-an-output-data-representation + * @param bool $paginationClientEnabled https://api-platform.com/docs/core/pagination/#for-a-specific-resource-1 + * @param bool $paginationClientItemsPerPage https://api-platform.com/docs/core/pagination/#for-a-specific-resource-3 + * @param bool $paginationClientPartial https://api-platform.com/docs/core/pagination/#for-a-specific-resource-6 + * @param array $paginationViaCursor https://api-platform.com/docs/core/pagination/#cursor-based-pagination + * @param bool $paginationEnabled https://api-platform.com/docs/core/pagination/#for-a-specific-resource + * @param bool $paginationFetchJoinCollection https://api-platform.com/docs/core/pagination/#controlling-the-behavior-of-the-doctrine-orm-paginator + * @param int $paginationItemsPerPage https://api-platform.com/docs/core/pagination/#changing-the-number-of-items-per-page + * @param int $paginationMaximumItemsPerPage https://api-platform.com/docs/core/pagination/#changing-maximum-items-per-page + * @param bool $paginationPartial https://api-platform.com/docs/core/performance/#partial-pagination + * @param string $routePrefix https://api-platform.com/docs/core/operations/#prefixing-all-routes-of-all-operations + * @param string $security https://api-platform.com/docs/core/security + * @param string $securityMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message + * @param string $securityPostDenormalize https://api-platform.com/docs/core/security/#executing-access-control-rules-after-denormalization + * @param string $securityPostDenormalizeMessage https://api-platform.com/docs/core/security/#configuring-the-access-control-error-message + * @param bool $stateless + * @param string $sunset https://api-platform.com/docs/core/deprecations/#setting-the-sunset-http-header-to-indicate-when-a-resource-or-an-operation-will-be-removed + * @param array $swaggerContext https://api-platform.com/docs/core/swagger/#using-the-openapi-and-swagger-contexts + * @param array $validationGroups https://api-platform.com/docs/core/validation/#using-validation-groups + * @param int $urlGenerationStrategy + * * @throws InvalidArgumentException */ - public function __construct(array $values = []) - { - $this->hydrateAttributes($values); + public function __construct( + $description = null, + array $collectionOperations = [], + array $graphql = [], + string $iri = '', + array $itemOperations = [], + string $shortName = '', + array $subresourceOperations = [], + + // attributes + ?array $attributes = null, + ?array $cacheHeaders = null, + ?array $denormalizationContext = null, + ?string $deprecationReason = null, + ?bool $elasticsearch = null, + ?bool $fetchPartial = null, + ?bool $forceEager = null, + ?array $formats = null, + ?array $filters = null, + ?array $hydraContext = null, + $input = null, + $mercure = null, + $messenger = null, + ?array $normalizationContext = null, + ?array $openapiContext = null, + ?array $order = null, + $output = null, + ?bool $paginationClientEnabled = null, + ?bool $paginationClientItemsPerPage = null, + ?bool $paginationClientPartial = null, + ?array $paginationViaCursor = null, + ?bool $paginationEnabled = null, + ?bool $paginationFetchJoinCollection = null, + ?int $paginationItemsPerPage = null, + ?int $paginationMaximumItemsPerPage = null, + ?bool $paginationPartial = null, + ?string $routePrefix = null, + ?string $security = null, + ?string $securityMessage = null, + ?string $securityPostDenormalize = null, + ?string $securityPostDenormalizeMessage = null, + ?bool $stateless = null, + ?string $sunset = null, + ?array $swaggerContext = null, + ?array $validationGroups = null, + ?int $urlGenerationStrategy = null + ) { + if (!\is_array($description)) { // @phpstan-ignore-line Doctrine annotations support + [$publicProperties, $configurableAttributes] = self::getConfigMetadata(); + + foreach ($publicProperties as $prop => $_) { + $this->{$prop} = ${$prop}; + } + + $description = []; + foreach ($configurableAttributes as $attribute => $_) { + $description[$attribute] = ${$attribute}; + } + } + + $this->hydrateAttributes($description ?? []); } } diff --git a/src/Annotation/AttributesHydratorTrait.php b/src/Annotation/AttributesHydratorTrait.php index 19822977e31..9b13ab480b7 100644 --- a/src/Annotation/AttributesHydratorTrait.php +++ b/src/Annotation/AttributesHydratorTrait.php @@ -26,6 +26,34 @@ */ trait AttributesHydratorTrait { + private static $configMetadata; + + /** + * @internal + */ + public static function getConfigMetadata(): array + { + if (null !== self::$configMetadata) { + return self::$configMetadata; + } + + $rc = new \ReflectionClass(self::class); + + $publicProperties = []; + foreach ($rc->getProperties(\ReflectionProperty::IS_PUBLIC) as $reflectionProperty) { + $publicProperties[$reflectionProperty->getName()] = true; + } + + $configurableAttributes = []; + foreach ($rc->getConstructor()->getParameters() as $param) { + if (!isset($publicProperties[$name = $param->getName()])) { + $configurableAttributes[$name] = true; + } + } + + return [$publicProperties, $configurableAttributes]; + } + /** * @var array */ @@ -41,20 +69,22 @@ private function hydrateAttributes(array $values): void unset($values['attributes']); } - if (\array_key_exists('accessControl', $values)) { - $values['security'] = $values['accessControl']; - @trigger_error('Attribute "accessControl" is deprecated in annotation since API Platform 2.5, prefer using "security" attribute instead', E_USER_DEPRECATED); - unset($values['accessControl']); - } - if (\array_key_exists('accessControlMessage', $values)) { - $values['securityMessage'] = $values['accessControlMessage']; - @trigger_error('Attribute "accessControlMessage" is deprecated in annotation since API Platform 2.5, prefer using "securityMessage" attribute instead', E_USER_DEPRECATED); - unset($values['accessControlMessage']); + foreach (self::$deprecatedAttributes as $deprecatedAttribute => $options) { + if (\array_key_exists($deprecatedAttribute, $values)) { + $values[$options[0]] = $values[$deprecatedAttribute]; + @trigger_error(sprintf('Attribute "%s" is deprecated in annotation since API Platform %s, prefer using "%s" attribute instead', $deprecatedAttribute, $options[1], $options[0]), E_USER_DEPRECATED); + unset($values[$deprecatedAttribute]); + } } + [$publicProperties, $configurableAttributes] = self::getConfigMetadata(); foreach ($values as $key => $value) { $key = (string) $key; - if (property_exists($this, $key) && (new \ReflectionProperty($this, $key))->isPublic()) { + if (!isset($publicProperties[$key]) && !isset($configurableAttributes[$key]) && !isset(self::$deprecatedAttributes[$key])) { + throw new InvalidArgumentException(sprintf('Unknown property "%s" on annotation "%s".', $key, self::class)); + } + + if (isset($publicProperties[$key])) { $this->{$key} = $value; continue; } diff --git a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php index e20dc2683d2..aae7a77d51c 100644 --- a/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php +++ b/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php @@ -19,10 +19,15 @@ use ApiPlatform\Core\Bridge\Symfony\Messenger\DispatchTrait; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface as GraphQlMercureSubscriptionIriGeneratorInterface; +use ApiPlatform\Core\GraphQl\Subscription\SubscriptionManagerInterface as GraphQlSubscriptionManagerInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Util\ResourceClassInfoTrait; -use Doctrine\ORM\Event\OnFlushEventArgs; +use Doctrine\Common\EventArgs; +use Doctrine\ODM\MongoDB\Event\OnFlushEventArgs as MongoDbOdmOnFlushEventArgs; +use Doctrine\ORM\Event\OnFlushEventArgs as OrmOnFlushEventArgs; use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\Mercure\Update; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Serializer\SerializerInterface; @@ -43,6 +48,7 @@ final class PublishMercureUpdatesListener 'id' => true, 'type' => true, 'retry' => true, + 'normalization_context' => true, ]; use DispatchTrait; @@ -52,15 +58,17 @@ final class PublishMercureUpdatesListener private $serializer; private $publisher; private $expressionLanguage; - private $createdEntities; - private $updatedEntities; - private $deletedEntities; + private $createdObjects; + private $updatedObjects; + private $deletedObjects; private $formats; + private $graphQlSubscriptionManager; + private $graphQlMercureSubscriptionIriGenerator; /** * @param array $formats */ - public function __construct(ResourceClassResolverInterface $resourceClassResolver, IriConverterInterface $iriConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, SerializerInterface $serializer, array $formats, MessageBusInterface $messageBus = null, callable $publisher = null, ExpressionLanguage $expressionLanguage = null) + public function __construct(ResourceClassResolverInterface $resourceClassResolver, IriConverterInterface $iriConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, SerializerInterface $serializer, array $formats, MessageBusInterface $messageBus = null, callable $publisher = null, ?GraphQlSubscriptionManagerInterface $graphQlSubscriptionManager = null, ?GraphQlMercureSubscriptionIriGeneratorInterface $graphQlMercureSubscriptionIriGenerator = null, ExpressionLanguage $expressionLanguage = null) { if (null === $messageBus && null === $publisher) { throw new InvalidArgumentException('A message bus or a publisher must be provided.'); @@ -74,26 +82,37 @@ public function __construct(ResourceClassResolverInterface $resourceClassResolve $this->messageBus = $messageBus; $this->publisher = $publisher; $this->expressionLanguage = $expressionLanguage ?? (class_exists(ExpressionLanguage::class) ? new ExpressionLanguage() : null); + $this->graphQlSubscriptionManager = $graphQlSubscriptionManager; + $this->graphQlMercureSubscriptionIriGenerator = $graphQlMercureSubscriptionIriGenerator; $this->reset(); } /** - * Collects created, updated and deleted entities. + * Collects created, updated and deleted objects. */ - public function onFlush(OnFlushEventArgs $eventArgs): void + public function onFlush(EventArgs $eventArgs): void { - $uow = $eventArgs->getEntityManager()->getUnitOfWork(); + if ($eventArgs instanceof OrmOnFlushEventArgs) { + $uow = $eventArgs->getEntityManager()->getUnitOfWork(); + } elseif ($eventArgs instanceof MongoDbOdmOnFlushEventArgs) { + $uow = $eventArgs->getDocumentManager()->getUnitOfWork(); + } else { + return; + } - foreach ($uow->getScheduledEntityInsertions() as $entity) { - $this->storeEntityToPublish($entity, 'createdEntities'); + $methodName = $eventArgs instanceof OrmOnFlushEventArgs ? 'getScheduledEntityInsertions' : 'getScheduledDocumentInsertions'; + foreach ($uow->{$methodName}() as $object) { + $this->storeObjectToPublish($object, 'createdObjects'); } - foreach ($uow->getScheduledEntityUpdates() as $entity) { - $this->storeEntityToPublish($entity, 'updatedEntities'); + $methodName = $eventArgs instanceof OrmOnFlushEventArgs ? 'getScheduledEntityUpdates' : 'getScheduledDocumentUpdates'; + foreach ($uow->{$methodName}() as $object) { + $this->storeObjectToPublish($object, 'updatedObjects'); } - foreach ($uow->getScheduledEntityDeletions() as $entity) { - $this->storeEntityToPublish($entity, 'deletedEntities'); + $methodName = $eventArgs instanceof OrmOnFlushEventArgs ? 'getScheduledEntityDeletions' : 'getScheduledDocumentDeletions'; + foreach ($uow->{$methodName}() as $object) { + $this->storeObjectToPublish($object, 'deletedObjects'); } } @@ -103,16 +122,16 @@ public function onFlush(OnFlushEventArgs $eventArgs): void public function postFlush(): void { try { - foreach ($this->createdEntities as $entity) { - $this->publishUpdate($entity, $this->createdEntities[$entity]); + foreach ($this->createdObjects as $object) { + $this->publishUpdate($object, $this->createdObjects[$object], 'create'); } - foreach ($this->updatedEntities as $entity) { - $this->publishUpdate($entity, $this->updatedEntities[$entity]); + foreach ($this->updatedObjects as $object) { + $this->publishUpdate($object, $this->updatedObjects[$object], 'update'); } - foreach ($this->deletedEntities as $entity) { - $this->publishUpdate($entity, $this->deletedEntities[$entity]); + foreach ($this->deletedObjects as $object) { + $this->publishUpdate($object, $this->deletedObjects[$object], 'delete'); } } finally { $this->reset(); @@ -121,17 +140,17 @@ public function postFlush(): void private function reset(): void { - $this->createdEntities = new \SplObjectStorage(); - $this->updatedEntities = new \SplObjectStorage(); - $this->deletedEntities = new \SplObjectStorage(); + $this->createdObjects = new \SplObjectStorage(); + $this->updatedObjects = new \SplObjectStorage(); + $this->deletedObjects = new \SplObjectStorage(); } /** - * @param object $entity + * @param object $object */ - private function storeEntityToPublish($entity, string $property): void + private function storeObjectToPublish($object, string $property): void { - if (null === $resourceClass = $this->getResourceClass($entity)) { + if (null === $resourceClass = $this->getResourceClass($object)) { return; } @@ -142,7 +161,7 @@ private function storeEntityToPublish($entity, string $property): void throw new RuntimeException('The Expression Language component is not installed. Try running "composer require symfony/expression-language".'); } - $options = $this->expressionLanguage->evaluate($options, ['object' => $entity]); + $options = $this->expressionLanguage->evaluate($options, ['object' => $object]); } if (false === $options) { @@ -172,48 +191,78 @@ private function storeEntityToPublish($entity, string $property): void } } - if ('deletedEntities' === $property) { - $this->deletedEntities[(object) [ - 'id' => $this->iriConverter->getIriFromItem($entity), - 'iri' => $this->iriConverter->getIriFromItem($entity, UrlGeneratorInterface::ABS_URL), + if ('deletedObjects' === $property) { + $this->deletedObjects[(object) [ + 'id' => $this->iriConverter->getIriFromItem($object), + 'iri' => $this->iriConverter->getIriFromItem($object, UrlGeneratorInterface::ABS_URL), ]] = $options; return; } - $this->{$property}[$entity] = $options; + $this->{$property}[$object] = $options; } /** - * @param object $entity + * @param object $object */ - private function publishUpdate($entity, array $options): void + private function publishUpdate($object, array $options, string $type): void { - if ($entity instanceof \stdClass) { - // By convention, if the entity has been deleted, we send only its IRI + if ($object instanceof \stdClass) { + // By convention, if the object has been deleted, we send only its IRI. // This may change in the feature, because it's not JSON Merge Patch compliant, - // and I'm not a fond of this approach - $iri = $options['topics'] ?? $entity->iri; + // and I'm not a fond of this approach. + $iri = $options['topics'] ?? $object->iri; /** @var string $data */ - $data = $options['data'] ?? json_encode(['@id' => $entity->id]); + $data = $options['data'] ?? json_encode(['@id' => $object->id]); } else { - $resourceClass = $this->getObjectClass($entity); - $context = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('normalization_context', []); + $resourceClass = $this->getObjectClass($object); + $context = $options['normalization_context'] ?? $this->resourceMetadataFactory->create($resourceClass)->getAttribute('normalization_context', []); - $iri = $options['topics'] ?? $this->iriConverter->getIriFromItem($entity, UrlGeneratorInterface::ABS_URL); - $data = $options['data'] ?? $this->serializer->serialize($entity, key($this->formats), $context); + $iri = $options['topics'] ?? $this->iriConverter->getIriFromItem($object, UrlGeneratorInterface::ABS_URL); + $data = $options['data'] ?? $this->serializer->serialize($object, key($this->formats), $context); } + $updates = array_merge([$this->buildUpdate($iri, $data, $options)], $this->getGraphQlSubscriptionUpdates($object, $options, $type)); + + foreach ($updates as $update) { + $this->messageBus ? $this->dispatch($update) : ($this->publisher)($update); + } + } + + /** + * @param object $object + * + * @return Update[] + */ + private function getGraphQlSubscriptionUpdates($object, array $options, string $type): array + { + if ('update' !== $type || !$this->graphQlSubscriptionManager || !$this->graphQlMercureSubscriptionIriGenerator) { + return []; + } + + $payloads = $this->graphQlSubscriptionManager->getPushPayloads($object); + + $updates = []; + foreach ($payloads as [$subscriptionId, $data]) { + $updates[] = $this->buildUpdate( + $this->graphQlMercureSubscriptionIriGenerator->generateTopicIri($subscriptionId), + (string) (new JsonResponse($data))->getContent(), + $options + ); + } + + return $updates; + } + + private function buildUpdate(string $iri, string $data, array $options): Update + { if (method_exists(Update::class, 'isPrivate')) { - $update = new Update($iri, $data, $options['private'] ?? false, $options['id'] ?? null, $options['type'] ?? null, $options['retry'] ?? null); - } else { - /** - * Mercure Component < 0.4. - * - * @phpstan-ignore-next-line - */ - $update = new Update($iri, $data, $options); - } - $this->messageBus ? $this->dispatch($update) : ($this->publisher)($update); + return new Update($iri, $data, $options['private'] ?? false, $options['id'] ?? null, $options['type'] ?? null, $options['retry'] ?? null); + } + + // Mercure Component < 0.4. + /* @phpstan-ignore-next-line */ + return new Update($iri, $data, $options); } } diff --git a/src/Bridge/Doctrine/MongoDbOdm/CollectionDataProvider.php b/src/Bridge/Doctrine/MongoDbOdm/CollectionDataProvider.php index e9734d19d3c..2c0381e444f 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/CollectionDataProvider.php +++ b/src/Bridge/Doctrine/MongoDbOdm/CollectionDataProvider.php @@ -18,6 +18,7 @@ use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface; use ApiPlatform\Core\DataProvider\RestrictedDataProviderInterface; use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; use Doctrine\Persistence\ManagerRegistry; @@ -32,14 +33,16 @@ final class CollectionDataProvider implements CollectionDataProviderInterface, RestrictedDataProviderInterface { private $managerRegistry; + private $resourceMetadataFactory; private $collectionExtensions; /** * @param AggregationCollectionExtensionInterface[] $collectionExtensions */ - public function __construct(ManagerRegistry $managerRegistry, iterable $collectionExtensions = []) + public function __construct(ManagerRegistry $managerRegistry, ResourceMetadataFactoryInterface $resourceMetadataFactory, iterable $collectionExtensions = []) { $this->managerRegistry = $managerRegistry; + $this->resourceMetadataFactory = $resourceMetadataFactory; $this->collectionExtensions = $collectionExtensions; } @@ -72,6 +75,10 @@ public function getCollection(string $resourceClass, string $operationName = nul } } - return $aggregationBuilder->hydrate($resourceClass)->execute(); + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + $attribute = $resourceMetadata->getCollectionOperationAttribute($operationName, 'doctrine_mongodb', [], true); + $executeOptions = $attribute['execute_options'] ?? []; + + return $aggregationBuilder->hydrate($resourceClass)->execute($executeOptions); } } diff --git a/src/Bridge/Doctrine/MongoDbOdm/Extension/OrderExtension.php b/src/Bridge/Doctrine/MongoDbOdm/Extension/OrderExtension.php index 6def4f23a74..fdd25ccb346 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/Extension/OrderExtension.php +++ b/src/Bridge/Doctrine/MongoDbOdm/Extension/OrderExtension.php @@ -53,8 +53,12 @@ public function applyToCollection(Builder $aggregationBuilder, string $resourceC $classMetaData = $this->getClassMetadata($resourceClass); $identifiers = $classMetaData->getIdentifier(); if (null !== $this->resourceMetadataFactory) { - $defaultOrder = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('order'); - if (null !== $defaultOrder) { + $defaultOrder = $this->resourceMetadataFactory->create($resourceClass) + ->getCollectionOperationAttribute($operationName, 'order', [], true); + if (empty($defaultOrder)) { + $defaultOrder = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('order'); + } + if (\is_array($defaultOrder)) { foreach ($defaultOrder as $field => $order) { if (\is_int($field)) { // Default direction diff --git a/src/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtension.php b/src/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtension.php index 9bfcde233ef..cb6dae92d66 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtension.php +++ b/src/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtension.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\Paginator; use ApiPlatform\Core\DataProvider\Pagination; use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; @@ -33,11 +34,13 @@ final class PaginationExtension implements AggregationResultCollectionExtensionInterface { private $managerRegistry; + private $resourceMetadataFactory; private $pagination; - public function __construct(ManagerRegistry $managerRegistry, Pagination $pagination) + public function __construct(ManagerRegistry $managerRegistry, ResourceMetadataFactoryInterface $resourceMetadataFactory, Pagination $pagination) { $this->managerRegistry = $managerRegistry; + $this->resourceMetadataFactory = $resourceMetadataFactory; $this->pagination = $pagination; } @@ -113,7 +116,11 @@ public function getResult(Builder $aggregationBuilder, string $resourceClass, st throw new RuntimeException(sprintf('The manager for "%s" must be an instance of "%s".', $resourceClass, DocumentManager::class)); } - return new Paginator($aggregationBuilder->execute(), $manager->getUnitOfWork(), $resourceClass, $aggregationBuilder->getPipeline()); + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + $attribute = $resourceMetadata->getCollectionOperationAttribute($operationName, 'doctrine_mongodb', [], true); + $executeOptions = $attribute['execute_options'] ?? []; + + return new Paginator($aggregationBuilder->execute($executeOptions), $manager->getUnitOfWork(), $resourceClass, $aggregationBuilder->getPipeline()); } private function addCountToContext(Builder $aggregationBuilder, array $context): array diff --git a/src/Bridge/Doctrine/MongoDbOdm/ItemDataProvider.php b/src/Bridge/Doctrine/MongoDbOdm/ItemDataProvider.php index 69baeab8c33..adf95c289f6 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/ItemDataProvider.php +++ b/src/Bridge/Doctrine/MongoDbOdm/ItemDataProvider.php @@ -22,6 +22,7 @@ use ApiPlatform\Core\Identifier\IdentifierConverterInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Repository\DocumentRepository; use Doctrine\Persistence\ManagerRegistry; @@ -38,14 +39,16 @@ final class ItemDataProvider implements DenormalizedIdentifiersAwareItemDataProv use IdentifierManagerTrait; private $managerRegistry; + private $resourceMetadataFactory; private $itemExtensions; /** * @param AggregationItemExtensionInterface[] $itemExtensions */ - public function __construct(ManagerRegistry $managerRegistry, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, iterable $itemExtensions = []) + public function __construct(ManagerRegistry $managerRegistry, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, iterable $itemExtensions = []) { $this->managerRegistry = $managerRegistry; + $this->resourceMetadataFactory = $resourceMetadataFactory; $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; $this->propertyMetadataFactory = $propertyMetadataFactory; $this->itemExtensions = $itemExtensions; @@ -95,6 +98,10 @@ public function getItem(string $resourceClass, $id, string $operationName = null } } - return $aggregationBuilder->hydrate($resourceClass)->execute()->current() ?: null; + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + $attribute = $resourceMetadata->getItemOperationAttribute($operationName, 'doctrine_mongodb', [], true); + $executeOptions = $attribute['execute_options'] ?? []; + + return $aggregationBuilder->hydrate($resourceClass)->execute($executeOptions)->current() ?: null; } } diff --git a/src/Bridge/Doctrine/MongoDbOdm/SubresourceDataProvider.php b/src/Bridge/Doctrine/MongoDbOdm/SubresourceDataProvider.php index 8b8fbac09c2..e263466320f 100644 --- a/src/Bridge/Doctrine/MongoDbOdm/SubresourceDataProvider.php +++ b/src/Bridge/Doctrine/MongoDbOdm/SubresourceDataProvider.php @@ -24,6 +24,7 @@ use ApiPlatform\Core\Identifier\IdentifierConverterInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use Doctrine\ODM\MongoDB\Aggregation\Builder; use Doctrine\ODM\MongoDB\DocumentManager; use Doctrine\ODM\MongoDB\Mapping\ClassMetadata; @@ -43,6 +44,7 @@ final class SubresourceDataProvider implements SubresourceDataProviderInterface use IdentifierManagerTrait; private $managerRegistry; + private $resourceMetadataFactory; private $collectionExtensions; private $itemExtensions; @@ -50,9 +52,10 @@ final class SubresourceDataProvider implements SubresourceDataProviderInterface * @param AggregationCollectionExtensionInterface[] $collectionExtensions * @param AggregationItemExtensionInterface[] $itemExtensions */ - public function __construct(ManagerRegistry $managerRegistry, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, iterable $collectionExtensions = [], iterable $itemExtensions = []) + public function __construct(ManagerRegistry $managerRegistry, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, iterable $collectionExtensions = [], iterable $itemExtensions = []) { $this->managerRegistry = $managerRegistry; + $this->resourceMetadataFactory = $resourceMetadataFactory; $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; $this->propertyMetadataFactory = $propertyMetadataFactory; $this->collectionExtensions = $collectionExtensions; @@ -80,7 +83,11 @@ public function getSubresource(string $resourceClass, array $identifiers, array throw new ResourceClassNotSupportedException('The given resource class is not a subresource.'); } - $aggregationBuilder = $this->buildAggregation($identifiers, $context, $repository->createAggregationBuilder(), \count($context['identifiers'])); + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + $attribute = $resourceMetadata->getSubresourceOperationAttribute($operationName, 'doctrine_mongodb', [], true); + $executeOptions = $attribute['execute_options'] ?? []; + + $aggregationBuilder = $this->buildAggregation($identifiers, $context, $executeOptions, $repository->createAggregationBuilder(), \count($context['identifiers'])); if (true === $context['collection']) { foreach ($this->collectionExtensions as $extension) { @@ -98,7 +105,7 @@ public function getSubresource(string $resourceClass, array $identifiers, array } } - $iterator = $aggregationBuilder->hydrate($resourceClass)->execute(); + $iterator = $aggregationBuilder->hydrate($resourceClass)->execute($executeOptions); return $context['collection'] ? $iterator->toArray() : ($iterator->current() ?: null); } @@ -106,7 +113,7 @@ public function getSubresource(string $resourceClass, array $identifiers, array /** * @throws RuntimeException */ - private function buildAggregation(array $identifiers, array $context, Builder $previousAggregationBuilder, int $remainingIdentifiers, Builder $topAggregationBuilder = null): Builder + private function buildAggregation(array $identifiers, array $context, array $executeOptions, Builder $previousAggregationBuilder, int $remainingIdentifiers, Builder $topAggregationBuilder = null): Builder { if ($remainingIdentifiers <= 0) { return $previousAggregationBuilder; @@ -154,9 +161,9 @@ private function buildAggregation(array $identifiers, array $context, Builder $p } // Recurse aggregations - $aggregation = $this->buildAggregation($identifiers, $context, $aggregation, --$remainingIdentifiers, $topAggregationBuilder); + $aggregation = $this->buildAggregation($identifiers, $context, $executeOptions, $aggregation, --$remainingIdentifiers, $topAggregationBuilder); - $results = $aggregation->execute()->toArray(); + $results = $aggregation->execute($executeOptions)->toArray(); $in = array_reduce($results, static function ($in, $result) use ($previousAssociationProperty) { return $in + array_map(static function ($result) { return $result['_id']; diff --git a/src/Bridge/Doctrine/Orm/Extension/OrderExtension.php b/src/Bridge/Doctrine/Orm/Extension/OrderExtension.php index 1d2b1e3f469..bcebbec9879 100644 --- a/src/Bridge/Doctrine/Orm/Extension/OrderExtension.php +++ b/src/Bridge/Doctrine/Orm/Extension/OrderExtension.php @@ -51,8 +51,12 @@ public function applyToCollection(QueryBuilder $queryBuilder, QueryNameGenerator $classMetaData = $queryBuilder->getEntityManager()->getClassMetadata($resourceClass); $identifiers = $classMetaData->getIdentifier(); if (null !== $this->resourceMetadataFactory) { - $defaultOrder = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('order'); - if (null !== $defaultOrder) { + $defaultOrder = $this->resourceMetadataFactory->create($resourceClass) + ->getCollectionOperationAttribute($operationName, 'order', [], true); + if (empty($defaultOrder)) { + $defaultOrder = $this->resourceMetadataFactory->create($resourceClass)->getAttribute('order'); + } + if (\is_array($defaultOrder)) { foreach ($defaultOrder as $field => $order) { if (\is_int($field)) { // Default direction diff --git a/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php b/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php index b229135b4a6..82c2a2fa256 100644 --- a/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php +++ b/src/Bridge/Doctrine/Orm/Filter/SearchFilter.php @@ -89,7 +89,6 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB return; } - $caseSensitive = true; $metadata = $this->getNestedMetadata($resourceClass, $associations); if ($metadata->hasField($field)) { @@ -105,6 +104,7 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB return; } + $caseSensitive = true; $strategy = $this->properties[$property] ?? self::STRATEGY_EXACT; // prefixing the strategy with i makes it case insensitive @@ -113,26 +113,9 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB $caseSensitive = false; } - if (1 === \count($values)) { - $this->addWhereByStrategy($strategy, $queryBuilder, $queryNameGenerator, $alias, $field, $values[0], $caseSensitive); + $this->addWhereByStrategy($strategy, $queryBuilder, $queryNameGenerator, $alias, $field, $values, $caseSensitive); - return; - } - - if (self::STRATEGY_EXACT !== $strategy) { - $this->logger->notice('Invalid filter ignored', [ - 'exception' => new InvalidArgumentException(sprintf('"%s" strategy selected for "%s" property, but only "%s" strategy supports multiple values', $strategy, $property, self::STRATEGY_EXACT)), - ]); - - return; - } - - $wrapCase = $this->createWrapCase($caseSensitive); - $valueParameter = $queryNameGenerator->generateParameterName($field); - - $queryBuilder - ->andWhere(sprintf($wrapCase('%s.%s').' IN (:%s)', $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $caseSensitive ? $values : array_map('strtolower', $values)); + return; } // metadata doesn't have the field, nor an association on the field @@ -158,23 +141,21 @@ protected function filterProperty(string $property, $value, QueryBuilder $queryB return; } - $association = $field; - $valueParameter = $queryNameGenerator->generateParameterName($association); - if ($metadata->isCollectionValuedAssociation($association)) { - $associationAlias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $alias, $association); + $associationAlias = $alias; + $associationField = $field; + $valueParameter = $queryNameGenerator->generateParameterName($associationField); + if ($metadata->isCollectionValuedAssociation($associationField)) { + $associationAlias = QueryBuilderHelper::addJoinOnce($queryBuilder, $queryNameGenerator, $alias, $associationField); $associationField = $associationFieldIdentifier; - } else { - $associationAlias = $alias; - $associationField = $field; } if (1 === \count($values)) { $queryBuilder - ->andWhere(sprintf('%s.%s = :%s', $associationAlias, $associationField, $valueParameter)) + ->andWhere($queryBuilder->expr()->eq($associationAlias.'.'.$associationField, ':'.$valueParameter)) ->setParameter($valueParameter, $values[0]); } else { $queryBuilder - ->andWhere(sprintf('%s.%s IN (:%s)', $associationAlias, $associationField, $valueParameter)) + ->andWhere($queryBuilder->expr()->in($associationAlias.'.'.$associationField, ':'.$valueParameter)) ->setParameter($valueParameter, $values); } } @@ -184,41 +165,70 @@ 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, $value, bool $caseSensitive) + protected function addWhereByStrategy(string $strategy, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $alias, string $field, $values, bool $caseSensitive) { + if (!\is_array($values)) { + $values = [$values]; + } + $wrapCase = $this->createWrapCase($caseSensitive); - $valueParameter = $queryNameGenerator->generateParameterName($field); + $valueParameter = ':'.$queryNameGenerator->generateParameterName($field); + $aliasedField = sprintf('%s.%s', $alias, $field); - switch ($strategy) { - case null: - case self::STRATEGY_EXACT: + if (null == $strategy || self::STRATEGY_EXACT == $strategy) { + if (1 == \count($values)) { $queryBuilder - ->andWhere(sprintf($wrapCase('%s.%s').' = '.$wrapCase(':%s'), $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value); - break; - case self::STRATEGY_PARTIAL: - $queryBuilder - ->andWhere(sprintf($wrapCase('%s.%s').' LIKE '.$wrapCase('CONCAT(\'%%\', :%s, \'%%\')'), $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value); - break; - case self::STRATEGY_START: - $queryBuilder - ->andWhere(sprintf($wrapCase('%s.%s').' LIKE '.$wrapCase('CONCAT(:%s, \'%%\')'), $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value); - break; - case self::STRATEGY_END: - $queryBuilder - ->andWhere(sprintf($wrapCase('%s.%s').' LIKE '.$wrapCase('CONCAT(\'%%\', :%s)'), $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value); - break; - case self::STRATEGY_WORD_START: - $queryBuilder - ->andWhere(sprintf($wrapCase('%1$s.%2$s').' LIKE '.$wrapCase('CONCAT(:%3$s, \'%%\')').' OR '.$wrapCase('%1$s.%2$s').' LIKE '.$wrapCase('CONCAT(\'%% \', :%3$s, \'%%\')'), $alias, $field, $valueParameter)) - ->setParameter($valueParameter, $value); - break; - default: - throw new InvalidArgumentException(sprintf('strategy %s does not exist.', $strategy)); + ->andWhere($queryBuilder->expr()->eq($wrapCase($aliasedField), $wrapCase($valueParameter))) + ->setParameter($valueParameter, $values[0]); + + return; + } + + $queryBuilder + ->andWhere($queryBuilder->expr()->in($wrapCase($aliasedField), $valueParameter)) + ->setParameter($valueParameter, $caseSensitive ? $values : array_map('strtolower', $values)); + + return; } + + $ors = []; + $parameters = []; + foreach ($values as $key => $value) { + $keyValueParameter = sprintf('%s_%s', $valueParameter, $key); + $parameters[$caseSensitive ? $value : strtolower($value)] = $keyValueParameter; + + switch ($strategy) { + case self::STRATEGY_PARTIAL: + $ors[] = $queryBuilder->expr()->like( + $wrapCase($aliasedField), + $wrapCase((string) $queryBuilder->expr()->concat("'%'", $keyValueParameter, "'%'")) + ); + break; + case self::STRATEGY_START: + $ors[] = $queryBuilder->expr()->like( + $wrapCase($aliasedField), + $wrapCase((string) $queryBuilder->expr()->concat($keyValueParameter, "'%'")) + ); + break; + case self::STRATEGY_END: + $ors[] = $queryBuilder->expr()->like( + $wrapCase($aliasedField), + $wrapCase((string) $queryBuilder->expr()->concat("'%'", $keyValueParameter)) + ); + break; + case self::STRATEGY_WORD_START: + $ors[] = $queryBuilder->expr()->orX( + $queryBuilder->expr()->like($wrapCase($aliasedField), $wrapCase((string) $queryBuilder->expr()->concat($keyValueParameter, "'%'"))), + $queryBuilder->expr()->like($wrapCase($aliasedField), $wrapCase((string) $queryBuilder->expr()->concat("'% '", $keyValueParameter, "'%'"))) + ); + break; + default: + throw new InvalidArgumentException(sprintf('strategy %s does not exist.', $strategy)); + } + } + + $queryBuilder->andWhere($queryBuilder->expr()->orX(...$ors)); + array_walk($parameters, [$queryBuilder, 'setParameter']); } /** diff --git a/src/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php b/src/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php index 4414714e277..429c4b295b5 100644 --- a/src/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php +++ b/src/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactory.php @@ -76,6 +76,13 @@ public function create(string $resourceClass, string $property, array $options = $propertyMetadata = $propertyMetadata->withIdentifier(false); } + if ($doctrineClassMetadata instanceof ClassMetadataInfo && \in_array($property, $doctrineClassMetadata->getFieldNames(), true)) { + $fieldMapping = $doctrineClassMetadata->getFieldMapping($property); + if (\array_key_exists('options', $fieldMapping) && \array_key_exists('default', $fieldMapping['options'])) { + $propertyMetadata = $propertyMetadata->withDefault($fieldMapping['options']['default']); + } + } + return $propertyMetadata; } } diff --git a/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php b/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php index 83bebc0e64e..d518f81c8a8 100644 --- a/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php +++ b/src/Bridge/Symfony/Bundle/Action/SwaggerUiAction.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Bridge\Symfony\Bundle\Action; use ApiPlatform\Core\Api\FormatsProviderInterface; +use ApiPlatform\Core\Bridge\Symfony\Bundle\SwaggerUi\SwaggerUiAction as OpenApiSwaggerUiAction; use ApiPlatform\Core\Documentation\Documentation; use ApiPlatform\Core\Exception\RuntimeException; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; @@ -28,6 +29,8 @@ /** * Displays the documentation. * + * @deprecated please refer to ApiPlatform\Core\Bridge\Symfony\Bundle\SwaggerUi\SwaggerUiAction for further changes + * * @author Kévin Dunglas */ final class SwaggerUiAction @@ -57,11 +60,14 @@ final class SwaggerUiAction private $graphiQlEnabled; private $graphQlPlaygroundEnabled; private $swaggerVersions; + private $swaggerUiAction; + private $assetPackage; /** - * @param int[] $swaggerVersions + * @param int[] $swaggerVersions + * @param mixed|null $assetPackage */ - public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, NormalizerInterface $normalizer, TwigEnvironment $twig, UrlGeneratorInterface $urlGenerator, string $title = '', string $description = '', string $version = '', $formats = [], $oauthEnabled = false, $oauthClientId = '', $oauthClientSecret = '', $oauthType = '', $oauthFlow = '', $oauthTokenUrl = '', $oauthAuthorizationUrl = '', $oauthScopes = [], bool $showWebby = true, bool $swaggerUiEnabled = false, bool $reDocEnabled = false, bool $graphqlEnabled = false, bool $graphiQlEnabled = false, bool $graphQlPlaygroundEnabled = false, array $swaggerVersions = [2, 3]) + public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, NormalizerInterface $normalizer, TwigEnvironment $twig, UrlGeneratorInterface $urlGenerator, string $title = '', string $description = '', string $version = '', $formats = [], $oauthEnabled = false, $oauthClientId = '', $oauthClientSecret = '', $oauthType = '', $oauthFlow = '', $oauthTokenUrl = '', $oauthAuthorizationUrl = '', $oauthScopes = [], bool $showWebby = true, bool $swaggerUiEnabled = false, bool $reDocEnabled = false, bool $graphqlEnabled = false, bool $graphiQlEnabled = false, bool $graphQlPlaygroundEnabled = false, array $swaggerVersions = [2, 3], OpenApiSwaggerUiAction $swaggerUiAction = null, $assetPackage = null) { $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; $this->resourceMetadataFactory = $resourceMetadataFactory; @@ -86,6 +92,12 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName $this->graphiQlEnabled = $graphiQlEnabled; $this->graphQlPlaygroundEnabled = $graphQlPlaygroundEnabled; $this->swaggerVersions = $swaggerVersions; + $this->swaggerUiAction = $swaggerUiAction; + $this->assetPackage = $assetPackage; + + if (null === $this->swaggerUiAction) { + @trigger_error(sprintf('The use of "%s" is deprecated since API Platform 2.6, use "%s" instead.', __CLASS__, OpenApiSwaggerUiAction::class), E_USER_DEPRECATED); + } if (\is_array($formats)) { $this->formats = $formats; @@ -99,6 +111,10 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName public function __invoke(Request $request) { + if ($this->swaggerUiAction) { + return $this->swaggerUiAction->__invoke($request); + } + $attributes = RequestAttributesExtractor::extractAttributes($request); // BC check to be removed in 3.0 @@ -130,6 +146,7 @@ private function getContext(Request $request, Documentation $documentation): arr 'graphqlEnabled' => $this->graphqlEnabled, 'graphiQlEnabled' => $this->graphiQlEnabled, 'graphQlPlaygroundEnabled' => $this->graphQlPlaygroundEnabled, + 'assetPackage' => $this->assetPackage, ]; $swaggerContext = ['spec_version' => $request->query->getInt('spec_version', $this->swaggerVersions[0] ?? 2)]; diff --git a/src/Bridge/Symfony/Bundle/ApiPlatformBundle.php b/src/Bridge/Symfony/Bundle/ApiPlatformBundle.php index a80a8a40a48..3411ee33937 100644 --- a/src/Bridge/Symfony/Bundle/ApiPlatformBundle.php +++ b/src/Bridge/Symfony/Bundle/ApiPlatformBundle.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\AnnotationFilterPass; 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; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\FilterPass; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\GraphQlMutationResolverPass; @@ -48,6 +49,7 @@ public function build(ContainerBuilder $container) $container->addCompilerPass(new GraphQlTypePass()); $container->addCompilerPass(new GraphQlQueryResolverPass()); $container->addCompilerPass(new GraphQlMutationResolverPass()); + $container->addCompilerPass(new DeprecateMercurePublisherPass()); $container->addCompilerPass(new MetadataAwareNameConverterPass()); } } diff --git a/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php b/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php new file mode 100644 index 00000000000..b89925b0c3d --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Command/OpenApiCommand.php @@ -0,0 +1,94 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Bundle\Command; + +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Yaml\Yaml; + +/** + * Dumps Open API documentation. + */ +final class OpenApiCommand extends Command +{ + protected static $defaultName = 'api:openapi:export'; + + private $openApiFactory; + private $normalizer; + + public function __construct(OpenApiFactoryInterface $openApiFactory, NormalizerInterface $normalizer) + { + parent::__construct(); + $this->openApiFactory = $openApiFactory; + $this->normalizer = $normalizer; + } + + /** + * {@inheritdoc} + */ + protected function configure() + { + $this + ->setDescription('Dump the Open API documentation') + ->addOption('yaml', 'y', InputOption::VALUE_NONE, 'Dump the documentation in YAML') + ->addOption('output', 'o', InputOption::VALUE_OPTIONAL, 'Write output to file') + ->addOption('spec-version', null, InputOption::VALUE_OPTIONAL, 'Open API version to use (2 or 3) (2 is deprecated)', 3) + ->addOption('api-gateway', null, InputOption::VALUE_NONE, 'Enable the Amazon API Gateway compatibility mode'); + } + + /** + * {@inheritdoc} + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + // Backwards compatibility + if (2 === $specVersion = (int) $input->getOption('spec-version')) { + $command = $this->getApplication()->find('api:swagger:export'); + + return $command->run(new ArrayInput([ + 'command' => 'api:swagger:export', + '--spec-version' => $specVersion, + '--yaml' => $input->getOption('yaml'), + '--output' => $input->getOption('output'), + '--api-gateway' => $input->getOption('api-gateway'), + ]), $output); + } + + $filesystem = new Filesystem(); + $io = new SymfonyStyle($input, $output); + $data = $this->normalizer->normalize($this->openApiFactory->__invoke(), 'json'); + $content = $input->getOption('yaml') + ? Yaml::dump($data, 10, 2, Yaml::DUMP_OBJECT_AS_MAP | Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_MULTI_LINE_LITERAL_BLOCK) + : (json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) ?: ''); + + $filename = $input->getOption('output'); + if ($filename && \is_string($filename)) { + $filesystem->dumpFile($filename, $content); + $io->success(sprintf('Data written to %s.', $filename)); + + return 0; + } + + $output->writeln($content); + + return 0; + } +} diff --git a/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php b/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php index b26a597f7f0..ebb80d512fc 100644 --- a/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php +++ b/src/Bridge/Symfony/Bundle/Command/SwaggerCommand.php @@ -69,7 +69,7 @@ public function __construct(NormalizerInterface $normalizer, ResourceNameCollect protected function configure() { $this - ->setDescription('Dump the OpenAPI documentation') + ->setDescription('Dump the Swagger v2 documentation') ->addOption('yaml', 'y', InputOption::VALUE_NONE, 'Dump the documentation in YAML') ->addOption('spec-version', null, InputOption::VALUE_OPTIONAL, sprintf('OpenAPI version to use (%s)', implode(' or ', $this->swaggerVersions)), $this->swaggerVersions[0] ?? 2) ->addOption('output', 'o', InputOption::VALUE_OPTIONAL, 'Write output to file') @@ -90,6 +90,10 @@ protected function execute(InputInterface $input, OutputInterface $output) throw new InvalidOptionException(sprintf('This tool only supports versions %s of the OpenAPI specification ("%s" given).', implode(', ', $this->swaggerVersions), $version)); } + if (3 === (int) $version) { + @trigger_error('The command "api:swagger:export" is deprecated for the spec version 3 use "api:openapi:export".', E_USER_DEPRECATED); + } + $documentation = new Documentation($this->resourceNameCollectionFactory->create(), $this->apiTitle, $this->apiDescription, $this->apiVersion, $this->apiFormats); $data = $this->normalizer->normalize($documentation, DocumentationNormalizer::FORMAT, ['spec_version' => (int) $version, ApiGatewayNormalizer::API_GATEWAY => $input->getOption('api-gateway')]); $content = $input->getOption('yaml') diff --git a/src/Bridge/Symfony/Bundle/DataCollector/RequestDataCollector.php b/src/Bridge/Symfony/Bundle/DataCollector/RequestDataCollector.php index 9ecf4c46c30..a5c8db17701 100644 --- a/src/Bridge/Symfony/Bundle/DataCollector/RequestDataCollector.php +++ b/src/Bridge/Symfony/Bundle/DataCollector/RequestDataCollector.php @@ -23,6 +23,7 @@ use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Util\RequestAttributesExtractor; +use PackageVersions\Versions; use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -158,6 +159,18 @@ public function getDataPersisters(): array return $this->data['dataPersisters'] ?? ['responses' => []]; } + public function getVersion(): ?string + { + if (!class_exists(Versions::class)) { + return null; + } + + $version = Versions::getVersion('api-platform/core'); + preg_match('/^v(.*?)@/', $version, $output); + + return $output[1] ?? strtok($version, '@'); + } + /** * {@inheritdoc} */ diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php index 2cb000e055f..15c9cebba52 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtension.php @@ -13,7 +13,9 @@ namespace ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection; +use ApiPlatform\Core\Annotation\ApiResource; use ApiPlatform\Core\Api\FilterInterface; +use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\Extension\AggregationCollectionExtensionInterface; use ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\Extension\AggregationItemExtensionInterface; use ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\Filter\AbstractFilter as DoctrineMongoDbOdmAbstractFilter; @@ -23,11 +25,15 @@ use ApiPlatform\Core\Bridge\Doctrine\Orm\Extension\QueryItemExtensionInterface; use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractContextAwareFilter as DoctrineOrmAbstractContextAwareFilter; use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Extension\RequestBodySearchCollectionExtensionInterface; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRestrictionMetadataInterface; +use ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface; use ApiPlatform\Core\DataPersister\DataPersisterInterface; use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; +use ApiPlatform\Core\DataTransformer\DataTransformerInitializerInterface; use ApiPlatform\Core\DataTransformer\DataTransformerInterface; +use ApiPlatform\Core\GraphQl\Error\ErrorHandlerInterface; use ApiPlatform\Core\GraphQl\Resolver\MutationResolverInterface; use ApiPlatform\Core\GraphQl\Resolver\QueryCollectionResolverInterface; use ApiPlatform\Core\GraphQl\Resolver\QueryItemResolverInterface; @@ -49,6 +55,7 @@ use Symfony\Component\Finder\Finder; use Symfony\Component\HttpClient\HttpClientTrait; use Symfony\Component\HttpKernel\DependencyInjection\Extension; +use Symfony\Component\Uid\AbstractUid; use Symfony\Component\Validator\Validator\ValidatorInterface; use Symfony\Component\Yaml\Yaml; @@ -100,6 +107,7 @@ public function load(array $configs, ContainerBuilder $container): void $this->registerCommonConfiguration($container, $config, $loader, $formats, $patchFormats, $errorFormats); $this->registerMetadataConfiguration($container, $config, $loader); $this->registerOAuthConfiguration($container, $config); + $this->registerOpenApiConfiguration($container, $config); $this->registerSwaggerConfiguration($container, $config, $loader); $this->registerJsonApiConfiguration($formats, $loader); $this->registerJsonLdHydraConfiguration($container, $formats, $loader, $config['enable_docs']); @@ -163,12 +171,17 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $loader->load('ramsey_uuid.xml'); } + if (class_exists(AbstractUid::class)) { + $loader->load('symfony_uid.xml'); + } + $container->setParameter('api_platform.enable_entrypoint', $config['enable_entrypoint']); $container->setParameter('api_platform.enable_docs', $config['enable_docs']); $container->setParameter('api_platform.title', $config['title']); $container->setParameter('api_platform.description', $config['description']); $container->setParameter('api_platform.version', $config['version']); $container->setParameter('api_platform.show_webby', $config['show_webby']); + $container->setParameter('api_platform.url_generation_strategy', $config['defaults']['url_generation_strategy'] ?? UrlGeneratorInterface::ABS_PATH); $container->setParameter('api_platform.exception_to_status', $config['exception_to_status']); $container->setParameter('api_platform.formats', $formats); $container->setParameter('api_platform.patch_formats', $patchFormats); @@ -179,25 +192,26 @@ private function registerCommonConfiguration(ContainerBuilder $container, array $container->setParameter('api_platform.eager_loading.fetch_partial', $config['eager_loading']['fetch_partial']); $container->setParameter('api_platform.eager_loading.force_eager', $config['eager_loading']['force_eager']); $container->setParameter('api_platform.collection.exists_parameter_name', $config['collection']['exists_parameter_name']); - $container->setParameter('api_platform.collection.order', $config['collection']['order']); + $container->setParameter('api_platform.collection.order', $config['defaults']['order'] ?? $config['collection']['order']); $container->setParameter('api_platform.collection.order_parameter_name', $config['collection']['order_parameter_name']); - $container->setParameter('api_platform.collection.pagination.enabled', $this->isConfigEnabled($container, $config['collection']['pagination'])); - $container->setParameter('api_platform.collection.pagination.partial', $config['collection']['pagination']['partial']); - $container->setParameter('api_platform.collection.pagination.client_enabled', $config['collection']['pagination']['client_enabled']); - $container->setParameter('api_platform.collection.pagination.client_items_per_page', $config['collection']['pagination']['client_items_per_page']); - $container->setParameter('api_platform.collection.pagination.client_partial', $config['collection']['pagination']['client_partial']); - $container->setParameter('api_platform.collection.pagination.items_per_page', $config['collection']['pagination']['items_per_page']); - $container->setParameter('api_platform.collection.pagination.maximum_items_per_page', $config['collection']['pagination']['maximum_items_per_page']); - $container->setParameter('api_platform.collection.pagination.page_parameter_name', $config['collection']['pagination']['page_parameter_name']); - $container->setParameter('api_platform.collection.pagination.enabled_parameter_name', $config['collection']['pagination']['enabled_parameter_name']); - $container->setParameter('api_platform.collection.pagination.items_per_page_parameter_name', $config['collection']['pagination']['items_per_page_parameter_name']); - $container->setParameter('api_platform.collection.pagination.partial_parameter_name', $config['collection']['pagination']['partial_parameter_name']); - $container->setParameter('api_platform.collection.pagination', $config['collection']['pagination']); - $container->setParameter('api_platform.http_cache.etag', $config['http_cache']['etag']); - $container->setParameter('api_platform.http_cache.max_age', $config['http_cache']['max_age']); - $container->setParameter('api_platform.http_cache.shared_max_age', $config['http_cache']['shared_max_age']); - $container->setParameter('api_platform.http_cache.vary', $config['http_cache']['vary']); - $container->setParameter('api_platform.http_cache.public', $config['http_cache']['public']); + $container->setParameter('api_platform.collection.pagination.enabled', $this->isConfigEnabled($container, $config['defaults']['pagination_enabled'] ?? $config['collection']['pagination'])); + $container->setParameter('api_platform.collection.pagination.partial', $config['defaults']['pagination_partial'] ?? $config['collection']['pagination']['partial']); + $container->setParameter('api_platform.collection.pagination.client_enabled', $config['defaults']['pagination_client_enabled'] ?? $config['collection']['pagination']['client_enabled']); + $container->setParameter('api_platform.collection.pagination.client_items_per_page', $config['defaults']['pagination_client_items_per_page'] ?? $config['collection']['pagination']['client_items_per_page']); + $container->setParameter('api_platform.collection.pagination.client_partial', $config['defaults']['pagination_client_partial'] ?? $config['collection']['pagination']['client_partial']); + $container->setParameter('api_platform.collection.pagination.items_per_page', $config['defaults']['pagination_items_per_page'] ?? $config['collection']['pagination']['items_per_page']); + $container->setParameter('api_platform.collection.pagination.maximum_items_per_page', $config['defaults']['pagination_maximum_items_per_page'] ?? $config['collection']['pagination']['maximum_items_per_page']); + $container->setParameter('api_platform.collection.pagination.page_parameter_name', $config['defaults']['pagination_page_parameter_name'] ?? $config['collection']['pagination']['page_parameter_name']); + $container->setParameter('api_platform.collection.pagination.enabled_parameter_name', $config['defaults']['pagination_enabled_parameter_name'] ?? $config['collection']['pagination']['enabled_parameter_name']); + $container->setParameter('api_platform.collection.pagination.items_per_page_parameter_name', $config['defaults']['pagination_items_per_page_parameter_name'] ?? $config['collection']['pagination']['items_per_page_parameter_name']); + $container->setParameter('api_platform.collection.pagination.partial_parameter_name', $config['defaults']['pagination_partial_parameter_name'] ?? $config['collection']['pagination']['partial_parameter_name']); + $container->setParameter('api_platform.collection.pagination', $this->getPaginationDefaults($config['defaults'] ?? [], $config['collection']['pagination'])); + $container->setParameter('api_platform.http_cache.etag', $config['defaults']['cache_headers']['etag'] ?? $config['http_cache']['etag']); + $container->setParameter('api_platform.http_cache.max_age', $config['defaults']['cache_headers']['max_age'] ?? $config['http_cache']['max_age']); + $container->setParameter('api_platform.http_cache.shared_max_age', $config['defaults']['cache_headers']['shared_max_age'] ?? $config['http_cache']['shared_max_age']); + $container->setParameter('api_platform.http_cache.vary', $config['defaults']['cache_headers']['vary'] ?? $config['http_cache']['vary']); + $container->setParameter('api_platform.http_cache.public', $config['defaults']['cache_headers']['public'] ?? $config['http_cache']['public']); + $container->setParameter('api_platform.http_cache.invalidation.max_header_length', $config['defaults']['cache_headers']['invalidation']['max_header_length'] ?? $config['http_cache']['invalidation']['max_header_length']); $container->setAlias('api_platform.operation_path_resolver.default', $config['default_operation_path_resolver']); $container->setAlias('api_platform.path_segment_name_generator', $config['path_segment_name_generator']); @@ -205,6 +219,51 @@ private function registerCommonConfiguration(ContainerBuilder $container, array if ($config['name_converter']) { $container->setAlias('api_platform.name_converter', $config['name_converter']); } + $container->setParameter('api_platform.asset_package', $config['asset_package']); + $container->setParameter('api_platform.defaults', $this->normalizeDefaults($config['defaults'] ?? [])); + } + + /** + * This method will be removed in 3.0 when "defaults" will be the regular configuration path for the pagination. + */ + private function getPaginationDefaults(array $defaults, array $collectionPaginationConfiguration): array + { + $paginationOptions = []; + + foreach ($defaults as $key => $value) { + if (0 !== strpos($key, 'pagination_')) { + continue; + } + + $paginationOptions[str_replace('pagination_', '', $key)] = $value; + } + + return array_merge($collectionPaginationConfiguration, $paginationOptions); + } + + private function normalizeDefaults(array $defaults): array + { + $normalizedDefaults = ['attributes' => $defaults['attributes'] ?? []]; + unset($defaults['attributes']); + + [$publicProperties,] = ApiResource::getConfigMetadata(); + + foreach ($defaults as $option => $value) { + if (isset($publicProperties[$option])) { + $normalizedDefaults[$option] = $value; + + continue; + } + + $normalizedDefaults['attributes'][$option] = $value; + } + + if (!\array_key_exists('stateless', $defaults)) { + @trigger_error('Not setting the "api_platform.defaults.stateless" configuration is deprecated since API Platform 2.6 and it will default to `true` in 3.0. You can override this at the operation level if you have stateful operations (highly not recommended).', E_USER_DEPRECATED); + $normalizedDefaults['attributes']['stateless'] = false; + } + + return $normalizedDefaults; } private function registerMetadataConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader): void @@ -318,6 +377,7 @@ private function registerOAuthConfiguration(ContainerBuilder $container, array $ $container->setParameter('api_platform.oauth.flow', $config['oauth']['flow']); $container->setParameter('api_platform.oauth.tokenUrl', $config['oauth']['tokenUrl']); $container->setParameter('api_platform.oauth.authorizationUrl', $config['oauth']['authorizationUrl']); + $container->setParameter('api_platform.oauth.refreshUrl', $config['oauth']['refreshUrl']); $container->setParameter('api_platform.oauth.scopes', $config['oauth']['scopes']); } @@ -334,6 +394,7 @@ private function registerSwaggerConfiguration(ContainerBuilder $container, array $loader->load('json_schema.xml'); $loader->load('swagger.xml'); + $loader->load('openapi.xml'); $loader->load('swagger-ui.xml'); if (!$config['enable_swagger_ui'] && !$config['enable_re_doc']) { @@ -417,6 +478,8 @@ private function registerGraphQlConfiguration(ContainerBuilder $container, array ->addTag('api_platform.graphql.mutation_resolver'); $container->registerForAutoconfiguration(GraphQlTypeInterface::class) ->addTag('api_platform.graphql.type'); + $container->registerForAutoconfiguration(ErrorHandlerInterface::class) + ->addTag('api_platform.graphql.error_handler'); } private function registerLegacyBundlesConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader): void @@ -526,7 +589,8 @@ private function registerHttpCacheConfiguration(ContainerBuilder $container, arr $definitions[] = $definition; } - $container->getDefinition('api_platform.http_cache.purger.varnish')->addArgument($definitions); + $container->getDefinition('api_platform.http_cache.purger.varnish')->setArguments([$definitions, + $config['http_cache']['invalidation']['max_header_length'], ]); $container->setAlias('api_platform.http_cache.purger', 'api_platform.http_cache.purger.varnish'); } @@ -549,6 +613,12 @@ private function registerValidatorConfiguration(ContainerBuilder $container, arr { if (interface_exists(ValidatorInterface::class)) { $loader->load('validator.xml'); + + $container->registerForAutoconfiguration(ValidationGroupsGeneratorInterface::class) + ->addTag('api_platform.validation_groups_generator') + ->setPublic(true); // this line should be removed in 3.0 + $container->registerForAutoconfiguration(PropertySchemaRestrictionMetadataInterface::class) + ->addTag('api_platform.metadata.property_schema_restriction'); } if (!$config['validator']) { @@ -583,6 +653,14 @@ private function registerMercureConfiguration(ContainerBuilder $container, array if ($this->isConfigEnabled($container, $config['doctrine'])) { $loader->load('doctrine_orm_mercure_publisher.xml'); } + if ($this->isConfigEnabled($container, $config['doctrine_mongodb_odm'])) { + $loader->load('doctrine_mongodb_odm_mercure_publisher.xml'); + } + + if ($this->isConfigEnabled($container, $config['graphql'])) { + $loader->load('graphql_mercure.xml'); + $container->getDefinition('api_platform.graphql.subscription.mercure_iri_generator')->addArgument($config['mercure']['hub_url'] ?? '%mercure.default_hub%'); + } } private function registerMessengerConfiguration(ContainerBuilder $container, array $config, XmlFileLoader $loader): void @@ -617,6 +695,9 @@ private function registerDataTransformerConfiguration(ContainerBuilder $containe { $container->registerForAutoconfiguration(DataTransformerInterface::class) ->addTag('api_platform.data_transformer'); + + $container->registerForAutoconfiguration(DataTransformerInitializerInterface::class) + ->addTag('api_platform.data_transformer'); } private function registerSecurityConfiguration(ContainerBuilder $container, XmlFileLoader $loader): void @@ -629,6 +710,16 @@ private function registerSecurityConfiguration(ContainerBuilder $container, XmlF } } + private function registerOpenApiConfiguration(ContainerBuilder $container, array $config): void + { + $container->setParameter('api_platform.openapi.termsOfService', $config['openapi']['termsOfService']); + $container->setParameter('api_platform.openapi.contact.name', $config['openapi']['contact']['name']); + $container->setParameter('api_platform.openapi.contact.url', $config['openapi']['contact']['url']); + $container->setParameter('api_platform.openapi.contact.email', $config['openapi']['contact']['email']); + $container->setParameter('api_platform.openapi.license.name', $config['openapi']['license']['name']); + $container->setParameter('api_platform.openapi.license.url', $config['openapi']['license']['url']); + } + private function buildDeprecationArgs(string $version, string $message): array { return method_exists(Definition::class, 'getDeprecation') diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DeprecateMercurePublisherPass.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DeprecateMercurePublisherPass.php new file mode 100644 index 00000000000..a7eb16c5ee5 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DeprecateMercurePublisherPass.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler; + +use Symfony\Component\Config\Definition\BaseNode; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +/** + * Handles Mercure Publisher depreciation. + * + * @internal calls `setDeprecated` method with valid arguments + * depending which version of symfony/dependency-injection is used + */ +final class DeprecateMercurePublisherPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container) + { + $container + ->setAlias('api_platform.doctrine.listener.mercure.publish', 'api_platform.doctrine.orm.listener.mercure.publish') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'Using "%alias_id%" service is deprecated since API Platform 2.6. Use "api_platform.doctrine.orm.listener.mercure.publish" instead.')); + } + + private function buildDeprecationArgs(string $version, string $message): array + { + return method_exists(BaseNode::class, 'getDeprecation') + ? ['api-platform/core', $version, $message] + : [$message]; + } +} diff --git a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php index 70a19e0049c..ee5381b10a0 100644 --- a/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php +++ b/src/Bridge/Symfony/Bundle/DependencyInjection/Configuration.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection; +use ApiPlatform\Core\Annotation\ApiResource; use ApiPlatform\Core\Bridge\Elasticsearch\Metadata\Document\DocumentMetadata; use ApiPlatform\Core\Exception\FilterValidationException; use ApiPlatform\Core\Exception\InvalidArgumentException; @@ -34,6 +35,7 @@ use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerExceptionInterface; +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; /** * The configuration of the bundle. @@ -90,6 +92,7 @@ public function getConfigTreeBuilder() ->info('Specify the default operation path resolver to use for generating resources operations path.') ->end() ->scalarNode('name_converter')->defaultNull()->info('Specify a name converter to use.')->end() + ->scalarNode('asset_package')->defaultNull()->info('Specify an asset package name to use.')->end() ->scalarNode('path_segment_name_generator')->defaultValue('api_platform.path_segment_name_generator.underscore')->info('Specify a path name generator to use.')->end() ->booleanNode('allow_plain_identifiers')->defaultFalse()->info('Allow plain identifiers, for example "id" instead of "@id" when denormalizing a relation.')->end() ->arrayNode('validator') @@ -133,13 +136,41 @@ public function getConfigTreeBuilder() ->canBeDisabled() ->addDefaultsIfNotSet() ->children() - ->booleanNode('enabled')->defaultTrue()->info('To enable or disable pagination for all resource collections by default.')->end() - ->booleanNode('partial')->defaultFalse()->info('To enable or disable partial pagination for all resource collections by default when pagination is enabled.')->end() - ->booleanNode('client_enabled')->defaultFalse()->info('To allow the client to enable or disable the pagination.')->end() - ->booleanNode('client_items_per_page')->defaultFalse()->info('To allow the client to set the number of items per page.')->end() - ->booleanNode('client_partial')->defaultFalse()->info('To allow the client to enable or disable partial pagination.')->end() - ->integerNode('items_per_page')->defaultValue(30)->info('The default number of items per page.')->end() - ->integerNode('maximum_items_per_page')->defaultNull()->info('The maximum number of items per page.')->end() + ->booleanNode('enabled') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `collection.pagination.enabled` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_enabled` instead.')) + ->defaultTrue() + ->info('To enable or disable pagination for all resource collections by default.') + ->end() + ->booleanNode('partial') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `collection.pagination.partial` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_partial` instead.')) + ->defaultFalse() + ->info('To enable or disable partial pagination for all resource collections by default when pagination is enabled.') + ->end() + ->booleanNode('client_enabled') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `collection.pagination.client_enabled` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_client_enabled` instead.')) + ->defaultFalse() + ->info('To allow the client to enable or disable the pagination.') + ->end() + ->booleanNode('client_items_per_page') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `collection.pagination.client_items_per_page` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_client_items_per_page` instead.')) + ->defaultFalse() + ->info('To allow the client to set the number of items per page.') + ->end() + ->booleanNode('client_partial') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `collection.pagination.client_partial` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_client_partial` instead.')) + ->defaultFalse() + ->info('To allow the client to enable or disable partial pagination.') + ->end() + ->integerNode('items_per_page') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `collection.pagination.items_per_page` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_items_per_page` instead.')) + ->defaultValue(30) + ->info('The default number of items per page.') + ->end() + ->integerNode('maximum_items_per_page') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `collection.pagination.maximum_items_per_page` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.pagination_maximum_items_per_page` instead.')) + ->defaultNull() + ->info('The maximum number of items per page.') + ->end() ->scalarNode('page_parameter_name')->defaultValue('page')->cannotBeEmpty()->info('The default name of the parameter handling the page number.')->end() ->scalarNode('enabled_parameter_name')->defaultValue('pagination')->cannotBeEmpty()->info('The name of the query parameter to enable or disable pagination.')->end() ->scalarNode('items_per_page_parameter_name')->defaultValue('itemsPerPage')->cannotBeEmpty()->info('The name of the query parameter to set the number of items per page.')->end() @@ -170,6 +201,7 @@ public function getConfigTreeBuilder() $this->addMercureSection($rootNode); $this->addMessengerSection($rootNode); $this->addElasticsearchSection($rootNode); + $this->addOpenApiSection($rootNode); $this->addExceptionToStatusSection($rootNode); @@ -184,6 +216,8 @@ public function getConfigTreeBuilder() 'jsonld' => ['mime_types' => ['application/ld+json']], ]); + $this->addDefaultsSection($rootNode); + return $treeBuilder; } @@ -221,6 +255,7 @@ private function addOAuthSection(ArrayNodeDefinition $rootNode): void ->scalarNode('flow')->defaultValue('application')->info('The oauth flow grant type.')->end() ->scalarNode('tokenUrl')->defaultValue('/oauth/v2/token')->info('The oauth token url.')->end() ->scalarNode('authorizationUrl')->defaultValue('/oauth/v2/auth')->info('The oauth authentication url.')->end() + ->scalarNode('refreshUrl')->defaultValue('/oauth/v2/refresh')->info('The oauth refresh url.')->end() ->arrayNode('scopes') ->prototype('scalar')->end() ->end() @@ -268,7 +303,7 @@ private function addSwaggerSection(ArrayNodeDefinition $rootNode): void ->addDefaultsIfNotSet() ->children() ->arrayNode('versions') - ->info('The active versions of OpenAPI to be exported or used in the swagger_ui. The first value is the default.') + ->info('The active versions of Open API to be exported or used in the swagger_ui. The first value is the default.') ->defaultValue($defaultVersions) ->beforeNormalization() ->always(static function ($v) { @@ -316,10 +351,23 @@ private function addHttpCacheSection(ArrayNodeDefinition $rootNode): void ->arrayNode('http_cache') ->addDefaultsIfNotSet() ->children() - ->booleanNode('etag')->defaultTrue()->info('Automatically generate etags for API responses.')->end() - ->integerNode('max_age')->defaultNull()->info('Default value for the response max age.')->end() - ->integerNode('shared_max_age')->defaultNull()->info('Default value for the response shared (proxy) max age.')->end() + ->booleanNode('etag') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `http_cache.etag` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.cache_headers.etag` instead.')) + ->defaultTrue() + ->info('Automatically generate etags for API responses.') + ->end() + ->integerNode('max_age') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `http_cache.max_age` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.cache_headers.max_age` instead.')) + ->defaultNull() + ->info('Default value for the response max age.') + ->end() + ->integerNode('shared_max_age') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `http_cache.shared_max_age` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.cache_headers.shared_max_age` instead.')) + ->defaultNull() + ->info('Default value for the response shared (proxy) max age.') + ->end() ->arrayNode('vary') + ->setDeprecated(...$this->buildDeprecationArgs('2.6', 'The use of the `http_cache.vary` has been deprecated in 2.6 and will be removed in 3.0. Use `defaults.cache_headers.vary` instead.')) ->defaultValue(['Accept']) ->prototype('scalar')->end() ->info('Default values of the "Vary" HTTP header.') @@ -334,6 +382,10 @@ private function addHttpCacheSection(ArrayNodeDefinition $rootNode): void ->prototype('scalar')->end() ->info('URLs of the Varnish servers to purge using cache tags when a resource is updated.') ->end() + ->integerNode('max_header_length') + ->defaultValue(7500) + ->info('Max header length supported by the server') + ->end() ->variableNode('request_options') ->defaultValue([]) ->validate() @@ -416,6 +468,34 @@ private function addElasticsearchSection(ArrayNodeDefinition $rootNode): void ->end(); } + private function addOpenApiSection(ArrayNodeDefinition $rootNode): void + { + $rootNode + ->children() + ->arrayNode('openapi') + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('contact') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('name')->defaultNull()->info('The identifying name of the contact person/organization.')->end() + ->scalarNode('url')->defaultNull()->info('The URL pointing to the contact information. MUST be in the format of a URL.')->end() + ->scalarNode('email')->defaultNull()->info('The email address of the contact person/organization. MUST be in the format of an email address.')->end() + ->end() + ->end() + ->scalarNode('termsOfService')->defaultNull()->info('A URL to the Terms of Service for the API. MUST be in the format of a URL.')->end() + ->arrayNode('license') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('name')->defaultNull()->info('The license name used for the API.')->end() + ->scalarNode('url')->defaultNull()->info('URL to the license used for the API. MUST be in the format of a URL.')->end() + ->end() + ->end() + ->end() + ->end() + ->end(); + } + /** * @throws InvalidConfigurationException */ @@ -500,6 +580,30 @@ private function addFormatSection(ArrayNodeDefinition $rootNode, string $key, ar ->end(); } + private function addDefaultsSection(ArrayNodeDefinition $rootNode): void + { + $nameConverter = new CamelCaseToSnakeCaseNameConverter(); + $defaultsNode = $rootNode->children()->arrayNode('defaults'); + + $defaultsNode + ->ignoreExtraKeys() + ->beforeNormalization() + ->always(static function (array $defaults) use ($nameConverter) { + $normalizedDefaults = []; + foreach ($defaults as $option => $value) { + $option = $nameConverter->normalize($option); + $normalizedDefaults[$option] = $value; + } + + return $normalizedDefaults; + }); + + foreach (ApiResource::getConfigMetadata()[1] as $attribute => $_) { + $snakeCased = $nameConverter->normalize($attribute); + $defaultsNode->children()->variableNode($snakeCased); + } + } + private function buildDeprecationArgs(string $version, string $message): array { return method_exists(BaseNode::class, 'getDeprecation') diff --git a/src/Bridge/Symfony/Bundle/Resources/config/api.xml b/src/Bridge/Symfony/Bundle/Resources/config/api.xml index 3b8fc340494..f5b94eb917d 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/api.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/api.xml @@ -51,6 +51,7 @@ + %api_platform.url_generation_strategy% @@ -64,6 +65,7 @@ + @@ -112,7 +114,7 @@ null - false + @@ -200,6 +202,7 @@ %api_platform.error_formats% + %api_platform.exception_to_status% @@ -238,6 +241,7 @@ %api_platform.version% null %api_platform.swagger.versions% + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/data_provider.xml b/src/Bridge/Symfony/Bundle/Resources/config/data_provider.xml index 471da830649..a838e119689 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/data_provider.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/data_provider.xml @@ -33,6 +33,17 @@ %api_platform.graphql.collection.pagination% + + + %api_platform.collection.pagination.enabled% + %api_platform.collection.pagination.page_parameter_name% + %api_platform.collection.pagination.client_items_per_page% + %api_platform.collection.pagination.items_per_page_parameter_name% + %api_platform.collection.pagination.client_enabled% + %api_platform.collection.pagination.enabled_parameter_name% + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml index 9f29b8b4bba..a8f68402635 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_mongodb_odm.xml @@ -24,11 +24,13 @@ + + @@ -36,6 +38,7 @@ + @@ -46,7 +49,6 @@ parent="api_platform.doctrine_mongodb.odm.collection_data_provider" class="ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\CollectionDataProvider"> - + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_mongodb_odm_mercure_publisher.xml b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_mongodb_odm_mercure_publisher.xml new file mode 100644 index 00000000000..8d7c9672b12 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_mongodb_odm_mercure_publisher.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + %api_platform.formats% + + + + + + + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml index 54f4f4d1b79..fbd3d138618 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/doctrine_orm_mercure_publisher.xml @@ -8,7 +8,7 @@ - + @@ -16,6 +16,8 @@ %api_platform.formats% + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml index 07617218bf5..d9400932005 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/graphql.xml @@ -40,6 +40,15 @@ + + + + + + + + + @@ -127,6 +136,7 @@ + @@ -139,6 +149,7 @@ + @@ -167,6 +178,8 @@ + + %kernel.debug% %api_platform.graphql.graphiql.enabled% %api_platform.graphql.graphql_playground.enabled% @@ -187,6 +200,10 @@ %api_platform.title% + + + + @@ -217,12 +234,45 @@ + + + + + + %api_platform.exception_to_status% + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/graphql_mercure.xml b/src/Bridge/Symfony/Bundle/Resources/config/graphql_mercure.xml new file mode 100644 index 00000000000..8307be7cb87 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/graphql_mercure.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/hal.xml b/src/Bridge/Symfony/Bundle/Resources/config/hal.xml index 2828d13eec5..ac713781345 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/hal.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/hal.xml @@ -22,6 +22,7 @@ %api_platform.collection.pagination.page_parameter_name% + @@ -40,7 +41,7 @@ - false + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml b/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml index 410a327b43d..2c2203cbad5 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/jsonapi.xml @@ -26,6 +26,7 @@ %api_platform.collection.pagination.page_parameter_name% + @@ -41,7 +42,7 @@ - false + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml b/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml index b40b0ed1c2d..7c1715f9e67 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/jsonld.xml @@ -27,7 +27,7 @@ - false + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/messenger.xml b/src/Bridge/Symfony/Bundle/Resources/config/messenger.xml index d50ec0e3d69..76a4c6d3b51 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/messenger.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/messenger.xml @@ -10,6 +10,7 @@ + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/metadata/annotation.xml b/src/Bridge/Symfony/Bundle/Resources/config/metadata/annotation.xml index 70556d4f8bc..6440d8c921e 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/metadata/annotation.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/metadata/annotation.xml @@ -14,6 +14,7 @@ + %api_platform.defaults% diff --git a/src/Bridge/Symfony/Bundle/Resources/config/metadata/metadata.xml b/src/Bridge/Symfony/Bundle/Resources/config/metadata/metadata.xml index 1a50a9b0e14..3efa9dd56b2 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/metadata/metadata.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/metadata/metadata.xml @@ -49,11 +49,6 @@ - - - - - @@ -67,11 +62,6 @@ - - - - - @@ -84,6 +74,10 @@ + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/metadata/yaml.xml b/src/Bridge/Symfony/Bundle/Resources/config/metadata/yaml.xml index 1657eb99b86..7344a04a22e 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/metadata/yaml.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/metadata/yaml.xml @@ -18,6 +18,7 @@ + %api_platform.defaults% diff --git a/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml new file mode 100644 index 00000000000..065dc4d33ad --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/openapi.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + %api_platform.title% + %api_platform.description% + %api_platform.version% + %api_platform.oauth.enabled% + %api_platform.oauth.type% + %api_platform.oauth.flow% + %api_platform.oauth.tokenUrl% + %api_platform.oauth.authorizationUrl% + %api_platform.oauth.refreshUrl% + %api_platform.oauth.scopes% + %api_platform.swagger.api_keys% + %api_platform.openapi.contact.name% + %api_platform.openapi.contact.url% + %api_platform.openapi.contact.email% + %api_platform.openapi.termsOfService% + %api_platform.openapi.license.name% + %api_platform.openapi.license.url% + + + + + + + + + + + + + + %api_platform.formats% + + + + + + + + + + + + + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/swagger-ui.xml b/src/Bridge/Symfony/Bundle/Resources/config/swagger-ui.xml index 2bcb783853c..c56d3a3440c 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/swagger-ui.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/swagger-ui.xml @@ -9,6 +9,7 @@ + @@ -35,6 +36,31 @@ %api_platform.graphql.graphiql.enabled% %api_platform.graphql.graphql_playground.enabled% %api_platform.swagger.versions% + + %api_platform.asset_package% + + + + %api_platform.enable_swagger_ui% + %api_platform.show_webby% + %api_platform.enable_re_doc% + %api_platform.graphql.enabled% + %api_platform.graphql.graphiql.enabled% + %api_platform.graphql.graphql_playground.enabled% + %api_platform.asset_package% + + + + + + + + + + + %api_platform.formats% + %api_platform.oauth.clientId% + %api_platform.oauth.clientSecret% diff --git a/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml b/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml index e1a509a41a5..df28ac3f8fc 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/swagger.xml @@ -50,7 +50,6 @@ null %api_platform.swagger.versions% - diff --git a/src/Bridge/Symfony/Bundle/Resources/config/symfony_uid.xml b/src/Bridge/Symfony/Bundle/Resources/config/symfony_uid.xml new file mode 100644 index 00000000000..6dbc7a8ff06 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/Resources/config/symfony_uid.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml index d34dcd8cfe6..9a5a50a6d7e 100644 --- a/src/Bridge/Symfony/Bundle/Resources/config/validator.xml +++ b/src/Bridge/Symfony/Bundle/Resources/config/validator.xml @@ -14,6 +14,19 @@ + + + + + + + + + + + + + @@ -23,9 +36,13 @@ - - + + + + + + diff --git a/src/Bridge/Symfony/Bundle/Resources/views/DataCollector/request.html.twig b/src/Bridge/Symfony/Bundle/Resources/views/DataCollector/request.html.twig index 79d78a25bf2..13bfe8939e2 100644 --- a/src/Bridge/Symfony/Bundle/Resources/views/DataCollector/request.html.twig +++ b/src/Bridge/Symfony/Bundle/Resources/views/DataCollector/request.html.twig @@ -80,10 +80,15 @@ {% set icon %} {% set status_color = collector.counters.ignored_filters|default(false) ? 'yellow' : 'default' %} {{ include('@ApiPlatform/DataCollector/api-platform-icon.svg') }} - {% endset %} {% set text %} + {% if collector.version %} +
+ Version + {{ collector.version }} +
+ {% endif %}
Resource Class {{ collector.resourceClass|default('Not an API Platform resource') }} diff --git a/src/Bridge/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig b/src/Bridge/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig index 620c58ce7ee..83fccc03799 100644 --- a/src/Bridge/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig +++ b/src/Bridge/Symfony/Bundle/Resources/views/SwaggerUi/index.html.twig @@ -6,11 +6,11 @@ {% block stylesheet %} - - + + {% endblock %} - {% set oauth_data = {'oauth': swagger_data.oauth|merge({'redirectUrl' : absolute_url(asset('bundles/apiplatform/swagger-ui/oauth2-redirect.html')) })} %} + {% set oauth_data = {'oauth': swagger_data.oauth|merge({'redirectUrl' : absolute_url(asset('bundles/apiplatform/swagger-ui/oauth2-redirect.html', assetPackage)) })} %} {# json_encode(65) is for JSON_UNESCAPED_SLASHES|JSON_HEX_TAG to avoid JS XSS #} @@ -50,12 +50,12 @@
- +
{% if showWebby %} -
-
+
+
{% endif %}
@@ -72,6 +72,7 @@ {% set active_ui = app.request.get('ui', 'swagger_ui') %} {% if swaggerUiEnabled and active_ui != 'swagger_ui' %}Swagger UI{% endif %} {% if reDocEnabled and active_ui != 're_doc' %}ReDoc{% endif %} + {# FIXME: Typo in graphql => graphQl in SwaggerUiAction #} {% if not graphqlEnabled %}GraphiQL{% endif %} {% if graphiQlEnabled %}GraphiQL{% endif %} {% if graphQlPlaygroundEnabled %}GraphQL Playground{% endif %} @@ -81,12 +82,12 @@ {% block javascript %} {% if (reDocEnabled and not swaggerUiEnabled) or (reDocEnabled and 're_doc' == active_ui) %} - - + + {% else %} - - - + + + {% endif %} {% endblock %} diff --git a/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php b/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php new file mode 100644 index 00000000000..81bf60ebb39 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiAction.php @@ -0,0 +1,124 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Bundle\SwaggerUi; + +use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface; +use ApiPlatform\Core\OpenApi\Options; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Generator\UrlGeneratorInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Twig\Environment as TwigEnvironment; + +/** + * Displays the swaggerui interface. + * + * @author Antoine Bluchet + */ +final class SwaggerUiAction +{ + private $twig; + private $urlGenerator; + private $normalizer; + private $openApiFactory; + private $openApiOptions; + private $swaggerUiContext; + private $formats; + private $resourceMetadataFactory; + private $oauthClientId; + private $oauthClientSecret; + + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, TwigEnvironment $twig, UrlGeneratorInterface $urlGenerator, NormalizerInterface $normalizer, OpenApiFactoryInterface $openApiFactory, Options $openApiOptions, SwaggerUiContext $swaggerUiContext, array $formats = [], string $oauthClientId = null, string $oauthClientSecret = null) + { + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->twig = $twig; + $this->urlGenerator = $urlGenerator; + $this->normalizer = $normalizer; + $this->openApiFactory = $openApiFactory; + $this->openApiOptions = $openApiOptions; + $this->swaggerUiContext = $swaggerUiContext; + $this->formats = $formats; + $this->oauthClientId = $oauthClientId; + $this->oauthClientSecret = $oauthClientSecret; + } + + public function __invoke(Request $request) + { + $openApi = $this->openApiFactory->__invoke(['base_url' => $request->getBaseUrl() ?: '/']); + + $swaggerContext = [ + 'formats' => $this->formats, + 'title' => $openApi->getInfo()->getTitle(), + 'description' => $openApi->getInfo()->getDescription(), + 'showWebby' => $this->swaggerUiContext->isWebbyShown(), + 'swaggerUiEnabled' => $this->swaggerUiContext->isSwaggerUiEnabled(), + 'reDocEnabled' => $this->swaggerUiContext->isRedocEnabled(), + // FIXME: typo graphql => graphQl + 'graphqlEnabled' => $this->swaggerUiContext->isGraphQlEnabled(), + 'graphiQlEnabled' => $this->swaggerUiContext->isGraphiQlEnabled(), + 'graphQlPlaygroundEnabled' => $this->swaggerUiContext->isGraphQlPlaygroundEnabled(), + 'assetPackage' => $this->swaggerUiContext->getAssetPackage(), + ]; + + $swaggerData = [ + 'url' => $this->urlGenerator->generate('api_doc', ['format' => 'json']), + 'spec' => $this->normalizer->normalize($openApi, 'json', []), + 'oauth' => [ + 'enabled' => $this->openApiOptions->getOAuthEnabled(), + 'type' => $this->openApiOptions->getOAuthType(), + 'flow' => $this->openApiOptions->getOAuthFlow(), + 'tokenUrl' => $this->openApiOptions->getOAuthTokenUrl(), + 'authorizationUrl' => $this->openApiOptions->getOAuthAuthorizationUrl(), + 'scopes' => $this->openApiOptions->getOAuthScopes(), + 'clientId' => $this->oauthClientId, + 'clientSecret' => $this->oauthClientSecret, + ], + ]; + + if ($request->isMethodSafe() && null !== $resourceClass = $request->attributes->get('_api_resource_class')) { + $swaggerData['id'] = $request->attributes->get('id'); + $swaggerData['queryParameters'] = $request->query->all(); + + $metadata = $this->resourceMetadataFactory->create($resourceClass); + $swaggerData['shortName'] = $metadata->getShortName(); + + if (null !== $collectionOperationName = $request->attributes->get('_api_collection_operation_name')) { + $swaggerData['operationId'] = sprintf('%s%sCollection', $collectionOperationName, ucfirst($swaggerData['shortName'])); + } elseif (null !== $itemOperationName = $request->attributes->get('_api_item_operation_name')) { + $swaggerData['operationId'] = sprintf('%s%sItem', $itemOperationName, ucfirst($swaggerData['shortName'])); + } elseif (null !== $subresourceOperationContext = $request->attributes->get('_api_subresource_context')) { + $swaggerData['operationId'] = $subresourceOperationContext['operationId']; + } + + [$swaggerData['path'], $swaggerData['method']] = $this->getPathAndMethod($swaggerData); + } + + return new Response($this->twig->render('@ApiPlatform/SwaggerUi/index.html.twig', $swaggerContext + ['swagger_data' => $swaggerData])); + } + + private function getPathAndMethod(array $swaggerData): array + { + foreach ($swaggerData['spec']['paths'] as $path => $operations) { + foreach ($operations as $method => $operation) { + if (($operation['operationId'] ?? null) === $swaggerData['operationId']) { + return [$path, $method]; + } + } + } + + throw new RuntimeException(sprintf('The operation "%s" cannot be found in the Swagger specification.', $swaggerData['operationId'])); + } +} diff --git a/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php b/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php new file mode 100644 index 00000000000..29aaf205600 --- /dev/null +++ b/src/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiContext.php @@ -0,0 +1,71 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Bundle\SwaggerUi; + +final class SwaggerUiContext +{ + private $swaggerUiEnabled; + private $showWebby; + private $reDocEnabled; + private $graphQlEnabled; + private $graphiQlEnabled; + private $graphQlPlaygroundEnabled; + private $assetPackage; + + public function __construct(bool $swaggerUiEnabled = false, bool $showWebby = true, bool $reDocEnabled = false, bool $graphQlEnabled = false, bool $graphiQlEnabled = false, bool $graphQlPlaygroundEnabled = false, $assetPackage = null) + { + $this->swaggerUiEnabled = $swaggerUiEnabled; + $this->showWebby = $showWebby; + $this->reDocEnabled = $reDocEnabled; + $this->graphQlEnabled = $graphQlEnabled; + $this->graphiQlEnabled = $graphiQlEnabled; + $this->graphQlPlaygroundEnabled = $graphQlPlaygroundEnabled; + $this->assetPackage = $assetPackage; + } + + public function isSwaggerUiEnabled(): bool + { + return $this->swaggerUiEnabled; + } + + public function isWebbyShown(): bool + { + return $this->showWebby; + } + + public function isRedocEnabled(): bool + { + return $this->reDocEnabled; + } + + public function isGraphQlEnabled(): bool + { + return $this->graphQlEnabled; + } + + public function isGraphiQlEnabled(): bool + { + return $this->graphiQlEnabled; + } + + public function isGraphQlPlaygroundEnabled(): bool + { + return $this->graphQlPlaygroundEnabled; + } + + public function getAssetPackage(): ?string + { + return $this->assetPackage; + } +} diff --git a/src/Bridge/Symfony/Bundle/Test/Client.php b/src/Bridge/Symfony/Bundle/Test/Client.php index 270d560c8e0..bf6ec3df815 100644 --- a/src/Bridge/Symfony/Bundle/Test/Client.php +++ b/src/Bridge/Symfony/Bundle/Test/Client.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Bridge\Symfony\Bundle\Test; use Symfony\Bundle\FrameworkBundle\KernelBrowser; +use Symfony\Component\BrowserKit\CookieJar; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpClient\HttpClientTrait; use Symfony\Component\HttpKernel\KernelInterface; @@ -167,6 +168,14 @@ public function getContainer(): ?ContainerInterface return $this->kernelBrowser->getContainer(); } + /** + * Returns the CookieJar instance. + */ + public function getCookieJar(): CookieJar + { + return $this->kernelBrowser->getCookieJar(); + } + /** * Returns the kernel. */ diff --git a/src/Bridge/Symfony/Identifier/Normalizer/UlidNormalizer.php b/src/Bridge/Symfony/Identifier/Normalizer/UlidNormalizer.php new file mode 100644 index 00000000000..fbfd6387b2d --- /dev/null +++ b/src/Bridge/Symfony/Identifier/Normalizer/UlidNormalizer.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Identifier\Normalizer; + +use ApiPlatform\Core\Exception\InvalidIdentifierException; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Uid\Ulid; + +/** + * Denormalizes an ULID string to an instance of Symfony\Component\Uid\Ulid. + */ +final class UlidNormalizer implements DenormalizerInterface +{ + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = null, array $context = []) + { + try { + return Ulid::fromString($data); + } catch (\InvalidArgumentException $e) { + throw new InvalidIdentifierException($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, $type, $format = null) + { + return \is_string($data) && is_a($type, Ulid::class, true); + } +} diff --git a/src/Bridge/Symfony/Identifier/Normalizer/UuidNormalizer.php b/src/Bridge/Symfony/Identifier/Normalizer/UuidNormalizer.php new file mode 100644 index 00000000000..7123181c387 --- /dev/null +++ b/src/Bridge/Symfony/Identifier/Normalizer/UuidNormalizer.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Identifier\Normalizer; + +use ApiPlatform\Core\Exception\InvalidIdentifierException; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Uid\Uuid; + +/** + * Denormalizes an UUID string to an instance of Symfony\Component\Uid\Uuid. + */ +final class UuidNormalizer implements DenormalizerInterface +{ + /** + * {@inheritdoc} + */ + public function denormalize($data, $class, $format = null, array $context = []) + { + try { + return Uuid::fromString($data); + } catch (\InvalidArgumentException $e) { + throw new InvalidIdentifierException($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * {@inheritdoc} + */ + public function supportsDenormalization($data, $type, $format = null) + { + return \is_string($data) && is_a($type, Uuid::class, true); + } +} diff --git a/src/Bridge/Symfony/Messenger/ContextStamp.php b/src/Bridge/Symfony/Messenger/ContextStamp.php new file mode 100644 index 00000000000..40afe1b1384 --- /dev/null +++ b/src/Bridge/Symfony/Messenger/ContextStamp.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Messenger; + +use Symfony\Component\Messenger\Stamp\StampInterface; + +/** + * An envelope stamp with context which related to a message. + * + * @experimental + * + * @author Sergii Pavlenko + */ +final class ContextStamp implements StampInterface +{ + private $context; + + public function __construct(array $context = []) + { + $this->context = $context; + } + + /** + * Get the context related to a message. + */ + public function getContext(): array + { + return $this->context; + } +} diff --git a/src/Bridge/Symfony/Messenger/DataPersister.php b/src/Bridge/Symfony/Messenger/DataPersister.php index 525cd71b304..c7fb6536fe6 100644 --- a/src/Bridge/Symfony/Messenger/DataPersister.php +++ b/src/Bridge/Symfony/Messenger/DataPersister.php @@ -17,6 +17,7 @@ use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface; use ApiPlatform\Core\Exception\ResourceClassNotFoundException; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Util\ClassInfoTrait; use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\MessageBusInterface; @@ -35,11 +36,13 @@ final class DataPersister implements ContextAwareDataPersisterInterface use DispatchTrait; private $resourceMetadataFactory; + private $dataPersister; - public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, MessageBusInterface $messageBus) + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, MessageBusInterface $messageBus, ContextAwareDataPersisterInterface $dataPersister) { $this->resourceMetadataFactory = $resourceMetadataFactory; $this->messageBus = $messageBus; + $this->dataPersister = $dataPersister; } /** @@ -47,27 +50,17 @@ public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFa */ public function supports($data, array $context = []): bool { + if (true === ($context['messenger_dispatched'] ?? false)) { + return false; + } + try { $resourceMetadata = $this->resourceMetadataFactory->create($context['resource_class'] ?? $this->getObjectClass($data)); } catch (ResourceClassNotFoundException $e) { return false; } - if (null !== $operationName = $context['collection_operation_name'] ?? $context['item_operation_name'] ?? null) { - return false !== $resourceMetadata->getTypedOperationAttribute( - $context['collection_operation_name'] ?? false ? OperationType::COLLECTION : OperationType::ITEM, - $operationName, - 'messenger', - false, - true - ); - } - - if (isset($context['graphql_operation_name'])) { - return false !== $resourceMetadata->getGraphqlAttribute($context['graphql_operation_name'], 'messenger', false, true); - } - - return false !== $resourceMetadata->getAttribute('messenger', false); + return false !== $this->getMessengerAttributeValue($resourceMetadata, $context); } /** @@ -75,7 +68,14 @@ public function supports($data, array $context = []): bool */ public function persist($data, array $context = []) { - $envelope = $this->dispatch($data); + if ($this->handOver($data, $context)) { + $data = $this->dataPersister->persist($data, $context + ['messenger_dispatched' => true]); + } + + $envelope = $this->dispatch( + (new Envelope($data)) + ->with(new ContextStamp($context)) + ); $handledStamp = $envelope->last(HandledStamp::class); if (!$handledStamp instanceof HandledStamp) { @@ -90,9 +90,49 @@ public function persist($data, array $context = []) */ public function remove($data, array $context = []) { + if ($this->handOver($data, $context)) { + $this->dataPersister->remove($data, $context + ['messenger_dispatched' => true]); + } + $this->dispatch( (new Envelope($data)) ->with(new RemoveStamp()) ); } + + /** + * Should this DataPersister hand over in "persist" mode? + */ + private function handOver($data, array $context = []): bool + { + try { + $value = $this->getMessengerAttributeValue($this->resourceMetadataFactory->create($context['resource_class'] ?? $this->getObjectClass($data)), $context); + } catch (ResourceClassNotFoundException $exception) { + return false; + } + + return 'persist' === $value || (\is_array($value) && (\in_array('persist', $value, true) || (true === $value['persist'] ?? false))); + } + + /** + * @return bool|string|array|null + */ + private function getMessengerAttributeValue(ResourceMetadata $resourceMetadata, array $context = []) + { + if (null !== $operationName = $context['collection_operation_name'] ?? $context['item_operation_name'] ?? null) { + return $resourceMetadata->getTypedOperationAttribute( + $context['collection_operation_name'] ?? false ? OperationType::COLLECTION : OperationType::ITEM, + $operationName, + 'messenger', + false, + true + ); + } + + if (isset($context['graphql_operation_name'])) { + return $resourceMetadata->getGraphqlAttribute($context['graphql_operation_name'], 'messenger', false, true); + } + + return $resourceMetadata->getAttribute('messenger', false); + } } diff --git a/src/Bridge/Symfony/Messenger/DataTransformer.php b/src/Bridge/Symfony/Messenger/DataTransformer.php index dac49c37d21..bdb01ab10c8 100644 --- a/src/Bridge/Symfony/Messenger/DataTransformer.php +++ b/src/Bridge/Symfony/Messenger/DataTransformer.php @@ -55,19 +55,19 @@ public function supportsTransformation($data, string $to, array $context = []): $metadata = $this->resourceMetadataFactory->create($context['resource_class'] ?? $to); if (isset($context['graphql_operation_name'])) { - return 'input' === $metadata->getGraphqlAttribute($context['graphql_operation_name'], 'messenger', null, true); + $attribute = $metadata->getGraphqlAttribute($context['graphql_operation_name'], 'messenger', null, true); + } elseif (!isset($context['operation_type'])) { + $attribute = $metadata->getAttribute('messenger'); + } else { + $attribute = $metadata->getTypedOperationAttribute( + $context['operation_type'], + $context[$context['operation_type'].'_operation_name'] ?? '', + 'messenger', + null, + true + ); } - if (!isset($context['operation_type'])) { - return 'input' === $metadata->getAttribute('messenger'); - } - - return 'input' === $metadata->getTypedOperationAttribute( - $context['operation_type'], - $context[$context['operation_type'].'_operation_name'] ?? '', - 'messenger', - null, - true - ); + return 'input' === $attribute || (\is_array($attribute) && \in_array('input', $attribute, true)); } } diff --git a/src/Bridge/Symfony/Routing/ApiLoader.php b/src/Bridge/Symfony/Routing/ApiLoader.php index aaa093a1cfb..3bdad5ac363 100644 --- a/src/Bridge/Symfony/Routing/ApiLoader.php +++ b/src/Bridge/Symfony/Routing/ApiLoader.php @@ -126,6 +126,7 @@ public function load($data, $type = null): RouteCollection [ '_controller' => $controller, '_format' => null, + '_stateless' => $operation['stateless'] ?? $resourceMetadata->getAttribute('stateless'), '_api_resource_class' => $operation['resource_class'], '_api_subresource_operation_name' => $operation['route_name'], '_api_subresource_context' => [ @@ -229,6 +230,7 @@ private function addRoute(RouteCollection $routeCollection, string $resourceClas [ '_controller' => $controller, '_format' => null, + '_stateless' => $operation['stateless'], '_api_resource_class' => $resourceClass, sprintf('_api_%s_operation_name', $operationType) => $operationName, ] + ($operation['defaults'] ?? []), diff --git a/src/Bridge/Symfony/Routing/IriConverter.php b/src/Bridge/Symfony/Routing/IriConverter.php index 001bb7f3bd9..e0c8e5c7211 100644 --- a/src/Bridge/Symfony/Routing/IriConverter.php +++ b/src/Bridge/Symfony/Routing/IriConverter.php @@ -29,6 +29,7 @@ use ApiPlatform\Core\Identifier\IdentifierConverterInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Util\AttributesExtractor; use ApiPlatform\Core\Util\ResourceClassInfoTrait; use Symfony\Component\PropertyAccess\PropertyAccess; @@ -50,7 +51,7 @@ final class IriConverter implements IriConverterInterface private $router; private $identifiersExtractor; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ItemDataProviderInterface $itemDataProvider, RouteNameResolverInterface $routeNameResolver, RouterInterface $router, PropertyAccessorInterface $propertyAccessor = null, IdentifiersExtractorInterface $identifiersExtractor = null, SubresourceDataProviderInterface $subresourceDataProvider = null, IdentifierConverterInterface $identifierConverter = null, ResourceClassResolverInterface $resourceClassResolver = null) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ItemDataProviderInterface $itemDataProvider, RouteNameResolverInterface $routeNameResolver, RouterInterface $router, PropertyAccessorInterface $propertyAccessor = null, IdentifiersExtractorInterface $identifiersExtractor = null, SubresourceDataProviderInterface $subresourceDataProvider = null, IdentifierConverterInterface $identifierConverter = null, ResourceClassResolverInterface $resourceClassResolver = null, ResourceMetadataFactoryInterface $resourceMetadataFactory = null) { $this->itemDataProvider = $itemDataProvider; $this->routeNameResolver = $routeNameResolver; @@ -64,6 +65,7 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName @trigger_error(sprintf('Not injecting "%s" is deprecated since API Platform 2.1 and will not be possible anymore in API Platform 3', IdentifiersExtractorInterface::class), E_USER_DEPRECATED); $this->identifiersExtractor = new IdentifiersExtractor($propertyNameCollectionFactory, $propertyMetadataFactory, $propertyAccessor ?? PropertyAccess::createPropertyAccessor()); } + $this->resourceMetadataFactory = $resourceMetadataFactory; } /** @@ -115,7 +117,7 @@ public function getItemFromIri(string $iri, array $context = []) /** * {@inheritdoc} */ - public function getIriFromItem($item, int $referenceType = UrlGeneratorInterface::ABS_PATH): string + public function getIriFromItem($item, int $referenceType = null): string { $resourceClass = $this->getResourceClass($item, true); @@ -125,16 +127,16 @@ public function getIriFromItem($item, int $referenceType = UrlGeneratorInterface throw new InvalidArgumentException(sprintf('Unable to generate an IRI for the item of type "%s"', $resourceClass), $e->getCode(), $e); } - return $this->getItemIriFromResourceClass($resourceClass, $identifiers, $referenceType); + return $this->getItemIriFromResourceClass($resourceClass, $identifiers, $this->getReferenceType($resourceClass, $referenceType)); } /** * {@inheritdoc} */ - public function getIriFromResourceClass(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH): string + public function getIriFromResourceClass(string $resourceClass, int $referenceType = null): string { try { - return $this->router->generate($this->routeNameResolver->getRouteName($resourceClass, OperationType::COLLECTION), [], $referenceType); + return $this->router->generate($this->routeNameResolver->getRouteName($resourceClass, OperationType::COLLECTION), [], $this->getReferenceType($resourceClass, $referenceType)); } catch (RoutingExceptionInterface $e) { throw new InvalidArgumentException(sprintf('Unable to generate an IRI for "%s".', $resourceClass), $e->getCode(), $e); } @@ -143,14 +145,14 @@ public function getIriFromResourceClass(string $resourceClass, int $referenceTyp /** * {@inheritdoc} */ - public function getItemIriFromResourceClass(string $resourceClass, array $identifiers, int $referenceType = UrlGeneratorInterface::ABS_PATH): string + public function getItemIriFromResourceClass(string $resourceClass, array $identifiers, int $referenceType = null): string { $routeName = $this->routeNameResolver->getRouteName($resourceClass, OperationType::ITEM); try { $identifiers = $this->generateIdentifiersUrl($identifiers, $resourceClass); - return $this->router->generate($routeName, ['id' => implode(';', $identifiers)], $referenceType); + return $this->router->generate($routeName, ['id' => implode(';', $identifiers)], $this->getReferenceType($resourceClass, $referenceType)); } catch (RoutingExceptionInterface $e) { throw new InvalidArgumentException(sprintf('Unable to generate an IRI for "%s".', $resourceClass), $e->getCode(), $e); } @@ -159,10 +161,10 @@ public function getItemIriFromResourceClass(string $resourceClass, array $identi /** * {@inheritdoc} */ - public function getSubresourceIriFromResourceClass(string $resourceClass, array $context, int $referenceType = UrlGeneratorInterface::ABS_PATH): string + public function getSubresourceIriFromResourceClass(string $resourceClass, array $context, int $referenceType = null): string { try { - return $this->router->generate($this->routeNameResolver->getRouteName($resourceClass, OperationType::SUBRESOURCE, $context), $context['subresource_identifiers'], $referenceType); + return $this->router->generate($this->routeNameResolver->getRouteName($resourceClass, OperationType::SUBRESOURCE, $context), $context['subresource_identifiers'], $this->getReferenceType($resourceClass, $referenceType)); } catch (RoutingExceptionInterface $e) { throw new InvalidArgumentException(sprintf('Unable to generate an IRI for "%s".', $resourceClass), $e->getCode(), $e); } @@ -182,7 +184,7 @@ private function generateIdentifiersUrl(array $identifiers, string $resourceClas } if (1 === \count($identifiers)) { - return [rawurlencode((string) reset($identifiers))]; + return [(string) reset($identifiers)]; } foreach ($identifiers as $name => $value) { @@ -191,4 +193,14 @@ private function generateIdentifiersUrl(array $identifiers, string $resourceClas return array_values($identifiers); } + + private function getReferenceType(string $resourceClass, ?int $referenceType): ?int + { + if (null === $referenceType && null !== $this->resourceMetadataFactory) { + $metadata = $this->resourceMetadataFactory->create($resourceClass); + $referenceType = $metadata->getAttribute('url_generation_strategy'); + } + + return $referenceType ?? UrlGeneratorInterface::ABS_PATH; + } } diff --git a/src/Bridge/Symfony/Routing/Router.php b/src/Bridge/Symfony/Routing/Router.php index a2092dc790f..51dfe827a2e 100644 --- a/src/Bridge/Symfony/Routing/Router.php +++ b/src/Bridge/Symfony/Routing/Router.php @@ -35,10 +35,12 @@ final class Router implements RouterInterface, UrlGeneratorInterface ]; private $router; + private $urlGenerationStrategy; - public function __construct(RouterInterface $router) + public function __construct(RouterInterface $router, int $urlGenerationStrategy = self::ABS_PATH) { $this->router = $router; + $this->urlGenerationStrategy = $urlGenerationStrategy; } /** @@ -99,8 +101,8 @@ public function match($pathInfo) /** * {@inheritdoc} */ - public function generate($name, $parameters = [], $referenceType = self::ABS_PATH) + public function generate($name, $parameters = [], $referenceType = null) { - return $this->router->generate($name, $parameters, self::CONST_MAP[$referenceType]); + return $this->router->generate($name, $parameters, self::CONST_MAP[$referenceType ?? $this->urlGenerationStrategy]); } } diff --git a/src/Bridge/Symfony/Validator/EventListener/ValidationExceptionListener.php b/src/Bridge/Symfony/Validator/EventListener/ValidationExceptionListener.php index 5f72211c5e5..d0da61f1d2d 100644 --- a/src/Bridge/Symfony/Validator/EventListener/ValidationExceptionListener.php +++ b/src/Bridge/Symfony/Validator/EventListener/ValidationExceptionListener.php @@ -28,11 +28,13 @@ final class ValidationExceptionListener { private $serializer; private $errorFormats; + private $exceptionToStatus; - public function __construct(SerializerInterface $serializer, array $errorFormats) + public function __construct(SerializerInterface $serializer, array $errorFormats, array $exceptionToStatus = []) { $this->serializer = $serializer; $this->errorFormats = $errorFormats; + $this->exceptionToStatus = $exceptionToStatus; } /** @@ -44,12 +46,22 @@ public function onKernelException(ExceptionEvent $event): void if (!$exception instanceof ValidationException) { return; } + $exceptionClass = \get_class($exception); + $statusCode = Response::HTTP_UNPROCESSABLE_ENTITY; + + foreach ($this->exceptionToStatus as $class => $status) { + if (is_a($exceptionClass, $class, true)) { + $statusCode = $status; + + break; + } + } $format = ErrorFormatGuesser::guessErrorFormat($event->getRequest(), $this->errorFormats); $event->setResponse(new Response( $this->serializer->serialize($exception->getConstraintViolationList(), $format['key']), - Response::HTTP_BAD_REQUEST, + $statusCode, [ 'Content-Type' => sprintf('%s; charset=utf-8', $format['value'][0]), 'X-Content-Type-Options' => 'nosniff', diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaFormat.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaFormat.php new file mode 100644 index 00000000000..67990bfcab5 --- /dev/null +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaFormat.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Email; +use Symfony\Component\Validator\Constraints\Ip; +use Symfony\Component\Validator\Constraints\Uuid; + +/** + * Class PropertySchemaFormat. + * + * @author Andrii Penchuk penja7@gmail.com + */ +class PropertySchemaFormat implements PropertySchemaRestrictionMetadataInterface +{ + /** + * {@inheritdoc} + */ + public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array + { + if ($constraint instanceof Email) { + return ['format' => 'email']; + } + + if ($constraint instanceof Uuid) { + return ['format' => 'uuid']; + } + + if ($constraint instanceof Ip) { + if ($constraint->version === $constraint::V4) { + return ['format' => 'ipv4']; + } + + return ['format' => 'ipv6']; + } + + return []; + } + + /** + * {@inheritdoc} + */ + public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool + { + $schema = $propertyMetadata->getSchema(); + + return empty($schema['format']); + } +} diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php new file mode 100644 index 00000000000..c6c2a7abffa --- /dev/null +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaLengthRestriction.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Length; + +/** + * Class PropertySchemaLengthRestrictions. + * + * @author Andrii Penchuk penja7@gmail.com + */ +class PropertySchemaLengthRestriction implements PropertySchemaRestrictionMetadataInterface +{ + /** + * {@inheritdoc} + */ + public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array + { + $restriction = []; + + switch ($propertyMetadata->getType()->getBuiltinType()) { + case Type::BUILTIN_TYPE_STRING: + + if (isset($constraint->min)) { + $restriction['minLength'] = (int) $constraint->min; + } + + if (isset($constraint->max)) { + $restriction['maxLength'] = (int) $constraint->max; + } + + break; + case Type::BUILTIN_TYPE_INT: + case Type::BUILTIN_TYPE_FLOAT: + if (isset($constraint->min)) { + $restriction['minimum'] = (int) $constraint->min; + } + + if (isset($constraint->max)) { + $restriction['maximum'] = (int) $constraint->max; + } + + break; + } + + return $restriction; + } + + /** + * {@inheritdoc} + */ + public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool + { + return $constraint instanceof Length && null !== $propertyMetadata->getType(); + } +} diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRegexRestriction.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRegexRestriction.php new file mode 100644 index 00000000000..dccc81e9fbd --- /dev/null +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRegexRestriction.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use Symfony\Component\Validator\Constraint; +use Symfony\Component\Validator\Constraints\Regex; + +/** + * Class PropertySchemaRegexRestriction. + * + * @author Andrii Penchuk penja7@gmail.com + */ +class PropertySchemaRegexRestriction implements PropertySchemaRestrictionMetadataInterface +{ + /** + * {@inheritdoc} + */ + public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array + { + return isset($constraint->pattern) ? ['pattern' => $constraint->pattern] : []; + } + + /** + * {@inheritdoc} + */ + public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool + { + return $constraint instanceof Regex && $constraint->match; + } +} diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRestrictionMetadataInterface.php b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRestrictionMetadataInterface.php new file mode 100644 index 00000000000..2486342de40 --- /dev/null +++ b/src/Bridge/Symfony/Validator/Metadata/Property/Restriction/PropertySchemaRestrictionMetadataInterface.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction; + +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use Symfony\Component\Validator\Constraint; + +/** + * Interface PropertySchemaRestrictionsInterface. + * + * @author Andrii Penchuk penja7@gmail.com + */ +interface PropertySchemaRestrictionMetadataInterface +{ + /** + * Creates json schema restrictions based on the validation constraints. + * + * @param Constraint $constraint The validation constraint + * @param PropertyMetadata $propertyMetadata The property metadata + * + * @return array The array of restrictions + */ + public function create(Constraint $constraint, PropertyMetadata $propertyMetadata): array; + + /** + * Is the constraint supported by the schema restriction? + * + * @param Constraint $constraint The validation constraint + * @param PropertyMetadata $propertyMetadata The property metadata + */ + public function supports(Constraint $constraint, PropertyMetadata $propertyMetadata): bool; +} diff --git a/src/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactory.php b/src/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactory.php index 5568720c592..438f20d1a2d 100644 --- a/src/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactory.php +++ b/src/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactory.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRestrictionMetadataInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use Symfony\Component\Validator\Constraint; @@ -67,11 +68,19 @@ final class ValidatorPropertyMetadataFactory implements PropertyMetadataFactoryI private $decorated; private $validatorMetadataFactory; + /** + * @var iterable + */ + private $restrictionsMetadata; - public function __construct(ValidatorMetadataFactoryInterface $validatorMetadataFactory, PropertyMetadataFactoryInterface $decorated) + /** + * @param PropertySchemaRestrictionMetadataInterface[] $restrictionsMetadata + */ + public function __construct(ValidatorMetadataFactoryInterface $validatorMetadataFactory, PropertyMetadataFactoryInterface $decorated, iterable $restrictionsMetadata = []) { $this->validatorMetadataFactory = $validatorMetadataFactory; $this->decorated = $decorated; + $this->restrictionsMetadata = $restrictionsMetadata; } /** @@ -83,26 +92,23 @@ public function create(string $resourceClass, string $name, array $options = []) $required = $propertyMetadata->isRequired(); $iri = $propertyMetadata->getIri(); + $schema = $propertyMetadata->getSchema(); - if (null !== $required && null !== $iri) { + if (null !== $required && null !== $iri && null !== $schema) { return $propertyMetadata; } $validatorClassMetadata = $this->validatorMetadataFactory->getMetadataFor($resourceClass); + if (!$validatorClassMetadata instanceof ValidatorClassMetadataInterface) { throw new \UnexpectedValueException(sprintf('Validator class metadata expected to be of type "%s".', ValidatorClassMetadataInterface::class)); } - foreach ($validatorClassMetadata->getPropertyMetadata($name) as $validatorPropertyMetadata) { - if (null === $required && isset($options['validation_groups'])) { - $required = $this->isRequiredByGroups($validatorPropertyMetadata, $options); - } - - if (!method_exists($validatorClassMetadata, 'getDefaultGroup')) { - throw new \UnexpectedValueException(sprintf('Validator class metadata expected to have method "%s".', 'getDefaultGroup')); - } + $validationGroups = $this->getValidationGroups($validatorClassMetadata, $options); + $restrictions = []; - foreach ($validatorPropertyMetadata->findConstraints($validatorClassMetadata->getDefaultGroup()) as $constraint) { + foreach ($validatorClassMetadata->getPropertyMetadata($name) as $validatorPropertyMetadata) { + foreach ($this->getPropertyConstraints($validatorPropertyMetadata, $validationGroups) as $constraint) { if (null === $required && $this->isRequired($constraint)) { $required = true; } @@ -111,33 +117,64 @@ public function create(string $resourceClass, string $name, array $options = []) $iri = self::SCHEMA_MAPPED_CONSTRAINTS[\get_class($constraint)] ?? null; } - if (null !== $required && null !== $iri) { - break 2; + foreach ($this->restrictionsMetadata as $restrictionMetadata) { + if ($restrictionMetadata->supports($constraint, $propertyMetadata)) { + $restrictions[] = $restrictionMetadata->create($constraint, $propertyMetadata); + } } } } - return $propertyMetadata->withIri($iri)->withRequired($required ?? false); + $propertyMetadata = $propertyMetadata->withIri($iri)->withRequired($required ?? false); + + if (!empty($restrictions)) { + if (null === $schema) { + $schema = []; + } + + $schema += array_merge(...$restrictions); + $propertyMetadata = $propertyMetadata->withSchema($schema); + } + + return $propertyMetadata; } /** - * Tests if the property is required because of its validation groups. + * Returns the list of validation groups. */ - private function isRequiredByGroups(ValidatorPropertyMetadataInterface $validatorPropertyMetadata, array $options): bool + private function getValidationGroups(ValidatorClassMetadataInterface $classMetadata, array $options): array { - foreach ($options['validation_groups'] as $validationGroup) { + if (isset($options['validation_groups'])) { + return $options['validation_groups']; + } + + if (!method_exists($classMetadata, 'getDefaultGroup')) { + throw new \UnexpectedValueException(sprintf('Validator class metadata expected to have method "%s".', 'getDefaultGroup')); + } + + return [$classMetadata->getDefaultGroup()]; + } + + /** + * Tests if the property is required because of its validation groups. + */ + private function getPropertyConstraints( + ValidatorPropertyMetadataInterface $validatorPropertyMetadata, + array $groups + ): array { + $constraints = []; + + foreach ($groups as $validationGroup) { if (!\is_string($validationGroup)) { continue; } - foreach ($validatorPropertyMetadata->findConstraints($validationGroup) as $constraint) { - if ($this->isRequired($constraint)) { - return true; - } + foreach ($validatorPropertyMetadata->findConstraints($validationGroup) as $propertyConstraint) { + $constraints[] = $propertyConstraint; } } - return false; + return $constraints; } /** diff --git a/src/Bridge/Symfony/Validator/ValidationGroupsGeneratorInterface.php b/src/Bridge/Symfony/Validator/ValidationGroupsGeneratorInterface.php new file mode 100644 index 00000000000..d8fffcef5ae --- /dev/null +++ b/src/Bridge/Symfony/Validator/ValidationGroupsGeneratorInterface.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Bridge\Symfony\Validator; + +use Symfony\Component\Validator\Constraints\GroupSequence; + +/** + * Generates validation groups for an object. + * + * @author Tomas Norkūnas + */ +interface ValidationGroupsGeneratorInterface +{ + /** + * @param object $object + * + * @return GroupSequence|string[] + */ + public function __invoke($object); +} diff --git a/src/Bridge/Symfony/Validator/Validator.php b/src/Bridge/Symfony/Validator/Validator.php index 84a7e3a36dc..a7aca34c75c 100644 --- a/src/Bridge/Symfony/Validator/Validator.php +++ b/src/Bridge/Symfony/Validator/Validator.php @@ -23,6 +23,8 @@ * Validates an item using the Symfony validator component. * * @author Kévin Dunglas + * + * @final */ class Validator implements ValidatorInterface { @@ -48,6 +50,10 @@ public function validate($data, array $context = []) ($service = $this->container->get($validationGroups)) && \is_callable($service) ) { + if (!$service instanceof ValidationGroupsGeneratorInterface) { + @trigger_error(sprintf('Using a public validation groups generator service not implementing "%s" is deprecated since 2.6 and will be removed in 3.0.', ValidationGroupsGeneratorInterface::class), E_USER_DEPRECATED); + } + $validationGroups = $service($data); } elseif (\is_callable($validationGroups)) { $validationGroups = $validationGroups($data); diff --git a/src/DataProvider/Pagination.php b/src/DataProvider/Pagination.php index aeab11b3e81..ff2c19c2c7a 100644 --- a/src/DataProvider/Pagination.php +++ b/src/DataProvider/Pagination.php @@ -202,6 +202,22 @@ public function isPartialEnabled(string $resourceClass = null, string $operation return $this->getEnabled($context, $resourceClass, $operationName, true); } + public function getOptions(): array + { + return $this->options; + } + + public function getGraphQlPaginationType(string $resourceClass, string $operationName): string + { + try { + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + } catch (ResourceClassNotFoundException $e) { + return 'cursor'; + } + + return (string) $resourceMetadata->getGraphqlAttribute($operationName, 'pagination_type', 'cursor', true); + } + /** * Is the classic or partial pagination enabled? */ diff --git a/src/DataProvider/PaginationOptions.php b/src/DataProvider/PaginationOptions.php new file mode 100644 index 00000000000..1db7646943f --- /dev/null +++ b/src/DataProvider/PaginationOptions.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\DataProvider; + +final class PaginationOptions +{ + private $paginationEnabled; + private $paginationPageParameterName; + private $clientItemsPerPage; + private $itemsPerPageParameterName; + private $paginationClientEnabled; + private $paginationClientEnabledParameterName; + + public function __construct(bool $paginationEnabled = true, string $paginationPageParameterName = 'page', bool $clientItemsPerPage = false, string $itemsPerPageParameterName = 'itemsPerPage', bool $paginationClientEnabled = false, string $paginationClientEnabledParameterName = 'pagination') + { + $this->paginationEnabled = $paginationEnabled; + $this->paginationPageParameterName = $paginationPageParameterName; + $this->clientItemsPerPage = $clientItemsPerPage; + $this->itemsPerPageParameterName = $itemsPerPageParameterName; + $this->paginationClientEnabled = $paginationClientEnabled; + $this->paginationClientEnabledParameterName = $paginationClientEnabledParameterName; + } + + public function isPaginationEnabled(): bool + { + return $this->paginationEnabled; + } + + public function getPaginationPageParameterName(): string + { + return $this->paginationPageParameterName; + } + + public function getClientItemsPerPage(): bool + { + return $this->clientItemsPerPage; + } + + public function getItemsPerPageParameterName(): string + { + return $this->itemsPerPageParameterName; + } + + public function getPaginationClientEnabled(): bool + { + return $this->paginationClientEnabled; + } + + public function getPaginationClientEnabledParameterName(): string + { + return $this->paginationClientEnabledParameterName; + } +} diff --git a/src/DataTransformer/DataTransformerInitializerInterface.php b/src/DataTransformer/DataTransformerInitializerInterface.php new file mode 100644 index 00000000000..85007c818e1 --- /dev/null +++ b/src/DataTransformer/DataTransformerInitializerInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\DataTransformer; + +interface DataTransformerInitializerInterface extends DataTransformerInterface +{ + /** + * Creates a new DTO object that the data will then be serialized into (using object_to_populate). + * + * This is useful to "initialize" the DTO object based on the current resource's data. + * + * @return object|null + */ + public function initialize(string $inputClass, array $context = []); +} diff --git a/src/Documentation/Action/DocumentationAction.php b/src/Documentation/Action/DocumentationAction.php index 57fd1698372..29dab584ae6 100644 --- a/src/Documentation/Action/DocumentationAction.php +++ b/src/Documentation/Action/DocumentationAction.php @@ -15,7 +15,9 @@ use ApiPlatform\Core\Api\FormatsProviderInterface; use ApiPlatform\Core\Documentation\Documentation; +use ApiPlatform\Core\Documentation\DocumentationInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface; use ApiPlatform\Core\Util\RequestAttributesExtractor; use Symfony\Component\HttpFoundation\Request; @@ -33,18 +35,24 @@ final class DocumentationAction private $formats; private $formatsProvider; private $swaggerVersions; + private $openApiFactory; /** * @param int[] $swaggerVersions * @param mixed|array|FormatsProviderInterface $formatsProvider */ - public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, string $title = '', string $description = '', string $version = '', $formatsProvider = null, array $swaggerVersions = [2, 3]) + public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, string $title = '', string $description = '', string $version = '', $formatsProvider = null, array $swaggerVersions = [2, 3], OpenApiFactoryInterface $openApiFactory = null) { $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; $this->title = $title; $this->description = $description; $this->version = $version; $this->swaggerVersions = $swaggerVersions; + $this->openApiFactory = $openApiFactory; + + if (null === $openApiFactory) { + @trigger_error(sprintf('Not passing an instance of "%s" as 7th parameter of the constructor of "%s" is deprecated since API Platform 2.6', OpenApiFactoryInterface::class, __CLASS__), E_USER_DEPRECATED); + } if (null === $formatsProvider) { return; @@ -56,13 +64,14 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName return; } + $this->formatsProvider = $formatsProvider; } - public function __invoke(Request $request = null): Documentation + public function __invoke(Request $request = null): DocumentationInterface { if (null !== $request) { - $context = ['base_url' => $request->getBaseUrl(), 'spec_version' => $request->query->getInt('spec_version', $this->swaggerVersions[0] ?? 2)]; + $context = ['base_url' => $request->getBaseUrl(), 'spec_version' => $request->query->getInt('spec_version', $this->swaggerVersions[0] ?? 3)]; if ($request->query->getBoolean('api_gateway')) { $context['api_gateway'] = true; } @@ -70,11 +79,16 @@ public function __invoke(Request $request = null): Documentation $attributes = RequestAttributesExtractor::extractAttributes($request); } + // BC check to be removed in 3.0 if (null !== $this->formatsProvider) { $this->formats = $this->formatsProvider->getFormatsFromAttributes($attributes ?? []); } + if ('json' === $request->getRequestFormat() && null !== $this->openApiFactory && 3 === ($context['spec_version'] ?? null)) { + return $this->openApiFactory->__invoke($context ?? []); + } + return new Documentation($this->resourceNameCollectionFactory->create(), $this->title, $this->description, $this->version, $this->formats); } } diff --git a/src/Documentation/Documentation.php b/src/Documentation/Documentation.php index 53f5372df28..67228a77c5e 100644 --- a/src/Documentation/Documentation.php +++ b/src/Documentation/Documentation.php @@ -20,7 +20,7 @@ * * @author Amrouche Hamza */ -final class Documentation +final class Documentation implements DocumentationInterface { private $resourceNameCollection; private $title; diff --git a/src/Documentation/DocumentationInterface.php b/src/Documentation/DocumentationInterface.php new file mode 100644 index 00000000000..0bbea27c30c --- /dev/null +++ b/src/Documentation/DocumentationInterface.php @@ -0,0 +1,21 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Documentation; + +/** + * An API documentation. + */ +interface DocumentationInterface +{ +} diff --git a/src/EventListener/QueryParameterValidateListener.php b/src/EventListener/QueryParameterValidateListener.php new file mode 100644 index 00000000000..e9334c1583c --- /dev/null +++ b/src/EventListener/QueryParameterValidateListener.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\EventListener; + +use ApiPlatform\Core\Filter\QueryParameterValidator; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Util\RequestAttributesExtractor; +use ApiPlatform\Core\Util\RequestParser; +use Symfony\Component\HttpKernel\Event\RequestEvent; + +/** + * Validates query parameters depending on filter description. + * + * @author Julien Deniau + */ +final class QueryParameterValidateListener +{ + private $resourceMetadataFactory; + + private $queryParameterValidator; + + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, QueryParameterValidator $queryParameterValidator) + { + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->queryParameterValidator = $queryParameterValidator; + } + + public function onKernelRequest(RequestEvent $event) + { + $request = $event->getRequest(); + if ( + !$request->isMethodSafe() + || !($attributes = RequestAttributesExtractor::extractAttributes($request)) + || !isset($attributes['collection_operation_name']) + || !($operationName = $attributes['collection_operation_name']) + || 'GET' !== $request->getMethod() + ) { + return; + } + $queryString = RequestParser::getQueryString($request); + $queryParameters = $queryString ? RequestParser::parseRequestParams($queryString) : []; + + $resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']); + $resourceFilters = $resourceMetadata->getCollectionOperationAttribute($operationName, 'filters', [], true); + + $this->queryParameterValidator->validateFilters($attributes['resource_class'], $resourceFilters, $queryParameters); + } +} diff --git a/src/Exception/ErrorCodeSerializableInterface.php b/src/Exception/ErrorCodeSerializableInterface.php new file mode 100644 index 00000000000..89c8face799 --- /dev/null +++ b/src/Exception/ErrorCodeSerializableInterface.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Exception; + +/** + * An exception which has a serializable application-specific error code. + */ +interface ErrorCodeSerializableInterface +{ + /** + * Gets the application-specific error code. + */ + public static function getErrorCode(): string; +} diff --git a/src/Exception/ExceptionInterface.php b/src/Exception/ExceptionInterface.php index 604c654605b..c2fd9eb4990 100644 --- a/src/Exception/ExceptionInterface.php +++ b/src/Exception/ExceptionInterface.php @@ -18,6 +18,6 @@ * * @author Kévin Dunglas */ -interface ExceptionInterface +interface ExceptionInterface extends \Throwable { } diff --git a/src/Filter/QueryParameterValidateListener.php b/src/Filter/QueryParameterValidateListener.php deleted file mode 100644 index a9f0f630248..00000000000 --- a/src/Filter/QueryParameterValidateListener.php +++ /dev/null @@ -1,102 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -declare(strict_types=1); - -namespace ApiPlatform\Core\Filter; - -use ApiPlatform\Core\Api\FilterLocatorTrait; -use ApiPlatform\Core\Exception\FilterValidationException; -use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; -use ApiPlatform\Core\Util\RequestAttributesExtractor; -use Psr\Container\ContainerInterface; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\HttpKernel\Event\RequestEvent; - -/** - * Validates query parameters depending on filter description. - * - * @author Julien Deniau - */ -final class QueryParameterValidateListener -{ - use FilterLocatorTrait; - - private $resourceMetadataFactory; - - public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, ContainerInterface $filterLocator) - { - $this->resourceMetadataFactory = $resourceMetadataFactory; - $this->setFilterLocator($filterLocator); - } - - public function onKernelRequest(RequestEvent $event): void - { - $request = $event->getRequest(); - if ( - !$request->isMethodSafe() - || !($attributes = RequestAttributesExtractor::extractAttributes($request)) - || !isset($attributes['collection_operation_name']) - || 'get' !== ($operationName = $attributes['collection_operation_name']) - ) { - return; - } - - $resourceMetadata = $this->resourceMetadataFactory->create($attributes['resource_class']); - $resourceFilters = $resourceMetadata->getCollectionOperationAttribute($operationName, 'filters', [], true); - - $errorList = []; - foreach ($resourceFilters as $filterId) { - if (!$filter = $this->getFilter($filterId)) { - continue; - } - - foreach ($filter->getDescription($attributes['resource_class']) as $name => $data) { - if (!($data['required'] ?? false)) { // property is not required - continue; - } - - if (!$this->isRequiredFilterValid($name, $request)) { - $errorList[] = sprintf('Query parameter "%s" is required', $name); - } - } - } - - if ($errorList) { - throw new FilterValidationException($errorList); - } - } - - /** - * Test if required filter is valid. It validates array notation too like "required[bar]". - */ - private function isRequiredFilterValid(string $name, Request $request): bool - { - $matches = []; - parse_str($name, $matches); - if (!$matches) { - return false; - } - - $rootName = (string) (array_keys($matches)[0] ?? null); - if (!$rootName) { - return false; - } - - if (\is_array($matches[$rootName])) { - $queryParameter = $request->query->all()[$rootName] ?? null; - - return \is_array($queryParameter) && isset($queryParameter[array_keys($matches[$rootName])[0]]); - } - - return isset($request->query->all()[$rootName]); - } -} diff --git a/src/Filter/QueryParameterValidator.php b/src/Filter/QueryParameterValidator.php new file mode 100644 index 00000000000..5a85a22333a --- /dev/null +++ b/src/Filter/QueryParameterValidator.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Filter; + +use ApiPlatform\Core\Api\FilterLocatorTrait; +use ApiPlatform\Core\Exception\FilterValidationException; +use Psr\Container\ContainerInterface; + +/** + * Validates query parameters depending on filter description. + * + * @author Julien Deniau + */ +class QueryParameterValidator +{ + use FilterLocatorTrait; + + private $validators; + + public function __construct(ContainerInterface $filterLocator) + { + $this->setFilterLocator($filterLocator); + + $this->validators = [ + new Validator\ArrayItems(), + new Validator\Bounds(), + new Validator\Enum(), + new Validator\Length(), + new Validator\MultipleOf(), + new Validator\Pattern(), + new Validator\Required(), + ]; + } + + public function validateFilters(string $resourceClass, array $resourceFilters, array $queryParameters): void + { + $errorList = []; + + foreach ($resourceFilters as $filterId) { + if (!$filter = $this->getFilter($filterId)) { + continue; + } + + foreach ($filter->getDescription($resourceClass) as $name => $data) { + foreach ($this->validators as $validator) { + $errorList = array_merge($errorList, $validator->validate($name, $data, $queryParameters)); + } + } + } + + if ($errorList) { + throw new FilterValidationException($errorList); + } + } +} diff --git a/src/Filter/Validator/ArrayItems.php b/src/Filter/Validator/ArrayItems.php new file mode 100644 index 00000000000..e29e7200f84 --- /dev/null +++ b/src/Filter/Validator/ArrayItems.php @@ -0,0 +1,83 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Filter\Validator; + +final class ArrayItems implements ValidatorInterface +{ + /** + * {@inheritdoc} + */ + public function validate(string $name, array $filterDescription, array $queryParameters): array + { + if (!\array_key_exists($name, $queryParameters)) { + return []; + } + + $maxItems = $filterDescription['swagger']['maxItems'] ?? null; + $minItems = $filterDescription['swagger']['minItems'] ?? null; + $uniqueItems = $filterDescription['swagger']['uniqueItems'] ?? false; + + $errorList = []; + + $value = $this->getValue($name, $filterDescription, $queryParameters); + $nbItems = \count($value); + + if (null !== $maxItems && $nbItems > $maxItems) { + $errorList[] = sprintf('Query parameter "%s" must contain less than %d values', $name, $maxItems); + } + + if (null !== $minItems && $nbItems < $minItems) { + $errorList[] = sprintf('Query parameter "%s" must contain more than %d values', $name, $minItems); + } + + if (true === $uniqueItems && $nbItems > \count(array_unique($value))) { + $errorList[] = sprintf('Query parameter "%s" must contain unique values', $name); + } + + return $errorList; + } + + private function getValue(string $name, array $filterDescription, array $queryParameters): array + { + $value = $queryParameters[$name] ?? null; + + if (empty($value) && '0' !== $value) { + return []; + } + + if (\is_array($value)) { + return $value; + } + + $collectionFormat = $filterDescription['swagger']['collectionFormat'] ?? 'csv'; + + return explode(self::getSeparator($collectionFormat), $value) ?: []; + } + + private static function getSeparator(string $collectionFormat): string + { + switch ($collectionFormat) { + case 'csv': + return ','; + case 'ssv': + return ' '; + case 'tsv': + return '\t'; + case 'pipes': + return '|'; + default: + throw new \InvalidArgumentException(sprintf('Unknown collection format %s', $collectionFormat)); + } + } +} diff --git a/src/Filter/Validator/Bounds.php b/src/Filter/Validator/Bounds.php new file mode 100644 index 00000000000..bb7c974b2c1 --- /dev/null +++ b/src/Filter/Validator/Bounds.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Filter\Validator; + +final class Bounds implements ValidatorInterface +{ + /** + * {@inheritdoc} + */ + public function validate(string $name, array $filterDescription, array $queryParameters): array + { + $value = $queryParameters[$name] ?? null; + if (empty($value) && '0' !== $value) { + return []; + } + + $maximum = $filterDescription['swagger']['maximum'] ?? null; + $minimum = $filterDescription['swagger']['minimum'] ?? null; + + $errorList = []; + + if (null !== $maximum) { + if (($filterDescription['swagger']['exclusiveMaximum'] ?? false) && $value >= $maximum) { + $errorList[] = sprintf('Query parameter "%s" must be less than %s', $name, $maximum); + } elseif ($value > $maximum) { + $errorList[] = sprintf('Query parameter "%s" must be less than or equal to %s', $name, $maximum); + } + } + + if (null !== $minimum) { + if (($filterDescription['swagger']['exclusiveMinimum'] ?? false) && $value <= $minimum) { + $errorList[] = sprintf('Query parameter "%s" must be greater than %s', $name, $minimum); + } elseif ($value < $minimum) { + $errorList[] = sprintf('Query parameter "%s" must be greater than or equal to %s', $name, $minimum); + } + } + + return $errorList; + } +} diff --git a/src/Filter/Validator/Enum.php b/src/Filter/Validator/Enum.php new file mode 100644 index 00000000000..5393de43ad0 --- /dev/null +++ b/src/Filter/Validator/Enum.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\Filter\Validator; + +final class Enum implements ValidatorInterface +{ + /** + * {@inheritdoc} + */ + public function validate(string $name, array $filterDescription, array $queryParameters): array + { + $value = $queryParameters[$name] ?? null; + if (empty($value) && '0' !== $value || !\is_string($value)) { + return []; + } + + $enum = $filterDescription['swagger']['enum'] ?? null; + + if (null !== $enum && !\in_array($value, $enum, true)) { + return [ + sprintf('Query parameter "%s" must be one of "%s"', $name, implode(', ', $enum)), + ]; + } + + return []; + } +} diff --git a/src/Filter/Validator/Length.php b/src/Filter/Validator/Length.php new file mode 100644 index 00000000000..6897ef57e02 --- /dev/null +++ b/src/Filter/Validator/Length.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Filter\Validator; + +final class Length implements ValidatorInterface +{ + /** + * {@inheritdoc} + */ + public function validate(string $name, array $filterDescription, array $queryParameters): array + { + $value = $queryParameters[$name] ?? null; + if (empty($value) && '0' !== $value || !\is_string($value)) { + return []; + } + + $maxLength = $filterDescription['swagger']['maxLength'] ?? null; + $minLength = $filterDescription['swagger']['minLength'] ?? null; + + $errorList = []; + + if (null !== $maxLength && mb_strlen($value) > $maxLength) { + $errorList[] = sprintf('Query parameter "%s" length must be lower than or equal to %s', $name, $maxLength); + } + + if (null !== $minLength && mb_strlen($value) < $minLength) { + $errorList[] = sprintf('Query parameter "%s" length must be greater than or equal to %s', $name, $minLength); + } + + return $errorList; + } +} diff --git a/src/Filter/Validator/MultipleOf.php b/src/Filter/Validator/MultipleOf.php new file mode 100644 index 00000000000..75235007bdd --- /dev/null +++ b/src/Filter/Validator/MultipleOf.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\Filter\Validator; + +final class MultipleOf implements ValidatorInterface +{ + /** + * {@inheritdoc} + */ + public function validate(string $name, array $filterDescription, array $queryParameters): array + { + $value = $queryParameters[$name] ?? null; + if (empty($value) && '0' !== $value || !\is_string($value)) { + return []; + } + + $multipleOf = $filterDescription['swagger']['multipleOf'] ?? null; + + if (null !== $multipleOf && 0 !== ($value % $multipleOf)) { + return [ + sprintf('Query parameter "%s" must multiple of %s', $name, $multipleOf), + ]; + } + + return []; + } +} diff --git a/src/Filter/Validator/Pattern.php b/src/Filter/Validator/Pattern.php new file mode 100644 index 00000000000..5346feac04b --- /dev/null +++ b/src/Filter/Validator/Pattern.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\Filter\Validator; + +final class Pattern implements ValidatorInterface +{ + /** + * {@inheritdoc} + */ + public function validate(string $name, array $filterDescription, array $queryParameters): array + { + $value = $queryParameters[$name] ?? null; + if (empty($value) && '0' !== $value || !\is_string($value)) { + return []; + } + + $pattern = $filterDescription['swagger']['pattern'] ?? null; + + if (null !== $pattern && !preg_match($pattern, $value)) { + return [ + sprintf('Query parameter "%s" must match pattern %s', $name, $pattern), + ]; + } + + return []; + } +} diff --git a/src/Filter/Validator/Required.php b/src/Filter/Validator/Required.php new file mode 100644 index 00000000000..4c5fb2d92a8 --- /dev/null +++ b/src/Filter/Validator/Required.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Filter\Validator; + +final class Required implements ValidatorInterface +{ + /** + * {@inheritdoc} + */ + public function validate(string $name, array $filterDescription, array $queryParameters): array + { + // filter is not required, the `checkRequired` method can not break + if (!($filterDescription['required'] ?? false)) { + return []; + } + + // if query param is not given, then break + if (!$this->requestHasQueryParameter($queryParameters, $name)) { + return [ + sprintf('Query parameter "%s" is required', $name), + ]; + } + + // if query param is empty and the configuration does not allow it + if (!($filterDescription['swagger']['allowEmptyValue'] ?? false) && empty($this->requestGetQueryParameter($queryParameters, $name))) { + return [ + sprintf('Query parameter "%s" does not allow empty value', $name), + ]; + } + + return []; + } + + /** + * Test if request has required parameter. + */ + private function requestHasQueryParameter(array $queryParameters, string $name): bool + { + $matches = []; + parse_str($name, $matches); + if (!$matches) { + return false; + } + + $rootName = array_keys($matches)[0] ?? ''; + if (!$rootName) { + return false; + } + + if (\is_array($matches[$rootName])) { + $keyName = array_keys($matches[$rootName])[0]; + + $queryParameter = $queryParameters[(string) $rootName] ?? null; + + return \is_array($queryParameter) && isset($queryParameter[$keyName]); + } + + return \array_key_exists((string) $rootName, $queryParameters); + } + + /** + * Test if required filter is valid. It validates array notation too like "required[bar]". + * + * @return ?mixed + */ + private function requestGetQueryParameter(array $queryParameters, string $name) + { + $matches = []; + parse_str($name, $matches); + if (empty($matches)) { + return null; + } + + $rootName = array_keys($matches)[0] ?? ''; + if (!$rootName) { + return null; + } + + if (\is_array($matches[$rootName])) { + $keyName = array_keys($matches[$rootName])[0]; + + $queryParameter = $queryParameters[(string) $rootName] ?? null; + + if (\is_array($queryParameter) && isset($queryParameter[$keyName])) { + return $queryParameter[$keyName]; + } + + return null; + } + + return $queryParameters[(string) $rootName]; + } +} diff --git a/src/Filter/Validator/ValidatorInterface.php b/src/Filter/Validator/ValidatorInterface.php new file mode 100644 index 00000000000..f111137c8c3 --- /dev/null +++ b/src/Filter/Validator/ValidatorInterface.php @@ -0,0 +1,24 @@ + + * + * For 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\Filter\Validator; + +interface ValidatorInterface +{ + /** + * @param string $name the parameter name to validate + * @param array $filterDescription the filter descriptions as returned by `ApiPlatform\Core\Api\FilterInterface::getDescription()` + * @param array $queryParameters the list of query parameter + */ + public function validate(string $name, array $filterDescription, array $queryParameters): array; +} diff --git a/src/GraphQl/Action/EntrypointAction.php b/src/GraphQl/Action/EntrypointAction.php index a036b10632f..926236a8735 100644 --- a/src/GraphQl/Action/EntrypointAction.php +++ b/src/GraphQl/Action/EntrypointAction.php @@ -13,21 +13,24 @@ namespace ApiPlatform\Core\GraphQl\Action; +use ApiPlatform\Core\GraphQl\Error\ErrorHandlerInterface; use ApiPlatform\Core\GraphQl\ExecutorInterface; use ApiPlatform\Core\GraphQl\Type\SchemaBuilderInterface; use GraphQL\Error\Debug; use GraphQL\Error\DebugFlag; use GraphQL\Error\Error; -use GraphQL\Error\UserError; use GraphQL\Executor\ExecutionResult; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; /** * GraphQL API entrypoint. * + * @experimental + * * @author Alan Poulain */ final class EntrypointAction @@ -36,17 +39,21 @@ final class EntrypointAction private $executor; private $graphiQlAction; private $graphQlPlaygroundAction; + private $normalizer; + private $errorHandler; private $debug; private $graphiqlEnabled; private $graphQlPlaygroundEnabled; private $defaultIde; - public function __construct(SchemaBuilderInterface $schemaBuilder, ExecutorInterface $executor, GraphiQlAction $graphiQlAction, GraphQlPlaygroundAction $graphQlPlaygroundAction, bool $debug = false, bool $graphiqlEnabled = false, bool $graphQlPlaygroundEnabled = false, $defaultIde = false) + public function __construct(SchemaBuilderInterface $schemaBuilder, ExecutorInterface $executor, GraphiQlAction $graphiQlAction, GraphQlPlaygroundAction $graphQlPlaygroundAction, NormalizerInterface $normalizer, ErrorHandlerInterface $errorHandler, bool $debug = false, bool $graphiqlEnabled = false, bool $graphQlPlaygroundEnabled = false, $defaultIde = false) { $this->schemaBuilder = $schemaBuilder; $this->executor = $executor; $this->graphiQlAction = $graphiQlAction; $this->graphQlPlaygroundAction = $graphQlPlaygroundAction; + $this->normalizer = $normalizer; + $this->errorHandler = $errorHandler; if (class_exists(Debug::class)) { $this->debug = $debug ? Debug::INCLUDE_DEBUG_MESSAGE | Debug::INCLUDE_TRACE : false; } else { @@ -59,29 +66,30 @@ public function __construct(SchemaBuilderInterface $schemaBuilder, ExecutorInter public function __invoke(Request $request): Response { - if ($request->isMethod('GET') && 'html' === $request->getRequestFormat()) { - if ('graphiql' === $this->defaultIde && $this->graphiqlEnabled) { - return ($this->graphiQlAction)($request); - } + try { + if ($request->isMethod('GET') && 'html' === $request->getRequestFormat()) { + if ('graphiql' === $this->defaultIde && $this->graphiqlEnabled) { + return ($this->graphiQlAction)($request); + } - if ('graphql-playground' === $this->defaultIde && $this->graphQlPlaygroundEnabled) { - return ($this->graphQlPlaygroundAction)($request); + if ('graphql-playground' === $this->defaultIde && $this->graphQlPlaygroundEnabled) { + return ($this->graphQlPlaygroundAction)($request); + } } - } - try { - [$query, $operation, $variables] = $this->parseRequest($request); + [$query, $operationName, $variables] = $this->parseRequest($request); if (null === $query) { throw new BadRequestHttpException('GraphQL query is not valid.'); } - $executionResult = $this->executor->executeQuery($this->schemaBuilder->getSchema(), $query, null, null, $variables, $operation); - } catch (BadRequestHttpException $e) { - $exception = new UserError($e->getMessage(), 0, $e); - - return $this->buildExceptionResponse($exception, Response::HTTP_BAD_REQUEST); - } catch (\Exception $e) { - return $this->buildExceptionResponse($e, Response::HTTP_OK); + $executionResult = $this->executor + ->executeQuery($this->schemaBuilder->getSchema(), $query, null, null, $variables, $operationName) + ->setErrorsHandler($this->errorHandler) + ->setErrorFormatter([$this->normalizer, 'normalize']); + } catch (\Exception $exception) { + $executionResult = (new ExecutionResult(null, [new Error($exception->getMessage(), null, null, [], null, $exception)])) + ->setErrorsHandler($this->errorHandler) + ->setErrorFormatter([$this->normalizer, 'normalize']); } return new JsonResponse($executionResult->toArray($this->debug)); @@ -94,17 +102,17 @@ private function parseRequest(Request $request): array { $queryParameters = $request->query->all(); $query = $queryParameters['query'] ?? null; - $operation = $queryParameters['operation'] ?? null; + $operationName = $queryParameters['operationName'] ?? null; if ($variables = $queryParameters['variables'] ?? []) { $variables = $this->decodeVariables($variables); } if (!$request->isMethod('POST')) { - return [$query, $operation, $variables]; + return [$query, $operationName, $variables]; } if ('json' === $request->getContentType()) { - return $this->parseData($query, $operation, $variables, $request->getContent()); + return $this->parseData($query, $operationName, $variables, $request->getContent()); } if ('graphql' === $request->getContentType()) { @@ -112,16 +120,16 @@ private function parseRequest(Request $request): array } if ('multipart' === $request->getContentType()) { - return $this->parseMultipartRequest($query, $operation, $variables, $request->request->all(), $request->files->all()); + return $this->parseMultipartRequest($query, $operationName, $variables, $request->request->all(), $request->files->all()); } - return [$query, $operation, $variables]; + return [$query, $operationName, $variables]; } /** * @throws BadRequestHttpException */ - private function parseData(?string $query, ?string $operation, array $variables, string $jsonContent): array + private function parseData(?string $query, ?string $operationName, array $variables, string $jsonContent): array { if (!\is_array($data = json_decode($jsonContent, true))) { throw new BadRequestHttpException('GraphQL data is not valid JSON.'); @@ -135,24 +143,23 @@ private function parseData(?string $query, ?string $operation, array $variables, $variables = \is_array($data['variables']) ? $data['variables'] : $this->decodeVariables($data['variables']); } - if (isset($data['operation'])) { - $operation = $data['operation']; + if (isset($data['operationName'])) { + $operationName = $data['operationName']; } - return [$query, $operation, $variables]; + return [$query, $operationName, $variables]; } /** * @throws BadRequestHttpException */ - private function parseMultipartRequest(?string $query, ?string $operation, array $variables, array $bodyParameters, array $files): array + private function parseMultipartRequest(?string $query, ?string $operationName, array $variables, array $bodyParameters, array $files): array { if ((null === $operations = $bodyParameters['operations'] ?? null) || (null === $map = $bodyParameters['map'] ?? null)) { throw new BadRequestHttpException('GraphQL multipart request does not respect the specification.'); } - /** @var string $operations */ - [$query, $operation, $variables] = $this->parseData($query, $operation, $variables, $operations); + [$query, $operationName, $variables] = $this->parseData($query, $operationName, $variables, $operations); /** @var string $map */ if (!\is_array($decodedMap = json_decode($map, true))) { @@ -161,7 +168,7 @@ private function parseMultipartRequest(?string $query, ?string $operation, array $variables = $this->applyMapToVariables($decodedMap, $variables, $files); - return [$query, $operation, $variables]; + return [$query, $operationName, $variables]; } /** @@ -213,11 +220,4 @@ private function decodeVariables(string $variables): array return $variables; } - - private function buildExceptionResponse(\Exception $e, int $statusCode): JsonResponse - { - $executionResult = new ExecutionResult(null, [new Error($e->getMessage(), null, null, [], null, $e)]); - - return new JsonResponse($executionResult->toArray($this->debug), $statusCode); - } } diff --git a/src/GraphQl/Action/GraphQlPlaygroundAction.php b/src/GraphQl/Action/GraphQlPlaygroundAction.php index 295064451f6..614b33ab49f 100644 --- a/src/GraphQl/Action/GraphQlPlaygroundAction.php +++ b/src/GraphQl/Action/GraphQlPlaygroundAction.php @@ -22,6 +22,8 @@ /** * GraphQL Playground entrypoint. * + * @experimental + * * @author Alan Poulain */ final class GraphQlPlaygroundAction diff --git a/src/GraphQl/Action/GraphiQlAction.php b/src/GraphQl/Action/GraphiQlAction.php index 13c24532fce..1a749cbba25 100644 --- a/src/GraphQl/Action/GraphiQlAction.php +++ b/src/GraphQl/Action/GraphiQlAction.php @@ -22,6 +22,8 @@ /** * GraphiQL entrypoint. * + * @experimental + * * @author Alan Poulain */ final class GraphiQlAction diff --git a/src/GraphQl/Error/ErrorHandler.php b/src/GraphQl/Error/ErrorHandler.php new file mode 100644 index 00000000000..955445cd982 --- /dev/null +++ b/src/GraphQl/Error/ErrorHandler.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\GraphQl\Error; + +/** + * Handles the errors thrown by the GraphQL library by applying the formatter to them (default behavior). + * + * @experimental + * + * @author Ollie Harridge + */ +final class ErrorHandler implements ErrorHandlerInterface +{ + /** + * {@inheritdoc} + */ + public function __invoke(array $errors, callable $formatter): array + { + return array_map($formatter, $errors); + } +} diff --git a/src/GraphQl/Error/ErrorHandlerInterface.php b/src/GraphQl/Error/ErrorHandlerInterface.php new file mode 100644 index 00000000000..4140df1d413 --- /dev/null +++ b/src/GraphQl/Error/ErrorHandlerInterface.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\GraphQl\Error; + +use GraphQL\Error\Error; + +/** + * Handles the errors thrown by the GraphQL library. + * It is responsible for applying the formatter to the errors and can be used for filtering or logging them. + * + * @experimental + * + * @author Ollie Harridge + */ +interface ErrorHandlerInterface +{ + /** + * @param Error[] $errors + */ + public function __invoke(array $errors, callable $formatter): array; +} diff --git a/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php b/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php index af846ce2c0f..8fff34f5643 100644 --- a/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/CollectionResolverFactory.php @@ -71,7 +71,7 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul } $operationName = $operationName ?? 'collection_query'; - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => false]; $collection = ($this->readStage)($resourceClass, $rootClass, $operationName, $resolverContext); if (!is_iterable($collection)) { diff --git a/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php b/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php index fe5948b990a..45fc233f00a 100644 --- a/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ItemMutationResolverFactory.php @@ -24,7 +24,6 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Util\ClassInfoTrait; use ApiPlatform\Core\Util\CloneTrait; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use Psr\Container\ContainerInterface; @@ -71,7 +70,7 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul return null; } - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true, 'is_subscription' => false]; $item = ($this->readStage)($resourceClass, $rootClass, $operationName, $resolverContext); if (null !== $item && !\is_object($item)) { @@ -106,7 +105,7 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul $mutationResolver = $this->mutationResolverLocator->get($mutationResolverId); $item = $mutationResolver($item, $resolverContext); if (null !== $item && $resourceClass !== $itemClass = $this->getObjectClass($item)) { - throw Error::createLocatedError(sprintf('Custom mutation resolver "%s" has to return an item of class %s but returned an item of class %s.', $mutationResolverId, $resourceMetadata->getShortName(), (new \ReflectionClass($itemClass))->getShortName()), $info->fieldNodes, $info->path); + throw new \LogicException(sprintf('Custom mutation resolver "%s" has to return an item of class %s but returned an item of class %s.', $mutationResolverId, $resourceMetadata->getShortName(), (new \ReflectionClass($itemClass))->getShortName())); } } diff --git a/src/GraphQl/Resolver/Factory/ItemResolverFactory.php b/src/GraphQl/Resolver/Factory/ItemResolverFactory.php index ed00054e537..41be00912f2 100644 --- a/src/GraphQl/Resolver/Factory/ItemResolverFactory.php +++ b/src/GraphQl/Resolver/Factory/ItemResolverFactory.php @@ -21,7 +21,6 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Util\ClassInfoTrait; use ApiPlatform\Core\Util\CloneTrait; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use Psr\Container\ContainerInterface; @@ -65,14 +64,14 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul } $operationName = $operationName ?? 'item_query'; - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => false]; $item = ($this->readStage)($resourceClass, $rootClass, $operationName, $resolverContext); if (null !== $item && !\is_object($item)) { throw new \LogicException('Item from read stage should be a nullable object.'); } - $resourceClass = $this->getResourceClass($item, $resourceClass, $info); + $resourceClass = $this->getResourceClass($item, $resourceClass); $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); $queryResolverId = $resourceMetadata->getGraphqlAttribute($operationName, 'item_query'); @@ -80,7 +79,7 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul /** @var QueryItemResolverInterface $queryResolver */ $queryResolver = $this->queryResolverLocator->get($queryResolverId); $item = $queryResolver($item, $resolverContext); - $resourceClass = $this->getResourceClass($item, $resourceClass, $info, sprintf('Custom query resolver "%s"', $queryResolverId).' has to return an item of class %s but returned an item of class %s.'); + $resourceClass = $this->getResourceClass($item, $resourceClass, sprintf('Custom query resolver "%s"', $queryResolverId).' has to return an item of class %s but returned an item of class %s.'); } ($this->securityStage)($resourceClass, $operationName, $resolverContext + [ @@ -102,13 +101,13 @@ public function __invoke(?string $resourceClass = null, ?string $rootClass = nul /** * @param object|null $item * - * @throws Error + * @throws \UnexpectedValueException */ - private function getResourceClass($item, ?string $resourceClass, ResolveInfo $info, string $errorMessage = 'Resolver only handles items of class %s but retrieved item is of class %s.'): string + private function getResourceClass($item, ?string $resourceClass, string $errorMessage = 'Resolver only handles items of class %s but retrieved item is of class %s.'): string { if (null === $item) { if (null === $resourceClass) { - throw Error::createLocatedError('Resource class cannot be determined.', $info->fieldNodes, $info->path); + throw new \UnexpectedValueException('Resource class cannot be determined.'); } return $resourceClass; @@ -121,7 +120,7 @@ private function getResourceClass($item, ?string $resourceClass, ResolveInfo $in } if ($resourceClass !== $itemClass) { - throw Error::createLocatedError(sprintf($errorMessage, (new \ReflectionClass($resourceClass))->getShortName(), (new \ReflectionClass($itemClass))->getShortName()), $info->fieldNodes, $info->path); + throw new \UnexpectedValueException(sprintf($errorMessage, (new \ReflectionClass($resourceClass))->getShortName(), (new \ReflectionClass($itemClass))->getShortName())); } return $resourceClass; diff --git a/src/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactory.php b/src/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactory.php new file mode 100644 index 00000000000..decad8398b3 --- /dev/null +++ b/src/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactory.php @@ -0,0 +1,90 @@ + + * + * For 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\GraphQl\Resolver\Factory; + +use ApiPlatform\Core\GraphQl\Resolver\Stage\ReadStageInterface; +use ApiPlatform\Core\GraphQl\Resolver\Stage\SecurityStageInterface; +use ApiPlatform\Core\GraphQl\Resolver\Stage\SerializeStageInterface; +use ApiPlatform\Core\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface; +use ApiPlatform\Core\GraphQl\Subscription\SubscriptionManagerInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Util\ClassInfoTrait; +use ApiPlatform\Core\Util\CloneTrait; +use GraphQL\Type\Definition\ResolveInfo; + +/** + * Creates a function resolving a GraphQL subscription of an item. + * + * @experimental + * + * @author Alan Poulain + */ +final class ItemSubscriptionResolverFactory implements ResolverFactoryInterface +{ + use ClassInfoTrait; + use CloneTrait; + + private $readStage; + private $securityStage; + private $serializeStage; + private $resourceMetadataFactory; + private $subscriptionManager; + private $mercureSubscriptionIriGenerator; + + public function __construct(ReadStageInterface $readStage, SecurityStageInterface $securityStage, SerializeStageInterface $serializeStage, ResourceMetadataFactoryInterface $resourceMetadataFactory, SubscriptionManagerInterface $subscriptionManager, ?MercureSubscriptionIriGeneratorInterface $mercureSubscriptionIriGenerator) + { + $this->readStage = $readStage; + $this->securityStage = $securityStage; + $this->serializeStage = $serializeStage; + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->subscriptionManager = $subscriptionManager; + $this->mercureSubscriptionIriGenerator = $mercureSubscriptionIriGenerator; + } + + public function __invoke(?string $resourceClass = null, ?string $rootClass = null, ?string $operationName = null): callable + { + return function (?array $source, array $args, $context, ResolveInfo $info) use ($resourceClass, $rootClass, $operationName) { + if (null === $resourceClass || null === $operationName) { + return null; + } + + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + + $item = ($this->readStage)($resourceClass, $rootClass, $operationName, $resolverContext); + if (null !== $item && !\is_object($item)) { + throw new \LogicException('Item from read stage should be a nullable object.'); + } + ($this->securityStage)($resourceClass, $operationName, $resolverContext + [ + 'extra_variables' => [ + 'object' => $item, + ], + ]); + + $result = ($this->serializeStage)($item, $resourceClass, $operationName, $resolverContext); + + $subscriptionId = $this->subscriptionManager->retrieveSubscriptionId($resolverContext, $result); + + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + + if ($subscriptionId && $resourceMetadata->getAttribute('mercure', false)) { + if (!$this->mercureSubscriptionIriGenerator) { + throw new \LogicException('Cannot use Mercure for subscriptions when MercureBundle is not installed. Try running "composer require mercure".'); + } + $result['mercureUrl'] = $this->mercureSubscriptionIriGenerator->generateMercureUrl($subscriptionId); + } + + return $result; + }; + } +} diff --git a/src/GraphQl/Resolver/Stage/ReadStage.php b/src/GraphQl/Resolver/Stage/ReadStage.php index 144baa8fb0d..213976035c5 100644 --- a/src/GraphQl/Resolver/Stage/ReadStage.php +++ b/src/GraphQl/Resolver/Stage/ReadStage.php @@ -17,12 +17,14 @@ use ApiPlatform\Core\DataProvider\ContextAwareCollectionDataProviderInterface; use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; use ApiPlatform\Core\Exception\ItemNotFoundException; +use ApiPlatform\Core\GraphQl\Resolver\Util\IdentifierTrait; use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilderInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Util\ArrayTrait; use ApiPlatform\Core\Util\ClassInfoTrait; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * Read stage of GraphQL resolvers. @@ -34,6 +36,8 @@ final class ReadStage implements ReadStageInterface { use ClassInfoTrait; + use IdentifierTrait; + use ArrayTrait; private $resourceMetadataFactory; private $iriConverter; @@ -63,22 +67,19 @@ public function __invoke(?string $resourceClass, ?string $rootClass, string $ope } $args = $context['args']; - /** @var ResolveInfo $info */ - $info = $context['info']; - $normalizationContext = $this->serializerContextBuilder->create($resourceClass, $operationName, $context, true); if (!$context['is_collection']) { - $identifier = $this->getIdentifier($context); + $identifier = $this->getIdentifierFromContext($context); $item = $this->getItem($identifier, $normalizationContext); - if ($identifier && $context['is_mutation']) { + if ($identifier && ($context['is_mutation'] || $context['is_subscription'])) { if (null === $item) { - throw Error::createLocatedError(sprintf('Item "%s" not found.', $args['input']['id']), $info->fieldNodes, $info->path); + throw new NotFoundHttpException(sprintf('Item "%s" not found.', $args['input']['id'])); } if ($resourceClass !== $this->getObjectClass($item)) { - throw Error::createLocatedError(sprintf('Item "%s" did not match expected type "%s".', $args['input']['id'], $resourceMetadata->getShortName()), $info->fieldNodes, $info->path); + throw new \UnexpectedValueException(sprintf('Item "%s" did not match expected type "%s".', $args['input']['id'], $resourceMetadata->getShortName())); } } @@ -92,12 +93,14 @@ public function __invoke(?string $resourceClass, ?string $rootClass, string $ope $normalizationContext['filters'] = $this->getNormalizedFilters($args); $source = $context['source']; + /** @var ResolveInfo $info */ + $info = $context['info']; if (isset($source[$rootProperty = $info->fieldName], $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY], $source[ItemNormalizer::ITEM_RESOURCE_CLASS_KEY])) { $rootResolvedFields = $source[ItemNormalizer::ITEM_IDENTIFIERS_KEY]; $rootResolvedClass = $source[ItemNormalizer::ITEM_RESOURCE_CLASS_KEY]; $subresourceCollection = $this->getSubresource($rootResolvedClass, $rootResolvedFields, $rootProperty, $resourceClass, $normalizationContext, $operationName); if (!is_iterable($subresourceCollection)) { - throw new \UnexpectedValueException('Expected subresource collection to be iterable'); + throw new \UnexpectedValueException('Expected subresource collection to be iterable.'); } return $subresourceCollection; @@ -106,17 +109,6 @@ public function __invoke(?string $resourceClass, ?string $rootClass, string $ope return $this->collectionDataProvider->getCollection($resourceClass, $operationName, $normalizationContext); } - private function getIdentifier(array $context): ?string - { - $args = $context['args']; - - if ($context['is_mutation']) { - return $args['input']['id'] ?? null; - } - - return $args['id'] ?? null; - } - /** * @return object|null */ @@ -144,6 +136,22 @@ private function getNormalizedFilters(array $args): array if (strpos($name, '_list')) { $name = substr($name, 0, \strlen($name) - \strlen('_list')); } + + // If the value contains arrays, we need to merge them for the filters to understand this syntax, proper to GraphQL to preserve the order of the arguments. + if ($this->isSequentialArrayOfArrays($value)) { + if (\count($value[0]) > 1) { + $deprecationMessage = "The filter syntax \"$name: {"; + $filterArgsOld = []; + $filterArgsNew = []; + foreach ($value[0] as $filterArgName => $filterArgValue) { + $filterArgsOld[] = "$filterArgName: \"$filterArgValue\""; + $filterArgsNew[] = sprintf('{%s: "%s"}', $filterArgName, $filterArgValue); + } + $deprecationMessage .= sprintf('%s}" is deprecated since API Platform 2.6, use the following syntax instead: "%s: [%s]".', implode(', ', $filterArgsOld), $name, implode(', ', $filterArgsNew)); + @trigger_error($deprecationMessage, E_USER_DEPRECATED); + } + $value = array_merge(...$value); + } $filters[$name] = $this->getNormalizedFilters($value); } diff --git a/src/GraphQl/Resolver/Stage/SecurityPostDenormalizeStage.php b/src/GraphQl/Resolver/Stage/SecurityPostDenormalizeStage.php index d6ab052d085..23c7b4cd86d 100644 --- a/src/GraphQl/Resolver/Stage/SecurityPostDenormalizeStage.php +++ b/src/GraphQl/Resolver/Stage/SecurityPostDenormalizeStage.php @@ -15,8 +15,7 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; -use GraphQL\Error\Error; -use GraphQL\Type\Definition\ResolveInfo; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; /** * Security post denormalize stage of GraphQL resolvers. @@ -61,8 +60,6 @@ public function __invoke(string $resourceClass, string $operationName, array $co return; } - /** @var ResolveInfo $info */ - $info = $context['info']; - throw Error::createLocatedError($resourceMetadata->getGraphqlAttribute($operationName, 'security_post_denormalize_message', 'Access Denied.'), $info->fieldNodes, $info->path); + throw new AccessDeniedHttpException($resourceMetadata->getGraphqlAttribute($operationName, 'security_post_denormalize_message', 'Access Denied.')); } } diff --git a/src/GraphQl/Resolver/Stage/SecurityStage.php b/src/GraphQl/Resolver/Stage/SecurityStage.php index b3afa035618..6297c6eba4d 100644 --- a/src/GraphQl/Resolver/Stage/SecurityStage.php +++ b/src/GraphQl/Resolver/Stage/SecurityStage.php @@ -15,8 +15,7 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; -use GraphQL\Error\Error; -use GraphQL\Type\Definition\ResolveInfo; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; /** * Security stage of GraphQL resolvers. @@ -53,8 +52,6 @@ public function __invoke(string $resourceClass, string $operationName, array $co return; } - /** @var ResolveInfo $info */ - $info = $context['info']; - throw Error::createLocatedError($resourceMetadata->getGraphqlAttribute($operationName, 'security_message', 'Access Denied.'), $info->fieldNodes, $info->path); + throw new AccessDeniedHttpException($resourceMetadata->getGraphqlAttribute($operationName, 'security_message', 'Access Denied.')); } } diff --git a/src/GraphQl/Resolver/Stage/SerializeStage.php b/src/GraphQl/Resolver/Stage/SerializeStage.php index 3f2fcdc37a3..35bbedc5d93 100644 --- a/src/GraphQl/Resolver/Stage/SerializeStage.php +++ b/src/GraphQl/Resolver/Stage/SerializeStage.php @@ -15,11 +15,10 @@ use ApiPlatform\Core\DataProvider\Pagination; use ApiPlatform\Core\DataProvider\PaginatorInterface; +use ApiPlatform\Core\GraphQl\Resolver\Util\IdentifierTrait; use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\Core\GraphQl\Serializer\SerializerContextBuilderInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; -use GraphQL\Error\Error; -use GraphQL\Type\Definition\ResolveInfo; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; /** @@ -31,6 +30,8 @@ */ final class SerializeStage implements SerializeStageInterface { + use IdentifierTrait; + private $resourceMetadataFactory; private $normalizer; private $serializerContextBuilder; @@ -51,12 +52,15 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera { $isCollection = $context['is_collection']; $isMutation = $context['is_mutation']; + $isSubscription = $context['is_subscription']; $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); if (!$resourceMetadata->getGraphqlAttribute($operationName, 'serialize', true, true)) { if ($isCollection) { if ($this->pagination->isGraphQlEnabled($resourceClass, $operationName, $context)) { - return $this->getDefaultPaginatedData(); + return 'cursor' === $this->pagination->getGraphQlPaginationType($resourceClass, $operationName) ? + $this->getDefaultCursorBasedPaginatedData() : + $this->getDefaultPageBasedPaginatedData(); } return []; @@ -66,19 +70,19 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera return $this->getDefaultMutationData($context); } + if ($isSubscription) { + return $this->getDefaultSubscriptionData($context); + } + return null; } $normalizationContext = $this->serializerContextBuilder->create($resourceClass, $operationName, $context, true); - $args = $context['args']; - /** @var ResolveInfo $info */ - $info = $context['info']; - $data = null; if (!$isCollection) { if ($isMutation && 'delete' === $operationName) { - $data = ['id' => $args['input']['id'] ?? null]; + $data = ['id' => $this->getIdentifierFromContext($context)]; } else { $data = $this->normalizer->normalize($itemOrCollection, ItemNormalizer::FORMAT, $normalizationContext); } @@ -91,34 +95,35 @@ public function __invoke($itemOrCollection, string $resourceClass, string $opera $data[$index] = $this->normalizer->normalize($object, ItemNormalizer::FORMAT, $normalizationContext); } } else { - $data = $this->serializePaginatedCollection($itemOrCollection, $normalizationContext, $context); + $data = 'cursor' === $this->pagination->getGraphQlPaginationType($resourceClass, $operationName) ? + $this->serializeCursorBasedPaginatedCollection($itemOrCollection, $normalizationContext, $context) : + $this->serializePageBasedPaginatedCollection($itemOrCollection, $normalizationContext); } } if (null !== $data && !\is_array($data)) { - throw Error::createLocatedError('Expected serialized data to be a nullable array.', $info->fieldNodes, $info->path); + throw new \UnexpectedValueException('Expected serialized data to be a nullable array.'); } - if ($isMutation) { + if ($isMutation || $isSubscription) { $wrapFieldName = lcfirst($resourceMetadata->getShortName()); - return [$wrapFieldName => $data] + $this->getDefaultMutationData($context); + return [$wrapFieldName => $data] + ($isMutation ? $this->getDefaultMutationData($context) : $this->getDefaultSubscriptionData($context)); } return $data; } /** - * @throws Error + * @throws \LogicException + * @throws \UnexpectedValueException */ - private function serializePaginatedCollection(iterable $collection, array $normalizationContext, array $context): array + private function serializeCursorBasedPaginatedCollection(iterable $collection, array $normalizationContext, array $context): array { $args = $context['args']; - /** @var ResolveInfo $info */ - $info = $context['info']; if (!($collection instanceof PaginatorInterface)) { - throw Error::createLocatedError(sprintf('Collection returned by the collection data provider must implement %s', PaginatorInterface::class), $info->fieldNodes, $info->path); + throw new \LogicException(sprintf('Collection returned by the collection data provider must implement %s.', PaginatorInterface::class)); } $offset = 0; @@ -127,16 +132,14 @@ private function serializePaginatedCollection(iterable $collection, array $norma if (isset($args['after'])) { $after = base64_decode($args['after'], true); if (false === $after || '' === $args['after']) { - $msg = '' === $args['after'] ? 'Empty cursor is invalid' : sprintf('Cursor %s is invalid', $args['after']); - throw Error::createLocatedError($msg, $info->fieldNodes, $info->path); + throw new \UnexpectedValueException('' === $args['after'] ? 'Empty cursor is invalid' : sprintf('Cursor %s is invalid', $args['after'])); } $offset = 1 + (int) $after; } if (isset($args['before'])) { $before = base64_decode($args['before'], true); if (false === $before || '' === $args['before']) { - $msg = '' === $args['before'] ? 'Empty cursor is invalid' : sprintf('Cursor %s is invalid', $args['before']); - throw Error::createLocatedError($msg, $info->fieldNodes, $info->path); + throw new \UnexpectedValueException('' === $args['before'] ? 'Empty cursor is invalid' : sprintf('Cursor %s is invalid', $args['before'])); } $offset = (int) $before - $nbPageItems; } @@ -145,7 +148,7 @@ private function serializePaginatedCollection(iterable $collection, array $norma } $offset = 0 > $offset ? 0 : $offset; - $data = $this->getDefaultPaginatedData(); + $data = $this->getDefaultCursorBasedPaginatedData(); if (($totalItems = $collection->getTotalItems()) > 0) { $data['totalCount'] = $totalItems; @@ -169,13 +172,44 @@ private function serializePaginatedCollection(iterable $collection, array $norma return $data; } - private function getDefaultPaginatedData(): array + /** + * @throws \LogicException + */ + private function serializePageBasedPaginatedCollection(iterable $collection, array $normalizationContext): array + { + if (!($collection instanceof PaginatorInterface)) { + throw new \LogicException(sprintf('Collection returned by the collection data provider must implement %s.', PaginatorInterface::class)); + } + + $data = $this->getDefaultPageBasedPaginatedData(); + $data['paginationInfo']['totalCount'] = $collection->getTotalItems(); + $data['paginationInfo']['lastPage'] = $collection->getLastPage(); + $data['paginationInfo']['itemsPerPage'] = $collection->getItemsPerPage(); + + foreach ($collection as $object) { + $data['collection'][] = $this->normalizer->normalize($object, ItemNormalizer::FORMAT, $normalizationContext); + } + + return $data; + } + + private function getDefaultCursorBasedPaginatedData(): array { return ['totalCount' => 0., 'edges' => [], 'pageInfo' => ['startCursor' => null, 'endCursor' => null, 'hasNextPage' => false, 'hasPreviousPage' => false]]; } + private function getDefaultPageBasedPaginatedData(): array + { + return ['collection' => [], 'paginationInfo' => ['itemsPerPage' => 0., 'totalCount' => 0., 'lastPage' => 0.]]; + } + private function getDefaultMutationData(array $context): array { return ['clientMutationId' => $context['args']['input']['clientMutationId'] ?? null]; } + + private function getDefaultSubscriptionData(array $context): array + { + return ['clientSubscriptionId' => $context['args']['input']['clientSubscriptionId'] ?? null]; + } } diff --git a/src/GraphQl/Resolver/Stage/ValidateStage.php b/src/GraphQl/Resolver/Stage/ValidateStage.php index 14d98e51ae0..3c46aea7746 100644 --- a/src/GraphQl/Resolver/Stage/ValidateStage.php +++ b/src/GraphQl/Resolver/Stage/ValidateStage.php @@ -14,10 +14,7 @@ namespace ApiPlatform\Core\GraphQl\Resolver\Stage; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; -use ApiPlatform\Core\Validator\Exception\ValidationException; use ApiPlatform\Core\Validator\ValidatorInterface; -use GraphQL\Error\Error; -use GraphQL\Type\Definition\ResolveInfo; /** * Validate stage of GraphQL resolvers. @@ -48,13 +45,6 @@ public function __invoke($object, string $resourceClass, string $operationName, } $validationGroups = $resourceMetadata->getGraphqlAttribute($operationName, 'validation_groups', null, true); - try { - $this->validator->validate($object, ['groups' => $validationGroups]); - } catch (ValidationException $e) { - /** @var ResolveInfo $info */ - $info = $context['info']; - - throw Error::createLocatedError($e->getMessage(), $info->fieldNodes, $info->path); - } + $this->validator->validate($object, ['groups' => $validationGroups]); } } diff --git a/src/GraphQl/Resolver/Util/IdentifierTrait.php b/src/GraphQl/Resolver/Util/IdentifierTrait.php new file mode 100644 index 00000000000..0dcee001ffc --- /dev/null +++ b/src/GraphQl/Resolver/Util/IdentifierTrait.php @@ -0,0 +1,35 @@ + + * + * For 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\GraphQl\Resolver\Util; + +/** + * Identifier helper methods. + * + * @internal + * + * @author Alan Poulain + */ +trait IdentifierTrait +{ + private function getIdentifierFromContext(array $context): ?string + { + $args = $context['args']; + + if ($context['is_mutation'] || $context['is_subscription']) { + return $args['input']['id'] ?? null; + } + + return $args['id'] ?? null; + } +} diff --git a/src/GraphQl/Serializer/Exception/ErrorNormalizer.php b/src/GraphQl/Serializer/Exception/ErrorNormalizer.php new file mode 100644 index 00000000000..05b214f1c58 --- /dev/null +++ b/src/GraphQl/Serializer/Exception/ErrorNormalizer.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\GraphQl\Serializer\Exception; + +use GraphQL\Error\Error; +use GraphQL\Error\FormattedError; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Normalize GraphQL error (fallback). + * + * @experimental + * + * @author Alan Poulain + */ +final class ErrorNormalizer implements NormalizerInterface +{ + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []): array + { + return FormattedError::createFromException($object); + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null): bool + { + return $data instanceof Error; + } +} diff --git a/src/GraphQl/Serializer/Exception/HttpExceptionNormalizer.php b/src/GraphQl/Serializer/Exception/HttpExceptionNormalizer.php new file mode 100644 index 00000000000..4c7e97c2b90 --- /dev/null +++ b/src/GraphQl/Serializer/Exception/HttpExceptionNormalizer.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\GraphQl\Serializer\Exception; + +use GraphQL\Error\Error; +use GraphQL\Error\FormattedError; +use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Normalize HTTP exceptions. + * + * @experimental + * + * @author Alan Poulain + */ +final class HttpExceptionNormalizer implements NormalizerInterface +{ + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []): array + { + /** @var HttpExceptionInterface */ + $httpException = $object->getPrevious(); + $error = FormattedError::createFromException($object); + $error['message'] = $httpException->getMessage(); + $error['extensions']['status'] = $statusCode = $httpException->getStatusCode(); + $error['extensions']['category'] = $statusCode < 500 ? 'user' : Error::CATEGORY_INTERNAL; + + return $error; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null): bool + { + return $data instanceof Error && $data->getPrevious() instanceof HttpExceptionInterface; + } +} diff --git a/src/GraphQl/Serializer/Exception/RuntimeExceptionNormalizer.php b/src/GraphQl/Serializer/Exception/RuntimeExceptionNormalizer.php new file mode 100644 index 00000000000..61bc85fe7e8 --- /dev/null +++ b/src/GraphQl/Serializer/Exception/RuntimeExceptionNormalizer.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\GraphQl\Serializer\Exception; + +use GraphQL\Error\Error; +use GraphQL\Error\FormattedError; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Normalize runtime exceptions to have the right message in production mode. + * + * @experimental + * + * @author Alan Poulain + */ +final class RuntimeExceptionNormalizer implements NormalizerInterface +{ + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []): array + { + /** @var \RuntimeException */ + $runtimeException = $object->getPrevious(); + $error = FormattedError::createFromException($object); + $error['message'] = $runtimeException->getMessage(); + + return $error; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null): bool + { + return $data instanceof Error && $data->getPrevious() instanceof \RuntimeException; + } +} diff --git a/src/GraphQl/Serializer/Exception/ValidationExceptionNormalizer.php b/src/GraphQl/Serializer/Exception/ValidationExceptionNormalizer.php new file mode 100644 index 00000000000..f00473ff966 --- /dev/null +++ b/src/GraphQl/Serializer/Exception/ValidationExceptionNormalizer.php @@ -0,0 +1,82 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\GraphQl\Serializer\Exception; + +use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException; +use GraphQL\Error\Error; +use GraphQL\Error\FormattedError; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Symfony\Component\Validator\ConstraintViolation; + +/** + * Normalize validation exceptions. + * + * @experimental + * + * @author Mahmood Bazdar + * @author Alan Poulain + */ +final class ValidationExceptionNormalizer implements NormalizerInterface +{ + private $exceptionToStatus; + + public function __construct(array $exceptionToStatus = []) + { + $this->exceptionToStatus = $exceptionToStatus; + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []): array + { + /** @var ValidationException */ + $validationException = $object->getPrevious(); + $error = FormattedError::createFromException($object); + $error['message'] = $validationException->getMessage(); + + $exceptionClass = \get_class($validationException); + $statusCode = Response::HTTP_UNPROCESSABLE_ENTITY; + + foreach ($this->exceptionToStatus as $class => $status) { + if (is_a($exceptionClass, $class, true)) { + $statusCode = $status; + + break; + } + } + $error['extensions']['status'] = $statusCode; + $error['extensions']['category'] = 'user'; + $error['extensions']['violations'] = []; + + /** @var ConstraintViolation $violation */ + foreach ($validationException->getConstraintViolationList() as $violation) { + $error['extensions']['violations'][] = [ + 'path' => $violation->getPropertyPath(), + 'message' => $violation->getMessage(), + ]; + } + + return $error; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null): bool + { + return $data instanceof Error && $data->getPrevious() instanceof ValidationException; + } +} diff --git a/src/GraphQl/Serializer/ItemNormalizer.php b/src/GraphQl/Serializer/ItemNormalizer.php index 12fa55c1f23..e7c5e6c8a0c 100644 --- a/src/GraphQl/Serializer/ItemNormalizer.php +++ b/src/GraphQl/Serializer/ItemNormalizer.php @@ -73,11 +73,13 @@ public function normalize($object, $format = null, array $context = []) $data = parent::normalize($object, $format, $context); if (!\is_array($data)) { - throw new UnexpectedValueException('Expected data to be an array'); + throw new UnexpectedValueException('Expected data to be an array.'); } - $data[self::ITEM_RESOURCE_CLASS_KEY] = $this->getObjectClass($object); - $data[self::ITEM_IDENTIFIERS_KEY] = $this->identifiersExtractor->getIdentifiersFromItem($object); + if (!($context['no_resolver_data'] ?? false)) { + $data[self::ITEM_RESOURCE_CLASS_KEY] = $this->getObjectClass($object); + $data[self::ITEM_IDENTIFIERS_KEY] = $this->identifiersExtractor->getIdentifiersFromItem($object); + } return $data; } diff --git a/src/GraphQl/Serializer/ObjectNormalizer.php b/src/GraphQl/Serializer/ObjectNormalizer.php index 2fd5651fe95..dc93a1dc40c 100644 --- a/src/GraphQl/Serializer/ObjectNormalizer.php +++ b/src/GraphQl/Serializer/ObjectNormalizer.php @@ -73,7 +73,7 @@ public function normalize($object, $format = null, array $context = []) $data = $this->decorated->normalize($object, $format, $context); if (!\is_array($data)) { - throw new UnexpectedValueException('Expected data to be an array'); + throw new UnexpectedValueException('Expected data to be an array.'); } if (!isset($originalResource)) { @@ -85,8 +85,10 @@ public function normalize($object, $format = null, array $context = []) $data['id'] = $this->iriConverter->getIriFromItem($originalResource); } - $data[self::ITEM_RESOURCE_CLASS_KEY] = $this->getObjectClass($originalResource); - $data[self::ITEM_IDENTIFIERS_KEY] = $this->identifiersExtractor->getIdentifiersFromItem($originalResource); + if (!($context['no_resolver_data'] ?? false)) { + $data[self::ITEM_RESOURCE_CLASS_KEY] = $this->getObjectClass($originalResource); + $data[self::ITEM_IDENTIFIERS_KEY] = $this->identifiersExtractor->getIdentifiersFromItem($originalResource); + } return $data; } diff --git a/src/GraphQl/Serializer/SerializerContextBuilder.php b/src/GraphQl/Serializer/SerializerContextBuilder.php index 2f56d5be72c..5a4b353a4d1 100644 --- a/src/GraphQl/Serializer/SerializerContextBuilder.php +++ b/src/GraphQl/Serializer/SerializerContextBuilder.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use GraphQL\Type\Definition\ResolveInfo; +use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** @@ -45,8 +46,8 @@ public function create(?string $resourceClass, string $operationName, array $res 'graphql_operation_name' => $operationName, ]; - if ($normalization) { - $context['attributes'] = $this->fieldsToAttributes($resourceMetadata, $resolverContext); + if (isset($resolverContext['fields'])) { + $context['no_resolver_data'] = true; } if ($resourceMetadata) { @@ -57,23 +58,31 @@ public function create(?string $resourceClass, string $operationName, array $res $context = array_merge($resourceMetadata->getGraphqlAttribute($operationName, $key, [], true), $context); } + if ($normalization) { + $context['attributes'] = $this->fieldsToAttributes($resourceClass, $resourceMetadata, $resolverContext, $context); + } + return $context; } /** * Retrieves fields, recursively replaces the "_id" key (the raw id) by "id" (the name of the property expected by the Serializer) and flattens edge and node structures (pagination). */ - private function fieldsToAttributes(?ResourceMetadata $resourceMetadata, array $context): array + private function fieldsToAttributes(?string $resourceClass, ?ResourceMetadata $resourceMetadata, array $resolverContext, array $context): array { - /** @var ResolveInfo $info */ - $info = $context['info']; - $fields = $info->getFieldSelection(PHP_INT_MAX); + if (isset($resolverContext['fields'])) { + $fields = $resolverContext['fields']; + } else { + /** @var ResolveInfo $info */ + $info = $resolverContext['info']; + $fields = $info->getFieldSelection(PHP_INT_MAX); + } - $attributes = $this->replaceIdKeys($fields['edges']['node'] ?? $fields); + $attributes = $this->replaceIdKeys($fields['edges']['node'] ?? $fields['collection'] ?? $fields, $resourceClass, $context); - if ($context['is_mutation']) { + if ($resolverContext['is_mutation'] || $resolverContext['is_subscription']) { if (!$resourceMetadata) { - throw new \LogicException('ResourceMetadata should always exist for a mutation.'); + throw new \LogicException('ResourceMetadata should always exist for a mutation or a subscription.'); } $wrapFieldName = lcfirst($resourceMetadata->getShortName()); @@ -84,7 +93,7 @@ private function fieldsToAttributes(?ResourceMetadata $resourceMetadata, array $ return $attributes; } - private function replaceIdKeys(array $fields): array + private function replaceIdKeys(array $fields, ?string $resourceClass, array $context): array { $denormalizedFields = []; @@ -95,14 +104,21 @@ private function replaceIdKeys(array $fields): array continue; } - $denormalizedFields[$this->denormalizePropertyName((string) $key)] = \is_array($fields[$key]) ? $this->replaceIdKeys($fields[$key]) : $value; + $denormalizedFields[$this->denormalizePropertyName((string) $key, $resourceClass, $context)] = \is_array($fields[$key]) ? $this->replaceIdKeys($fields[$key], $resourceClass, $context) : $value; } return $denormalizedFields; } - private function denormalizePropertyName(string $property): string + private function denormalizePropertyName(string $property, ?string $resourceClass, array $context): string { - return null !== $this->nameConverter ? $this->nameConverter->denormalize($property) : $property; + if (null === $this->nameConverter) { + return $property; + } + if ($this->nameConverter instanceof AdvancedNameConverterInterface) { + return $this->nameConverter->denormalize($property, $resourceClass, null, $context); + } + + return $this->nameConverter->denormalize($property); } } diff --git a/src/GraphQl/Subscription/MercureSubscriptionIriGenerator.php b/src/GraphQl/Subscription/MercureSubscriptionIriGenerator.php new file mode 100644 index 00000000000..80ca4e73ab8 --- /dev/null +++ b/src/GraphQl/Subscription/MercureSubscriptionIriGenerator.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\GraphQl\Subscription; + +use Symfony\Component\Routing\RequestContext; + +/** + * Generates Mercure-related IRIs from a subscription ID. + * + * @experimental + * + * @author Alan Poulain + */ +final class MercureSubscriptionIriGenerator implements MercureSubscriptionIriGeneratorInterface +{ + private $requestContext; + private $hub; + + public function __construct(RequestContext $requestContext, string $hub) + { + $this->requestContext = $requestContext; + $this->hub = $hub; + } + + public function generateTopicIri(string $subscriptionId): string + { + if ('' === $scheme = $this->requestContext->getScheme()) { + $scheme = 'https'; + } + if ('' === $host = $this->requestContext->getHost()) { + $host = 'api-platform.com'; + } + + return "$scheme://$host/subscriptions/$subscriptionId"; + } + + public function generateMercureUrl(string $subscriptionId): string + { + return $this->hub.'?topic='.$this->generateTopicIri($subscriptionId); + } +} diff --git a/src/GraphQl/Subscription/MercureSubscriptionIriGeneratorInterface.php b/src/GraphQl/Subscription/MercureSubscriptionIriGeneratorInterface.php new file mode 100644 index 00000000000..605cc773701 --- /dev/null +++ b/src/GraphQl/Subscription/MercureSubscriptionIriGeneratorInterface.php @@ -0,0 +1,28 @@ + + * + * For 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\GraphQl\Subscription; + +/** + * Generates Mercure-related IRIs from a subscription ID. + * + * @experimental + * + * @author Alan Poulain + */ +interface MercureSubscriptionIriGeneratorInterface +{ + public function generateTopicIri(string $subscriptionId): string; + + public function generateMercureUrl(string $subscriptionId): string; +} diff --git a/src/GraphQl/Subscription/SubscriptionIdentifierGenerator.php b/src/GraphQl/Subscription/SubscriptionIdentifierGenerator.php new file mode 100644 index 00000000000..b64786e45b5 --- /dev/null +++ b/src/GraphQl/Subscription/SubscriptionIdentifierGenerator.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\GraphQl\Subscription; + +/** + * Generates an identifier used to identify a subscription. + * + * @experimental + * + * @author Alan Poulain + */ +final class SubscriptionIdentifierGenerator implements SubscriptionIdentifierGeneratorInterface +{ + public function generateSubscriptionIdentifier(array $fields): string + { + unset($fields['mercureUrl'], $fields['clientSubscriptionId']); + + return hash('sha256', print_r($fields, true)); + } +} diff --git a/src/GraphQl/Subscription/SubscriptionIdentifierGeneratorInterface.php b/src/GraphQl/Subscription/SubscriptionIdentifierGeneratorInterface.php new file mode 100644 index 00000000000..bcef927f80f --- /dev/null +++ b/src/GraphQl/Subscription/SubscriptionIdentifierGeneratorInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\GraphQl\Subscription; + +/** + * Generates an identifier used to identify a subscription. + * + * @experimental + * + * @author Alan Poulain + */ +interface SubscriptionIdentifierGeneratorInterface +{ + public function generateSubscriptionIdentifier(array $fields): string; +} diff --git a/src/GraphQl/Subscription/SubscriptionManager.php b/src/GraphQl/Subscription/SubscriptionManager.php new file mode 100644 index 00000000000..df2d43a5172 --- /dev/null +++ b/src/GraphQl/Subscription/SubscriptionManager.php @@ -0,0 +1,124 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\GraphQl\Subscription; + +use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\GraphQl\Resolver\Stage\SerializeStageInterface; +use ApiPlatform\Core\GraphQl\Resolver\Util\IdentifierTrait; +use ApiPlatform\Core\Util\ResourceClassInfoTrait; +use ApiPlatform\Core\Util\SortTrait; +use GraphQL\Type\Definition\ResolveInfo; +use Psr\Cache\CacheItemPoolInterface; + +/** + * Manages all the queried subscriptions by creating their ID + * and saving to a cache the information needed to publish updated data. + * + * @experimental + * + * @author Alan Poulain + */ +final class SubscriptionManager implements SubscriptionManagerInterface +{ + use IdentifierTrait; + use ResourceClassInfoTrait; + use SortTrait; + + private $subscriptionsCache; + private $subscriptionIdentifierGenerator; + private $serializeStage; + private $iriConverter; + + public function __construct(CacheItemPoolInterface $subscriptionsCache, SubscriptionIdentifierGeneratorInterface $subscriptionIdentifierGenerator, SerializeStageInterface $serializeStage, IriConverterInterface $iriConverter) + { + $this->subscriptionsCache = $subscriptionsCache; + $this->subscriptionIdentifierGenerator = $subscriptionIdentifierGenerator; + $this->serializeStage = $serializeStage; + $this->iriConverter = $iriConverter; + } + + public function retrieveSubscriptionId(array $context, ?array $result): ?string + { + /** @var ResolveInfo $info */ + $info = $context['info']; + $fields = $info->getFieldSelection(PHP_INT_MAX); + $this->arrayRecursiveSort($fields, 'ksort'); + $iri = $this->getIdentifierFromContext($context); + if (null === $iri) { + return null; + } + $subscriptionsCacheItem = $this->subscriptionsCache->getItem($this->encodeIriToCacheKey($iri)); + $subscriptions = []; + if ($subscriptionsCacheItem->isHit()) { + $subscriptions = $subscriptionsCacheItem->get(); + foreach ($subscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { + if ($subscriptionFields === $fields) { + return $subscriptionId; + } + } + } + + $subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier($fields); + unset($result['clientSubscriptionId']); + $subscriptions[] = [$subscriptionId, $fields, $result]; + $subscriptionsCacheItem->set($subscriptions); + $this->subscriptionsCache->save($subscriptionsCacheItem); + + return $subscriptionId; + } + + /** + * @param object $object + */ + public function getPushPayloads($object): array + { + $iri = $this->iriConverter->getIriFromItem($object); + $subscriptions = $this->getSubscriptionsFromIri($iri); + + $resourceClass = $this->getObjectClass($object); + + $payloads = []; + foreach ($subscriptions as [$subscriptionId, $subscriptionFields, $subscriptionResult]) { + $resolverContext = ['fields' => $subscriptionFields, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + + $data = ($this->serializeStage)($object, $resourceClass, 'update', $resolverContext); + unset($data['clientSubscriptionId']); + + if ($data !== $subscriptionResult) { + $payloads[] = [$subscriptionId, $data]; + } + } + + return $payloads; + } + + /** + * @return array + */ + private function getSubscriptionsFromIri(string $iri): array + { + $subscriptionsCacheItem = $this->subscriptionsCache->getItem($this->encodeIriToCacheKey($iri)); + + if ($subscriptionsCacheItem->isHit()) { + return $subscriptionsCacheItem->get(); + } + + return []; + } + + private function encodeIriToCacheKey(string $iri): string + { + return str_replace('/', '_', $iri); + } +} diff --git a/src/GraphQl/Subscription/SubscriptionManagerInterface.php b/src/GraphQl/Subscription/SubscriptionManagerInterface.php new file mode 100644 index 00000000000..e745b754b51 --- /dev/null +++ b/src/GraphQl/Subscription/SubscriptionManagerInterface.php @@ -0,0 +1,28 @@ + + * + * For 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\GraphQl\Subscription; + +/** + * Manages all the queried subscriptions and creates their ID. + * + * @experimental + * + * @author Alan Poulain + */ +interface SubscriptionManagerInterface +{ + public function retrieveSubscriptionId(array $context, ?array $result): ?string; + + public function getPushPayloads($object): array; +} diff --git a/src/GraphQl/Type/FieldsBuilder.php b/src/GraphQl/Type/FieldsBuilder.php index f28e90c6f36..e4913177df7 100644 --- a/src/GraphQl/Type/FieldsBuilder.php +++ b/src/GraphQl/Type/FieldsBuilder.php @@ -29,6 +29,7 @@ use Psr\Container\ContainerInterface; use Symfony\Component\Config\Definition\Exception\InvalidTypeException; use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; use Symfony\Component\Serializer\NameConverter\NameConverterInterface; /** @@ -49,12 +50,13 @@ final class FieldsBuilder implements FieldsBuilderInterface private $itemResolverFactory; private $collectionResolverFactory; private $itemMutationResolverFactory; + private $itemSubscriptionResolverFactory; private $filterLocator; private $pagination; private $nameConverter; private $nestingSeparator; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, TypesContainerInterface $typesContainer, TypeBuilderInterface $typeBuilder, TypeConverterInterface $typeConverter, ResolverFactoryInterface $itemResolverFactory, ResolverFactoryInterface $collectionResolverFactory, ResolverFactoryInterface $itemMutationResolverFactory, ContainerInterface $filterLocator, Pagination $pagination, ?NameConverterInterface $nameConverter, string $nestingSeparator) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, TypesContainerInterface $typesContainer, TypeBuilderInterface $typeBuilder, TypeConverterInterface $typeConverter, ResolverFactoryInterface $itemResolverFactory, ResolverFactoryInterface $collectionResolverFactory, ResolverFactoryInterface $itemMutationResolverFactory, ResolverFactoryInterface $itemSubscriptionResolverFactory, ContainerInterface $filterLocator, Pagination $pagination, ?NameConverterInterface $nameConverter, string $nestingSeparator) { $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; $this->propertyMetadataFactory = $propertyMetadataFactory; @@ -65,6 +67,7 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName $this->itemResolverFactory = $itemResolverFactory; $this->collectionResolverFactory = $collectionResolverFactory; $this->itemMutationResolverFactory = $itemMutationResolverFactory; + $this->itemSubscriptionResolverFactory = $itemSubscriptionResolverFactory; $this->filterLocator = $filterLocator; $this->pagination = $pagination; $this->nameConverter = $nameConverter; @@ -92,10 +95,10 @@ public function getItemQueryFields(string $resourceClass, ResourceMetadata $reso { $shortName = $resourceMetadata->getShortName(); $fieldName = lcfirst('item_query' === $queryName ? $shortName : $queryName.$shortName); - + $description = $resourceMetadata->getGraphqlAttribute($queryName, 'description'); $deprecationReason = $resourceMetadata->getGraphqlAttribute($queryName, 'deprecation_reason', null, true); - if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, null, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass), $resourceClass, false, $queryName, null)) { + if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, $description, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass), $resourceClass, false, $queryName, null, null)) { $args = $this->resolveResourceArgs($configuration['args'] ?? [], $queryName, $shortName); $configuration['args'] = $args ?: $configuration['args'] ?? ['id' => ['type' => GraphQLType::nonNull(GraphQLType::id())]]; @@ -112,10 +115,10 @@ public function getCollectionQueryFields(string $resourceClass, ResourceMetadata { $shortName = $resourceMetadata->getShortName(); $fieldName = lcfirst('collection_query' === $queryName ? $shortName : $queryName.$shortName); - + $description = $resourceMetadata->getGraphqlAttribute($queryName, 'description'); $deprecationReason = $resourceMetadata->getGraphqlAttribute($queryName, 'deprecation_reason', null, true); - if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, null, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, false, null, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, $resourceClass)), $resourceClass, false, $queryName, null)) { + if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, $description, $deprecationReason, new Type(Type::BUILTIN_TYPE_OBJECT, false, null, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, $resourceClass)), $resourceClass, false, $queryName, null, null)) { $args = $this->resolveResourceArgs($configuration['args'] ?? [], $queryName, $shortName); $configuration['args'] = $args ?: $configuration['args'] ?? $fieldConfiguration['args']; @@ -133,14 +136,11 @@ public function getMutationFields(string $resourceClass, ResourceMetadata $resou $mutationFields = []; $shortName = $resourceMetadata->getShortName(); $resourceType = new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass); + $description = $resourceMetadata->getGraphqlAttribute($mutationName, 'description', ucfirst("{$mutationName}s a $shortName."), false); $deprecationReason = $resourceMetadata->getGraphqlAttribute($mutationName, 'deprecation_reason', null, true); - if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, ucfirst("{$mutationName}s a $shortName."), $deprecationReason, $resourceType, $resourceClass, false, null, $mutationName)) { - $fieldConfiguration['args'] += ['input' => $this->getResourceFieldConfiguration(null, null, $deprecationReason, $resourceType, $resourceClass, true, null, $mutationName)]; - - if (!$this->typeBuilder->isCollection($resourceType)) { - $fieldConfiguration['resolve'] = ($this->itemMutationResolverFactory)($resourceClass, null, $mutationName); - } + if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, $description, $deprecationReason, $resourceType, $resourceClass, false, null, $mutationName, null)) { + $fieldConfiguration['args'] += ['input' => $this->getResourceFieldConfiguration(null, null, $deprecationReason, $resourceType, $resourceClass, true, null, $mutationName, null)]; } $mutationFields[$mutationName.$resourceMetadata->getShortName()] = $fieldConfiguration ?? []; @@ -151,11 +151,36 @@ public function getMutationFields(string $resourceClass, ResourceMetadata $resou /** * {@inheritdoc} */ - public function getResourceObjectTypeFields(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input, ?string $queryName, ?string $mutationName, int $depth = 0, ?array $ioMetadata = null): array + public function getSubscriptionFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $subscriptionName): array + { + $subscriptionFields = []; + $shortName = $resourceMetadata->getShortName(); + $resourceType = new Type(Type::BUILTIN_TYPE_OBJECT, true, $resourceClass); + $description = $resourceMetadata->getGraphqlAttribute($subscriptionName, 'description', "Subscribes to the $subscriptionName event of a $shortName.", false); + $deprecationReason = $resourceMetadata->getGraphqlAttribute($subscriptionName, 'deprecation_reason', null, true); + + if ($fieldConfiguration = $this->getResourceFieldConfiguration(null, $description, $deprecationReason, $resourceType, $resourceClass, false, null, null, $subscriptionName)) { + $fieldConfiguration['args'] += ['input' => $this->getResourceFieldConfiguration(null, null, $deprecationReason, $resourceType, $resourceClass, true, null, null, $subscriptionName)]; + } + + if (!$fieldConfiguration) { + return []; + } + + $subscriptionFields[$subscriptionName.$resourceMetadata->getShortName().'Subscribe'] = $fieldConfiguration; + + return $subscriptionFields; + } + + /** + * {@inheritdoc} + */ + public function getResourceObjectTypeFields(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, int $depth = 0, ?array $ioMetadata = null): array { $fields = []; $idField = ['type' => GraphQLType::nonNull(GraphQLType::id())]; $clientMutationId = GraphQLType::string(); + $clientSubscriptionId = GraphQLType::string(); if (null !== $ioMetadata && \array_key_exists('class', $ioMetadata) && null === $ioMetadata['class']) { if ($input) { @@ -165,6 +190,13 @@ public function getResourceObjectTypeFields(?string $resourceClass, ResourceMeta return []; } + if (null !== $subscriptionName && $input) { + return [ + 'id' => $idField, + 'clientSubscriptionId' => $clientSubscriptionId, + ]; + } + if ('delete' === $mutationName) { $fields = [ 'id' => $idField, @@ -185,7 +217,7 @@ public function getResourceObjectTypeFields(?string $resourceClass, ResourceMeta if (null !== $resourceClass) { foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $property) { - $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, ['graphql_operation_name' => $mutationName ?? $queryName]); + $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $property, ['graphql_operation_name' => $subscriptionName ?? $mutationName ?? $queryName]); if ( null === ($propertyType = $propertyMetadata->getType()) || (!$input && false === $propertyMetadata->isReadable()) @@ -194,8 +226,8 @@ public function getResourceObjectTypeFields(?string $resourceClass, ResourceMeta continue; } - if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getAttribute('deprecation_reason', null), $propertyType, $resourceClass, $input, $queryName, $mutationName, $depth)) { - $fields['id' === $property ? '_id' : $this->normalizePropertyName($property)] = $fieldConfiguration; + if ($fieldConfiguration = $this->getResourceFieldConfiguration($property, $propertyMetadata->getDescription(), $propertyMetadata->getAttribute('deprecation_reason', null), $propertyType, $resourceClass, $input, $queryName, $mutationName, $subscriptionName, $depth)) { + $fields['id' === $property ? '_id' : $this->normalizePropertyName($property, $resourceClass)] = $fieldConfiguration; } } } @@ -228,12 +260,12 @@ public function resolveResourceArgs(array $args, string $operationName, string $ * * @see http://webonyx.github.io/graphql-php/type-system/object-types/ */ - private function getResourceFieldConfiguration(?string $property, ?string $fieldDescription, ?string $deprecationReason, Type $type, string $rootResource, bool $input, ?string $queryName, ?string $mutationName, int $depth = 0): ?array + 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 (null === $graphqlType = $this->convertType($type, $input, $queryName, $mutationName, $resourceClass ?? '', $rootResource, $property, $depth)) { + if (null === $graphqlType = $this->convertType($type, $input, $queryName, $mutationName, $subscriptionName, $resourceClass ?? '', $rootResource, $property, $depth)) { return null; } @@ -251,27 +283,15 @@ private function getResourceFieldConfiguration(?string $property, ?string $field } } + // Check mercure attribute if it's a subscription at the root level. + if ($subscriptionName && null === $property && (!$resourceMetadata || !$resourceMetadata->getAttribute('mercure', false))) { + return null; + } + $args = []; - if (!$input && null === $mutationName && !$isStandardGraphqlType && $this->typeBuilder->isCollection($type)) { + if (!$input && null === $mutationName && null === $subscriptionName && !$isStandardGraphqlType && $this->typeBuilder->isCollection($type)) { if ($this->pagination->isGraphQlEnabled($resourceClass, $queryName)) { - $args = [ - 'first' => [ - 'type' => GraphQLType::int(), - 'description' => 'Returns the first n elements from the list.', - ], - 'last' => [ - 'type' => GraphQLType::int(), - 'description' => 'Returns the last n elements from the list.', - ], - 'before' => [ - 'type' => GraphQLType::string(), - 'description' => 'Returns the elements in the list that come before the specified cursor.', - ], - 'after' => [ - 'type' => GraphQLType::string(), - 'description' => 'Returns the elements in the list that come after the specified cursor.', - ], - ]; + $args = $this->getGraphQlPaginationArgs($resourceClass, $queryName); } $args = $this->getFilterArgs($args, $resourceClass, $resourceMetadata, $rootResource, $property, $queryName, $mutationName, $depth); @@ -279,6 +299,10 @@ private function getResourceFieldConfiguration(?string $property, ?string $field if ($isStandardGraphqlType || $input) { $resolve = null; + } elseif ($mutationName) { + $resolve = ($this->itemMutationResolverFactory)($resourceClass, $rootResource, $mutationName); + } elseif ($subscriptionName) { + $resolve = ($this->itemSubscriptionResolverFactory)($resourceClass, $rootResource, $subscriptionName); } elseif ($this->typeBuilder->isCollection($type)) { $resolve = ($this->collectionResolverFactory)($resourceClass, $rootResource, $queryName); } else { @@ -299,6 +323,50 @@ private function getResourceFieldConfiguration(?string $property, ?string $field return null; } + private function getGraphQlPaginationArgs(string $resourceClass, string $queryName): array + { + $paginationType = $this->pagination->getGraphQlPaginationType($resourceClass, $queryName); + + if ('cursor' === $paginationType) { + return [ + 'first' => [ + 'type' => GraphQLType::int(), + 'description' => 'Returns the first n elements from the list.', + ], + 'last' => [ + 'type' => GraphQLType::int(), + 'description' => 'Returns the last n elements from the list.', + ], + 'before' => [ + 'type' => GraphQLType::string(), + 'description' => 'Returns the elements in the list that come before the specified cursor.', + ], + 'after' => [ + 'type' => GraphQLType::string(), + 'description' => 'Returns the elements in the list that come after the specified cursor.', + ], + ]; + } + + $paginationOptions = $this->pagination->getOptions(); + + $args = [ + $paginationOptions['page_parameter_name'] => [ + 'type' => GraphQLType::int(), + 'description' => 'Returns the current page.', + ], + ]; + + if ($paginationOptions['client_items_per_page']) { + $args[$paginationOptions['items_per_page_parameter_name']] = [ + 'type' => GraphQLType::int(), + 'description' => 'Returns the number of items per page.', + ]; + } + + return $args; + } + private function getFilterArgs(array $args, ?string $resourceClass, ?ResourceMetadata $resourceMetadata, string $rootResource, ?string $property, ?string $queryName, ?string $mutationName, int $depth): array { if (null === $resourceMetadata || null === $resourceClass) { @@ -313,7 +381,7 @@ private function getFilterArgs(array $args, ?string $resourceClass, ?ResourceMet foreach ($this->filterLocator->get($filterId)->getDescription($resourceClass) as $key => $value) { $nullable = isset($value['required']) ? !$value['required'] : true; $filterType = \in_array($value['type'], Type::$builtinTypes, true) ? new Type($value['type'], $nullable) : new Type('object', $nullable, $value['type']); - $graphqlFilterType = $this->convertType($filterType, false, $queryName, $mutationName, $resourceClass, $rootResource, $property, $depth); + $graphqlFilterType = $this->convertType($filterType, false, $queryName, $mutationName, null, $resourceClass, $rootResource, $property, $depth); if ('[]' === substr($key, -2)) { $graphqlFilterType = GraphQLType::listOf($graphqlFilterType); @@ -383,10 +451,10 @@ private function convertFilterArgsToTypes(array $args): array unset($value['#name']); - $filterArgType = new InputObjectType([ + $filterArgType = GraphQLType::listOf(new InputObjectType([ 'name' => $name, 'fields' => $this->convertFilterArgsToTypes($value), - ]); + ])); $this->typesContainer->set($name, $filterArgType); @@ -401,9 +469,9 @@ private function convertFilterArgsToTypes(array $args): array * * @throws InvalidTypeException */ - private function convertType(Type $type, bool $input, ?string $queryName, ?string $mutationName, string $resourceClass, string $rootResource, ?string $property, int $depth) + private function convertType(Type $type, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, string $resourceClass, string $rootResource, ?string $property, int $depth) { - $graphqlType = $this->typeConverter->convertType($type, $input, $queryName, $mutationName, $resourceClass, $rootResource, $property, $depth); + $graphqlType = $this->typeConverter->convertType($type, $input, $queryName, $mutationName, $subscriptionName, $resourceClass, $rootResource, $property, $depth); if (null === $graphqlType) { throw new InvalidTypeException(sprintf('The type "%s" is not supported.', $type->getBuiltinType())); @@ -418,7 +486,9 @@ private function convertType(Type $type, bool $input, ?string $queryName, ?strin } if ($this->typeBuilder->isCollection($type)) { - return $this->pagination->isGraphQlEnabled($resourceClass, $queryName ?? $mutationName) && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType) : GraphQLType::listOf($graphqlType); + $operationName = $queryName ?? $mutationName ?? $subscriptionName; + + return $this->pagination->isGraphQlEnabled($resourceClass, $operationName) && !$input ? $this->typeBuilder->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $operationName) : GraphQLType::listOf($graphqlType); } return !$graphqlType instanceof NullableType || $type->isNullable() || (null !== $mutationName && 'update' === $mutationName) @@ -426,8 +496,15 @@ private function convertType(Type $type, bool $input, ?string $queryName, ?strin : GraphQLType::nonNull($graphqlType); } - private function normalizePropertyName(string $property): string + private function normalizePropertyName(string $property, string $resourceClass): string { - return null !== $this->nameConverter ? $this->nameConverter->normalize($property) : $property; + if (null === $this->nameConverter) { + return $property; + } + if ($this->nameConverter instanceof AdvancedNameConverterInterface) { + return $this->nameConverter->normalize($property, $resourceClass); + } + + return $this->nameConverter->normalize($property); } } diff --git a/src/GraphQl/Type/FieldsBuilderInterface.php b/src/GraphQl/Type/FieldsBuilderInterface.php index c5905f073fe..765144a2af7 100644 --- a/src/GraphQl/Type/FieldsBuilderInterface.php +++ b/src/GraphQl/Type/FieldsBuilderInterface.php @@ -44,10 +44,15 @@ public function getCollectionQueryFields(string $resourceClass, ResourceMetadata */ public function getMutationFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $mutationName): array; + /** + * Gets the subscription fields of the schema. + */ + public function getSubscriptionFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $subscriptionName): array; + /** * Gets the fields of the type of the given resource. */ - public function getResourceObjectTypeFields(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input, ?string $queryName, ?string $mutationName, int $depth, ?array $ioMetadata): array; + public function getResourceObjectTypeFields(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, int $depth, ?array $ioMetadata): array; /** * Resolve the args of a resource by resolving its types. diff --git a/src/GraphQl/Type/SchemaBuilder.php b/src/GraphQl/Type/SchemaBuilder.php index 1fdbfc1f2ca..3ac289c6482 100644 --- a/src/GraphQl/Type/SchemaBuilder.php +++ b/src/GraphQl/Type/SchemaBuilder.php @@ -54,6 +54,7 @@ public function getSchema(): Schema $queryFields = ['node' => $this->fieldsBuilder->getNodeQueryFields()]; $mutationFields = []; + $subscriptionFields = []; foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); @@ -84,6 +85,10 @@ public function getSchema(): Schema continue; } + if ('update' === $operationName) { + $subscriptionFields += $this->fieldsBuilder->getSubscriptionFields($resourceClass, $resourceMetadata, $operationName); + } + $mutationFields += $this->fieldsBuilder->getMutationFields($resourceClass, $resourceMetadata, $operationName); } } @@ -111,6 +116,13 @@ public function getSchema(): Schema ]); } + if ($subscriptionFields) { + $schema['subscription'] = new ObjectType([ + 'name' => 'Subscription', + 'fields' => $subscriptionFields, + ]); + } + return new Schema($schema); } } diff --git a/src/GraphQl/Type/TypeBuilder.php b/src/GraphQl/Type/TypeBuilder.php index 41506c7fbf3..ad66a3d463b 100644 --- a/src/GraphQl/Type/TypeBuilder.php +++ b/src/GraphQl/Type/TypeBuilder.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\GraphQl\Type; +use ApiPlatform\Core\DataProvider\Pagination; use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use GraphQL\Type\Definition\InputObjectType; @@ -35,27 +36,32 @@ final class TypeBuilder implements TypeBuilderInterface private $typesContainer; private $defaultFieldResolver; private $fieldsBuilderLocator; + private $pagination; - public function __construct(TypesContainerInterface $typesContainer, callable $defaultFieldResolver, ContainerInterface $fieldsBuilderLocator) + public function __construct(TypesContainerInterface $typesContainer, callable $defaultFieldResolver, ContainerInterface $fieldsBuilderLocator, Pagination $pagination) { $this->typesContainer = $typesContainer; $this->defaultFieldResolver = $defaultFieldResolver; $this->fieldsBuilderLocator = $fieldsBuilderLocator; + $this->pagination = $pagination; } /** * {@inheritdoc} */ - public function getResourceObjectType(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input, ?string $queryName, ?string $mutationName, bool $wrapped = false, int $depth = 0): GraphQLType + public function getResourceObjectType(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, bool $wrapped = false, int $depth = 0): GraphQLType { $shortName = $resourceMetadata->getShortName(); if (null !== $mutationName) { $shortName = $mutationName.ucfirst($shortName); } + if (null !== $subscriptionName) { + $shortName = $subscriptionName.ucfirst($shortName).'Subscription'; + } if ($input) { $shortName .= 'Input'; - } elseif (null !== $mutationName) { + } elseif (null !== $mutationName || null !== $subscriptionName) { if ($depth > 0) { $shortName .= 'Nested'; } @@ -70,49 +76,59 @@ public function getResourceObjectType(?string $resourceClass, ResourceMetadata $ $shortName .= 'Collection'; } } - if ($wrapped && null !== $mutationName) { + if ($wrapped && (null !== $mutationName || null !== $subscriptionName)) { $shortName .= 'Data'; } if ($this->typesContainer->has($shortName)) { $resourceObjectType = $this->typesContainer->get($shortName); if (!($resourceObjectType instanceof ObjectType || $resourceObjectType instanceof NonNull)) { - throw new \UnexpectedValueException(sprintf('Expected GraphQL type "%s" to be %s.', $shortName, implode('|', [ObjectType::class, NonNull::class]))); + throw new \LogicException(sprintf('Expected GraphQL type "%s" to be %s.', $shortName, implode('|', [ObjectType::class, NonNull::class]))); } return $resourceObjectType; } - $ioMetadata = $resourceMetadata->getGraphqlAttribute($mutationName ?? $queryName, $input ? 'input' : 'output', null, true); + $ioMetadata = $resourceMetadata->getGraphqlAttribute($subscriptionName ?? $mutationName ?? $queryName, $input ? 'input' : 'output', null, true); if (null !== $ioMetadata && \array_key_exists('class', $ioMetadata) && null !== $ioMetadata['class']) { $resourceClass = $ioMetadata['class']; } - $wrapData = !$wrapped && null !== $mutationName && !$input && $depth < 1; + $wrapData = !$wrapped && (null !== $mutationName || null !== $subscriptionName) && !$input && $depth < 1; $configuration = [ 'name' => $shortName, 'description' => $resourceMetadata->getDescription(), 'resolveField' => $this->defaultFieldResolver, - 'fields' => function () use ($resourceClass, $resourceMetadata, $input, $mutationName, $queryName, $wrapData, $depth, $ioMetadata) { + 'fields' => function () use ($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, $subscriptionName, $wrapData, $depth, $ioMetadata) { if ($wrapData) { $queryNormalizationContext = $resourceMetadata->getGraphqlAttribute($queryName ?? '', 'normalization_context', [], true); - $mutationNormalizationContext = $resourceMetadata->getGraphqlAttribute($mutationName ?? '', 'normalization_context', [], true); - // Use a new type for the wrapped object only if there is a specific normalization context for the mutation. + $mutationNormalizationContext = $resourceMetadata->getGraphqlAttribute($mutationName ?? $subscriptionName ?? '', 'normalization_context', [], true); + // Use a new type for the wrapped object only if there is a specific normalization context for the mutation or the subscription. // If not, use the query type in order to ensure the client cache could be used. $useWrappedType = $queryNormalizationContext !== $mutationNormalizationContext; - return [ + $fields = [ lcfirst($resourceMetadata->getShortName()) => $useWrappedType ? - $this->getResourceObjectType($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, true, $depth) : - $this->getResourceObjectType($resourceClass, $resourceMetadata, $input, $queryName ?? 'item_query', null, true, $depth), - 'clientMutationId' => GraphQLType::string(), + $this->getResourceObjectType($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, $subscriptionName, true, $depth) : + $this->getResourceObjectType($resourceClass, $resourceMetadata, $input, $queryName ?? 'item_query', null, null, true, $depth), ]; + + if (null !== $subscriptionName) { + $fields['clientSubscriptionId'] = GraphQLType::string(); + if ($resourceMetadata->getAttribute('mercure', false)) { + $fields['mercureUrl'] = GraphQLType::string(); + } + + return $fields; + } + + return $fields + ['clientMutationId' => GraphQLType::string()]; } $fieldsBuilder = $this->fieldsBuilderLocator->get('api_platform.graphql.fields_builder'); - $fields = $fieldsBuilder->getResourceObjectTypeFields($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, $depth, $ioMetadata); + $fields = $fieldsBuilder->getResourceObjectTypeFields($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, $subscriptionName, $depth, $ioMetadata); if ($input && null !== $mutationName && null !== $mutationArgs = $resourceMetadata->getGraphql()[$mutationName]['args'] ?? null) { return $fieldsBuilder->resolveResourceArgs($mutationArgs, $mutationName, $resourceMetadata->getShortName()) + ['clientMutationId' => $fields['clientMutationId']]; @@ -137,7 +153,7 @@ public function getNodeInterface(): InterfaceType if ($this->typesContainer->has('Node')) { $nodeInterface = $this->typesContainer->get('Node'); if (!$nodeInterface instanceof InterfaceType) { - throw new \UnexpectedValueException(sprintf('Expected GraphQL type "Node" to be %s.', InterfaceType::class)); + throw new \LogicException(sprintf('Expected GraphQL type "Node" to be %s.', InterfaceType::class)); } return $nodeInterface; @@ -171,7 +187,7 @@ public function getNodeInterface(): InterfaceType /** * {@inheritdoc} */ - public function getResourcePaginatedCollectionType(GraphQLType $resourceType): GraphQLType + public function getResourcePaginatedCollectionType(GraphQLType $resourceType, string $resourceClass, string $operationName): GraphQLType { $shortName = $resourceType->name; @@ -179,6 +195,36 @@ public function getResourcePaginatedCollectionType(GraphQLType $resourceType): G return $this->typesContainer->get("{$shortName}Connection"); } + $paginationType = $this->pagination->getGraphQlPaginationType($resourceClass, $operationName); + + $fields = 'cursor' === $paginationType ? + $this->getCursorBasedPaginationFields($resourceType) : + $this->getPageBasedPaginationFields($resourceType); + + $configuration = [ + 'name' => "{$shortName}Connection", + 'description' => "Connection for $shortName.", + 'fields' => $fields, + ]; + + $resourcePaginatedCollectionType = new ObjectType($configuration); + $this->typesContainer->set("{$shortName}Connection", $resourcePaginatedCollectionType); + + return $resourcePaginatedCollectionType; + } + + /** + * {@inheritdoc} + */ + public function isCollection(Type $type): bool + { + return $type->isCollection() && ($collectionValueType = $type->getCollectionValueType()) && null !== $collectionValueType->getClassName(); + } + + private function getCursorBasedPaginationFields(GraphQLType $resourceType): array + { + $shortName = $resourceType->name; + $edgeObjectTypeConfiguration = [ 'name' => "{$shortName}Edge", 'description' => "Edge of $shortName.", @@ -203,27 +249,32 @@ public function getResourcePaginatedCollectionType(GraphQLType $resourceType): G $pageInfoObjectType = new ObjectType($pageInfoObjectTypeConfiguration); $this->typesContainer->set("{$shortName}PageInfo", $pageInfoObjectType); - $configuration = [ - 'name' => "{$shortName}Connection", - 'description' => "Connection for $shortName.", + return [ + 'edges' => GraphQLType::listOf($edgeObjectType), + 'pageInfo' => GraphQLType::nonNull($pageInfoObjectType), + 'totalCount' => GraphQLType::nonNull(GraphQLType::int()), + ]; + } + + private function getPageBasedPaginationFields(GraphQLType $resourceType): array + { + $shortName = $resourceType->name; + + $paginationInfoObjectTypeConfiguration = [ + 'name' => "{$shortName}PaginationInfo", + 'description' => 'Information about the pagination.', 'fields' => [ - 'edges' => GraphQLType::listOf($edgeObjectType), - 'pageInfo' => GraphQLType::nonNull($pageInfoObjectType), + 'itemsPerPage' => GraphQLType::nonNull(GraphQLType::int()), + 'lastPage' => GraphQLType::nonNull(GraphQLType::int()), 'totalCount' => GraphQLType::nonNull(GraphQLType::int()), ], ]; + $paginationInfoObjectType = new ObjectType($paginationInfoObjectTypeConfiguration); + $this->typesContainer->set("{$shortName}PaginationInfo", $paginationInfoObjectType); - $resourcePaginatedCollectionType = new ObjectType($configuration); - $this->typesContainer->set("{$shortName}Connection", $resourcePaginatedCollectionType); - - return $resourcePaginatedCollectionType; - } - - /** - * {@inheritdoc} - */ - public function isCollection(Type $type): bool - { - return $type->isCollection() && ($collectionValueType = $type->getCollectionValueType()) && null !== $collectionValueType->getClassName(); + return [ + 'collection' => GraphQLType::listOf($resourceType), + 'paginationInfo' => GraphQLType::nonNull($paginationInfoObjectType), + ]; } } diff --git a/src/GraphQl/Type/TypeBuilderInterface.php b/src/GraphQl/Type/TypeBuilderInterface.php index 138cf8bd3e0..ed5aeb75e99 100644 --- a/src/GraphQl/Type/TypeBuilderInterface.php +++ b/src/GraphQl/Type/TypeBuilderInterface.php @@ -34,7 +34,7 @@ interface TypeBuilderInterface * * @return ObjectType|NonNull the object type, possibly wrapped by NonNull */ - public function getResourceObjectType(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input, ?string $queryName, ?string $mutationName, bool $wrapped, int $depth): GraphQLType; + public function getResourceObjectType(?string $resourceClass, ResourceMetadata $resourceMetadata, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, bool $wrapped, int $depth): GraphQLType; /** * Get the interface type of a node. @@ -44,7 +44,7 @@ public function getNodeInterface(): InterfaceType; /** * Gets the type of a paginated collection of the given resource type. */ - public function getResourcePaginatedCollectionType(GraphQLType $resourceType): GraphQLType; + public function getResourcePaginatedCollectionType(GraphQLType $resourceType, string $resourceClass, string $operationName): GraphQLType; /** * Returns true if a type is a collection. diff --git a/src/GraphQl/Type/TypeConverter.php b/src/GraphQl/Type/TypeConverter.php index 79c86351182..d25736b2993 100644 --- a/src/GraphQl/Type/TypeConverter.php +++ b/src/GraphQl/Type/TypeConverter.php @@ -49,7 +49,7 @@ public function __construct(TypeBuilderInterface $typeBuilder, TypesContainerInt /** * {@inheritdoc} */ - public function convertType(Type $type, bool $input, ?string $queryName, ?string $mutationName, string $resourceClass, string $rootResource, ?string $property, int $depth) + public function convertType(Type $type, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, string $resourceClass, string $rootResource, ?string $property, int $depth) { switch ($type->getBuiltinType()) { case Type::BUILTIN_TYPE_BOOL: @@ -62,7 +62,7 @@ public function convertType(Type $type, bool $input, ?string $queryName, ?string return GraphQLType::string(); case Type::BUILTIN_TYPE_ARRAY: case Type::BUILTIN_TYPE_ITERABLE: - if ($resourceType = $this->getResourceType($type, $input, $queryName, $mutationName, $depth)) { + if ($resourceType = $this->getResourceType($type, $input, $queryName, $mutationName, $subscriptionName, $depth)) { return $resourceType; } @@ -76,7 +76,7 @@ public function convertType(Type $type, bool $input, ?string $queryName, ?string return GraphQLType::string(); } - return $this->getResourceType($type, $input, $queryName, $mutationName, $depth); + return $this->getResourceType($type, $input, $queryName, $mutationName, $subscriptionName, $depth); default: return null; } @@ -100,7 +100,7 @@ public function resolveType(string $type): ?GraphQLType throw new InvalidArgumentException(sprintf('The type "%s" was not resolved.', $type)); } - private function getResourceType(Type $type, bool $input, ?string $queryName, ?string $mutationName, int $depth): ?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 (null === $resourceClass) { @@ -120,7 +120,7 @@ private function getResourceType(Type $type, bool $input, ?string $queryName, ?s return null; } - return $this->typeBuilder->getResourceObjectType($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, false, $depth); + return $this->typeBuilder->getResourceObjectType($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, $subscriptionName, false, $depth); } private function resolveAstTypeNode(TypeNode $astTypeNode, string $fromType): ?GraphQLType diff --git a/src/GraphQl/Type/TypeConverterInterface.php b/src/GraphQl/Type/TypeConverterInterface.php index 04f73c581d0..61373d15c3c 100644 --- a/src/GraphQl/Type/TypeConverterInterface.php +++ b/src/GraphQl/Type/TypeConverterInterface.php @@ -31,7 +31,7 @@ interface TypeConverterInterface * * @return string|GraphQLType|null */ - public function convertType(Type $type, bool $input, ?string $queryName, ?string $mutationName, string $resourceClass, string $rootResource, ?string $property, int $depth); + public function convertType(Type $type, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, string $resourceClass, string $rootResource, ?string $property, int $depth); /** * Resolves a type written with the GraphQL type system to its object representation. diff --git a/src/Hal/Serializer/CollectionNormalizer.php b/src/Hal/Serializer/CollectionNormalizer.php index d8266e9f406..8f00554de2f 100644 --- a/src/Hal/Serializer/CollectionNormalizer.php +++ b/src/Hal/Serializer/CollectionNormalizer.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Core\Hal\Serializer; +use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Serializer\AbstractCollectionNormalizer; use ApiPlatform\Core\Util\IriHelper; use Symfony\Component\Serializer\Exception\UnexpectedValueException; @@ -27,32 +29,40 @@ final class CollectionNormalizer extends AbstractCollectionNormalizer { public const FORMAT = 'jsonhal'; + public function __construct(ResourceClassResolverInterface $resourceClassResolver, string $pageParameterName, ResourceMetadataFactoryInterface $resourceMetadataFactory) + { + parent::__construct($resourceClassResolver, $pageParameterName, $resourceMetadataFactory); + } + /** * {@inheritdoc} */ protected function getPaginationData($object, array $context = []): array { [$paginator, $paginated, $currentPage, $itemsPerPage, $lastPage, $pageTotalItems, $totalItems] = $this->getPaginationConfig($object, $context); - $parsed = IriHelper::parseIri($context['request_uri'] ?? '/', $this->pageParameterName); + $parsed = IriHelper::parseIri($context['uri'] ?? '/', $this->pageParameterName); + + $metadata = $this->resourceMetadataFactory->create($context['resource_class'] ?? ''); + $urlGenerationStrategy = $metadata->getAttribute('url_generation_strategy'); $data = [ '_links' => [ - 'self' => ['href' => IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null)], + 'self' => ['href' => IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null, $urlGenerationStrategy)], ], ]; if ($paginated) { if (null !== $lastPage) { - $data['_links']['first']['href'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1.); - $data['_links']['last']['href'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage); + $data['_links']['first']['href'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1., $urlGenerationStrategy); + $data['_links']['last']['href'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage, $urlGenerationStrategy); } if (1. !== $currentPage) { - $data['_links']['prev']['href'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1.); + $data['_links']['prev']['href'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1., $urlGenerationStrategy); } if ((null !== $lastPage && $currentPage !== $lastPage) || (null === $lastPage && $pageTotalItems >= $itemsPerPage)) { - $data['_links']['next']['href'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1.); + $data['_links']['next']['href'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1., $urlGenerationStrategy); } } diff --git a/src/HttpCache/EventListener/AddHeadersListener.php b/src/HttpCache/EventListener/AddHeadersListener.php index a461d6137c6..304fcc14222 100644 --- a/src/HttpCache/EventListener/AddHeadersListener.php +++ b/src/HttpCache/EventListener/AddHeadersListener.php @@ -32,8 +32,10 @@ final class AddHeadersListener private $vary; private $public; private $resourceMetadataFactory; + private $staleWhileRevalidate; + private $staleIfError; - public function __construct(bool $etag = false, int $maxAge = null, int $sharedMaxAge = null, array $vary = null, bool $public = null, ResourceMetadataFactoryInterface $resourceMetadataFactory = null) + public function __construct(bool $etag = false, int $maxAge = null, int $sharedMaxAge = null, array $vary = null, bool $public = null, ResourceMetadataFactoryInterface $resourceMetadataFactory = null, int $staleWhileRevalidate = null, int $staleIfError = null) { $this->etag = $etag; $this->maxAge = $maxAge; @@ -41,6 +43,8 @@ public function __construct(bool $etag = false, int $maxAge = null, int $sharedM $this->vary = $vary; $this->public = $public; $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->staleWhileRevalidate = $staleWhileRevalidate; + $this->staleIfError = $staleIfError; } public function onKernelResponse(ResponseEvent $event): void @@ -86,5 +90,13 @@ public function onKernelResponse(ResponseEvent $event): void if (false !== $public && null !== ($sharedMaxAge = $resourceCacheHeaders['shared_max_age'] ?? $this->sharedMaxAge) && !$response->headers->hasCacheControlDirective('s-maxage')) { $response->setSharedMaxAge($sharedMaxAge); } + + if (null !== ($staleWhileRevalidate = $resourceCacheHeaders['stale_while_revalidate'] ?? $this->staleWhileRevalidate) && !$response->headers->hasCacheControlDirective('stale-while-revalidate')) { + $response->headers->addCacheControlDirective('stale-while-revalidate', $staleWhileRevalidate); + } + + if (null !== ($staleIfError = $resourceCacheHeaders['stale_if_error'] ?? $this->staleIfError) && !$response->headers->hasCacheControlDirective('stale-if-error')) { + $response->headers->addCacheControlDirective('stale-if-error', $staleIfError); + } } } diff --git a/src/HttpCache/VarnishPurger.php b/src/HttpCache/VarnishPurger.php index 674ce3007fb..33b7d225fb7 100644 --- a/src/HttpCache/VarnishPurger.php +++ b/src/HttpCache/VarnishPurger.php @@ -38,6 +38,32 @@ public function __construct(array $clients, int $maxHeaderLength = self::DEFAULT $this->maxHeaderLength = $maxHeaderLength; } + /** + * Calculate how many tags fit into the header. + * + * This assumes that the tags are separated by one character. + * + * From https://github.com/FriendsOfSymfony/FOSHttpCache/blob/2.8.0/src/ProxyClient/HttpProxyClient.php#L137 + * + * @param string[] $escapedTags + * @param string $glue The concatenation string to use + * + * @return int Number of tags per tag invalidation request + */ + private function determineTagsPerHeader(array $escapedTags, string $glue): int + { + if (mb_strlen(implode($glue, $escapedTags)) < $this->maxHeaderLength) { + return \count($escapedTags); + } + /* + * estimate the amount of tags to invalidate by dividing the max + * header length by the largest tag (minus the glue length) + */ + $tagsize = max(array_map('mb_strlen', $escapedTags)); + + return (int) floor($this->maxHeaderLength / ($tagsize + \strlen($glue))) ?: 1; + } + /** * {@inheritdoc} */ @@ -47,6 +73,16 @@ public function purge(array $iris) return; } + $chunkSize = $this->determineTagsPerHeader($iris, '|'); + + $irisChunks = array_chunk($iris, $chunkSize); + foreach ($irisChunks as $irisChunk) { + $this->purgeRequest($irisChunk); + } + } + + private function purgeRequest(array $iris) + { // Create the regex to purge all tags in just one request $parts = array_map(static function ($iri) { return sprintf('(^|\,)%s($|\,)', preg_quote($iri)); diff --git a/src/Hydra/Serializer/CollectionNormalizer.php b/src/Hydra/Serializer/CollectionNormalizer.php index bef56cff98d..dcfc7ab7c21 100644 --- a/src/Hydra/Serializer/CollectionNormalizer.php +++ b/src/Hydra/Serializer/CollectionNormalizer.php @@ -39,16 +39,21 @@ final class CollectionNormalizer implements NormalizerInterface, NormalizerAware use NormalizerAwareTrait; public const FORMAT = 'jsonld'; + public const IRI_ONLY = 'iri_only'; private $contextBuilder; private $resourceClassResolver; private $iriConverter; + private $defaultContext = [ + self::IRI_ONLY => false, + ]; - public function __construct(ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, IriConverterInterface $iriConverter) + public function __construct(ContextBuilderInterface $contextBuilder, ResourceClassResolverInterface $resourceClassResolver, IriConverterInterface $iriConverter, array $defaultContext = []) { $this->contextBuilder = $contextBuilder; $this->resourceClassResolver = $resourceClassResolver; $this->iriConverter = $iriConverter; + $this->defaultContext = array_merge($this->defaultContext, $defaultContext); } /** @@ -81,10 +86,10 @@ public function normalize($object, $format = null, array $context = []) } $data['@type'] = 'hydra:Collection'; - $data['hydra:member'] = []; + $iriOnly = $context[self::IRI_ONLY] ?? $this->defaultContext[self::IRI_ONLY]; foreach ($object as $obj) { - $data['hydra:member'][] = $this->normalizer->normalize($obj, $format, $context); + $data['hydra:member'][] = $iriOnly ? $this->iriConverter->getIriFromItem($obj) : $this->normalizer->normalize($obj, $format, $context); } if ($object instanceof PaginatorInterface) { diff --git a/src/Hydra/Serializer/EntrypointNormalizer.php b/src/Hydra/Serializer/EntrypointNormalizer.php index d64741593b8..45aa8cfaa46 100644 --- a/src/Hydra/Serializer/EntrypointNormalizer.php +++ b/src/Hydra/Serializer/EntrypointNormalizer.php @@ -65,6 +65,8 @@ public function normalize($object, $format = null, array $context = []) } } + ksort($entrypoint); + return $entrypoint; } diff --git a/src/JsonApi/Serializer/CollectionNormalizer.php b/src/JsonApi/Serializer/CollectionNormalizer.php index c92b2548f13..c0e920f9f29 100644 --- a/src/JsonApi/Serializer/CollectionNormalizer.php +++ b/src/JsonApi/Serializer/CollectionNormalizer.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Core\JsonApi\Serializer; +use ApiPlatform\Core\Api\ResourceClassResolverInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Serializer\AbstractCollectionNormalizer; use ApiPlatform\Core\Util\IriHelper; use Symfony\Component\Serializer\Exception\UnexpectedValueException; @@ -28,32 +30,40 @@ final class CollectionNormalizer extends AbstractCollectionNormalizer { public const FORMAT = 'jsonapi'; + public function __construct(ResourceClassResolverInterface $resourceClassResolver, string $pageParameterName, ResourceMetadataFactoryInterface $resourceMetadataFactory) + { + parent::__construct($resourceClassResolver, $pageParameterName, $resourceMetadataFactory); + } + /** * {@inheritdoc} */ protected function getPaginationData($object, array $context = []): array { [$paginator, $paginated, $currentPage, $itemsPerPage, $lastPage, $pageTotalItems, $totalItems] = $this->getPaginationConfig($object, $context); - $parsed = IriHelper::parseIri($context['request_uri'] ?? '/', $this->pageParameterName); + $parsed = IriHelper::parseIri($context['uri'] ?? '/', $this->pageParameterName); + + $metadata = $this->resourceMetadataFactory->create($context['resource_class'] ?? ''); + $urlGenerationStrategy = $metadata->getAttribute('url_generation_strategy'); $data = [ 'links' => [ - 'self' => IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null), + 'self' => IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $paginated ? $currentPage : null, $urlGenerationStrategy), ], ]; if ($paginated) { if (null !== $lastPage) { - $data['links']['first'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1.); - $data['links']['last'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage); + $data['links']['first'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, 1., $urlGenerationStrategy); + $data['links']['last'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $lastPage, $urlGenerationStrategy); } if (1. !== $currentPage) { - $data['links']['prev'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1.); + $data['links']['prev'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage - 1., $urlGenerationStrategy); } if (null !== $lastPage && $currentPage !== $lastPage || null === $lastPage && $pageTotalItems >= $itemsPerPage) { - $data['links']['next'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1.); + $data['links']['next'] = IriHelper::createIri($parsed['parts'], $parsed['parameters'], $this->pageParameterName, $currentPage + 1., $urlGenerationStrategy); } } diff --git a/src/JsonApi/Serializer/ErrorNormalizer.php b/src/JsonApi/Serializer/ErrorNormalizer.php index 618147b17ac..6ba2d582728 100644 --- a/src/JsonApi/Serializer/ErrorNormalizer.php +++ b/src/JsonApi/Serializer/ErrorNormalizer.php @@ -49,6 +49,10 @@ public function normalize($object, $format = null, array $context = []) 'description' => $this->getErrorMessage($object, $context, $this->debug), ]; + if (null !== $errorCode = $this->getErrorCode($object)) { + $data['code'] = $errorCode; + } + if ($this->debug && null !== $trace = $object->getTrace()) { $data['trace'] = $trace; } diff --git a/src/JsonApi/Serializer/ItemNormalizer.php b/src/JsonApi/Serializer/ItemNormalizer.php index 3efc74135c7..7e024d59fab 100644 --- a/src/JsonApi/Serializer/ItemNormalizer.php +++ b/src/JsonApi/Serializer/ItemNormalizer.php @@ -20,6 +20,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; use ApiPlatform\Core\Serializer\AbstractItemNormalizer; use ApiPlatform\Core\Serializer\CacheKeyTrait; use ApiPlatform\Core\Serializer\ContextTrait; @@ -49,9 +50,9 @@ final class ItemNormalizer extends AbstractItemNormalizer private $componentsCache = []; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor, ?NameConverterInterface $nameConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, array $defaultContext = [], iterable $dataTransformers = []) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ?PropertyAccessorInterface $propertyAccessor, ?NameConverterInterface $nameConverter, ResourceMetadataFactoryInterface $resourceMetadataFactory, array $defaultContext = [], iterable $dataTransformers = [], ResourceAccessCheckerInterface $resourceAccessChecker = null) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, null, null, false, $defaultContext, $dataTransformers, $resourceMetadataFactory); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, null, null, false, $defaultContext, $dataTransformers, $resourceMetadataFactory, $resourceAccessChecker); } /** diff --git a/src/JsonLd/ContextBuilder.php b/src/JsonLd/ContextBuilder.php index 556652f06be..6d5e715abd8 100644 --- a/src/JsonLd/ContextBuilder.php +++ b/src/JsonLd/ContextBuilder.php @@ -90,20 +90,30 @@ public function getEntrypointContext(int $referenceType = UrlGeneratorInterface: */ public function getResourceContext(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH): array { - $metadata = $this->resourceMetadataFactory->create($resourceClass); - if (null === $shortName = $metadata->getShortName()) { + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + if (null === $shortName = $resourceMetadata->getShortName()) { return []; } + if ($resourceMetadata->getAttribute('normalization_context')['iri_only'] ?? false) { + $context = $this->getBaseContext($referenceType); + $context['hydra:member']['@type'] = '@id'; + + return $context; + } + return $this->getResourceContextWithShortname($resourceClass, $referenceType, $shortName); } /** * {@inheritdoc} */ - public function getResourceContextUri(string $resourceClass, int $referenceType = UrlGeneratorInterface::ABS_PATH): string + public function getResourceContextUri(string $resourceClass, int $referenceType = null): string { $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + if (null === $referenceType) { + $referenceType = $resourceMetadata->getAttribute('url_generation_strategy'); + } return $this->urlGenerator->generate('api_jsonld_context', ['shortName' => $resourceMetadata->getShortName()], $referenceType); } diff --git a/src/JsonLd/Serializer/ItemNormalizer.php b/src/JsonLd/Serializer/ItemNormalizer.php index 832c23b8b59..793eba67639 100644 --- a/src/JsonLd/Serializer/ItemNormalizer.php +++ b/src/JsonLd/Serializer/ItemNormalizer.php @@ -19,6 +19,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; use ApiPlatform\Core\Serializer\AbstractItemNormalizer; use ApiPlatform\Core\Serializer\ContextTrait; use ApiPlatform\Core\Util\ClassInfoTrait; @@ -43,9 +44,9 @@ final class ItemNormalizer extends AbstractItemNormalizer private $contextBuilder; - public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ContextBuilderInterface $contextBuilder, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], iterable $dataTransformers = []) + public function __construct(ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, ContextBuilderInterface $contextBuilder, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, array $defaultContext = [], iterable $dataTransformers = [], ResourceAccessCheckerInterface $resourceAccessChecker = null) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, null, false, $defaultContext, $dataTransformers, $resourceMetadataFactory); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, null, false, $defaultContext, $dataTransformers, $resourceMetadataFactory, $resourceAccessChecker); $this->contextBuilder = $contextBuilder; } diff --git a/src/JsonSchema/Command/JsonSchemaGenerateCommand.php b/src/JsonSchema/Command/JsonSchemaGenerateCommand.php index c322da6ac9b..580e23fb608 100644 --- a/src/JsonSchema/Command/JsonSchemaGenerateCommand.php +++ b/src/JsonSchema/Command/JsonSchemaGenerateCommand.php @@ -31,6 +31,8 @@ */ final class JsonSchemaGenerateCommand extends Command { + protected static $defaultName = 'api:json-schema:generate'; + private $schemaFactory; private $formats; @@ -48,7 +50,6 @@ public function __construct(SchemaFactoryInterface $schemaFactory, array $format protected function configure() { $this - ->setName('api:json-schema:generate') ->setDescription('Generates the JSON Schema for a resource operation.') ->addArgument('resource', InputArgument::REQUIRED, 'The Fully Qualified Class Name (FQCN) of the resource') ->addOption('itemOperation', null, InputOption::VALUE_REQUIRED, 'The item operation') diff --git a/src/JsonSchema/SchemaFactory.php b/src/JsonSchema/SchemaFactory.php index 443c381571e..4edcbcf2b71 100644 --- a/src/JsonSchema/SchemaFactory.php +++ b/src/JsonSchema/SchemaFactory.php @@ -20,6 +20,7 @@ use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactory; use ApiPlatform\Core\Swagger\Serializer\DocumentationNormalizer; use ApiPlatform\Core\Util\ResourceClassInfoTrait; use Symfony\Component\PropertyInfo\Type; @@ -153,6 +154,8 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str { $version = $schema->getVersion(); $swagger = false; + $propertySchema = $propertyMetadata->getSchema() ?? []; + switch ($version) { case Schema::VERSION_SWAGGER: $swagger = true; @@ -165,7 +168,11 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $basePropertySchemaAttribute = 'json_schema_context'; } - $propertySchema = $propertyMetadata->getAttributes()[$basePropertySchemaAttribute] ?? []; + $propertySchema = array_merge( + $propertySchema, + $propertyMetadata->getAttributes()[$basePropertySchemaAttribute] ?? [] + ); + if (false === $propertyMetadata->isWritable() && !$propertyMetadata->isInitializable()) { $propertySchema['readOnly'] = true; } @@ -185,6 +192,18 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str $propertySchema['externalDocs'] = ['url' => $iri]; } + if (!isset($propertySchema['default']) && null !== $default = $propertyMetadata->getDefault()) { + $propertySchema['default'] = $default; + } + + if (!isset($propertySchema['example']) && null !== $example = $propertyMetadata->getExample()) { + $propertySchema['example'] = $example; + } + + if (!isset($propertySchema['example']) && isset($propertySchema['default'])) { + $propertySchema['example'] = $propertySchema['default']; + } + $valueSchema = []; if (null !== $type = $propertyMetadata->getType()) { $isCollection = $type->isCollection(); @@ -200,7 +219,6 @@ private function buildPropertySchema(Schema $schema, string $definitionName, str } $propertySchema = new \ArrayObject($propertySchema + $valueSchema); - $schema->getDefinitions()[$definitionName]['properties'][$normalizedPropertyName] = $propertySchema; } @@ -210,22 +228,30 @@ private function buildDefinitionName(string $className, string $format = 'json', $prefix = $resourceMetadata ? $resourceMetadata->getShortName() : (new \ReflectionClass($className))->getShortName(); if (null !== $inputOrOutputClass && $className !== $inputOrOutputClass) { - $prefix .= ':'.md5($inputOrOutputClass); + $parts = explode('\\', $inputOrOutputClass); + $shortName = end($parts); + $prefix .= '.'.$shortName; } if (isset($this->distinctFormats[$format])) { // JSON is the default, and so isn't included in the definition name - $prefix .= ':'.$format; + $prefix .= '.'.$format; } - if (isset($serializerContext[DocumentationNormalizer::SWAGGER_DEFINITION_NAME])) { - $name = sprintf('%s-%s', $prefix, $serializerContext[DocumentationNormalizer::SWAGGER_DEFINITION_NAME]); + $definitionName = $serializerContext[OpenApiFactory::OPENAPI_DEFINITION_NAME] ?? $serializerContext[DocumentationNormalizer::SWAGGER_DEFINITION_NAME] ?? null; + if ($definitionName) { + $name = sprintf('%s-%s', $prefix, $definitionName); } else { $groups = (array) ($serializerContext[AbstractNormalizer::GROUPS] ?? []); $name = $groups ? sprintf('%s-%s', $prefix, implode('_', $groups)) : $prefix; } - return $name; + return $this->encodeDefinitionName($name); + } + + private function encodeDefinitionName(string $name): string + { + return preg_replace('/[^a-zA-Z0-9.\-_]/', '.', $name); } private function getMetadata(string $className, string $type = Schema::TYPE_OUTPUT, ?string $operationType = null, ?string $operationName = null, ?array $serializerContext = null): ?array diff --git a/src/JsonSchema/TypeFactory.php b/src/JsonSchema/TypeFactory.php index 41e03253cca..e6a51796488 100644 --- a/src/JsonSchema/TypeFactory.php +++ b/src/JsonSchema/TypeFactory.php @@ -17,6 +17,8 @@ use ApiPlatform\Core\Util\ResourceClassInfoTrait; use Ramsey\Uuid\UuidInterface; use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Uid\Ulid; +use Symfony\Component\Uid\Uuid; /** * {@inheritdoc} @@ -106,12 +108,18 @@ private function getClassType(?string $className, string $format, ?bool $readabl 'format' => 'duration', ]; } - if (is_a($className, UuidInterface::class, true)) { + if (is_a($className, UuidInterface::class, true) || is_a($className, Uuid::class, true)) { return [ 'type' => 'string', 'format' => 'uuid', ]; } + if (is_a($className, Ulid::class, true)) { + return [ + 'type' => 'string', + 'format' => 'ulid', + ]; + } // Skip if $schema is null (filters only support basic types) if (null === $schema) { diff --git a/src/Metadata/Extractor/XmlExtractor.php b/src/Metadata/Extractor/XmlExtractor.php index e69cf6094d9..3de449b43c4 100644 --- a/src/Metadata/Extractor/XmlExtractor.php +++ b/src/Metadata/Extractor/XmlExtractor.php @@ -120,7 +120,7 @@ private function getProperties(\SimpleXMLElement $resource): array 'attributes' => $this->getAttributes($property, 'attribute'), 'subresource' => $property->subresource ? [ 'collection' => $this->phpize($property->subresource, 'collection', 'bool'), - 'resourceClass' => $this->phpize($property->subresource, 'resourceClass', 'string'), + 'resourceClass' => $this->resolve($this->phpize($property->subresource, 'resourceClass', 'string')), 'maxDepth' => $this->phpize($property->subresource, 'maxDepth', 'integer'), ] : null, ]; diff --git a/src/Metadata/Extractor/YamlExtractor.php b/src/Metadata/Extractor/YamlExtractor.php index 24271fab888..c3cefd33234 100644 --- a/src/Metadata/Extractor/YamlExtractor.php +++ b/src/Metadata/Extractor/YamlExtractor.php @@ -100,6 +100,9 @@ private function extractProperties(array $resourceYaml, string $resourceName, st if (!\is_array($propertyValues)) { throw new InvalidArgumentException(sprintf('"%s" setting is expected to be null or an array, %s given in "%s".', $propertyName, \gettype($propertyValues), $path)); } + if (isset($propertyValues['subresource']['resourceClass'])) { + $propertyValues['subresource']['resourceClass'] = $this->resolve($propertyValues['subresource']['resourceClass']); + } $this->resources[$resourceName]['properties'][$propertyName] = [ 'description' => $this->phpize($propertyValues, 'description', 'string'), diff --git a/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php b/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php index 9f882eb9cac..0a8efcbe4a2 100644 --- a/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/AnnotationPropertyMetadataFactory.php @@ -29,7 +29,7 @@ final class AnnotationPropertyMetadataFactory implements PropertyMetadataFactory private $reader; private $decorated; - public function __construct(Reader $reader, PropertyMetadataFactoryInterface $decorated = null) + public function __construct(Reader $reader = null, PropertyMetadataFactoryInterface $decorated = null) { $this->reader = $reader; $this->decorated = $decorated; @@ -56,7 +56,13 @@ public function create(string $resourceClass, string $property, array $options = } if ($reflectionClass->hasProperty($property)) { - $annotation = $this->reader->getPropertyAnnotation($reflectionClass->getProperty($property), ApiProperty::class); + $annotation = null; + $reflectionProperty = $reflectionClass->getProperty($property); + if (\PHP_VERSION_ID >= 80000 && $attributes = $reflectionProperty->getAttributes(ApiProperty::class)) { + $annotation = $attributes[0]->newInstance(); + } elseif (null !== $this->reader) { + $annotation = $this->reader->getPropertyAnnotation($reflectionProperty, ApiProperty::class); + } if ($annotation instanceof ApiProperty) { return $this->createMetadata($annotation, $parentPropertyMetadata); @@ -74,7 +80,12 @@ public function create(string $resourceClass, string $property, array $options = continue; } - $annotation = $this->reader->getMethodAnnotation($reflectionMethod, ApiProperty::class); + $annotation = null; + if (\PHP_VERSION_ID >= 80000 && $attributes = $reflectionMethod->getAttributes(ApiProperty::class)) { + $annotation = $attributes[0]->newInstance(); + } elseif (null !== $this->reader) { + $annotation = $this->reader->getMethodAnnotation($reflectionMethod, ApiProperty::class); + } if ($annotation instanceof ApiProperty) { return $this->createMetadata($annotation, $parentPropertyMetadata); @@ -112,12 +123,16 @@ private function createMetadata(ApiProperty $annotation, PropertyMetadata $paren $annotation->identifier, $annotation->iri, null, - $annotation->attributes + $annotation->attributes, + null, + null, + $annotation->default, + $annotation->example ); } $propertyMetadata = $parentPropertyMetadata; - foreach ([['get', 'description'], ['is', 'readable'], ['is', 'writable'], ['is', 'readableLink'], ['is', 'writableLink'], ['is', 'required'], ['get', 'iri'], ['is', 'identifier'], ['get', 'attributes']] as $property) { + foreach ([['get', 'description'], ['is', 'readable'], ['is', 'writable'], ['is', 'readableLink'], ['is', 'writableLink'], ['is', 'required'], ['get', 'iri'], ['is', 'identifier'], ['get', 'attributes'], ['get', 'default'], ['get', 'example']] as $property) { if (null !== $value = $annotation->{$property[1]}) { $propertyMetadata = $this->createWith($propertyMetadata, $property, $value); } diff --git a/src/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactory.php b/src/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactory.php index eae2bcd3293..fd1aa59e67f 100644 --- a/src/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactory.php +++ b/src/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactory.php @@ -30,7 +30,7 @@ final class AnnotationPropertyNameCollectionFactory implements PropertyNameColle private $decorated; private $reflection; - public function __construct(Reader $reader, PropertyNameCollectionFactoryInterface $decorated = null) + public function __construct(Reader $reader = null, PropertyNameCollectionFactoryInterface $decorated = null) { $this->reader = $reader; $this->decorated = $decorated; @@ -48,7 +48,7 @@ public function create(string $resourceClass, array $options = []): PropertyName try { $propertyNameCollection = $this->decorated->create($resourceClass, $options); } catch (ResourceClassNotFoundException $resourceClassNotFoundException) { - // Ignore not found exceptions from parent + // Ignore not found exceptions from decorated factory } } @@ -66,7 +66,10 @@ public function create(string $resourceClass, array $options = []): PropertyName // Properties foreach ($reflectionClass->getProperties() as $reflectionProperty) { - if (null !== $this->reader->getPropertyAnnotation($reflectionProperty, ApiProperty::class)) { + if ( + (\PHP_VERSION_ID >= 80000 && $reflectionProperty->getAttributes(ApiProperty::class)) || + (null !== $this->reader && null !== $this->reader->getPropertyAnnotation($reflectionProperty, ApiProperty::class)) + ) { $propertyNames[$reflectionProperty->name] = $reflectionProperty->name; } } @@ -82,12 +85,18 @@ public function create(string $resourceClass, array $options = []): PropertyName $propertyName = lcfirst($propertyName); } - if (null !== $propertyName && null !== $this->reader->getMethodAnnotation($reflectionMethod, ApiProperty::class)) { + if ( + null !== $propertyName && + ( + (\PHP_VERSION_ID >= 80000 && $reflectionMethod->getAttributes(ApiProperty::class)) || + (null !== $this->reader && null !== $this->reader->getMethodAnnotation($reflectionMethod, ApiProperty::class)) + ) + ) { $propertyNames[$propertyName] = $propertyName; } } - // Inherited from parent + // add property names from decorated factory if (null !== $propertyNameCollection) { foreach ($propertyNameCollection as $propertyName) { $propertyNames[$propertyName] = $propertyName; diff --git a/src/Metadata/Property/Factory/DefaultPropertyMetadataFactory.php b/src/Metadata/Property/Factory/DefaultPropertyMetadataFactory.php new file mode 100644 index 00000000000..9c951077753 --- /dev/null +++ b/src/Metadata/Property/Factory/DefaultPropertyMetadataFactory.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Metadata\Property\Factory; + +use ApiPlatform\Core\Exception\PropertyNotFoundException; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; + +/** + * Populates defaults values of the ressource properties using the default PHP values of properties. + */ +final class DefaultPropertyMetadataFactory implements PropertyMetadataFactoryInterface +{ + private $decorated; + + public function __construct(PropertyMetadataFactoryInterface $decorated = null) + { + $this->decorated = $decorated; + } + + public function create(string $resourceClass, string $property, array $options = []): PropertyMetadata + { + if (null === $this->decorated) { + $propertyMetadata = new PropertyMetadata(); + } else { + try { + $propertyMetadata = $this->decorated->create($resourceClass, $property, $options); + } catch (PropertyNotFoundException $propertyNotFoundException) { + $propertyMetadata = new PropertyMetadata(); + } + } + + try { + $reflectionClass = new \ReflectionClass($resourceClass); + } catch (\ReflectionException $reflectionException) { + return $propertyMetadata; + } + + $defaultProperties = $reflectionClass->getDefaultProperties(); + + if (!\array_key_exists($property, $defaultProperties) || null === ($defaultProperty = $defaultProperties[$property])) { + return $propertyMetadata; + } + + return $propertyMetadata->withDefault($defaultProperty); + } +} diff --git a/src/Metadata/Property/Factory/ExtractorPropertyNameCollectionFactory.php b/src/Metadata/Property/Factory/ExtractorPropertyNameCollectionFactory.php index 57421e8e88c..8ea1fc92489 100644 --- a/src/Metadata/Property/Factory/ExtractorPropertyNameCollectionFactory.php +++ b/src/Metadata/Property/Factory/ExtractorPropertyNameCollectionFactory.php @@ -49,7 +49,7 @@ public function create(string $resourceClass, array $options = []): PropertyName try { $propertyNameCollection = $this->decorated->create($resourceClass, $options); } catch (ResourceClassNotFoundException $resourceClassNotFoundException) { - // Ignore not found exceptions from parent + // Ignore not found exceptions from decorated factory } foreach ($propertyNameCollection as $propertyName) { diff --git a/src/Metadata/Property/Factory/InheritedPropertyMetadataFactory.php b/src/Metadata/Property/Factory/InheritedPropertyMetadataFactory.php index 7d494d6347d..4ff268a6372 100644 --- a/src/Metadata/Property/Factory/InheritedPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/InheritedPropertyMetadataFactory.php @@ -17,9 +17,7 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; /** - * Get property metadata from eventual child inherited properties. - * - * @author Antoine Bluchet + * @deprecated since 2.6, to be removed in 3.0 */ final class InheritedPropertyMetadataFactory implements PropertyMetadataFactoryInterface { @@ -28,6 +26,8 @@ final class InheritedPropertyMetadataFactory implements PropertyMetadataFactoryI public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, PropertyMetadataFactoryInterface $decorated = null) { + @trigger_error(sprintf('"%s" is deprecated since 2.6 and will be removed in 3.0.', __CLASS__), E_USER_DEPRECATED); + $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; $this->decorated = $decorated; } @@ -37,6 +37,8 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName */ public function create(string $resourceClass, string $property, array $options = []): PropertyMetadata { + @trigger_error(sprintf('"%s" is deprecated since 2.6 and will be removed in 3.0.', __CLASS__), E_USER_DEPRECATED); + $propertyMetadata = $this->decorated ? $this->decorated->create($resourceClass, $property, $options) : new PropertyMetadata(); foreach ($this->resourceNameCollectionFactory->create() as $knownResourceClass) { diff --git a/src/Metadata/Property/Factory/InheritedPropertyNameCollectionFactory.php b/src/Metadata/Property/Factory/InheritedPropertyNameCollectionFactory.php index 8795cc4126c..36c16cee917 100644 --- a/src/Metadata/Property/Factory/InheritedPropertyNameCollectionFactory.php +++ b/src/Metadata/Property/Factory/InheritedPropertyNameCollectionFactory.php @@ -17,9 +17,7 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; /** - * Creates a property name collection from eventual child inherited properties. - * - * @author Antoine Bluchet + * @deprecated since 2.6, to be removed in 3.0 */ final class InheritedPropertyNameCollectionFactory implements PropertyNameCollectionFactoryInterface { @@ -28,6 +26,8 @@ final class InheritedPropertyNameCollectionFactory implements PropertyNameCollec public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, PropertyNameCollectionFactoryInterface $decorated = null) { + @trigger_error(sprintf('"%s" is deprecated since 2.6 and will be removed in 3.0.', __CLASS__), E_USER_DEPRECATED); + $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; $this->decorated = $decorated; } @@ -37,6 +37,8 @@ public function __construct(ResourceNameCollectionFactoryInterface $resourceName */ public function create(string $resourceClass, array $options = []): PropertyNameCollection { + @trigger_error(sprintf('"%s" is deprecated since 2.6 and will be removed in 3.0.', __CLASS__), E_USER_DEPRECATED); + $propertyNames = []; // Inherited from parent @@ -51,7 +53,7 @@ public function create(string $resourceClass, array $options = []): PropertyName continue; } - if (is_subclass_of($knownResourceClass, $resourceClass)) { + if (is_subclass_of($resourceClass, $knownResourceClass)) { foreach ($this->create($knownResourceClass) as $propertyName) { $propertyNames[$propertyName] = $propertyName; } diff --git a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php index dd165208b26..226e84c95a7 100644 --- a/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php +++ b/src/Metadata/Property/Factory/SerializerPropertyMetadataFactory.php @@ -48,8 +48,7 @@ public function create(string $resourceClass, string $property, array $options = { $propertyMetadata = $this->decorated->create($resourceClass, $property, $options); - // in case of a property inherited (in a child class), we need it's properties - // to be mapped against serialization groups instead of the parent ones. + // BC to be removed in 3.0 if (null !== ($childResourceClass = $propertyMetadata->getChildInherited())) { $resourceClass = $childResourceClass; } diff --git a/src/Metadata/Property/PropertyMetadata.php b/src/Metadata/Property/PropertyMetadata.php index 1cdb697fe75..4629b89e291 100644 --- a/src/Metadata/Property/PropertyMetadata.php +++ b/src/Metadata/Property/PropertyMetadata.php @@ -31,12 +31,24 @@ final class PropertyMetadata private $required; private $iri; private $identifier; + /** + * @deprecated since 2.6, to be removed in 3.0 + */ private $childInherited; private $attributes; private $subresource; private $initializable; + /** + * @var null + */ + private $default; + /** + * @var null + */ + private $example; + private $schema; - public function __construct(Type $type = null, string $description = null, bool $readable = null, bool $writable = null, bool $readableLink = null, bool $writableLink = null, bool $required = null, bool $identifier = null, string $iri = null, $childInherited = null, array $attributes = null, SubresourceMetadata $subresource = null, bool $initializable = null) + public function __construct(Type $type = null, string $description = null, bool $readable = null, bool $writable = null, bool $readableLink = null, bool $writableLink = null, bool $required = null, bool $identifier = null, string $iri = null, $childInherited = null, array $attributes = null, SubresourceMetadata $subresource = null, bool $initializable = null, $default = null, $example = null, array $schema = null) { $this->type = $type; $this->description = $description; @@ -47,10 +59,16 @@ public function __construct(Type $type = null, string $description = null, bool $this->required = $required; $this->identifier = $identifier; $this->iri = $iri; + if (null !== $childInherited) { + @trigger_error(sprintf('Providing a non-null value for the 10th argument ($childInherited) of the "%s" constructor is deprecated since 2.6 and will not be supported in 3.0.', __CLASS__), E_USER_DEPRECATED); + } $this->childInherited = $childInherited; $this->attributes = $attributes; $this->subresource = $subresource; $this->initializable = $initializable; + $this->default = $default; + $this->example = $example; + $this->schema = $schema; } /** @@ -258,7 +276,7 @@ public function withAttributes(array $attributes): self } /** - * Gets child inherited. + * @deprecated since 2.6, to be removed in 3.0 */ public function getChildInherited(): ?string { @@ -266,7 +284,7 @@ public function getChildInherited(): ?string } /** - * Is the property inherited from a child class? + * @deprecated since 2.6, to be removed in 3.0 */ public function hasChildInherited(): bool { @@ -274,22 +292,22 @@ public function hasChildInherited(): bool } /** - * Is the property inherited from a child class? - * - * @deprecated since version 2.4, to be removed in 3.0. + * @deprecated since 2.4, to be removed in 3.0 */ public function isChildInherited(): ?string { - @trigger_error(sprintf('The use of "%1$s::isChildInherited()" is deprecated since 2.4 and will be removed in 3.0. Use "%1$s::getChildInherited()" or "%1$s::hasChildInherited()" directly instead.', __CLASS__), E_USER_DEPRECATED); + @trigger_error(sprintf('"%s::%s" is deprecated since 2.4 and will be removed in 3.0.', __CLASS__, __METHOD__), E_USER_DEPRECATED); return $this->getChildInherited(); } /** - * Returns a new instance with the given child inherited class. + * @deprecated since 2.6, to be removed in 3.0 */ public function withChildInherited(string $childInherited): self { + @trigger_error(sprintf('"%s::%s" is deprecated since 2.6 and will be removed in 3.0.', __CLASS__, __METHOD__), E_USER_DEPRECATED); + $metadata = clone $this; $metadata->childInherited = $childInherited; @@ -343,4 +361,61 @@ public function withInitializable(bool $initializable): self return $metadata; } + + /** + * Returns the default value of the property or NULL if the property doesn't have a default value. + */ + public function getDefault() + { + return $this->default; + } + + /** + * Returns a new instance with the given default value for the property. + */ + public function withDefault($default): self + { + $metadata = clone $this; + $metadata->default = $default; + + return $metadata; + } + + /** + * Returns an example of the value of the property. + */ + public function getExample() + { + return $this->example; + } + + /** + * Returns a new instance with the given example. + */ + public function withExample($example): self + { + $metadata = clone $this; + $metadata->example = $example; + + return $metadata; + } + + /** + * @return array + */ + public function getSchema(): ?array + { + return $this->schema; + } + + /** + * Returns a new instance with the given schema. + */ + public function withSchema(array $schema = null): self + { + $metadata = clone $this; + $metadata->schema = $schema; + + return $metadata; + } } diff --git a/src/Metadata/Resource/Factory/AnnotationResourceMetadataFactory.php b/src/Metadata/Resource/Factory/AnnotationResourceMetadataFactory.php index da231e5b964..cc9bc054499 100644 --- a/src/Metadata/Resource/Factory/AnnotationResourceMetadataFactory.php +++ b/src/Metadata/Resource/Factory/AnnotationResourceMetadataFactory.php @@ -27,11 +27,13 @@ final class AnnotationResourceMetadataFactory implements ResourceMetadataFactory { private $reader; private $decorated; + private $defaults; - public function __construct(Reader $reader, ResourceMetadataFactoryInterface $decorated = null) + public function __construct(Reader $reader = null, ResourceMetadataFactoryInterface $decorated = null, array $defaults = []) { $this->reader = $reader; $this->decorated = $decorated; + $this->defaults = $defaults + ['attributes' => []]; } /** @@ -54,6 +56,14 @@ public function create(string $resourceClass): ResourceMetadata return $this->handleNotFound($parentResourceMetadata, $resourceClass); } + if (\PHP_VERSION_ID >= 80000 && $attributes = $reflectionClass->getAttributes(ApiResource::class)) { + return $this->createMetadata($attributes[0]->newInstance(), $parentResourceMetadata); + } + + if (null === $this->reader) { + $this->handleNotFound($parentResourceMetadata, $resourceClass); + } + $resourceAnnotation = $this->reader->getClassAnnotation($reflectionClass, ApiResource::class); if (!$resourceAnnotation instanceof ApiResource) { return $this->handleNotFound($parentResourceMetadata, $resourceClass); @@ -78,16 +88,18 @@ private function handleNotFound(?ResourceMetadata $parentPropertyMetadata, strin private function createMetadata(ApiResource $annotation, ResourceMetadata $parentResourceMetadata = null): ResourceMetadata { + $attributes = (null === $annotation->attributes && [] === $this->defaults['attributes']) ? null : (array) $annotation->attributes + $this->defaults['attributes']; + if (!$parentResourceMetadata) { return new ResourceMetadata( $annotation->shortName, - $annotation->description, - $annotation->iri, - $annotation->itemOperations, - $annotation->collectionOperations, - $annotation->attributes, + $annotation->description ?? $this->defaults['description'] ?? null, + $annotation->iri ?? $this->defaults['iri'] ?? null, + $annotation->itemOperations ?? $this->defaults['item_operations'] ?? null, + $annotation->collectionOperations ?? $this->defaults['collection_operations'] ?? null, + $attributes, $annotation->subresourceOperations, - $annotation->graphql + $annotation->graphql ?? $this->defaults['graphql'] ?? null ); } diff --git a/src/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactory.php b/src/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactory.php index 102da335e0f..949e3b0a844 100644 --- a/src/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactory.php +++ b/src/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactory.php @@ -32,7 +32,7 @@ final class AnnotationResourceNameCollectionFactory implements ResourceNameColle /** * @param string[] $paths */ - public function __construct(Reader $reader, array $paths, ResourceNameCollectionFactoryInterface $decorated = null) + public function __construct(Reader $reader = null, array $paths, ResourceNameCollectionFactoryInterface $decorated = null) { $this->reader = $reader; $this->paths = $paths; @@ -53,7 +53,10 @@ public function create(): ResourceNameCollection } foreach (ReflectionClassRecursiveIterator::getReflectionClassesFromDirectories($this->paths) as $className => $reflectionClass) { - if ($this->reader->getClassAnnotation($reflectionClass, ApiResource::class)) { + if ( + (\PHP_VERSION_ID >= 80000 && $reflectionClass->getAttributes(ApiResource::class)) || + (null !== $this->reader && $this->reader->getClassAnnotation($reflectionClass, ApiResource::class)) + ) { $classes[$className] = true; } } diff --git a/src/Metadata/Resource/Factory/ExtractorResourceMetadataFactory.php b/src/Metadata/Resource/Factory/ExtractorResourceMetadataFactory.php index 369380b4364..fee663cf97c 100644 --- a/src/Metadata/Resource/Factory/ExtractorResourceMetadataFactory.php +++ b/src/Metadata/Resource/Factory/ExtractorResourceMetadataFactory.php @@ -27,11 +27,13 @@ final class ExtractorResourceMetadataFactory implements ResourceMetadataFactoryI { private $extractor; private $decorated; + private $defaults; - public function __construct(ExtractorInterface $extractor, ResourceMetadataFactoryInterface $decorated = null) + public function __construct(ExtractorInterface $extractor, ResourceMetadataFactoryInterface $decorated = null, array $defaults = []) { $this->extractor = $extractor; $this->decorated = $decorated; + $this->defaults = $defaults + ['attributes' => []]; } /** @@ -52,6 +54,13 @@ public function create(string $resourceClass): ResourceMetadata return $this->handleNotFound($parentResourceMetadata, $resourceClass); } + $resource['description'] = $resource['description'] ?? $this->defaults['description'] ?? null; + $resource['iri'] = $resource['iri'] ?? $this->defaults['iri'] ?? null; + $resource['itemOperations'] = $resource['itemOperations'] ?? $this->defaults['item_operations'] ?? null; + $resource['collectionOperations'] = $resource['collectionOperations'] ?? $this->defaults['collection_operations'] ?? null; + $resource['graphql'] = $resource['graphql'] ?? $this->defaults['graphql'] ?? null; + $resource['attributes'] = (null === $resource['attributes'] && [] === $this->defaults['attributes']) ? null : (array) $resource['attributes'] + $this->defaults['attributes']; + return $this->update($parentResourceMetadata ?: new ResourceMetadata(), $resource); } diff --git a/src/Metadata/Resource/Factory/OperationResourceMetadataFactory.php b/src/Metadata/Resource/Factory/OperationResourceMetadataFactory.php index eeb41baaacb..931f99ef311 100644 --- a/src/Metadata/Resource/Factory/OperationResourceMetadataFactory.php +++ b/src/Metadata/Resource/Factory/OperationResourceMetadataFactory.php @@ -59,7 +59,7 @@ public function create(string $resourceClass): ResourceMetadata $collectionOperations = $resourceMetadata->getCollectionOperations(); if (null === $collectionOperations) { - $resourceMetadata = $resourceMetadata->withCollectionOperations($this->createOperations($isAbstract ? ['GET'] : ['GET', 'POST'])); + $resourceMetadata = $resourceMetadata->withCollectionOperations($this->createOperations($isAbstract ? ['GET'] : ['GET', 'POST'], $resourceMetadata)); } else { $resourceMetadata = $this->normalize(true, $resourceClass, $resourceMetadata, $collectionOperations); } @@ -76,7 +76,7 @@ public function create(string $resourceClass): ResourceMetadata } } - $resourceMetadata = $resourceMetadata->withItemOperations($this->createOperations($methods)); + $resourceMetadata = $resourceMetadata->withItemOperations($this->createOperations($methods, $resourceMetadata)); } else { $resourceMetadata = $this->normalize(false, $resourceClass, $resourceMetadata, $itemOperations); } @@ -91,11 +91,11 @@ public function create(string $resourceClass): ResourceMetadata return $resourceMetadata; } - private function createOperations(array $methods): array + private function createOperations(array $methods, ResourceMetadata $resourceMetadata): array { $operations = []; foreach ($methods as $method) { - $operations[strtolower($method)] = ['method' => $method]; + $operations[strtolower($method)] = ['method' => $method, 'stateless' => $resourceMetadata->getAttribute('stateless')]; } return $operations; @@ -131,6 +131,8 @@ private function normalize(bool $collection, string $resourceClass, ResourceMeta $operation['method'] = strtoupper($operation['method']); } + $operation['stateless'] = $operation['stateless'] ?? $resourceMetadata->getAttribute('stateless'); + $newOperations[$operationName] = $operation; } diff --git a/src/OpenApi/Factory/OpenApiFactory.php b/src/OpenApi/Factory/OpenApiFactory.php new file mode 100644 index 00000000000..208299f814c --- /dev/null +++ b/src/OpenApi/Factory/OpenApiFactory.php @@ -0,0 +1,451 @@ + + * + * For 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\OpenApi\Factory; + +use ApiPlatform\Core\Api\FilterLocatorTrait; +use ApiPlatform\Core\Api\OperationType; +use ApiPlatform\Core\DataProvider\PaginationOptions; +use ApiPlatform\Core\JsonSchema\Schema; +use ApiPlatform\Core\JsonSchema\SchemaFactoryInterface; +use ApiPlatform\Core\JsonSchema\TypeFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\OpenApi\Model; +use ApiPlatform\Core\OpenApi\Model\ExternalDocumentation; +use ApiPlatform\Core\OpenApi\OpenApi; +use ApiPlatform\Core\OpenApi\Options; +use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactoryInterface; +use ApiPlatform\Core\PathResolver\OperationPathResolverInterface; +use Psr\Container\ContainerInterface; +use Symfony\Component\PropertyInfo\Type; + +/** + * Generates an Open API v3 specification. + */ +final class OpenApiFactory implements OpenApiFactoryInterface +{ + use FilterLocatorTrait; + + public const BASE_URL = 'base_url'; + public const OPENAPI_DEFINITION_NAME = 'openapi_definition_name'; + + private $resourceNameCollectionFactory; + private $resourceMetadataFactory; + private $propertyNameCollectionFactory; + private $propertyMetadataFactory; + private $operationPathResolver; + private $subresourceOperationFactory; + private $formats; + private $jsonSchemaFactory; + private $jsonSchemaTypeFactory; + private $openApiOptions; + private $paginationOptions; + + public function __construct(ResourceNameCollectionFactoryInterface $resourceNameCollectionFactory, ResourceMetadataFactoryInterface $resourceMetadataFactory, PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, SchemaFactoryInterface $jsonSchemaFactory, TypeFactoryInterface $jsonSchemaTypeFactory, OperationPathResolverInterface $operationPathResolver, ContainerInterface $filterLocator, SubresourceOperationFactoryInterface $subresourceOperationFactory, array $formats = [], Options $openApiOptions, PaginationOptions $paginationOptions) + { + $this->resourceNameCollectionFactory = $resourceNameCollectionFactory; + $this->jsonSchemaFactory = $jsonSchemaFactory; + $this->jsonSchemaTypeFactory = $jsonSchemaTypeFactory; + $this->formats = $formats; + $this->setFilterLocator($filterLocator, true); + $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->propertyNameCollectionFactory = $propertyNameCollectionFactory; + $this->propertyMetadataFactory = $propertyMetadataFactory; + $this->operationPathResolver = $operationPathResolver; + $this->openApiOptions = $openApiOptions; + $this->paginationOptions = $paginationOptions; + $this->subresourceOperationFactory = $subresourceOperationFactory; + } + + /** + * {@inheritdoc} + */ + public function __invoke(array $context = []): OpenApi + { + $baseUrl = $context[self::BASE_URL] ?? '/'; + $contact = null === $this->openApiOptions->getContactUrl() || null === $this->openApiOptions->getContactEmail() ? null : new Model\Contact($this->openApiOptions->getContactName(), $this->openApiOptions->getContactUrl(), $this->openApiOptions->getContactEmail()); + $license = null === $this->openApiOptions->getLicenseName() ? null : new Model\License($this->openApiOptions->getLicenseName(), $this->openApiOptions->getLicenseUrl()); + $info = new Model\Info($this->openApiOptions->getTitle(), $this->openApiOptions->getVersion(), trim($this->openApiOptions->getDescription()), $this->openApiOptions->getTermsOfService(), $contact, $license); + $servers = '/' === $baseUrl || '' === $baseUrl ? [new Model\Server('/')] : [new Model\Server($baseUrl)]; + $paths = new Model\Paths(); + $links = []; + $schemas = []; + + foreach ($this->resourceNameCollectionFactory->create() as $resourceClass) { + $resourceMetadata = $this->resourceMetadataFactory->create($resourceClass); + $resourceShortName = $resourceMetadata->getShortName(); + + // Items needs to be parsed first to be able to reference the lines from the collection operation + list($itemOperationLinks, $itemOperationSchemas) = $this->collectPaths($resourceMetadata, $resourceClass, OperationType::ITEM, $context, $paths, $links, $schemas); + $schemas += $itemOperationSchemas; + list($collectionOperationLinks, $collectionOperationSchemas) = $this->collectPaths($resourceMetadata, $resourceClass, OperationType::COLLECTION, $context, $paths, $links, $schemas); + + list($subresourceOperationLinks, $subresourceOperationSchemas) = $this->collectPaths($resourceMetadata, $resourceClass, OperationType::SUBRESOURCE, $context, $paths, $links, $schemas); + $schemas += $collectionOperationSchemas; + } + + $securitySchemes = $this->getSecuritySchemes(); + $securityRequirements = []; + + foreach (array_keys($securitySchemes) as $key) { + $securityRequirements[$key] = []; + } + + return new OpenApi($info, $servers, $paths, new Model\Components(new \ArrayObject($schemas), new \ArrayObject(), new \ArrayObject(), new \ArrayObject(), new \ArrayObject(), new \ArrayObject(), new \ArrayObject($securitySchemes)), $securityRequirements); + } + + /** + * @return array | array + */ + private function collectPaths(ResourceMetadata $resourceMetadata, string $resourceClass, string $operationType, array $context, Model\Paths $paths, array &$links, array $schemas = []): array + { + $resourceShortName = $resourceMetadata->getShortName(); + $operations = OperationType::COLLECTION === $operationType ? $resourceMetadata->getCollectionOperations() : (OperationType::ITEM === $operationType ? $resourceMetadata->getItemOperations() : $this->subresourceOperationFactory->create($resourceClass)); + if (!$operations) { + return [$links, $schemas]; + } + + $rootResourceClass = $resourceClass; + foreach ($operations as $operationName => $operation) { + $resourceClass = $operation['resource_class'] ?? $rootResourceClass; + $path = $this->getPath($resourceShortName, $operationName, $operation, $operationType); + $method = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'method', 'GET'); + list($requestMimeTypes, $responseMimeTypes) = $this->getMimeTypes($resourceClass, $operationName, $operationType, $resourceMetadata); + $operationId = $operation['openapi_context']['operationId'] ?? lcfirst($operationName).ucfirst($resourceShortName).ucfirst($operationType); + $linkedOperationId = 'get'.ucfirst($resourceShortName).ucfirst(OperationType::ITEM); + $pathItem = $paths->getPath($path) ?: new Model\PathItem(); + $forceSchemaCollection = OperationType::SUBRESOURCE === $operationType ? ($operation['collection'] ?? false) : false; + + $operationOutputSchemas = []; + foreach ($responseMimeTypes as $operationFormat) { + $operationOutputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_OUTPUT, $operationType, $operationName, new Schema('openapi'), null, $forceSchemaCollection); + $schemas += $operationOutputSchema->getDefinitions()->getArrayCopy(); + $operationOutputSchemas[$operationFormat] = $operationOutputSchema; + } + + $parameters = []; + $responses = []; + + // Set up parameters + if (OperationType::ITEM === $operationType) { + $parameters[] = new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string']); + $links[$operationId] = $this->getLink($resourceClass, $operationId, $path); + } elseif (OperationType::COLLECTION === $operationType && 'GET' === $method) { + $parameters = array_merge($parameters, $this->getPaginationParameters($resourceMetadata, $operationName), $this->getFiltersParameters($resourceMetadata, $operationName, $resourceClass)); + } elseif (OperationType::SUBRESOURCE === $operationType) { + // FIXME: In SubresourceOperationFactory identifiers may happen twice + $added = []; + foreach ($operation['identifiers'] as $identifier) { + if (\in_array($identifier[0], $added, true)) { + continue; + } + $added[] = $identifier[0]; + $parameterShortname = $this->resourceMetadataFactory->create($identifier[1])->getShortName(); + $parameters[] = new Model\Parameter($identifier[0], 'path', $parameterShortname.' identifier', true, false, false, ['type' => 'string']); + } + + if ($operation['collection']) { + $parameters = array_merge($parameters, $this->getPaginationParameters($resourceMetadata, $operationName), $this->getFiltersParameters($resourceMetadata, $operationName, $resourceClass)); + } + } + + // Create responses + switch ($method) { + case 'GET': + $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '200'); + $responseContent = $this->buildContent($responseMimeTypes, $operationOutputSchemas); + $responses[$successStatus] = new Model\Response(sprintf('%s %s', $resourceShortName, OperationType::COLLECTION === $operationType ? 'collection' : 'resource'), $responseContent); + break; + case 'POST': + $responseLinks = new \ArrayObject(isset($links[$linkedOperationId]) ? [ucfirst($linkedOperationId) => $links[$linkedOperationId]] : []); + $responseContent = $this->buildContent($responseMimeTypes, $operationOutputSchemas); + $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'); + break; + case 'PATCH': + case 'PUT': + $responseLinks = new \ArrayObject(isset($links[$linkedOperationId]) ? [ucfirst($linkedOperationId) => $links[$linkedOperationId]] : []); + $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '200'); + $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'); + break; + case 'DELETE': + $successStatus = (string) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'status', '204'); + $responses[$successStatus] = new Model\Response(sprintf('%s resource deleted', $resourceShortName)); + break; + } + + if (OperationType::ITEM === $operationType) { + $responses['404'] = new Model\Response('Resource not found'); + } + + if (!$responses) { + $responses['default'] = new Model\Response('Unexpected error'); + } + + $requestBody = null; + if ('PUT' === $method || 'POST' === $method || 'PATCH' === $method) { + $operationInputSchemas = []; + foreach ($requestMimeTypes as $operationFormat) { + $operationInputSchema = $this->jsonSchemaFactory->buildSchema($resourceClass, $operationFormat, Schema::TYPE_INPUT, $operationType, $operationName, new Schema('openapi'), null, $forceSchemaCollection); + $schemas += $operationInputSchema->getDefinitions()->getArrayCopy(); + $operationInputSchemas[$operationFormat] = $operationInputSchema; + } + + $requestBody = new Model\RequestBody(sprintf('The %s %s resource', 'POST' === $method ? 'new' : 'updated', $resourceShortName), $this->buildContent($requestMimeTypes, $operationInputSchemas), true); + } + + $pathItem = $pathItem->{'with'.ucfirst($method)}(new Model\Operation( + $operationId, + $operation['openapi_context']['tags'] ?? (OperationType::SUBRESOURCE === $operationType ? $operation['shortNames'] : [$resourceShortName]), + $responses, + $operation['openapi_context']['summary'] ?? '', + $operation['openapi_context']['description'] ?? $this->getPathDescription($resourceShortName, $method, $operationType), + isset($operation['openapi_context']['externalDocs']) ? new ExternalDocumentation($operation['openapi_context']['externalDocs']['description'] ?? null, $operation['openapi_context']['externalDocs']['url']) : null, + $parameters, + $requestBody, + isset($operation['openapi_context']['callbacks']) ? new \ArrayObject($operation['openapi_context']['callbacks']) : null, + $operation['openapi_context']['deprecated'] ?? (bool) $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'deprecation_reason', false, true), + $operation['openapi_context']['security'] ?? null, + $operation['openapi_context']['servers'] ?? null + )); + + $paths->addPath($path, $pathItem); + } + + return [$links, $schemas]; + } + + private function buildContent(array $responseMimeTypes, array $operationSchemas): \ArrayObject + { + $content = new \ArrayObject(); + + foreach ($responseMimeTypes as $mimeType => $format) { + $content[$mimeType] = new Model\MediaType(new \ArrayObject($operationSchemas[$format]->getArrayCopy(false))); + } + + return $content; + } + + private function getMimeTypes(string $resourceClass, string $operationName, string $operationType, ResourceMetadata $resourceMetadata = null): array + { + $requestFormats = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'input_formats', $this->formats, true); + $responseFormats = $resourceMetadata->getTypedOperationAttribute($operationType, $operationName, 'output_formats', $this->formats, true); + + $requestMimeTypes = $this->flattenMimeTypes($requestFormats); + $responseMimeTypes = $this->flattenMimeTypes($responseFormats); + + return [$requestMimeTypes, $responseMimeTypes]; + } + + private function flattenMimeTypes(array $responseFormats): array + { + $responseMimeTypes = []; + foreach ($responseFormats as $responseFormat => $mimeTypes) { + foreach ($mimeTypes as $mimeType) { + $responseMimeTypes[$mimeType] = $responseFormat; + } + } + + return $responseMimeTypes; + } + + /** + * Gets the path for an operation. + * + * If the path ends with the optional _format parameter, it is removed + * as optional path parameters are not yet supported. + * + * @see https://github.com/OAI/OpenAPI-Specification/issues/93 + */ + private function getPath(string $resourceShortName, string $operationName, array $operation, string $operationType): string + { + $path = $operation['path'] ?? $this->operationPathResolver->resolveOperationPath($resourceShortName, $operation, $operationType, $operationName); + if ('.{_format}' === substr($path, -10)) { + $path = substr($path, 0, -10); + } + + return 0 === strpos($path, '/') ? $path : '/'.$path; + } + + private function getPathDescription(string $resourceShortName, string $method, string $operationType): string + { + switch ($method) { + case 'GET': + $pathSummary = OperationType::COLLECTION === $operationType ? 'Retrieves the collection of %s resources.' : 'Retrieves a %s resource.'; + break; + case 'POST': + $pathSummary = 'Creates a %s resource.'; + break; + case 'PATCH': + $pathSummary = 'Updates the %s resource.'; + break; + case 'PUT': + $pathSummary = 'Replaces the %s resource.'; + break; + case 'DELETE': + $pathSummary = 'Removes the %s resource.'; + break; + default: + return $resourceShortName; + } + + return sprintf($pathSummary, $resourceShortName); + } + + /** + * @see https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#linkObject. + */ + private function getLink(string $resourceClass, string $operationId, string $path): Model\Link + { + $parameters = []; + + foreach ($this->propertyNameCollectionFactory->create($resourceClass) as $propertyName) { + $propertyMetadata = $this->propertyMetadataFactory->create($resourceClass, $propertyName); + if (!$propertyMetadata->isIdentifier()) { + continue; + } + + $parameters[$propertyName] = sprintf('$response.body#/%s', $propertyName); + } + + return new Model\Link( + $operationId, + new \ArrayObject($parameters), + [], + 1 === \count($parameters) ? sprintf('The `%1$s` value returned in the response can be used as the `%1$s` parameter in `GET %2$s`.', key($parameters), $path) : sprintf('The values returned in the response can be used in `GET %s`.', $path) + ); + } + + /** + * Gets parameters corresponding to enabled filters. + */ + private function getFiltersParameters(ResourceMetadata $resourceMetadata, string $operationName, string $resourceClass): array + { + $parameters = []; + $resourceFilters = $resourceMetadata->getCollectionOperationAttribute($operationName, 'filters', [], true); + foreach ($resourceFilters as $filterId) { + if (!$filter = $this->getFilter($filterId)) { + continue; + } + + foreach ($filter->getDescription($resourceClass) as $name => $data) { + $schema = $data['schema'] ?? \in_array($data['type'], Type::$builtinTypes, true) ? $this->jsonSchemaTypeFactory->getType(new Type($data['type'], false, null, $data['is_collection'] ?? false)) : ['type' => 'string']; + + $parameters[] = new Model\Parameter( + $name, + 'query', + $data['description'] ?? '', + $data['required'] ?? false, + $data['openapi']['deprecated'] ?? false, + $data['openapi']['allowEmptyValue'] ?? true, + $schema, + 'array' === $schema['type'] && \in_array($data['type'], + [Type::BUILTIN_TYPE_ARRAY, Type::BUILTIN_TYPE_OBJECT], true) ? 'deepObject' : 'form', + 'array' === $schema['type'], + $data['openapi']['allowReserved'] ?? false, + $data['openapi']['example'] ?? null, + isset($data['openapi']['examples'] + ) ? new \ArrayObject($data['openapi']['examples']) : null); + } + } + + return $parameters; + } + + private function getPaginationParameters(ResourceMetadata $resourceMetadata, string $operationName): array + { + if (!$this->paginationOptions->isPaginationEnabled()) { + return []; + } + + $parameters = []; + + if ($resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_enabled', true, true)) { + $parameters[] = new Model\Parameter($this->paginationOptions->getPaginationPageParameterName(), 'query', 'The collection page number', false, false, true, ['type' => 'integer', 'default' => 1]); + + if ($resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_items_per_page', $this->paginationOptions->getClientItemsPerPage(), true)) { + $schema = [ + 'type' => 'integer', + 'default' => $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_items_per_page', 30, true), + 'minimum' => 0, + ]; + + if (null !== $maxItemsPerPage = $resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_maximum_items_per_page', null, true)) { + $schema['maximum'] = $maxItemsPerPage; + } + + $parameters[] = new Model\Parameter($this->paginationOptions->getItemsPerPageParameterName(), 'query', 'The number of items per page', false, false, true, $schema); + } + } + + if ($resourceMetadata->getCollectionOperationAttribute($operationName, 'pagination_client_enabled', $this->paginationOptions->getPaginationClientEnabled(), true)) { + $parameters[] = new Model\Parameter($this->paginationOptions->getPaginationClientEnabledParameterName(), 'query', 'Enable or disable pagination', false, false, true, ['type' => 'boolean']); + } + + return $parameters; + } + + private function getOauthSecurityScheme(): Model\SecurityScheme + { + $oauthFlow = new Model\OAuthFlow($this->openApiOptions->getOAuthAuthorizationUrl(), $this->openApiOptions->getOAuthTokenUrl(), $this->openApiOptions->getOAuthRefreshUrl(), new \ArrayObject($this->openApiOptions->getOAuthScopes())); + $description = sprintf( + 'OAuth 2.0 %s Grant', + strtolower(preg_replace('/[A-Z]/', ' \\0', lcfirst($this->openApiOptions->getOAuthFlow()))) + ); + $implicit = $password = $clientCredentials = $authorizationCode = null; + + switch ($this->openApiOptions->getOAuthFlow()) { + case 'implicit': + $implicit = $oauthFlow; + break; + case 'password': + $password = $oauthFlow; + break; + case 'application': + case 'clientCredentials': + $clientCredentials = $oauthFlow; + break; + case 'accessCode': + case 'authorizationCode': + $authorizationCode = $oauthFlow; + break; + default: + 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); + } + + private function getSecuritySchemes(): array + { + $securitySchemes = []; + + if ($this->openApiOptions->getOAuthEnabled()) { + $securitySchemes['oauth'] = $this->getOauthSecurityScheme(); + } + + foreach ($this->openApiOptions->getApiKeys() as $key => $apiKey) { + $description = sprintf('Value for the %s %s parameter.', $apiKey['name'], $apiKey['type']); + $securitySchemes[$key] = new Model\SecurityScheme('apiKey', $description, $apiKey['name'], $apiKey['type'], 'bearer'); + } + + return $securitySchemes; + } +} diff --git a/src/OpenApi/Factory/OpenApiFactoryInterface.php b/src/OpenApi/Factory/OpenApiFactoryInterface.php new file mode 100644 index 00000000000..d4b04bb190f --- /dev/null +++ b/src/OpenApi/Factory/OpenApiFactoryInterface.php @@ -0,0 +1,24 @@ + + * + * For 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\OpenApi\Factory; + +use ApiPlatform\Core\OpenApi\OpenApi; + +interface OpenApiFactoryInterface +{ + /** + * Creates an OpenApi class. + */ + public function __invoke(array $context = []): OpenApi; +} diff --git a/src/OpenApi/Model/Components.php b/src/OpenApi/Model/Components.php new file mode 100644 index 00000000000..1035ab1d7f0 --- /dev/null +++ b/src/OpenApi/Model/Components.php @@ -0,0 +1,159 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class Components +{ + use ExtensionTrait; + + private $schemas; + private $responses; + private $parameters; + private $examples; + private $requestBodies; + private $headers; + private $securitySchemes; + private $links; + private $callbacks; + + public function __construct(\ArrayObject $schemas = null, \ArrayObject $responses = null, \ArrayObject $parameters = null, \ArrayObject $examples = null, \ArrayObject $requestBodies = null, \ArrayObject $headers = null, \ArrayObject $securitySchemes = null, \ArrayObject $links = null, \ArrayObject $callbacks = null) + { + $this->schemas = $schemas; + $this->responses = $responses; + $this->parameters = $parameters; + $this->examples = $examples; + $this->requestBodies = $requestBodies; + $this->headers = $headers; + $this->securitySchemes = $securitySchemes; + $this->links = $links; + $this->callbacks = $callbacks; + } + + public function getSchemas(): ?\ArrayObject + { + return $this->schemas; + } + + public function getResponses(): ?\ArrayObject + { + return $this->responses; + } + + public function getParameters(): ?\ArrayObject + { + return $this->parameters; + } + + public function getExamples(): ?\ArrayObject + { + return $this->examples; + } + + public function getRequestBodies(): ?\ArrayObject + { + return $this->requestBodies; + } + + public function getHeaders(): ?\ArrayObject + { + return $this->headers; + } + + public function getSecuritySchemes(): ?\ArrayObject + { + return $this->securitySchemes; + } + + public function getLinks(): ?\ArrayObject + { + return $this->links; + } + + public function getCallbacks(): ?\ArrayObject + { + return $this->callbacks; + } + + public function withSchemas(\ArrayObject $schemas): self + { + $clone = clone $this; + $clone->schemas = $schemas; + + return $clone; + } + + public function withResponses(\ArrayObject $responses): self + { + $clone = clone $this; + $clone->responses = $responses; + + return $clone; + } + + public function withParameters(\ArrayObject $parameters): self + { + $clone = clone $this; + $clone->parameters = $parameters; + + return $clone; + } + + public function withExamples(\ArrayObject $examples): self + { + $clone = clone $this; + $clone->examples = $examples; + + return $clone; + } + + public function withRequestBodies(\ArrayObject $requestBodies): self + { + $clone = clone $this; + $clone->requestBodies = $requestBodies; + + return $clone; + } + + public function withHeaders(\ArrayObject $headers): self + { + $clone = clone $this; + $clone->headers = $headers; + + return $clone; + } + + public function withSecuritySchemes(\ArrayObject $securitySchemes): self + { + $clone = clone $this; + $clone->securitySchemes = $securitySchemes; + + return $clone; + } + + public function withLinks(\ArrayObject $links): self + { + $clone = clone $this; + $clone->links = $links; + + return $clone; + } + + public function withCallbacks(\ArrayObject $callbacks): self + { + $clone = clone $this; + $clone->callbacks = $callbacks; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Contact.php b/src/OpenApi/Model/Contact.php new file mode 100644 index 00000000000..61cc320ef99 --- /dev/null +++ b/src/OpenApi/Model/Contact.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class Contact +{ + use ExtensionTrait; + + private $name; + private $url; + private $email; + + public function __construct(string $name = null, string $url = null, string $email = null) + { + $this->name = $name; + $this->url = $url; + $this->email = $email; + } + + public function getName(): ?string + { + return $this->name; + } + + public function getUrl(): ?string + { + return $this->url; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function withName(?string $name): self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } + + public function withUrl(?string $url): self + { + $clone = clone $this; + $clone->url = $url; + + return $clone; + } + + public function withEmail(?string $email): self + { + $clone = clone $this; + $clone->email = $email; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Encoding.php b/src/OpenApi/Model/Encoding.php new file mode 100644 index 00000000000..12b8d91a1d2 --- /dev/null +++ b/src/OpenApi/Model/Encoding.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class Encoding +{ + use ExtensionTrait; + + private $contentType; + private $headers; + private $style; + private $explode; + private $allowReserved; + + public function __construct(string $contentType = '', \ArrayObject $headers = null, string $style = '', bool $explode = false, bool $allowReserved = false) + { + $this->contentType = $contentType; + $this->headers = $headers; + $this->style = $style; + $this->explode = $explode; + $this->allowReserved = $allowReserved; + } + + public function getContentType(): string + { + return $this->contentType; + } + + public function getHeaders(): ?\ArrayObject + { + return $this->headers; + } + + public function getStyle(): string + { + return $this->style; + } + + public function canExplode(): bool + { + return $this->explode; + } + + public function canAllowReserved(): bool + { + return $this->allowReserved; + } + + public function withContentType(string $contentType): self + { + $clone = clone $this; + $clone->contentType = $contentType; + + return $clone; + } + + public function withHeaders(?\ArrayObject $headers): self + { + $clone = clone $this; + $clone->headers = $headers; + + return $clone; + } + + public function withStyle(string $style): self + { + $clone = clone $this; + $clone->style = $style; + + return $clone; + } + + public function withExplode(bool $explode): self + { + $clone = clone $this; + $clone->explode = $explode; + + return $clone; + } + + public function withAllowReserved(bool $allowReserved): self + { + $clone = clone $this; + $clone->allowReserved = $allowReserved; + + return $clone; + } +} diff --git a/src/OpenApi/Model/ExtensionTrait.php b/src/OpenApi/Model/ExtensionTrait.php new file mode 100644 index 00000000000..62f97f2151d --- /dev/null +++ b/src/OpenApi/Model/ExtensionTrait.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\OpenApi\Model; + +trait ExtensionTrait +{ + private $extensionProperties = []; + + public function withExtensionProperty(string $key, string $value) + { + if (0 !== strpos($key, 'x-')) { + $key = 'x-'.$key; + } + + $clone = clone $this; + $clone->extensionProperties[$key] = $value; + + return $clone; + } + + public function getExtensionProperties(): array + { + return $this->extensionProperties; + } +} diff --git a/src/OpenApi/Model/ExternalDocumentation.php b/src/OpenApi/Model/ExternalDocumentation.php new file mode 100644 index 00000000000..f606a26b4d7 --- /dev/null +++ b/src/OpenApi/Model/ExternalDocumentation.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class ExternalDocumentation +{ + use ExtensionTrait; + + private $description; + private $url; + + public function __construct(string $description = '', string $url = '') + { + $this->description = $description; + $this->url = $url; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getUrl(): string + { + return $this->url; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withUrl(string $url): self + { + $clone = clone $this; + $clone->url = $url; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Info.php b/src/OpenApi/Model/Info.php new file mode 100644 index 00000000000..9b16e9e8909 --- /dev/null +++ b/src/OpenApi/Model/Info.php @@ -0,0 +1,114 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class Info +{ + use ExtensionTrait; + + private $title; + private $description; + private $termsOfService; + private $contact; + private $license; + private $version; + + public function __construct(string $title, string $version, string $description = '', string $termsOfService = null, Contact $contact = null, License $license = null) + { + $this->title = $title; + $this->version = $version; + $this->description = $description; + $this->termsOfService = $termsOfService; + $this->contact = $contact; + $this->license = $license; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getTermsOfService(): ?string + { + return $this->termsOfService; + } + + public function getContact(): ?Contact + { + return $this->contact; + } + + public function getLicense(): ?License + { + return $this->license; + } + + public function getVersion(): string + { + return $this->version; + } + + public function withTitle(string $title): self + { + $info = clone $this; + $info->title = $title; + + return $info; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withTermsOfService(string $termsOfService): self + { + $clone = clone $this; + $clone->termsOfService = $termsOfService; + + return $clone; + } + + public function withContact(Contact $contact): self + { + $clone = clone $this; + $clone->contact = $contact; + + return $clone; + } + + public function withLicense(License $license): self + { + $clone = clone $this; + $clone->license = $license; + + return $clone; + } + + public function withVersion(string $version): self + { + $clone = clone $this; + $clone->version = $version; + + return $clone; + } +} diff --git a/src/OpenApi/Model/License.php b/src/OpenApi/Model/License.php new file mode 100644 index 00000000000..11c80bb849b --- /dev/null +++ b/src/OpenApi/Model/License.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class License +{ + use ExtensionTrait; + + private $name; + private $url; + + public function __construct(string $name, string $url = null) + { + $this->name = $name; + $this->url = $url; + } + + public function getName(): string + { + return $this->name; + } + + public function getUrl(): ?string + { + return $this->url; + } + + public function withName(string $name): self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } + + public function withUrl(?string $url): self + { + $clone = clone $this; + $clone->url = $url; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Link.php b/src/OpenApi/Model/Link.php new file mode 100644 index 00000000000..a0feb9511d6 --- /dev/null +++ b/src/OpenApi/Model/Link.php @@ -0,0 +1,99 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class Link +{ + use ExtensionTrait; + + private $operationId; + private $parameters; + private $requestBody; + private $description; + private $server; + + public function __construct(string $operationId, \ArrayObject $parameters = null, $requestBody = null, string $description = '', Server $server = null) + { + $this->operationId = $operationId; + $this->parameters = $parameters; + $this->requestBody = $requestBody; + $this->description = $description; + $this->server = $server; + } + + public function getOperationId(): string + { + return $this->operationId; + } + + public function getParameters(): \ArrayObject + { + return $this->parameters; + } + + public function getRequestBody() + { + return $this->requestBody; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getServer(): ?Server + { + return $this->server; + } + + public function withOperationId(string $operationId): self + { + $clone = clone $this; + $clone->operationId = $operationId; + + return $clone; + } + + public function withParameters(\ArrayObject $parameters): self + { + $clone = clone $this; + $clone->parameters = $parameters; + + return $clone; + } + + public function withRequestBody($requestBody): self + { + $clone = clone $this; + $clone->requestBody = $requestBody; + + return $clone; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withServer(Server $server): self + { + $clone = clone $this; + $clone->server = $server; + + return $clone; + } +} diff --git a/src/OpenApi/Model/MediaType.php b/src/OpenApi/Model/MediaType.php new file mode 100644 index 00000000000..8f232bb4b4b --- /dev/null +++ b/src/OpenApi/Model/MediaType.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class MediaType +{ + use ExtensionTrait; + + private $schema; + private $example; + private $examples; + private $encoding; + + public function __construct(\ArrayObject $schema = null, $example = null, \ArrayObject $examples = null, Encoding $encoding = null) + { + $this->schema = $schema; + $this->example = $example; + $this->examples = $examples; + $this->encoding = $encoding; + } + + public function getSchema(): ?\ArrayObject + { + return $this->schema; + } + + public function getExample() + { + return $this->example; + } + + public function getExamples(): ?\ArrayObject + { + return $this->examples; + } + + public function getEncoding(): ?Encoding + { + return $this->encoding; + } + + public function withSchema(\ArrayObject $schema): self + { + $clone = clone $this; + $clone->schema = $schema; + + return $clone; + } + + public function withExample($example): self + { + $clone = clone $this; + $clone->example = $example; + + return $clone; + } + + public function withExamples(\ArrayObject $examples): self + { + $clone = clone $this; + $clone->examples = $examples; + + return $clone; + } + + public function withEncoding(Encoding $encoding): self + { + $clone = clone $this; + $clone->encoding = $encoding; + + return $clone; + } +} diff --git a/src/OpenApi/Model/OAuthFlow.php b/src/OpenApi/Model/OAuthFlow.php new file mode 100644 index 00000000000..1b3dfd4849b --- /dev/null +++ b/src/OpenApi/Model/OAuthFlow.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class OAuthFlow +{ + use ExtensionTrait; + + private $authorizationUrl; + private $tokenUrl; + private $refreshUrl; + private $scopes; + + public function __construct(string $authorizationUrl = null, string $tokenUrl = null, string $refreshUrl = null, \ArrayObject $scopes = null) + { + $this->authorizationUrl = $authorizationUrl; + $this->tokenUrl = $tokenUrl; + $this->refreshUrl = $refreshUrl; + $this->scopes = $scopes; + } + + public function getAuthorizationUrl(): ?string + { + return $this->authorizationUrl; + } + + public function getTokenUrl(): ?string + { + return $this->tokenUrl; + } + + public function getRefreshUrl(): ?string + { + return $this->refreshUrl; + } + + public function getScopes(): \ArrayObject + { + return $this->scopes; + } + + public function withAuthorizationUrl(string $authorizationUrl): self + { + $clone = clone $this; + $clone->authorizationUrl = $authorizationUrl; + + return $clone; + } + + public function withTokenUrl(string $tokenUrl): self + { + $clone = clone $this; + $clone->tokenUrl = $tokenUrl; + + return $clone; + } + + public function withRefreshUrl(string $refreshUrl): self + { + $clone = clone $this; + $clone->refreshUrl = $refreshUrl; + + return $clone; + } + + public function withScopes(\ArrayObject $scopes): self + { + $clone = clone $this; + $clone->scopes = $scopes; + + return $clone; + } +} diff --git a/src/OpenApi/Model/OAuthFlows.php b/src/OpenApi/Model/OAuthFlows.php new file mode 100644 index 00000000000..afe06ad4824 --- /dev/null +++ b/src/OpenApi/Model/OAuthFlows.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class OAuthFlows +{ + use ExtensionTrait; + + private $implicit; + private $password; + private $clientCredentials; + private $authorizationCode; + + public function __construct(OAuthFlow $implicit = null, OAuthFlow $password = null, OAuthFlow $clientCredentials = null, OAuthFlow $authorizationCode = null) + { + $this->implicit = $implicit; + $this->password = $password; + $this->clientCredentials = $clientCredentials; + $this->authorizationCode = $authorizationCode; + } + + public function getImplicit(): ?OAuthFlow + { + return $this->implicit; + } + + public function getPassword(): ?OAuthFlow + { + return $this->password; + } + + public function getClientCredentials(): ?OAuthFlow + { + return $this->clientCredentials; + } + + public function getAuthorizationCode(): ?OAuthFlow + { + return $this->authorizationCode; + } + + public function withImplicit(OAuthFlow $implicit): self + { + $clone = clone $this; + $clone->implicit = $implicit; + + return $clone; + } + + public function withPassword(OAuthFlow $password): self + { + $clone = clone $this; + $clone->password = $password; + + return $clone; + } + + public function withClientCredentials(OAuthFlow $clientCredentials): self + { + $clone = clone $this; + $clone->clientCredentials = $clientCredentials; + + return $clone; + } + + public function withAuthorizationCode(OAuthFlow $authorizationCode): self + { + $clone = clone $this; + $clone->authorizationCode = $authorizationCode; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Operation.php b/src/OpenApi/Model/Operation.php new file mode 100644 index 00000000000..f7163dd5c35 --- /dev/null +++ b/src/OpenApi/Model/Operation.php @@ -0,0 +1,211 @@ + + * + * For 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\OpenApi\Model; + +final class Operation +{ + use ExtensionTrait; + + private $tags; + private $summary; + private $description; + private $operationId; + private $parameters; + private $requestBody; + private $responses; + private $callbacks; + private $deprecated; + private $security; + private $servers; + private $externalDocs; + + public function __construct(string $operationId = null, array $tags = [], array $responses = [], string $summary = '', string $description = '', ExternalDocumentation $externalDocs = null, array $parameters = [], RequestBody $requestBody = null, \ArrayObject $callbacks = null, bool $deprecated = false, ?array $security = null, ?array $servers = null) + { + $this->tags = $tags; + $this->summary = $summary; + $this->description = $description; + $this->operationId = $operationId; + $this->parameters = $parameters; + $this->requestBody = $requestBody; + $this->responses = $responses; + $this->callbacks = $callbacks; + $this->deprecated = $deprecated; + $this->security = $security; + $this->servers = $servers; + $this->externalDocs = $externalDocs; + } + + public function addResponse(Response $response, $status = 'default'): self + { + $this->responses[$status] = $response; + + return $this; + } + + public function getOperationId(): string + { + return $this->operationId; + } + + public function getTags(): array + { + return $this->tags; + } + + public function getResponses(): array + { + return $this->responses; + } + + public function getSummary(): string + { + return $this->summary; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getExternalDocs(): ?ExternalDocumentation + { + return $this->externalDocs; + } + + public function getParameters(): array + { + return $this->parameters; + } + + public function getRequestBody(): ?RequestBody + { + return $this->requestBody; + } + + public function getCallbacks(): ?\ArrayObject + { + return $this->callbacks; + } + + public function getDeprecated(): bool + { + return $this->deprecated; + } + + public function getSecurity(): ?array + { + return $this->security; + } + + public function getServers(): ?array + { + return $this->servers; + } + + public function withOperationId(string $operationId): self + { + $clone = clone $this; + $clone->operationId = $operationId; + + return $clone; + } + + public function withTags(array $tags): self + { + $clone = clone $this; + $clone->tags = $tags; + + return $clone; + } + + public function withResponses(array $responses): self + { + $clone = clone $this; + $clone->responses = $responses; + + return $clone; + } + + public function withSummary(string $summary): self + { + $clone = clone $this; + $clone->summary = $summary; + + return $clone; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withExternalDocs(ExternalDocumentation $externalDocs): self + { + $clone = clone $this; + $clone->externalDocs = $externalDocs; + + return $clone; + } + + public function withParameters(array $parameters): self + { + $clone = clone $this; + $clone->parameters = $parameters; + + return $clone; + } + + public function withRequestBody(RequestBody $requestBody): self + { + $clone = clone $this; + $clone->requestBody = $requestBody; + + return $clone; + } + + public function withCallbacks(\ArrayObject $callbacks): self + { + $clone = clone $this; + $clone->callbacks = $callbacks; + + return $clone; + } + + public function withDeprecated(bool $deprecated): self + { + $clone = clone $this; + $clone->deprecated = $deprecated; + + return $clone; + } + + public function withSecurity(?array $security = null): self + { + $clone = clone $this; + $clone->security = $security; + + return $clone; + } + + public function withServers(?array $servers = null): self + { + $clone = clone $this; + $clone->servers = $servers; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Parameter.php b/src/OpenApi/Model/Parameter.php new file mode 100644 index 00000000000..f68f0cbd231 --- /dev/null +++ b/src/OpenApi/Model/Parameter.php @@ -0,0 +1,227 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class Parameter +{ + use ExtensionTrait; + + private $name; + private $in; + private $description; + private $required; + private $deprecated; + private $allowEmptyValue; + private $schema; + private $explode; + private $allowReserved; + private $style; + private $example; + private $examples; + private $content; + + public function __construct(string $name, string $in, string $description = '', bool $required = false, bool $deprecated = false, bool $allowEmptyValue = false, array $schema = [], string $style = null, bool $explode = false, bool $allowReserved = false, $example = null, \ArrayObject $examples = null, \ArrayObject $content = null) + { + $this->name = $name; + $this->in = $in; + $this->description = $description; + $this->required = $required; + $this->deprecated = $deprecated; + $this->allowEmptyValue = $allowEmptyValue; + $this->schema = $schema; + $this->explode = $explode; + $this->allowReserved = $allowReserved; + $this->example = $example; + $this->examples = $examples; + $this->content = $content; + $this->style = $style; + + if (null === $style) { + if ('query' === $in || 'cookie' === $in) { + $this->style = 'form'; + } elseif ('path' === $in || 'header' === $in) { + $this->style = 'simple'; + } + } + } + + public function getName(): ?string + { + return $this->name; + } + + public function getIn(): ?string + { + return $this->in; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getRequired(): bool + { + return $this->required; + } + + public function getDeprecated(): bool + { + return $this->deprecated; + } + + public function canAllowEmptyValue(): bool + { + return $this->allowEmptyValue; + } + + public function getSchema(): array + { + return $this->schema; + } + + public function getStyle(): string + { + return $this->style; + } + + public function canExplode(): bool + { + return $this->explode; + } + + public function canAllowReserved(): bool + { + return $this->allowReserved; + } + + public function getExample() + { + return $this->example; + } + + public function getExamples(): ?\ArrayObject + { + return $this->examples; + } + + public function getContent(): ?\ArrayObject + { + return $this->content; + } + + public function withName(string $name): self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } + + public function withIn(string $in): self + { + $clone = clone $this; + $clone->in = $in; + + return $clone; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withRequired(bool $required): self + { + $clone = clone $this; + $clone->required = $required; + + return $clone; + } + + public function withDeprecated(bool $deprecated): self + { + $clone = clone $this; + $clone->deprecated = $deprecated; + + return $clone; + } + + public function withAllowEmptyValue(bool $allowEmptyValue): self + { + $clone = clone $this; + $clone->allowEmptyValue = $allowEmptyValue; + + return $clone; + } + + public function withSchema(array $schema): self + { + $clone = clone $this; + $clone->schema = $schema; + + return $clone; + } + + public function withStyle(string $style): self + { + $clone = clone $this; + $clone->style = $style; + + return $clone; + } + + public function withExplode(bool $explode): self + { + $clone = clone $this; + $clone->explode = $explode; + + return $clone; + } + + public function withAllowReserved(bool $allowReserved): self + { + $clone = clone $this; + $clone->allowReserved = $allowReserved; + + return $clone; + } + + public function withExample($example): self + { + $clone = clone $this; + $clone->example = $example; + + return $clone; + } + + public function withExamples(\ArrayObject $examples): self + { + $clone = clone $this; + $clone->examples = $examples; + + return $clone; + } + + public function withContent(\ArrayObject $content): self + { + $clone = clone $this; + $clone->content = $content; + + return $clone; + } +} diff --git a/src/OpenApi/Model/PathItem.php b/src/OpenApi/Model/PathItem.php new file mode 100644 index 00000000000..5c07c9006c2 --- /dev/null +++ b/src/OpenApi/Model/PathItem.php @@ -0,0 +1,220 @@ + + * + * For 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\OpenApi\Model; + +final class PathItem +{ + use ExtensionTrait; + + private static $methods = ['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH', 'TRACE']; + private $ref; + private $summary; + private $description; + private $get; + private $put; + private $post; + private $delete; + private $options; + private $head; + private $patch; + private $trace; + private $servers; + private $parameters; + + public function __construct(string $ref = null, string $summary = null, string $description = null, Operation $get = null, Operation $put = null, Operation $post = null, Operation $delete = null, Operation $options = null, Operation $head = null, Operation $patch = null, Operation $trace = null, ?array $servers = null, array $parameters = []) + { + $this->ref = $ref; + $this->summary = $summary; + $this->description = $description; + $this->get = $get; + $this->put = $put; + $this->post = $post; + $this->delete = $delete; + $this->options = $options; + $this->head = $head; + $this->patch = $patch; + $this->trace = $trace; + $this->servers = $servers; + $this->parameters = $parameters; + } + + public function getRef(): ?string + { + return $this->ref; + } + + public function getSummary(): ?string + { + return $this->summary; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function getGet(): ?Operation + { + return $this->get; + } + + public function getPut(): ?Operation + { + return $this->put; + } + + public function getPost(): ?Operation + { + return $this->post; + } + + public function getDelete(): ?Operation + { + return $this->delete; + } + + public function getOptions(): ?Operation + { + return $this->options; + } + + public function getHead(): ?Operation + { + return $this->head; + } + + public function getPatch(): ?Operation + { + return $this->patch; + } + + public function getTrace(): ?Operation + { + return $this->trace; + } + + public function getServers(): ?array + { + return $this->servers; + } + + public function getParameters(): array + { + return $this->parameters; + } + + public function withRef(string $ref): self + { + $clone = clone $this; + $clone->ref = $ref; + + return $clone; + } + + public function withSummary(string $summary): self + { + $clone = clone $this; + $clone->summary = $summary; + + return $clone; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withGet(Operation $get): self + { + $clone = clone $this; + $clone->get = $get; + + return $clone; + } + + public function withPut(Operation $put): self + { + $clone = clone $this; + $clone->put = $put; + + return $clone; + } + + public function withPost(Operation $post): self + { + $clone = clone $this; + $clone->post = $post; + + return $clone; + } + + public function withDelete(Operation $delete): self + { + $clone = clone $this; + $clone->delete = $delete; + + return $clone; + } + + public function withOptions(Operation $options): self + { + $clone = clone $this; + $clone->options = $options; + + return $clone; + } + + public function withHead(Operation $head): self + { + $clone = clone $this; + $clone->head = $head; + + return $clone; + } + + public function withPatch(Operation $patch): self + { + $clone = clone $this; + $clone->patch = $patch; + + return $clone; + } + + public function withTrace(Operation $trace): self + { + $clone = clone $this; + $clone->trace = $trace; + + return $clone; + } + + public function withServers(?array $servers = null): self + { + $clone = clone $this; + $clone->servers = $servers; + + return $clone; + } + + public function withParameters(array $parameters): self + { + $clone = clone $this; + $clone->parameters = $parameters; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Paths.php b/src/OpenApi/Model/Paths.php new file mode 100644 index 00000000000..80829b3d876 --- /dev/null +++ b/src/OpenApi/Model/Paths.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\OpenApi\Model; + +final class Paths +{ + private $paths; + + public function addPath(string $path, PathItem $pathItem) + { + $this->paths[$path] = $pathItem; + } + + public function getPath(string $path): ?PathItem + { + return $this->paths[$path] ?? null; + } + + public function getPaths(): array + { + return $this->paths ?? []; + } +} diff --git a/src/OpenApi/Model/RequestBody.php b/src/OpenApi/Model/RequestBody.php new file mode 100644 index 00000000000..04235777a5e --- /dev/null +++ b/src/OpenApi/Model/RequestBody.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class RequestBody +{ + use ExtensionTrait; + + private $description; + private $content; + private $required; + + public function __construct(string $description = '', \ArrayObject $content = null, bool $required = false) + { + $this->description = $description; + $this->content = $content; + $this->required = $required; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getContent(): \ArrayObject + { + return $this->content; + } + + public function getRequired(): bool + { + return $this->required; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withContent(\ArrayObject $content): self + { + $clone = clone $this; + $clone->content = $content; + + return $clone; + } + + public function withRequired(bool $required): self + { + $clone = clone $this; + $clone->required = $required; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Response.php b/src/OpenApi/Model/Response.php new file mode 100644 index 00000000000..9bb2f84d353 --- /dev/null +++ b/src/OpenApi/Model/Response.php @@ -0,0 +1,84 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class Response +{ + use ExtensionTrait; + + private $description; + private $content; + private $headers; + private $links; + + public function __construct(string $description = '', \ArrayObject $content = null, \ArrayObject $headers = null, \ArrayObject $links = null) + { + $this->description = $description; + $this->content = $content; + $this->headers = $headers; + $this->links = $links; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getContent(): ?\ArrayObject + { + return $this->content; + } + + public function getHeaders(): ?\ArrayObject + { + return $this->headers; + } + + public function getLinks(): ?\ArrayObject + { + return $this->links; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withContent(\ArrayObject $content): self + { + $clone = clone $this; + $clone->content = $content; + + return $clone; + } + + public function withHeaders(\ArrayObject $headers): self + { + $clone = clone $this; + $clone->headers = $headers; + + return $clone; + } + + public function withLinks(\ArrayObject $links): self + { + $clone = clone $this; + $clone->links = $links; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Schema.php b/src/OpenApi/Model/Schema.php new file mode 100644 index 00000000000..5b9060238f7 --- /dev/null +++ b/src/OpenApi/Model/Schema.php @@ -0,0 +1,171 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +use ApiPlatform\Core\JsonSchema\Schema as JsonSchema; + +final class Schema extends \ArrayObject +{ + use ExtensionTrait; + + private $nullable; + private $discriminator; + private $readOnly; + private $writeOnly; + private $xml; + private $externalDocs; + private $example; + private $deprecated; + private $schema; + + public function __construct(bool $nullable = false, $discriminator = null, bool $readOnly = false, bool $writeOnly = false, string $xml = null, $externalDocs = null, $example = null, bool $deprecated = false) + { + $this->nullable = $nullable; + $this->discriminator = $discriminator; + $this->readOnly = $readOnly; + $this->writeOnly = $writeOnly; + $this->xml = $xml; + $this->externalDocs = $externalDocs; + $this->example = $example; + $this->deprecated = $deprecated; + $this->schema = new JsonSchema(); + + parent::__construct([]); + } + + public function setDefinitions(array $definitions) + { + $this->schema->setDefinitions(new \ArrayObject($definitions)); + } + + /** + * {@inheritdoc} + */ + public function getArrayCopy(): array + { + $schema = parent::getArrayCopy(); + unset($schema['schema']); + + return $schema; + } + + public function getDefinitions(): \ArrayObject + { + return new \ArrayObject(array_merge($this->schema->getArrayCopy(true), $this->getArrayCopy())); + } + + public function getNullable(): bool + { + return $this->nullable; + } + + public function getDiscriminator() + { + return $this->discriminator; + } + + public function getReadOnly(): bool + { + return $this->readOnly; + } + + public function getWriteOnly(): bool + { + return $this->writeOnly; + } + + public function getXml(): string + { + return $this->xml; + } + + public function getExternalDocs() + { + return $this->externalDocs; + } + + public function getExample() + { + return $this->example; + } + + public function getDeprecated(): bool + { + return $this->deprecated; + } + + public function withNullable(bool $nullable): self + { + $clone = clone $this; + $clone->nullable = $nullable; + + return $clone; + } + + public function withDiscriminator($discriminator): self + { + $clone = clone $this; + $clone->discriminator = $discriminator; + + return $clone; + } + + public function withReadOnly(bool $readOnly): self + { + $clone = clone $this; + $clone->readOnly = $readOnly; + + return $clone; + } + + public function withWriteOnly(bool $writeOnly): self + { + $clone = clone $this; + $clone->writeOnly = $writeOnly; + + return $clone; + } + + public function withXml(string $xml): self + { + $clone = clone $this; + $clone->xml = $xml; + + return $clone; + } + + public function withExternalDocs($externalDocs): self + { + $clone = clone $this; + $clone->externalDocs = $externalDocs; + + return $clone; + } + + public function withExample($example): self + { + $clone = clone $this; + $clone->example = $example; + + return $clone; + } + + public function withDeprecated(bool $deprecated): self + { + $clone = clone $this; + $clone->deprecated = $deprecated; + + return $clone; + } +} diff --git a/src/OpenApi/Model/SecurityScheme.php b/src/OpenApi/Model/SecurityScheme.php new file mode 100644 index 00000000000..385e4e028c5 --- /dev/null +++ b/src/OpenApi/Model/SecurityScheme.php @@ -0,0 +1,144 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class SecurityScheme +{ + use ExtensionTrait; + + private $type; + private $description; + private $name; + private $in; + private $scheme; + private $bearerFormat; + private $flows; + private $openIdConnectUrl; + + public function __construct(string $type = null, string $description = '', string $name = null, string $in = null, string $scheme = null, string $bearerFormat = null, OAuthFlows $flows = null, string $openIdConnectUrl = null) + { + $this->type = $type; + $this->description = $description; + $this->name = $name; + $this->in = $in; + $this->scheme = $scheme; + $this->bearerFormat = $bearerFormat; + $this->flows = $flows; + $this->openIdConnectUrl = $openIdConnectUrl; + } + + public function getType(): string + { + return $this->type; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getName(): ?string + { + return $this->name; + } + + public function getIn(): ?string + { + return $this->in; + } + + public function getScheme(): string + { + return $this->scheme; + } + + public function getBearerFormat(): ?string + { + return $this->bearerFormat; + } + + public function getFlows(): ?OAuthFlows + { + return $this->flows; + } + + public function getOpenIdConnectUrl(): ?string + { + return $this->openIdConnectUrl; + } + + public function withType(string $type): self + { + $clone = clone $this; + $clone->type = $type; + + return $clone; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withName(string $name): self + { + $clone = clone $this; + $clone->name = $name; + + return $clone; + } + + public function withIn(string $in): self + { + $clone = clone $this; + $clone->in = $in; + + return $clone; + } + + public function withScheme(string $scheme): self + { + $clone = clone $this; + $clone->scheme = $scheme; + + return $clone; + } + + public function withBearerFormat(string $bearerFormat): self + { + $clone = clone $this; + $clone->bearerFormat = $bearerFormat; + + return $clone; + } + + public function withFlows(OAuthFlows $flows): self + { + $clone = clone $this; + $clone->flows = $flows; + + return $clone; + } + + public function withOpenIdConnectUrl(string $openIdConnectUrl): self + { + $clone = clone $this; + $clone->openIdConnectUrl = $openIdConnectUrl; + + return $clone; + } +} diff --git a/src/OpenApi/Model/Server.php b/src/OpenApi/Model/Server.php new file mode 100644 index 00000000000..852afd5c9ad --- /dev/null +++ b/src/OpenApi/Model/Server.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi\Model; + +final class Server +{ + use ExtensionTrait; + + private $url; + private $description; + private $variables; + + public function __construct(string $url, string $description = '', \ArrayObject $variables = null) + { + $this->url = $url; + $this->description = $description; + $this->variables = $variables; + } + + public function getUrl(): string + { + return $this->url; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getVariables(): ?\ArrayObject + { + return $this->variables; + } + + public function withUrl(string $url): self + { + $clone = clone $this; + $clone->url = $url; + + return $clone; + } + + public function withDescription(string $description): self + { + $clone = clone $this; + $clone->description = $description; + + return $clone; + } + + public function withVariables(\ArrayObject $variables): self + { + $clone = clone $this; + $clone->variables = $variables; + + return $clone; + } +} diff --git a/src/OpenApi/OpenApi.php b/src/OpenApi/OpenApi.php new file mode 100644 index 00000000000..94c50377671 --- /dev/null +++ b/src/OpenApi/OpenApi.php @@ -0,0 +1,152 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi; + +use ApiPlatform\Core\Documentation\DocumentationInterface; +use ApiPlatform\Core\OpenApi\Model\Components; +use ApiPlatform\Core\OpenApi\Model\ExtensionTrait; +use ApiPlatform\Core\OpenApi\Model\Info; +use ApiPlatform\Core\OpenApi\Model\Paths; + +final class OpenApi implements DocumentationInterface +{ + use ExtensionTrait; + + public const VERSION = '3.0.3'; + + private $openapi; + private $info; + private $servers; + private $paths; + private $components; + private $security; + private $tags; + private $externalDocs; + + public function __construct(Info $info, array $servers = [], Paths $paths, Components $components = null, array $security = [], array $tags = [], $externalDocs = null) + { + $this->openapi = self::VERSION; + $this->info = $info; + $this->servers = $servers; + $this->paths = $paths; + $this->components = $components; + $this->security = $security; + $this->tags = $tags; + $this->externalDocs = $externalDocs; + } + + public function getOpenapi(): string + { + return $this->openapi; + } + + public function getInfo(): Info + { + return $this->info; + } + + public function getServers(): array + { + return $this->servers; + } + + public function getPaths() + { + return $this->paths; + } + + public function getComponents(): Components + { + return $this->components; + } + + public function getSecurity(): array + { + return $this->security; + } + + public function getTags(): array + { + return $this->tags; + } + + public function getExternalDocs(): ?array + { + return $this->externalDocs; + } + + public function withOpenapi(string $openapi): self + { + $clone = clone $this; + $clone->openapi = $openapi; + + return $clone; + } + + public function withInfo(Info $info): self + { + $clone = clone $this; + $clone->info = $info; + + return $clone; + } + + public function withServers(array $servers): self + { + $clone = clone $this; + $clone->servers = $servers; + + return $clone; + } + + public function withPaths(Paths $paths): self + { + $clone = clone $this; + $clone->paths = $paths; + + return $clone; + } + + public function withComponents(Components $components): self + { + $clone = clone $this; + $clone->components = $components; + + return $clone; + } + + public function withSecurity(array $security): self + { + $clone = clone $this; + $clone->security = $security; + + return $clone; + } + + public function withTags(array $tags): self + { + $clone = clone $this; + $clone->tags = $tags; + + return $clone; + } + + public function withExternalDocs(array $externalDocs): self + { + $clone = clone $this; + $clone->externalDocs = $externalDocs; + + return $clone; + } +} diff --git a/src/OpenApi/Options.php b/src/OpenApi/Options.php new file mode 100644 index 00000000000..043c16f1b47 --- /dev/null +++ b/src/OpenApi/Options.php @@ -0,0 +1,141 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\OpenApi; + +final class Options +{ + private $title; + private $description; + private $version; + private $oAuthEnabled; + private $oAuthType; + private $oAuthFlow; + private $oAuthTokenUrl; + private $oAuthAuthorizationUrl; + private $oAuthRefreshUrl; + private $oAuthScopes; + private $apiKeys; + private $contactName; + private $contactUrl; + private $contactEmail; + private $termsOfService; + private $licenseName; + private $licenseUrl; + + public function __construct(string $title, string $description = '', string $version = '', bool $oAuthEnabled = false, string $oAuthType = '', string $oAuthFlow = '', string $oAuthTokenUrl = '', string $oAuthAuthorizationUrl = '', string $oAuthRefreshUrl = '', array $oAuthScopes = [], array $apiKeys = [], string $contactName = null, string $contactUrl = null, string $contactEmail = null, string $termsOfService = null, string $licenseName = null, string $licenseUrl = null) + { + $this->title = $title; + $this->description = $description; + $this->version = $version; + $this->oAuthEnabled = $oAuthEnabled; + $this->oAuthType = $oAuthType; + $this->oAuthFlow = $oAuthFlow; + $this->oAuthTokenUrl = $oAuthTokenUrl; + $this->oAuthAuthorizationUrl = $oAuthAuthorizationUrl; + $this->oAuthRefreshUrl = $oAuthRefreshUrl; + $this->oAuthScopes = $oAuthScopes; + $this->apiKeys = $apiKeys; + $this->contactName = $contactName; + $this->contactUrl = $contactUrl; + $this->contactEmail = $contactEmail; + $this->termsOfService = $termsOfService; + $this->licenseName = $licenseName; + $this->licenseUrl = $licenseUrl; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getVersion(): string + { + return $this->version; + } + + public function getOAuthEnabled(): bool + { + return $this->oAuthEnabled; + } + + public function getOAuthType(): string + { + return $this->oAuthType; + } + + public function getOAuthFlow(): string + { + return $this->oAuthFlow; + } + + public function getOAuthTokenUrl(): string + { + return $this->oAuthTokenUrl; + } + + public function getOAuthAuthorizationUrl(): string + { + return $this->oAuthAuthorizationUrl; + } + + public function getOAuthRefreshUrl(): string + { + return $this->oAuthRefreshUrl; + } + + public function getOAuthScopes(): array + { + return $this->oAuthScopes; + } + + public function getApiKeys(): array + { + return $this->apiKeys; + } + + public function getContactName(): ?string + { + return $this->contactName; + } + + public function getContactUrl(): ?string + { + return $this->contactUrl; + } + + public function getContactEmail(): ?string + { + return $this->contactEmail; + } + + public function getTermsOfService(): ?string + { + return $this->termsOfService; + } + + public function getLicenseName(): ?string + { + return $this->licenseName; + } + + public function getLicenseUrl(): ?string + { + return $this->licenseUrl; + } +} diff --git a/src/OpenApi/Serializer/OpenApiNormalizer.php b/src/OpenApi/Serializer/OpenApiNormalizer.php new file mode 100644 index 00000000000..381b2599574 --- /dev/null +++ b/src/OpenApi/Serializer/OpenApiNormalizer.php @@ -0,0 +1,90 @@ + + * + * For 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\OpenApi\Serializer; + +use ApiPlatform\Core\OpenApi\OpenApi; +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; +use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Generates an OpenAPI v3 specification. + */ +final class OpenApiNormalizer implements NormalizerInterface, CacheableSupportsMethodInterface +{ + public const FORMAT = 'json'; + private const EXTENSION_PROPERTIES_KEY = 'extensionProperties'; + + private $decorated; + + public function __construct(NormalizerInterface $decorated) + { + $this->decorated = $decorated; + } + + /** + * {@inheritdoc} + */ + public function normalize($object, $format = null, array $context = []): array + { + $context[AbstractObjectNormalizer::PRESERVE_EMPTY_OBJECTS] = true; + $context[AbstractObjectNormalizer::SKIP_NULL_VALUES] = true; + + return $this->recursiveClean($this->decorated->normalize($object, $format, $context)); + } + + private function recursiveClean($data): array + { + foreach ($data as $key => $value) { + if (self::EXTENSION_PROPERTIES_KEY === $key) { + foreach ($data[self::EXTENSION_PROPERTIES_KEY] as $extensionPropertyKey => $extensionPropertyValue) { + $data[$extensionPropertyKey] = $extensionPropertyValue; + } + continue; + } + + // Side effect of using getPaths(): Paths which itself contains the array + if ('paths' === $key) { + $value = $data['paths'] = $data['paths']['paths']; + unset($data['paths']['paths']); + } + + if (\is_array($value)) { + $data[$key] = $this->recursiveClean($value); + // arrays must stay even if empty + continue; + } + } + + unset($data[self::EXTENSION_PROPERTIES_KEY]); + + return $data; + } + + /** + * {@inheritdoc} + */ + public function supportsNormalization($data, $format = null): bool + { + return self::FORMAT === $format && $data instanceof OpenApi; + } + + /** + * {@inheritdoc} + */ + public function hasCacheableSupportsMethod(): bool + { + return true; + } +} diff --git a/src/Operation/Factory/SubresourceOperationFactory.php b/src/Operation/Factory/SubresourceOperationFactory.php index 227b9d494fb..b6dbd4298e5 100644 --- a/src/Operation/Factory/SubresourceOperationFactory.php +++ b/src/Operation/Factory/SubresourceOperationFactory.php @@ -26,7 +26,7 @@ final class SubresourceOperationFactory implements SubresourceOperationFactoryIn { public const SUBRESOURCE_SUFFIX = '_subresource'; public const FORMAT_SUFFIX = '.{_format}'; - public const ROUTE_OPTIONS = ['defaults' => [], 'requirements' => [], 'options' => [], 'host' => '', 'schemes' => [], 'condition' => '', 'controller' => null]; + public const ROUTE_OPTIONS = ['defaults' => [], 'requirements' => [], 'options' => [], 'host' => '', 'schemes' => [], 'condition' => '', 'controller' => null, 'stateless' => null]; private $resourceMetadataFactory; private $propertyNameCollectionFactory; diff --git a/src/Problem/Serializer/ErrorNormalizerTrait.php b/src/Problem/Serializer/ErrorNormalizerTrait.php index cca553eb3c7..13bc6e3e6d5 100644 --- a/src/Problem/Serializer/ErrorNormalizerTrait.php +++ b/src/Problem/Serializer/ErrorNormalizerTrait.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Problem\Serializer; +use ApiPlatform\Core\Exception\ErrorCodeSerializableInterface; use Symfony\Component\Debug\Exception\FlattenException as LegacyFlattenException; use Symfony\Component\ErrorHandler\Exception\FlattenException; use Symfony\Component\HttpFoundation\Response; @@ -36,4 +37,19 @@ private function getErrorMessage($object, array $context, bool $debug = false): return $message; } + + private function getErrorCode($object): ?string + { + if ($object instanceof FlattenException || $object instanceof LegacyFlattenException) { + $exceptionClass = $object->getClass(); + } else { + $exceptionClass = \get_class($object); + } + + if (is_a($exceptionClass, ErrorCodeSerializableInterface::class, true)) { + return $exceptionClass::getErrorCode(); + } + + return null; + } } diff --git a/src/Serializer/AbstractCollectionNormalizer.php b/src/Serializer/AbstractCollectionNormalizer.php index 59268475747..4af7d4c456e 100644 --- a/src/Serializer/AbstractCollectionNormalizer.php +++ b/src/Serializer/AbstractCollectionNormalizer.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Api\ResourceClassResolverInterface; use ApiPlatform\Core\DataProvider\PaginatorInterface; use ApiPlatform\Core\DataProvider\PartialPaginatorInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use Symfony\Component\Serializer\Normalizer\CacheableSupportsMethodInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; @@ -40,11 +41,13 @@ abstract class AbstractCollectionNormalizer implements NormalizerInterface, Norm protected $resourceClassResolver; protected $pageParameterName; + protected $resourceMetadataFactory; - public function __construct(ResourceClassResolverInterface $resourceClassResolver, string $pageParameterName) + public function __construct(ResourceClassResolverInterface $resourceClassResolver, string $pageParameterName, ResourceMetadataFactoryInterface $resourceMetadataFactory = null) { $this->resourceClassResolver = $resourceClassResolver; $this->pageParameterName = $pageParameterName; + $this->resourceMetadataFactory = $resourceMetadataFactory; } /** diff --git a/src/Serializer/AbstractConstraintViolationListNormalizer.php b/src/Serializer/AbstractConstraintViolationListNormalizer.php index 4d71b1f1839..67960646c0f 100644 --- a/src/Serializer/AbstractConstraintViolationListNormalizer.php +++ b/src/Serializer/AbstractConstraintViolationListNormalizer.php @@ -63,6 +63,7 @@ protected function getMessagesAndViolations(ConstraintViolationListInterface $co $violationData = [ 'propertyPath' => $this->nameConverter ? $this->nameConverter->normalize($violation->getPropertyPath(), $class, static::FORMAT) : $violation->getPropertyPath(), 'message' => $violation->getMessage(), + 'code' => $violation->getCode(), ]; $constraint = $violation->getConstraint(); diff --git a/src/Serializer/AbstractItemNormalizer.php b/src/Serializer/AbstractItemNormalizer.php index 51ad8577399..d69dcb41b60 100644 --- a/src/Serializer/AbstractItemNormalizer.php +++ b/src/Serializer/AbstractItemNormalizer.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Api\IriConverterInterface; use ApiPlatform\Core\Api\ResourceClassResolverInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; +use ApiPlatform\Core\DataTransformer\DataTransformerInitializerInterface; use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Exception\InvalidValueException; @@ -24,6 +25,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; use ApiPlatform\Core\Util\ClassInfoTrait; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccess; @@ -58,13 +60,14 @@ abstract class AbstractItemNormalizer extends AbstractObjectNormalizer protected $propertyMetadataFactory; protected $iriConverter; protected $resourceClassResolver; + protected $resourceAccessChecker; protected $propertyAccessor; protected $itemDataProvider; protected $allowPlainIdentifiers; protected $dataTransformers = []; protected $localCache = []; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, ItemDataProviderInterface $itemDataProvider = null, bool $allowPlainIdentifiers = false, array $defaultContext = [], iterable $dataTransformers = [], ResourceMetadataFactoryInterface $resourceMetadataFactory = null) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, ItemDataProviderInterface $itemDataProvider = null, bool $allowPlainIdentifiers = false, array $defaultContext = [], iterable $dataTransformers = [], ResourceMetadataFactoryInterface $resourceMetadataFactory = null, ResourceAccessCheckerInterface $resourceAccessChecker = null) { if (!isset($defaultContext['circular_reference_handler'])) { $defaultContext['circular_reference_handler'] = function ($object) { @@ -86,6 +89,7 @@ public function __construct(PropertyNameCollectionFactoryInterface $propertyName $this->allowPlainIdentifiers = $allowPlainIdentifiers; $this->dataTransformers = $dataTransformers; $this->resourceMetadataFactory = $resourceMetadataFactory; + $this->resourceAccessChecker = $resourceAccessChecker; } /** @@ -178,7 +182,11 @@ public function supportsDenormalization($data, $type, $format = null) */ public function denormalize($data, $class, $format = null, array $context = []) { - $resourceClass = $this->resourceClassResolver->getResourceClass(null, $class); + if (null === $objectToPopulate = $this->extractObjectToPopulate($class, $context, static::OBJECT_TO_POPULATE)) { + $normalizedData = $this->prepareForDenormalization($data); + $class = $this->getClassDiscriminatorResolvedClass($normalizedData, $class); + } + $resourceClass = $this->resourceClassResolver->getResourceClass($objectToPopulate, $class); $context['api_denormalize'] = true; $context['resource_class'] = $resourceClass; @@ -191,6 +199,12 @@ public function denormalize($data, $class, $format = null, array $context = []) if (!$this->serializer instanceof DenormalizerInterface) { throw new LogicException('Cannot denormalize the input because the injected serializer is not a denormalizer'); } + + if ($dataTransformer instanceof DataTransformerInitializerInterface) { + $context[AbstractObjectNormalizer::OBJECT_TO_POPULATE] = $dataTransformer->initialize($inputClass, $context); + $context[AbstractObjectNormalizer::DEEP_OBJECT_TO_POPULATE] = true; + } + try { $denormalizedInput = $this->serializer->denormalize($data, $inputClass, $format, $context); } catch (NotNormalizableValueException $e) { @@ -237,8 +251,7 @@ public function denormalize($data, $class, $format = null, array $context = []) } /** - * Method copy-pasted from symfony/serializer. - * Remove it after symfony/serializer version update @link https://github.com/symfony/symfony/pull/28263. + * Originally from {@see https://github.com/symfony/symfony/pull/28263}. Refactor after it is merged. * * {@inheritdoc} * @@ -252,19 +265,8 @@ protected function instantiateObject(array &$data, $class, array &$context, \Ref return $object; } - if ($this->classDiscriminatorResolver && $mapping = $this->classDiscriminatorResolver->getMappingForClass($class)) { - if (!isset($data[$mapping->getTypeProperty()])) { - throw new RuntimeException(sprintf('Type property "%s" not found for the abstract object "%s"', $mapping->getTypeProperty(), $class)); - } - - $type = $data[$mapping->getTypeProperty()]; - if (null === ($mappedClass = $mapping->getClassForType($type))) { - throw new RuntimeException(sprintf('The type "%s" has no mapped class for the abstract object "%s"', $type, $class)); - } - - $class = $mappedClass; - $reflectionClass = new \ReflectionClass($class); - } + $class = $this->getClassDiscriminatorResolvedClass($data, $class); + $reflectionClass = new \ReflectionClass($class); $constructor = $this->getConstructor($data, $class, $context, $reflectionClass, $allowedAttributes); if ($constructor) { @@ -309,6 +311,24 @@ protected function instantiateObject(array &$data, $class, array &$context, \Ref return new $class(); } + protected function getClassDiscriminatorResolvedClass(array &$data, string $class): string + { + if (null === $this->classDiscriminatorResolver || (null === $mapping = $this->classDiscriminatorResolver->getMappingForClass($class))) { + return $class; + } + + if (!isset($data[$mapping->getTypeProperty()])) { + throw new RuntimeException(sprintf('Type property "%s" not found for the abstract object "%s"', $mapping->getTypeProperty(), $class)); + } + + $type = $data[$mapping->getTypeProperty()]; + if (null === ($mappedClass = $mapping->getClassForType($type))) { + throw new RuntimeException(sprintf('The type "%s" has no mapped class for the abstract object "%s"', $type, $class)); + } + + return $mappedClass; + } + /** * {@inheritdoc} */ @@ -335,8 +355,19 @@ protected function getAllowedAttributes($classOrObject, array $context, $attribu $options = $this->getFactoryOptions($context); $propertyNames = $this->propertyNameCollectionFactory->create($context['resource_class'], $options); + $attributesMetadata = $this->classMetadataFactory ? + $this->classMetadataFactory->getMetadataFor($context['resource_class'])->getAttributesMetadata() : + null; + $allowedAttributes = []; foreach ($propertyNames as $propertyName) { + if ( + null != $attributesMetadata && \array_key_exists($propertyName, $attributesMetadata) && + method_exists($attributesMetadata[$propertyName], 'isIgnored') && + $attributesMetadata[$propertyName]->isIgnored()) { + continue; + } + $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $propertyName, $options); if ( @@ -353,6 +384,25 @@ protected function getAllowedAttributes($classOrObject, array $context, $attribu return $allowedAttributes; } + /** + * {@inheritdoc} + */ + protected function isAllowedAttribute($classOrObject, $attribute, $format = null, array $context = []) + { + if (!parent::isAllowedAttribute($classOrObject, $attribute, $format, $context)) { + return false; + } + + $options = $this->getFactoryOptions($context); + $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $options); + $security = $propertyMetadata->getAttribute('security'); + if ($this->resourceAccessChecker && $security) { + return $this->resourceAccessChecker->isGranted($attribute, $security); + } + + return true; + } + /** * {@inheritdoc} */ @@ -508,7 +558,6 @@ protected function createRelationSerializationContext(string $resourceClass, arr /** * {@inheritdoc} * - * @throws NoSuchPropertyException * @throws UnexpectedValueException * @throws LogicException */ @@ -517,6 +566,7 @@ protected function getAttributeValue($object, $attribute, $format = null, array $context['api_attribute'] = $attribute; $propertyMetadata = $this->propertyMetadataFactory->create($context['resource_class'], $attribute, $this->getFactoryOptions($context)); + // BC to be removed in 3.0 try { $attributeValue = $this->propertyAccessor->getValue($object, $attribute); } catch (NoSuchPropertyException $e) { @@ -527,6 +577,10 @@ protected function getAttributeValue($object, $attribute, $format = null, array $attributeValue = null; } + if ($context['api_denormalize'] ?? false) { + return $attributeValue; + } + $type = $propertyMetadata->getType(); if ( diff --git a/src/Serializer/ItemNormalizer.php b/src/Serializer/ItemNormalizer.php index f593dafddbb..293b7955766 100644 --- a/src/Serializer/ItemNormalizer.php +++ b/src/Serializer/ItemNormalizer.php @@ -20,6 +20,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; @@ -36,9 +37,9 @@ class ItemNormalizer extends AbstractItemNormalizer { private $logger; - public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, ItemDataProviderInterface $itemDataProvider = null, bool $allowPlainIdentifiers = false, LoggerInterface $logger = null, iterable $dataTransformers = [], ResourceMetadataFactoryInterface $resourceMetadataFactory = null) + public function __construct(PropertyNameCollectionFactoryInterface $propertyNameCollectionFactory, PropertyMetadataFactoryInterface $propertyMetadataFactory, IriConverterInterface $iriConverter, ResourceClassResolverInterface $resourceClassResolver, PropertyAccessorInterface $propertyAccessor = null, NameConverterInterface $nameConverter = null, ClassMetadataFactoryInterface $classMetadataFactory = null, ItemDataProviderInterface $itemDataProvider = null, bool $allowPlainIdentifiers = false, LoggerInterface $logger = null, iterable $dataTransformers = [], ResourceMetadataFactoryInterface $resourceMetadataFactory = null, ResourceAccessCheckerInterface $resourceAccessChecker = null) { - parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $itemDataProvider, $allowPlainIdentifiers, [], $dataTransformers, $resourceMetadataFactory); + parent::__construct($propertyNameCollectionFactory, $propertyMetadataFactory, $iriConverter, $resourceClassResolver, $propertyAccessor, $nameConverter, $classMetadataFactory, $itemDataProvider, $allowPlainIdentifiers, [], $dataTransformers, $resourceMetadataFactory, $resourceAccessChecker); $this->logger = $logger ?: new NullLogger(); } diff --git a/src/Serializer/SerializerContextBuilder.php b/src/Serializer/SerializerContextBuilder.php index 6cab2e7265f..d280b751a14 100644 --- a/src/Serializer/SerializerContextBuilder.php +++ b/src/Serializer/SerializerContextBuilder.php @@ -63,7 +63,11 @@ public function createFromRequest(Request $request, bool $normalization, array $ if (!$normalization) { if (!isset($context['api_allow_update'])) { - $context['api_allow_update'] = \in_array($request->getMethod(), ['PUT', 'PATCH'], true); + $context['api_allow_update'] = \in_array($method = $request->getMethod(), ['PUT', 'PATCH'], true); + + if ($context['api_allow_update'] && 'PATCH' === $method) { + $context['deep_object_to_populate'] = $context['deep_object_to_populate'] ?? true; + } } if ('csv' === $request->getContentType()) { @@ -72,6 +76,7 @@ public function createFromRequest(Request $request, bool $normalization, array $ } $context['resource_class'] = $attributes['resource_class']; + $context['iri_only'] = $resourceMetadata->getAttribute('normalization_context')['iri_only'] ?? false; $context['input'] = $resourceMetadata->getTypedOperationAttribute($operationType, $attributes[$operationKey], 'input', null, true); $context['output'] = $resourceMetadata->getTypedOperationAttribute($operationType, $attributes[$operationKey], 'output', null, true); $context['request_uri'] = $request->getRequestUri(); @@ -100,6 +105,7 @@ public function createFromRequest(Request $request, bool $normalization, array $ return $context; } + // TODO: We should always use `skip_null_values` but changing this would be a BC break, for now use it only when `merge-patch+json` is activated on a Resource foreach ($resourceMetadata->getItemOperations() as $operation) { if ('PATCH' === ($operation['method'] ?? '') && \in_array('application/merge-patch+json', $operation['input_formats']['json'] ?? [], true)) { $context['skip_null_values'] = true; diff --git a/src/Swagger/Serializer/DocumentationNormalizer.php b/src/Swagger/Serializer/DocumentationNormalizer.php index b13b73d7ee2..a6a1085b845 100644 --- a/src/Swagger/Serializer/DocumentationNormalizer.php +++ b/src/Swagger/Serializer/DocumentationNormalizer.php @@ -85,6 +85,7 @@ final class DocumentationNormalizer implements NormalizerInterface, CacheableSup private $paginationClientEnabledParameterName; private $formats; private $formatsProvider; + /** * @var SchemaFactoryInterface */ diff --git a/src/Util/ArrayTrait.php b/src/Util/ArrayTrait.php new file mode 100644 index 00000000000..788ddac2c49 --- /dev/null +++ b/src/Util/ArrayTrait.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Util; + +trait ArrayTrait +{ + public function isSequentialArrayOfArrays(array $array): bool + { + if (!$this->isSequentialArray($array)) { + return false; + } + + return $this->arrayContainsOnly($array, 'array'); + } + + public function isSequentialArray(array $array): bool + { + if ([] === $array) { + return false; + } + + return array_keys($array) === range(0, \count($array) - 1); + } + + public function arrayContainsOnly(array $array, string $type): bool + { + return $array === array_filter($array, static function ($item) use ($type): bool { + return $type === \gettype($item); + }); + } +} diff --git a/src/Util/AttributesExtractor.php b/src/Util/AttributesExtractor.php index fcb93a59242..56b73217b9d 100644 --- a/src/Util/AttributesExtractor.php +++ b/src/Util/AttributesExtractor.php @@ -53,6 +53,10 @@ public static function extractAttributes(array $attributes): array } } + if ($previousObject = $attributes['previous_data'] ?? null) { + $result['previous_data'] = $previousObject; + } + if (false === $hasRequestAttributeKey) { return []; } diff --git a/src/Util/IriHelper.php b/src/Util/IriHelper.php index d88a61f0592..4762a1e20d1 100644 --- a/src/Util/IriHelper.php +++ b/src/Util/IriHelper.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Util; +use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; /** @@ -56,27 +57,30 @@ public static function parseIri(string $iri, string $pageParameterName): array * * @param float $page */ - public static function createIri(array $parts, array $parameters, string $pageParameterName = null, float $page = null, bool $absoluteUrl = false): string + public static function createIri(array $parts, array $parameters, string $pageParameterName = null, float $page = null, $urlGenerationStrategy = UrlGeneratorInterface::ABS_PATH): string { if (null !== $page && null !== $pageParameterName) { $parameters[$pageParameterName] = $page; } + if (\is_bool($urlGenerationStrategy)) { + @trigger_error(sprintf('Passing a bool as 5th parameter to "%s::createIri()" is deprecated since API Platform 2.6. Pass an "%s" constant (int) instead.', __CLASS__, UrlGeneratorInterface::class), E_USER_DEPRECATED); + $urlGenerationStrategy = $urlGenerationStrategy ? UrlGeneratorInterface::ABS_URL : UrlGeneratorInterface::ABS_PATH; + } + $query = http_build_query($parameters, '', '&', PHP_QUERY_RFC3986); $parts['query'] = preg_replace('/%5B\d+%5D/', '%5B%5D', $query); $url = ''; - - if ($absoluteUrl && isset($parts['host'])) { + if ((UrlGeneratorInterface::ABS_URL === $urlGenerationStrategy || UrlGeneratorInterface::NET_PATH === $urlGenerationStrategy) && isset($parts['host'])) { if (isset($parts['scheme'])) { - $url .= $parts['scheme']; + $scheme = $parts['scheme']; } elseif (isset($parts['port']) && 443 === $parts['port']) { - $url .= 'https'; + $scheme = 'https'; } else { - $url .= 'http'; + $scheme = 'http'; } - - $url .= '://'; + $url .= UrlGeneratorInterface::NET_PATH === $urlGenerationStrategy ? '//' : "$scheme://"; if (isset($parts['user'])) { $url .= $parts['user']; diff --git a/src/Util/SortTrait.php b/src/Util/SortTrait.php new file mode 100644 index 00000000000..56a23fae9af --- /dev/null +++ b/src/Util/SortTrait.php @@ -0,0 +1,35 @@ + + * + * For 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\Util; + +/** + * Sort helper methods. + * + * @internal + * + * @author Alan Poulain + */ +trait SortTrait +{ + private function arrayRecursiveSort(array &$array, callable $sortFunction): void + { + foreach ($array as &$value) { + if (\is_array($value)) { + $this->arrayRecursiveSort($value, $sortFunction); + } + } + unset($value); + $sortFunction($array); + } +} diff --git a/tests/Annotation/ApiPropertyTest.php b/tests/Annotation/ApiPropertyTest.php index ea08ce657d0..f4c1ec790c0 100644 --- a/tests/Annotation/ApiPropertyTest.php +++ b/tests/Annotation/ApiPropertyTest.php @@ -52,6 +52,7 @@ public function testConstruct() 'fetchable' => true, 'fetchEager' => false, 'jsonldContext' => ['foo' => 'bar'], + 'security' => 'is_granted(\'ROLE_ADMIN\')', 'swaggerContext' => ['foo' => 'baz'], 'openapiContext' => ['foo' => 'baz'], 'push' => true, @@ -62,6 +63,40 @@ public function testConstruct() 'fetchable' => false, 'fetch_eager' => false, 'jsonld_context' => ['foo' => 'bar'], + 'security' => 'is_granted(\'ROLE_ADMIN\')', + 'swagger_context' => ['foo' => 'baz'], + 'openapi_context' => ['foo' => 'baz'], + 'push' => true, + 'unknown' => 'unknown', + ], $property->attributes); + } + + /** + * @requires PHP 8.0 + */ + public function testConstructAttribute() + { + $property = eval(<<<'PHP' +return new \ApiPlatform\Core\Annotation\ApiProperty( + deprecationReason: 'this field is deprecated', + fetchable: true, + fetchEager: false, + jsonldContext: ['foo' => 'bar'], + security: 'is_granted(\'ROLE_ADMIN\')', + swaggerContext: ['foo' => 'baz'], + openapiContext: ['foo' => 'baz'], + push: true, + attributes: ['unknown' => 'unknown', 'fetchable' => false] +); +PHP + ); + + $this->assertEquals([ + 'deprecation_reason' => 'this field is deprecated', + 'fetchable' => false, + 'fetch_eager' => false, + 'jsonld_context' => ['foo' => 'bar'], + 'security' => 'is_granted(\'ROLE_ADMIN\')', 'swagger_context' => ['foo' => 'baz'], 'openapi_context' => ['foo' => 'baz'], 'push' => true, diff --git a/tests/Annotation/ApiResourceTest.php b/tests/Annotation/ApiResourceTest.php index f3f2a21fbe7..82d2c210ec7 100644 --- a/tests/Annotation/ApiResourceTest.php +++ b/tests/Annotation/ApiResourceTest.php @@ -14,7 +14,10 @@ namespace ApiPlatform\Core\Tests\Annotation; use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Tests\Fixtures\AnnotatedClass; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyPhp8; use Doctrine\Common\Annotations\AnnotationReader; use PHPUnit\Framework\TestCase; @@ -54,7 +57,6 @@ public function testConstruct() 'paginationEnabled' => true, 'paginationFetchJoinCollection' => true, 'paginationItemsPerPage' => 42, - 'maximumItemsPerPage' => 42, // deprecated, see paginationMaximumItemsPerPage 'paginationMaximumItemsPerPage' => 50, 'paginationPartial' => true, 'routePrefix' => '/foo', @@ -63,6 +65,7 @@ public function testConstruct() 'swaggerContext' => ['description' => 'bar'], 'validationGroups' => ['foo', 'bar'], 'sunset' => 'Thu, 11 Oct 2018 00:00:00 +0200', + 'urlGenerationStrategy' => UrlGeneratorInterface::ABS_PATH, ]); $this->assertSame('shortName', $resource->shortName); @@ -96,7 +99,6 @@ public function testConstruct() 'pagination_enabled' => true, 'pagination_fetch_join_collection' => true, 'pagination_items_per_page' => 42, - 'maximum_items_per_page' => 42, 'pagination_maximum_items_per_page' => 50, 'pagination_partial' => true, 'route_prefix' => '/foo', @@ -104,9 +106,118 @@ public function testConstruct() 'validation_groups' => ['baz', 'qux'], 'cache_headers' => ['max_age' => 0, 'shared_max_age' => 0, 'vary' => ['Custom-Vary-1', 'Custom-Vary-2']], 'sunset' => 'Thu, 11 Oct 2018 00:00:00 +0200', + 'url_generation_strategy' => 1, ], $resource->attributes); } + /** + * @requires PHP 8.0 + */ + public function testConstructAttribute() + { + $resource = eval(<<<'PHP' +return new \ApiPlatform\Core\Annotation\ApiResource( + security: 'is_granted("ROLE_FOO")', + securityMessage: 'You are not foo.', + securityPostDenormalize: 'is_granted("ROLE_BAR")', + securityPostDenormalizeMessage: 'You are not bar.', + attributes: ['foo' => 'bar', 'validation_groups' => ['baz', 'qux'], 'cache_headers' => ['max_age' => 0, 'shared_max_age' => 0, 'vary' => ['Custom-Vary-1', 'Custom-Vary-2']]], + collectionOperations: ['bar' => ['foo']], + denormalizationContext: ['groups' => ['foo']], + description: 'description', + fetchPartial: true, + forceEager: false, + formats: ['foo', 'bar' => ['application/bar']], + filters: ['foo', 'bar'], + graphql: ['query' => ['normalization_context' => ['groups' => ['foo', 'bar']]]], + input: 'Foo', + iri: 'http://example.com/res', + itemOperations: ['foo' => ['bar']], + mercure: ['private' => true], + messenger: true, + normalizationContext: ['groups' => ['bar']], + order: ['foo', 'bar' => 'ASC'], + openapiContext: ['description' => 'foo'], + output: 'Bar', + paginationClientEnabled: true, + paginationClientItemsPerPage: true, + paginationClientPartial: true, + paginationEnabled: true, + paginationFetchJoinCollection: true, + paginationItemsPerPage: 42, + paginationMaximumItemsPerPage: 50, + paginationPartial: true, + routePrefix: '/foo', + shortName: 'shortName', + subresourceOperations: [], + swaggerContext: ['description' => 'bar'], + validationGroups: ['foo', 'bar'], + sunset: 'Thu, 11 Oct 2018 00:00:00 +0200', + urlGenerationStrategy: \ApiPlatform\Core\Api\UrlGeneratorInterface::ABS_PATH, + deprecationReason: 'reason', + elasticsearch: true, + hydraContext: ['hydra' => 'foo'], + paginationViaCursor: ['foo'], + stateless: true, +); +PHP + ); + + $this->assertSame('shortName', $resource->shortName); + $this->assertSame('description', $resource->description); + $this->assertSame('http://example.com/res', $resource->iri); + $this->assertSame(['foo' => ['bar']], $resource->itemOperations); + $this->assertSame(['bar' => ['foo']], $resource->collectionOperations); + $this->assertSame([], $resource->subresourceOperations); + $this->assertSame(['query' => ['normalization_context' => ['groups' => ['foo', 'bar']]]], $resource->graphql); + $this->assertEquals([ + 'security' => 'is_granted("ROLE_FOO")', + 'security_message' => 'You are not foo.', + 'security_post_denormalize' => 'is_granted("ROLE_BAR")', + 'security_post_denormalize_message' => 'You are not bar.', + 'denormalization_context' => ['groups' => ['foo']], + 'fetch_partial' => true, + 'foo' => 'bar', + 'force_eager' => false, + 'formats' => ['foo', 'bar' => ['application/bar']], + 'filters' => ['foo', 'bar'], + 'input' => 'Foo', + 'mercure' => ['private' => true], + 'messenger' => true, + 'normalization_context' => ['groups' => ['bar']], + 'order' => ['foo', 'bar' => 'ASC'], + 'openapi_context' => ['description' => 'foo'], + 'output' => 'Bar', + 'pagination_client_enabled' => true, + 'pagination_client_items_per_page' => true, + 'pagination_client_partial' => true, + 'pagination_enabled' => true, + 'pagination_fetch_join_collection' => true, + 'pagination_items_per_page' => 42, + 'pagination_maximum_items_per_page' => 50, + 'pagination_partial' => true, + 'route_prefix' => '/foo', + 'swagger_context' => ['description' => 'bar'], + 'validation_groups' => ['baz', 'qux'], + 'cache_headers' => ['max_age' => 0, 'shared_max_age' => 0, 'vary' => ['Custom-Vary-1', 'Custom-Vary-2']], + 'sunset' => 'Thu, 11 Oct 2018 00:00:00 +0200', + 'url_generation_strategy' => 1, + 'deprecation_reason' => 'reason', + 'elasticsearch' => true, + 'hydra_context' => ['hydra' => 'foo'], + 'pagination_via_cursor' => ['foo'], + 'stateless' => true, + ], $resource->attributes); + } + + /** + * @requires PHP 8.0 + */ + public function testUseAttribute() + { + $this->assertSame('Hey PHP 8', (new \ReflectionClass(DummyPhp8::class))->getAttributes(ApiResource::class)[0]->getArguments()['description']); + } + public function testApiResourceAnnotation() { $reader = new AnnotationReader(); @@ -131,6 +242,18 @@ public function testApiResourceAnnotation() ], $resource->attributes); } + public function testConstructWithInvalidAttribute() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown property "invalidAttribute" on annotation "ApiPlatform\\Core\\Annotation\\ApiResource".'); + + new ApiResource([ + 'shortName' => 'shortName', + 'routePrefix' => '/foo', + 'invalidAttribute' => 'exception', + ]); + } + /** * @group legacy * @expectedDeprecation Attribute "accessControl" is deprecated in annotation since API Platform 2.5, prefer using "security" attribute instead diff --git a/tests/Behat/CoverageContext.php b/tests/Behat/CoverageContext.php index 611c661ae3c..1b41bd426e8 100644 --- a/tests/Behat/CoverageContext.php +++ b/tests/Behat/CoverageContext.php @@ -47,8 +47,8 @@ public static function setup() return; } - $filter->addDirectoryToWhitelist(__DIR__.'/../../src'); // @phpstan-ignore-line - self::$coverage = new CodeCoverage(null, $filter); // @phpstan-ignore-line + $filter->addDirectoryToWhitelist(__DIR__.'/../../src'); + self::$coverage = new CodeCoverage(null, $filter); } /** diff --git a/tests/Behat/DoctrineContext.php b/tests/Behat/DoctrineContext.php index d0bafa2ed69..a3e8c367268 100644 --- a/tests/Behat/DoctrineContext.php +++ b/tests/Behat/DoctrineContext.php @@ -13,6 +13,8 @@ namespace ApiPlatform\Core\Tests\Behat; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\AbsoluteUrlDummy as AbsoluteUrlDummyDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\AbsoluteUrlRelationDummy as AbsoluteUrlRelationDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Address as AddressDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Answer as AnswerDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\CompositeItem as CompositeItemDocument; @@ -41,6 +43,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyDtoOutputSameClass as DummyDtoOutputSameClassDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyFriend as DummyFriendDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyGroup as DummyGroupDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyMercure as DummyMercureDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyOffer as DummyOfferDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyProduct as DummyProductDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\DummyProperty as DummyPropertyDocument; @@ -52,8 +55,13 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\FooDummy as FooDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\FourthLevel as FourthLevelDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Greeting as GreetingDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\InitializeInput as InitializeInputDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\IriOnlyDummy as IriOnlyDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\MaxDepthDummy as MaxDepthDummyDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\NetworkPathDummy as NetworkPathDummyDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\NetworkPathRelationDummy as NetworkPathRelationDummyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Order as OrderDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\PatchDummyRelation as PatchDummyRelationDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Person as PersonDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\PersonToPet as PersonToPetDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Pet as PetDocument; @@ -68,7 +76,10 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\SoMany as SoManyDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Taxon as TaxonDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\ThirdLevel as ThirdLevelDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\UrlEncodedId as UrlEncodedIdDocument; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\User as UserDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\AbsoluteUrlDummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\AbsoluteUrlRelationDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Address; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Answer; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\CompositeItem; @@ -98,6 +109,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyFriend; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyGroup; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyImmutableDate; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyMercure; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyOffer; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyProduct; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyProperty; @@ -110,9 +122,14 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\FooDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\FourthLevel; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Greeting; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\InitializeInput; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\InternalUser; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\IriOnlyDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\MaxDepthDummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\NetworkPathDummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\NetworkPathRelationDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Order; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\PatchDummyRelation; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Person; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\PersonToPet; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Pet; @@ -129,6 +146,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\SoMany; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Taxon; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\ThirdLevel; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\UrlEncodedId; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\User; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\UuidIdentifierDummy; use Behat\Behat\Context\Context; @@ -443,6 +461,34 @@ public function thereAreDummyObjectsWithRelatedDummy(int $nb) $this->manager->flush(); } + /** + * @Given there are dummies with similar properties + */ + public function thereAreDummiesWithSimilarProperties() + { + $dummy1 = $this->buildDummy(); + $dummy1->setName('foo'); + $dummy1->setDescription('bar'); + + $dummy2 = $this->buildDummy(); + $dummy2->setName('baz'); + $dummy2->setDescription('qux'); + + $dummy3 = $this->buildDummy(); + $dummy3->setName('foo'); + $dummy3->setDescription('qux'); + + $dummy4 = $this->buildDummy(); + $dummy4->setName('baz'); + $dummy4->setDescription('bar'); + + $this->manager->persist($dummy1); + $this->manager->persist($dummy2); + $this->manager->persist($dummy3); + $this->manager->persist($dummy4); + $this->manager->flush(); + } + /** * @Given there are :nb dummyDtoNoInput objects */ @@ -1062,6 +1108,17 @@ public function thereIsAnAnswerToTheQuestion(string $a, string $q) $this->manager->clear(); } + /** + * @Given there is a UrlEncodedId resource + */ + public function thereIsAUrlEncodedIdResource() + { + $urlEncodedIdResource = ($this->isOrm() ? new UrlEncodedId() : new UrlEncodedIdDocument()); + $this->manager->persist($urlEncodedIdResource); + $this->manager->flush(); + $this->manager->clear(); + } + /** * @Then the password :password for user :user should be hashed */ @@ -1520,6 +1577,102 @@ public function thereAreConvertedOwnerObjects(int $nb) $this->manager->flush(); } + /** + * @Given there are :nb dummy mercure objects + */ + public function thereAreDummyMercureObjects(int $nb) + { + for ($i = 1; $i <= $nb; ++$i) { + $relatedDummy = $this->buildRelatedDummy(); + $relatedDummy->setName('RelatedDummy #'.$i); + + $dummyMercure = $this->buildDummyMercure(); + $dummyMercure->name = "Dummy Mercure #$i"; + $dummyMercure->description = 'Description'; + $dummyMercure->relatedDummy = $relatedDummy; + + $this->manager->persist($relatedDummy); + $this->manager->persist($dummyMercure); + } + + $this->manager->flush(); + } + + /** + * @Given there are :nb iriOnlyDummies + */ + public function thereAreIriOnlyDummies(int $nb) + { + for ($i = 1; $i <= $nb; ++$i) { + $iriOnlyDummy = $this->buildIriOnlyDummy(); + $iriOnlyDummy->setFoo('bar'.$nb); + $this->manager->persist($iriOnlyDummy); + } + + $this->manager->flush(); + } + + /** + * @Given there are :nb absoluteUrlDummy objects with a related absoluteUrlRelationDummy + */ + public function thereAreAbsoluteUrlDummies(int $nb) + { + for ($i = 1; $i <= $nb; ++$i) { + $absoluteUrlRelationDummy = $this->buildAbsoluteUrlRelationDummy(); + $absoluteUrlDummy = $this->buildAbsoluteUrlDummy(); + $absoluteUrlDummy->absoluteUrlRelationDummy = $absoluteUrlRelationDummy; + + $this->manager->persist($absoluteUrlRelationDummy); + $this->manager->persist($absoluteUrlDummy); + } + + $this->manager->flush(); + } + + /** + * @Given there are :nb networkPathDummy objects with a related networkPathRelationDummy + */ + public function thereAreNetworkPathDummies(int $nb) + { + for ($i = 1; $i <= $nb; ++$i) { + $networkPathRelationDummy = $this->buildNetworkPathRelationDummy(); + $networkPathDummy = $this->buildNetworkPathDummy(); + $networkPathDummy->networkPathRelationDummy = $networkPathRelationDummy; + + $this->manager->persist($networkPathRelationDummy); + $this->manager->persist($networkPathDummy); + } + + $this->manager->flush(); + } + + /** + * @Given there is an InitializeInput object with id :id + */ + public function thereIsAnInitializeInput(int $id) + { + $initializeInput = $this->buildInitializeInput(); + $initializeInput->id = $id; + $initializeInput->manager = 'Orwell'; + $initializeInput->name = '1984'; + + $this->manager->persist($initializeInput); + $this->manager->flush(); + } + + /** + * @Given there is a PatchDummyRelation + */ + public function thereIsAPatchDummyRelation() + { + $dummy = $this->buildPatchDummyRelation(); + $related = $this->buildRelatedDummy(); + $dummy->setRelated($related); + $this->manager->persist($related); + $this->manager->persist($dummy); + $this->manager->flush(); + } + private function isOrm(): bool { return null !== $this->schemaTool; @@ -1754,6 +1907,14 @@ private function buildGreeting() return $this->isOrm() ? new Greeting() : new GreetingDocument(); } + /** + * @return IriOnlyDummy|IriOnlyDummyDocument + */ + private function buildIriOnlyDummy() + { + return $this->isOrm() ? new IriOnlyDummy() : new IriOnlyDummyDocument(); + } + /** * @return MaxDepthDummy|MaxDepthDummyDocument */ @@ -1897,4 +2058,60 @@ private function buildConvertedRelated() { return $this->isOrm() ? new ConvertedRelated() : new ConvertedRelatedDocument(); } + + /** + * @return DummyMercure|DummyMercureDocument + */ + private function buildDummyMercure() + { + return $this->isOrm() ? new DummyMercure() : new DummyMercureDocument(); + } + + /** + * @return AbsoluteUrlDummyDocument|AbsoluteUrlDummy + */ + private function buildAbsoluteUrlDummy() + { + return $this->isOrm() ? new AbsoluteUrlDummy() : new AbsoluteUrlDummyDocument(); + } + + /** + * @return AbsoluteUrlRelationDummyDocument|AbsoluteUrlRelationDummy + */ + private function buildAbsoluteUrlRelationDummy() + { + return $this->isOrm() ? new AbsoluteUrlRelationDummy() : new AbsoluteUrlRelationDummyDocument(); + } + + /** + * @return NetworkPathDummyDocument|NetworkPathDummy + */ + private function buildNetworkPathDummy() + { + return $this->isOrm() ? new NetworkPathDummy() : new NetworkPathDummyDocument(); + } + + /** + * @return NetworkPathRelationDummyDocument|NetworkPathRelationDummy + */ + private function buildNetworkPathRelationDummy() + { + return $this->isOrm() ? new NetworkPathRelationDummy() : new NetworkPathRelationDummyDocument(); + } + + /** + * @return InitializeInput|InitializeInputDocument + */ + private function buildInitializeInput() + { + return $this->isOrm() ? new InitializeInput() : new InitializeInputDocument(); + } + + /** + * @return PatchDummyRelation|PatchDummyRelationDocument + */ + private function buildPatchDummyRelation() + { + return $this->isOrm() ? new PatchDummyRelation() : new PatchDummyRelationDocument(); + } } diff --git a/tests/Behat/GraphqlContext.php b/tests/Behat/GraphqlContext.php index 813bcd9f94f..7ac7855c28e 100644 --- a/tests/Behat/GraphqlContext.php +++ b/tests/Behat/GraphqlContext.php @@ -98,11 +98,11 @@ public function ISendTheGraphqlRequestWithVariables(PyStringNode $variables) } /** - * @When I send the GraphQL request with operation :operation + * @When I send the GraphQL request with operationName :operationName */ - public function ISendTheGraphqlRequestWithOperation(string $operation) + public function ISendTheGraphqlRequestWithOperation(string $operationName) { - $this->graphqlRequest['operation'] = $operation; + $this->graphqlRequest['operationName'] = $operationName; $this->sendGraphqlRequest(); } diff --git a/tests/Behat/JsonApiContext.php b/tests/Behat/JsonApiContext.php index 805e74525a7..2c3699b9c30 100644 --- a/tests/Behat/JsonApiContext.php +++ b/tests/Behat/JsonApiContext.php @@ -115,6 +115,26 @@ public function theJsonNodeShouldNotBeAnEmptyString($node) } } + /** + * @Then the JSON node :node should be sorted + * @Then the JSON should be sorted + */ + public function theJsonNodeShouldBeSorted($node = '') + { + $actual = (array) $this->getValueOfNode($node); + + if (!\is_array($actual)) { + throw new \Exception(sprintf('The "%s" node value is not an array', $node)); + } + + $expected = $actual; + ksort($expected); + + if ($actual !== $expected) { + throw new ExpectationFailedException(sprintf('The json node "%s" is not sorted by keys', $node)); + } + } + /** * @Given there is a RelatedDummy */ diff --git a/tests/Behat/MercureContext.php b/tests/Behat/MercureContext.php new file mode 100644 index 00000000000..3b05f65e39d --- /dev/null +++ b/tests/Behat/MercureContext.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +use ApiPlatform\Core\Tests\Fixtures\DummyMercurePublisher; +use Behat\Behat\Context\Context; +use Behat\Gherkin\Node\PyStringNode; +use Symfony\Component\Mercure\Update; + +/** + * Context for Mercure. + * + * @author Alan Poulain + */ +final class MercureContext implements Context +{ + private $publisher; + + public function __construct(DummyMercurePublisher $publisher) + { + $this->publisher = $publisher; + } + + /** + * @Then the following Mercure update with topics :topics should have been sent: + */ + public function theFollowingMercureUpdateShouldHaveBeenSent(string $topics, PyStringNode $update): void + { + $topics = explode(',', $topics); + $update = json_decode($update->getRaw(), true); + + /** @var Update $sentUpdate */ + foreach ($this->publisher->getUpdates() as $sentUpdate) { + $toMatchTopics = count($topics); + foreach ($sentUpdate->getTopics() as $sentTopic) { + foreach ($topics as $topic) { + if (preg_match("@$topic@", $sentTopic)) { + --$toMatchTopics; + } + } + } + + if ($toMatchTopics > 0) { + continue; + } + + if ($sentUpdate->getData() === json_encode($update)) { + return; + } + } + + throw new \RuntimeException('Mercure update has not been sent.'); + } +} diff --git a/tests/Behat/OpenApiContext.php b/tests/Behat/OpenApiContext.php index 59d9035b5a4..19d06caa9ad 100644 --- a/tests/Behat/OpenApiContext.php +++ b/tests/Behat/OpenApiContext.php @@ -86,7 +86,7 @@ public function assertTheSwaggerClassNotExist(string $className) /** * @Then the OpenAPI class :class doesn't exist */ - public function assertTheOPenAPIClassNotExist(string $className) + public function assertTheOpenAPIClassNotExist(string $className) { try { $this->getClassInfo($className, 3); diff --git a/tests/Bridge/Doctrine/Common/Filter/SearchFilterTestTrait.php b/tests/Bridge/Doctrine/Common/Filter/SearchFilterTestTrait.php index 98b48b8322b..5062ed163a8 100644 --- a/tests/Bridge/Doctrine/Common/Filter/SearchFilterTestTrait.php +++ b/tests/Bridge/Doctrine/Common/Filter/SearchFilterTestTrait.php @@ -273,6 +273,30 @@ private function provideApplyTestArguments(): array 'name' => 'partial', ], ], + 'partial (multiple values)' => [ + [ + 'id' => null, + 'name' => 'partial', + ], + [ + 'name' => [ + 'CaSE', + 'SENSitive', + ], + ], + ], + 'partial (multiple values; case insensitive)' => [ + [ + 'id' => null, + 'name' => 'ipartial', + ], + [ + 'name' => [ + 'CaSE', + 'inSENSitive', + ], + ], + ], 'start' => [ [ 'id' => null, @@ -291,6 +315,30 @@ private function provideApplyTestArguments(): array 'name' => 'partial', ], ], + 'start (multiple values)' => [ + [ + 'id' => null, + 'name' => 'start', + ], + [ + 'name' => [ + 'CaSE', + 'SENSitive', + ], + ], + ], + 'start (multiple values; case insensitive)' => [ + [ + 'id' => null, + 'name' => 'istart', + ], + [ + 'name' => [ + 'CaSE', + 'inSENSitive', + ], + ], + ], 'end' => [ [ 'id' => null, @@ -309,6 +357,30 @@ private function provideApplyTestArguments(): array 'name' => 'partial', ], ], + 'end (multiple values)' => [ + [ + 'id' => null, + 'name' => 'end', + ], + [ + 'name' => [ + 'CaSE', + 'SENSitive', + ], + ], + ], + 'end (multiple values; case insensitive)' => [ + [ + 'id' => null, + 'name' => 'iend', + ], + [ + 'name' => [ + 'CaSE', + 'inSENSitive', + ], + ], + ], 'word_start' => [ [ 'id' => null, @@ -327,6 +399,30 @@ private function provideApplyTestArguments(): array 'name' => 'partial', ], ], + 'word_start (multiple values)' => [ + [ + 'id' => null, + 'name' => 'word_start', + ], + [ + 'name' => [ + 'CaSE', + 'SENSitive', + ], + ], + ], + 'word_start (multiple values; case insensitive)' => [ + [ + 'id' => null, + 'name' => 'iword_start', + ], + [ + 'name' => [ + 'CaSE', + 'inSENSitive', + ], + ], + ], 'invalid value for relation' => [ [ 'id' => null, diff --git a/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php b/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php index 0143f6960c4..ce444ed6e1b 100644 --- a/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php +++ b/tests/Bridge/Doctrine/EventListener/PublishMercureUpdatesListenerTest.php @@ -18,12 +18,15 @@ use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\Bridge\Doctrine\EventListener\PublishMercureUpdatesListener; use ApiPlatform\Core\Exception\InvalidArgumentException; +use ApiPlatform\Core\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface as GraphQlMercureSubscriptionIriGeneratorInterface; +use ApiPlatform\Core\GraphQl\Subscription\SubscriptionManagerInterface as GraphQlSubscriptionManagerInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\NotAResource; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyCar; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyFriend; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyOffer; use ApiPlatform\Core\Tests\ProphecyTrait; use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\Event\OnFlushEventArgs; @@ -135,6 +138,7 @@ public function testPublishUpdate(): void $toUpdate = new Dummy(); $toUpdate->setId(2); $toUpdateNoMercureAttribute = new DummyCar(); + $toUpdateMercureOptions = new DummyOffer(); $toDelete = new Dummy(); $toDelete->setId(3); @@ -145,10 +149,12 @@ public function testPublishUpdate(): void $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class); $resourceClassResolverProphecy->getResourceClass(Argument::type(DummyCar::class))->willReturn(DummyCar::class); $resourceClassResolverProphecy->getResourceClass(Argument::type(DummyFriend::class))->willReturn(DummyFriend::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(DummyOffer::class))->willReturn(DummyOffer::class); $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); $resourceClassResolverProphecy->isResourceClass(NotAResource::class)->willReturn(false); $resourceClassResolverProphecy->isResourceClass(DummyCar::class)->willReturn(true); $resourceClassResolverProphecy->isResourceClass(DummyFriend::class)->willReturn(true); + $resourceClassResolverProphecy->isResourceClass(DummyOffer::class)->willReturn(true); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromItem($toInsert, UrlGeneratorInterface::ABS_URL)->willReturn('http://example.com/dummies/1')->shouldBeCalled(); @@ -162,10 +168,12 @@ public function testPublishUpdate(): void $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['mercure' => true, 'normalization_context' => ['groups' => ['foo', 'bar']]])); $resourceMetadataFactoryProphecy->create(DummyCar::class)->willReturn(new ResourceMetadata()); $resourceMetadataFactoryProphecy->create(DummyFriend::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['mercure' => ['private' => true, 'retry' => 10]])); + $resourceMetadataFactoryProphecy->create(DummyOffer::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['mercure' => ['topics' => 'http://example.com/custom_topics/1', 'normalization_context' => ['groups' => ['baz']]]])); $serializerProphecy = $this->prophesize(SerializerInterface::class); $serializerProphecy->serialize($toInsert, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('1'); $serializerProphecy->serialize($toUpdate, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('2'); + $serializerProphecy->serialize($toUpdateMercureOptions, 'jsonld', ['groups' => ['baz']])->willReturn('mercure_options'); $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; @@ -192,7 +200,7 @@ public function testPublishUpdate(): void $uowProphecy = $this->prophesize(UnitOfWork::class); $uowProphecy->getScheduledEntityInsertions()->willReturn([$toInsert, $toInsertNotResource])->shouldBeCalled(); - $uowProphecy->getScheduledEntityUpdates()->willReturn([$toUpdate, $toUpdateNoMercureAttribute])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([$toUpdate, $toUpdateNoMercureAttribute, $toUpdateMercureOptions])->shouldBeCalled(); $uowProphecy->getScheduledEntityDeletions()->willReturn([$toDelete, $toDeleteExpressionLanguage])->shouldBeCalled(); $emProphecy = $this->prophesize(EntityManagerInterface::class); @@ -202,9 +210,84 @@ public function testPublishUpdate(): void $listener->onFlush($eventArgs); $listener->postFlush(); - $this->assertSame(['http://example.com/dummies/1', 'http://example.com/dummies/2', 'http://example.com/dummies/3', 'http://example.com/dummy_friends/4'], $topics); - $this->assertSame([false, false, false, true], $private); - $this->assertSame([null, null, null, 10], $retry); + $this->assertSame(['http://example.com/dummies/1', 'http://example.com/dummies/2', 'http://example.com/custom_topics/1', 'http://example.com/dummies/3', 'http://example.com/dummy_friends/4'], $topics); + $this->assertSame([false, false, false, false, true], $private); + $this->assertSame([null, null, null, null, 10], $retry); + } + + public function testPublishGraphQlUpdates(): void + { + if (!method_exists(Update::class, 'isPrivate')) { + $this->markTestSkipped(); + } + + $toUpdate = new Dummy(); + $toUpdate->setId(2); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass(Argument::type(Dummy::class))->willReturn(Dummy::class); + $resourceClassResolverProphecy->isResourceClass(Dummy::class)->willReturn(true); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($toUpdate, UrlGeneratorInterface::ABS_URL)->willReturn('http://example.com/dummies/2'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['mercure' => true, 'normalization_context' => ['groups' => ['foo', 'bar']]])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->serialize($toUpdate, 'jsonld', ['groups' => ['foo', 'bar']])->willReturn('2'); + + $formats = ['jsonld' => ['application/ld+json'], 'jsonhal' => ['application/hal+json']]; + + $topics = []; + $private = []; + $retry = []; + $data = []; + $publisher = function (Update $update) use (&$topics, &$private, &$retry, &$data): string { + $topics = array_merge($topics, $update->getTopics()); + $private[] = $update->isPrivate(); + $retry[] = $update->getRetry(); + $data[] = $update->getData(); + + return 'id'; + }; + + $graphQlSubscriptionManagerProphecy = $this->prophesize(GraphQlSubscriptionManagerInterface::class); + $graphQlSubscriptionId = 'subscription-id'; + $graphQlSubscriptionData = ['data']; + $graphQlSubscriptionManagerProphecy->getPushPayloads($toUpdate)->willReturn([[$graphQlSubscriptionId, $graphQlSubscriptionData]]); + $graphQlMercureSubscriptionIriGenerator = $this->prophesize(GraphQlMercureSubscriptionIriGeneratorInterface::class); + $topicIri = 'subscription-topic-iri'; + $graphQlMercureSubscriptionIriGenerator->generateTopicIri($graphQlSubscriptionId)->willReturn($topicIri); + + $listener = new PublishMercureUpdatesListener( + $resourceClassResolverProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceMetadataFactoryProphecy->reveal(), + $serializerProphecy->reveal(), + $formats, + null, + $publisher, + $graphQlSubscriptionManagerProphecy->reveal(), + $graphQlMercureSubscriptionIriGenerator->reveal() + ); + + $uowProphecy = $this->prophesize(UnitOfWork::class); + $uowProphecy->getScheduledEntityInsertions()->willReturn([])->shouldBeCalled(); + $uowProphecy->getScheduledEntityUpdates()->willReturn([$toUpdate])->shouldBeCalled(); + $uowProphecy->getScheduledEntityDeletions()->willReturn([])->shouldBeCalled(); + + $emProphecy = $this->prophesize(EntityManagerInterface::class); + $emProphecy->getUnitOfWork()->willReturn($uowProphecy->reveal())->shouldBeCalled(); + $eventArgs = new OnFlushEventArgs($emProphecy->reveal()); + + $listener->onFlush($eventArgs); + $listener->postFlush(); + + $this->assertSame(['http://example.com/dummies/2', 'subscription-topic-iri'], $topics); + $this->assertSame([false, false], $private); + $this->assertSame([null, null], $retry); + $this->assertSame(['2', '["data"]'], $data); } public function testNoPublisher(): void @@ -223,7 +306,7 @@ public function testNoPublisher(): void ); } - public function testInvalidMercureAttribute() + public function testInvalidMercureAttribute(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The value of the "mercure" attribute of the "ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy" resource class must be a boolean, an array of options or an expression returning this array, "integer" given.'); diff --git a/tests/Bridge/Doctrine/MongoDbOdm/CollectionDataProviderTest.php b/tests/Bridge/Doctrine/MongoDbOdm/CollectionDataProviderTest.php index 39adb6264b1..532f19b1bf6 100644 --- a/tests/Bridge/Doctrine/MongoDbOdm/CollectionDataProviderTest.php +++ b/tests/Bridge/Doctrine/MongoDbOdm/CollectionDataProviderTest.php @@ -17,6 +17,8 @@ use ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\Extension\AggregationCollectionExtensionInterface; use ApiPlatform\Core\Bridge\Doctrine\MongoDbOdm\Extension\AggregationResultCollectionExtensionInterface; use ApiPlatform\Core\Exception\RuntimeException; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Dummy; use ApiPlatform\Core\Tests\ProphecyTrait; use Doctrine\ODM\MongoDB\Aggregation\Builder; @@ -36,13 +38,27 @@ class CollectionDataProviderTest extends TestCase { use ProphecyTrait; + private $managerRegistryProphecy; + private $resourceMetadataFactoryProphecy; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + parent::setUp(); + + $this->managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); + $this->resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + } + public function testGetCollection() { $iterator = $this->prophesize(Iterator::class)->reveal(); $aggregationBuilderProphecy = $this->prophesize(Builder::class); $aggregationBuilderProphecy->hydrate(Dummy::class)->willReturn($aggregationBuilderProphecy)->shouldBeCalled(); - $aggregationBuilderProphecy->execute()->willReturn($iterator)->shouldBeCalled(); + $aggregationBuilderProphecy->execute([])->willReturn($iterator)->shouldBeCalled(); $aggregationBuilder = $aggregationBuilderProphecy->reveal(); $repositoryProphecy = $this->prophesize(DocumentRepository::class); @@ -51,13 +67,46 @@ public function testGetCollection() $managerProphecy = $this->prophesize(DocumentManager::class); $managerProphecy->getRepository(Dummy::class)->willReturn($repositoryProphecy->reveal())->shouldBeCalled(); - $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); - $managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); + $this->managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); + + $this->resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata()); + + $extensionProphecy = $this->prophesize(AggregationCollectionExtensionInterface::class); + $extensionProphecy->applyToCollection($aggregationBuilder, Dummy::class, 'foo', [])->shouldBeCalled(); + + $dataProvider = new CollectionDataProvider($this->managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), [$extensionProphecy->reveal()]); + $this->assertEquals($iterator, $dataProvider->getCollection(Dummy::class, 'foo')); + } + + public function testGetCollectionWithExecuteOptions() + { + $iterator = $this->prophesize(Iterator::class)->reveal(); + + $aggregationBuilderProphecy = $this->prophesize(Builder::class); + $aggregationBuilderProphecy->hydrate(Dummy::class)->willReturn($aggregationBuilderProphecy)->shouldBeCalled(); + $aggregationBuilderProphecy->execute(['allowDiskUse' => true])->willReturn($iterator)->shouldBeCalled(); + $aggregationBuilder = $aggregationBuilderProphecy->reveal(); + + $repositoryProphecy = $this->prophesize(DocumentRepository::class); + $repositoryProphecy->createAggregationBuilder()->willReturn($aggregationBuilder)->shouldBeCalled(); + + $managerProphecy = $this->prophesize(DocumentManager::class); + $managerProphecy->getRepository(Dummy::class)->willReturn($repositoryProphecy->reveal())->shouldBeCalled(); + + $this->managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); + + $this->resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata( + 'Dummy', + null, + null, + null, + ['foo' => ['doctrine_mongodb' => ['execute_options' => ['allowDiskUse' => true]]]] + )); $extensionProphecy = $this->prophesize(AggregationCollectionExtensionInterface::class); $extensionProphecy->applyToCollection($aggregationBuilder, Dummy::class, 'foo', [])->shouldBeCalled(); - $dataProvider = new CollectionDataProvider($managerRegistryProphecy->reveal(), [$extensionProphecy->reveal()]); + $dataProvider = new CollectionDataProvider($this->managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), [$extensionProphecy->reveal()]); $this->assertEquals($iterator, $dataProvider->getCollection(Dummy::class, 'foo')); } @@ -72,15 +121,14 @@ public function testAggregationResultExtension() $managerProphecy = $this->prophesize(DocumentManager::class); $managerProphecy->getRepository(Dummy::class)->willReturn($repositoryProphecy->reveal())->shouldBeCalled(); - $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); - $managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); + $this->managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); $extensionProphecy = $this->prophesize(AggregationResultCollectionExtensionInterface::class); $extensionProphecy->applyToCollection($aggregationBuilder, Dummy::class, 'foo', [])->shouldBeCalled(); $extensionProphecy->supportsResult(Dummy::class, 'foo', [])->willReturn(true)->shouldBeCalled(); $extensionProphecy->getResult($aggregationBuilder, Dummy::class, 'foo', [])->willReturn([])->shouldBeCalled(); - $dataProvider = new CollectionDataProvider($managerRegistryProphecy->reveal(), [$extensionProphecy->reveal()]); + $dataProvider = new CollectionDataProvider($this->managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), [$extensionProphecy->reveal()]); $this->assertEquals([], $dataProvider->getCollection(Dummy::class, 'foo')); } @@ -94,21 +142,19 @@ public function testCannotCreateAggregationBuilder() $managerProphecy = $this->prophesize(DocumentManager::class); $managerProphecy->getRepository(Dummy::class)->willReturn($repositoryProphecy->reveal())->shouldBeCalled(); - $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); - $managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); + $this->managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($managerProphecy->reveal())->shouldBeCalled(); - $dataProvider = new CollectionDataProvider($managerRegistryProphecy->reveal()); + $dataProvider = new CollectionDataProvider($this->managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal()); $this->assertEquals([], $dataProvider->getCollection(Dummy::class, 'foo')); } public function testUnsupportedClass() { - $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); - $managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn(null)->shouldBeCalled(); + $this->managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn(null)->shouldBeCalled(); $extensionProphecy = $this->prophesize(AggregationResultCollectionExtensionInterface::class); - $dataProvider = new CollectionDataProvider($managerRegistryProphecy->reveal(), [$extensionProphecy->reveal()]); + $dataProvider = new CollectionDataProvider($this->managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), [$extensionProphecy->reveal()]); $this->assertFalse($dataProvider->supports(Dummy::class, 'foo')); } } diff --git a/tests/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtensionTest.php b/tests/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtensionTest.php index 0ba8a4adb0a..bf020e09db5 100644 --- a/tests/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtensionTest.php +++ b/tests/Bridge/Doctrine/MongoDbOdm/Extension/PaginationExtensionTest.php @@ -45,24 +45,28 @@ class PaginationExtensionTest extends TestCase use ProphecyTrait; private $managerRegistryProphecy; + private $resourceMetadataFactoryProphecy; + /** + * {@inheritdoc} + */ protected function setUp(): void { parent::setUp(); $this->managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); + $this->resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); } public function testApplyToCollection() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $attributes = [ 'pagination_enabled' => true, 'pagination_client_enabled' => true, 'pagination_items_per_page' => 40, ]; - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [ 'page_parameter_name' => '_page', @@ -74,6 +78,7 @@ public function testApplyToCollection() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -81,14 +86,13 @@ public function testApplyToCollection() public function testApplyToCollectionWithItemPerPageZero() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $attributes = [ 'pagination_enabled' => true, 'pagination_client_enabled' => true, 'pagination_items_per_page' => 0, ]; - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [ 'items_per_page' => 0, @@ -101,6 +105,7 @@ public function testApplyToCollectionWithItemPerPageZero() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -111,14 +116,13 @@ public function testApplyToCollectionWithItemPerPageZeroAndPage2() $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Page should not be greater than 1 if limit is equal to 0'); - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $attributes = [ 'pagination_enabled' => true, 'pagination_client_enabled' => true, 'pagination_items_per_page' => 0, ]; - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [ 'items_per_page' => 0, @@ -132,6 +136,7 @@ public function testApplyToCollectionWithItemPerPageZeroAndPage2() $extension = new PaginationExtension( $this->prophesize(ManagerRegistry::class)->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -142,14 +147,13 @@ public function testApplyToCollectionWithItemPerPageLessThan0() $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Limit should not be less than 0'); - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $attributes = [ 'pagination_enabled' => true, 'pagination_client_enabled' => true, 'pagination_items_per_page' => -20, ]; - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [ 'items_per_page' => -20, @@ -163,6 +167,7 @@ public function testApplyToCollectionWithItemPerPageLessThan0() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -170,14 +175,13 @@ public function testApplyToCollectionWithItemPerPageLessThan0() public function testApplyToCollectionWithItemPerPageTooHigh() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $attributes = [ 'pagination_enabled' => true, 'pagination_client_enabled' => true, 'pagination_client_items_per_page' => true, ]; - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [ 'page_parameter_name' => '_page', @@ -190,6 +194,7 @@ public function testApplyToCollectionWithItemPerPageTooHigh() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -197,14 +202,13 @@ public function testApplyToCollectionWithItemPerPageTooHigh() public function testApplyToCollectionWithGraphql() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $attributes = [ 'pagination_enabled' => true, 'pagination_client_enabled' => true, 'pagination_client_items_per_page' => 20, ]; - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory); @@ -214,6 +218,7 @@ public function testApplyToCollectionWithGraphql() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -221,14 +226,13 @@ public function testApplyToCollectionWithGraphql() public function testApplyToCollectionWithGraphqlAndCountContext() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $attributes = [ 'pagination_enabled' => true, 'pagination_client_enabled' => true, 'pagination_client_items_per_page' => 20, ]; - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory); @@ -247,6 +251,7 @@ public function testApplyToCollectionWithGraphqlAndCountContext() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -254,9 +259,8 @@ public function testApplyToCollectionWithGraphqlAndCountContext() public function testApplyToCollectionNoFilters() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory); @@ -266,6 +270,7 @@ public function testApplyToCollectionNoFilters() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -273,9 +278,8 @@ public function testApplyToCollectionNoFilters() public function testApplyToCollectionPaginationDisabled() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [ 'enabled' => false, @@ -288,6 +292,7 @@ public function testApplyToCollectionPaginationDisabled() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -295,9 +300,8 @@ public function testApplyToCollectionPaginationDisabled() public function testApplyToCollectionGraphQlPaginationDisabled() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [], [ 'enabled' => false, @@ -310,6 +314,7 @@ public function testApplyToCollectionGraphQlPaginationDisabled() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -317,14 +322,13 @@ public function testApplyToCollectionGraphQlPaginationDisabled() public function testApplyToCollectionWithMaximumItemsPerPage() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $attributes = [ 'pagination_enabled' => true, 'pagination_client_enabled' => true, 'pagination_maximum_items_per_page' => 80, ]; - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [], $attributes)); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [ 'client_enabled' => true, @@ -338,6 +342,7 @@ public function testApplyToCollectionWithMaximumItemsPerPage() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $extension->applyToCollection($aggregationBuilderProphecy->reveal(), 'Foo', 'op', $context); @@ -345,14 +350,14 @@ public function testApplyToCollectionWithMaximumItemsPerPage() public function testSupportsResult() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory); $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $this->assertTrue($extension->supportsResult('Foo', 'op')); @@ -360,9 +365,8 @@ public function testSupportsResult() public function testSupportsResultClientNotAllowedToPaginate() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [ 'enabled' => false, @@ -371,6 +375,7 @@ public function testSupportsResultClientNotAllowedToPaginate() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $this->assertFalse($extension->supportsResult('Foo', 'op', ['filters' => ['pagination' => true]])); @@ -378,9 +383,8 @@ public function testSupportsResultClientNotAllowedToPaginate() public function testSupportsResultPaginationDisabled() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [ 'enabled' => false, @@ -388,6 +392,7 @@ public function testSupportsResultPaginationDisabled() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $this->assertFalse($extension->supportsResult('Foo', 'op', ['filters' => ['enabled' => false]])); @@ -395,9 +400,8 @@ public function testSupportsResultPaginationDisabled() public function testSupportsResultGraphQlPaginationDisabled() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $this->resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata(null, null, null, [], [])); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory, [], [ 'enabled' => false, @@ -405,6 +409,7 @@ public function testSupportsResultGraphQlPaginationDisabled() $extension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); $this->assertFalse($extension->supportsResult('Foo', 'op', ['filters' => ['enabled' => false], 'graphql_operation_name' => 'op'])); @@ -412,8 +417,7 @@ public function testSupportsResultGraphQlPaginationDisabled() public function testGetResult() { - $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); $pagination = new Pagination($resourceMetadataFactory); @@ -423,6 +427,8 @@ public function testGetResult() $this->managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($documentManager); + $this->resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata()); + $iteratorProphecy = $this->prophesize(Iterator::class); $iteratorProphecy->toArray()->willReturn([ [ @@ -435,7 +441,7 @@ public function testGetResult() ]); $aggregationBuilderProphecy = $this->prophesize(Builder::class); - $aggregationBuilderProphecy->execute()->willReturn($iteratorProphecy->reveal()); + $aggregationBuilderProphecy->execute([])->willReturn($iteratorProphecy->reveal()); $aggregationBuilderProphecy->getPipeline()->willReturn([ [ '$facet' => [ @@ -452,6 +458,7 @@ public function testGetResult() $paginationExtension = new PaginationExtension( $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, $pagination ); @@ -461,6 +468,65 @@ public function testGetResult() $this->assertInstanceOf(PaginatorInterface::class, $result); } + public function testGetResultWithExecuteOptions() + { + $resourceMetadataFactory = $this->resourceMetadataFactoryProphecy->reveal(); + + $pagination = new Pagination($resourceMetadataFactory); + + $fixturesPath = \dirname((string) (new \ReflectionClass(Dummy::class))->getFileName()); + $config = DoctrineMongoDbOdmSetup::createAnnotationMetadataConfiguration([$fixturesPath], true); + $documentManager = DocumentManager::create(null, $config); + + $this->managerRegistryProphecy->getManagerForClass(Dummy::class)->willReturn($documentManager); + + $this->resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata( + 'Dummy', + null, + null, + null, + ['foo' => ['doctrine_mongodb' => ['execute_options' => ['allowDiskUse' => true]]]] + )); + + $iteratorProphecy = $this->prophesize(Iterator::class); + $iteratorProphecy->toArray()->willReturn([ + [ + 'count' => [ + [ + 'count' => 9, + ], + ], + ], + ]); + + $aggregationBuilderProphecy = $this->prophesize(Builder::class); + $aggregationBuilderProphecy->execute(['allowDiskUse' => true])->willReturn($iteratorProphecy->reveal()); + $aggregationBuilderProphecy->getPipeline()->willReturn([ + [ + '$facet' => [ + 'results' => [ + ['$skip' => 3], + ['$limit' => 6], + ], + 'count' => [ + ['$count' => 'count'], + ], + ], + ], + ]); + + $paginationExtension = new PaginationExtension( + $this->managerRegistryProphecy->reveal(), + $resourceMetadataFactory, + $pagination + ); + + $result = $paginationExtension->getResult($aggregationBuilderProphecy->reveal(), Dummy::class, 'foo'); + + $this->assertInstanceOf(PartialPaginatorInterface::class, $result); + $this->assertInstanceOf(PaginatorInterface::class, $result); + } + private function mockAggregationBuilder($expectedOffset, $expectedLimit) { $skipProphecy = $this->prophesize(Skip::class); diff --git a/tests/Bridge/Doctrine/MongoDbOdm/ItemDataProviderTest.php b/tests/Bridge/Doctrine/MongoDbOdm/ItemDataProviderTest.php index f9214edf883..b38d3cd1073 100644 --- a/tests/Bridge/Doctrine/MongoDbOdm/ItemDataProviderTest.php +++ b/tests/Bridge/Doctrine/MongoDbOdm/ItemDataProviderTest.php @@ -23,6 +23,8 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Dummy; use ApiPlatform\Core\Tests\ProphecyTrait; use Doctrine\ODM\MongoDB\Aggregation\Builder; @@ -45,6 +47,18 @@ class ItemDataProviderTest extends TestCase { use ProphecyTrait; + private $resourceMetadataFactoryProphecy; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + parent::setUp(); + + $this->resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + } + public function testGetItemSingleIdentifier() { $context = ['foo' => 'bar', 'fetch_data' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; @@ -60,7 +74,40 @@ public function testGetItemSingleIdentifier() $aggregationBuilderProphecy = $this->prophesize(Builder::class); $aggregationBuilderProphecy->match()->willReturn($matchProphecy->reveal())->shouldBeCalled(); $aggregationBuilderProphecy->hydrate(Dummy::class)->willReturn($aggregationBuilderProphecy)->shouldBeCalled(); - $aggregationBuilderProphecy->execute()->willReturn($iterator->reveal())->shouldBeCalled(); + $aggregationBuilderProphecy->execute([])->willReturn($iterator->reveal())->shouldBeCalled(); + $aggregationBuilder = $aggregationBuilderProphecy->reveal(); + + [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ + 'id', + ]); + $managerRegistry = $this->getManagerRegistry(Dummy::class, $aggregationBuilder); + + $this->resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata()); + + $extensionProphecy = $this->prophesize(AggregationItemExtensionInterface::class); + $extensionProphecy->applyToItem($aggregationBuilder, Dummy::class, ['id' => 1], 'foo', $context)->shouldBeCalled(); + + $dataProvider = new ItemDataProvider($managerRegistry, $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + + $this->assertEquals($result, $dataProvider->getItem(Dummy::class, ['id' => 1], 'foo', $context)); + } + + public function testGetItemWithExecuteOptions() + { + $context = ['foo' => 'bar', 'fetch_data' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; + + $matchProphecy = $this->prophesize(AggregationMatch::class); + $matchProphecy->field('id')->willReturn($matchProphecy)->shouldBeCalled(); + $matchProphecy->equals(1)->shouldBeCalled(); + + $iterator = $this->prophesize(Iterator::class); + $result = new \stdClass(); + $iterator->current()->willReturn($result)->shouldBeCalled(); + + $aggregationBuilderProphecy = $this->prophesize(Builder::class); + $aggregationBuilderProphecy->match()->willReturn($matchProphecy->reveal())->shouldBeCalled(); + $aggregationBuilderProphecy->hydrate(Dummy::class)->willReturn($aggregationBuilderProphecy)->shouldBeCalled(); + $aggregationBuilderProphecy->execute(['allowDiskUse' => true])->willReturn($iterator->reveal())->shouldBeCalled(); $aggregationBuilder = $aggregationBuilderProphecy->reveal(); [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ @@ -68,10 +115,17 @@ public function testGetItemSingleIdentifier() ]); $managerRegistry = $this->getManagerRegistry(Dummy::class, $aggregationBuilder); + $this->resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata( + 'Dummy', + null, + null, + ['foo' => ['doctrine_mongodb' => ['execute_options' => ['allowDiskUse' => true]]]] + )); + $extensionProphecy = $this->prophesize(AggregationItemExtensionInterface::class); $extensionProphecy->applyToItem($aggregationBuilder, Dummy::class, ['id' => 1], 'foo', $context)->shouldBeCalled(); - $dataProvider = new ItemDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $dataProvider = new ItemDataProvider($managerRegistry, $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); $this->assertEquals($result, $dataProvider->getItem(Dummy::class, ['id' => 1], 'foo', $context)); } @@ -91,7 +145,7 @@ public function testGetItemDoubleIdentifier() $aggregationBuilderProphecy = $this->prophesize(Builder::class); $aggregationBuilderProphecy->match()->willReturn($matchProphecy->reveal())->shouldBeCalled(); $aggregationBuilderProphecy->hydrate(Dummy::class)->willReturn($aggregationBuilderProphecy)->shouldBeCalled(); - $aggregationBuilderProphecy->execute()->willReturn($iterator->reveal())->shouldBeCalled(); + $aggregationBuilderProphecy->execute([])->willReturn($iterator->reveal())->shouldBeCalled(); $aggregationBuilder = $aggregationBuilderProphecy->reveal(); [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataFactories(Dummy::class, [ @@ -100,11 +154,13 @@ public function testGetItemDoubleIdentifier() ]); $managerRegistry = $this->getManagerRegistry(Dummy::class, $aggregationBuilder); + $this->resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata()); + $context = [IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; $extensionProphecy = $this->prophesize(AggregationItemExtensionInterface::class); $extensionProphecy->applyToItem($aggregationBuilder, Dummy::class, ['ida' => 1, 'idb' => 2], 'foo', $context)->shouldBeCalled(); - $dataProvider = new ItemDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $dataProvider = new ItemDataProvider($managerRegistry, $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); $this->assertEquals($result, $dataProvider->getItem(Dummy::class, ['ida' => 1, 'idb' => 2], 'foo', $context)); } @@ -129,7 +185,7 @@ public function testGetItemWrongCompositeIdentifier() ], ]); - $dataProvider = new ItemDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new ItemDataProvider($managerRegistry, $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); $dataProvider->getItem(Dummy::class, 'ida=1;', 'foo'); } @@ -154,7 +210,7 @@ public function testAggregationResultExtension() $extensionProphecy->supportsResult(Dummy::class, 'foo', $context)->willReturn(true)->shouldBeCalled(); $extensionProphecy->getResult($aggregationBuilder, Dummy::class, 'foo', $context)->willReturn([])->shouldBeCalled(); - $dataProvider = new ItemDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $dataProvider = new ItemDataProvider($managerRegistry, $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); $this->assertEquals([], $dataProvider->getItem(Dummy::class, ['id' => 1], 'foo', $context)); } @@ -170,7 +226,7 @@ public function testUnsupportedClass() 'id', ]); - $dataProvider = new ItemDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $dataProvider = new ItemDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); $this->assertFalse($dataProvider->supports(Dummy::class, 'foo')); } @@ -193,7 +249,7 @@ public function testCannotCreateAggregationBuilder() 'id', ]); - (new ItemDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]))->getItem(Dummy::class, 'foo', null, [IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]); + (new ItemDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]))->getItem(Dummy::class, 'foo', null, [IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]); } /** diff --git a/tests/Bridge/Doctrine/MongoDbOdm/SubresourceDataProviderTest.php b/tests/Bridge/Doctrine/MongoDbOdm/SubresourceDataProviderTest.php index 3bf7b1fa367..0130565ffcc 100644 --- a/tests/Bridge/Doctrine/MongoDbOdm/SubresourceDataProviderTest.php +++ b/tests/Bridge/Doctrine/MongoDbOdm/SubresourceDataProviderTest.php @@ -22,6 +22,8 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\Dummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\RelatedDummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\RelatedOwningDummy; @@ -48,6 +50,18 @@ class SubresourceDataProviderTest extends TestCase { use ProphecyTrait; + private $resourceMetadataFactoryProphecy; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + parent::setUp(); + + $this->resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + } + private function getMetadataProphecies(array $resourceClassesIdentifiers) { $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); @@ -97,7 +111,7 @@ public function testNotASubresource() $aggregationBuilder = $this->prophesize(Builder::class)->reveal(); $managerRegistry = $this->getManagerRegistryProphecy($aggregationBuilder, $identifiers, Dummy::class); - $dataProvider = new SubresourceDataProvider($managerRegistry, $propertyNameCollectionFactory, $propertyMetadataFactory, []); + $dataProvider = new SubresourceDataProvider($managerRegistry, $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, []); $dataProvider->getSubresource(Dummy::class, ['id' => 1], []); } @@ -129,7 +143,7 @@ public function testGetSubresource() $dummyIterator = $this->prophesize(Iterator::class); $dummyIterator->toArray()->shouldBeCalled()->willReturn([['_id' => 1, 'relatedDummies' => [['_id' => 2]]]]); - $dummyAggregationBuilder->execute()->shouldBeCalled()->willReturn($dummyIterator->reveal()); + $dummyAggregationBuilder->execute([])->shouldBeCalled()->willReturn($dummyIterator->reveal()); $managerProphecy->createAggregationBuilder(Dummy::class)->shouldBeCalled()->willReturn($dummyAggregationBuilder->reveal()); @@ -140,16 +154,18 @@ public function testGetSubresource() $iterator = $this->prophesize(Iterator::class); $iterator->toArray()->shouldBeCalled()->willReturn([]); - $aggregationBuilder->execute()->shouldBeCalled()->willReturn($iterator->reveal()); + $aggregationBuilder->execute([])->shouldBeCalled()->willReturn($iterator->reveal()); $aggregationBuilder->hydrate(RelatedDummy::class)->shouldBeCalled()->willReturn($aggregationBuilder); $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); $managerRegistryProphecy->getManagerForClass(RelatedDummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); $managerRegistryProphecy->getManagerForClass(Dummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); + $this->resourceMetadataFactoryProphecy->create(RelatedDummy::class)->willReturn(new ResourceMetadata()); + [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => ['id']]); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); $context = ['property' => 'relatedDummies', 'identifiers' => [['id', Dummy::class]], 'collection' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; @@ -174,7 +190,7 @@ public function testGetSubSubresourceItem() $dummyIterator = $this->prophesize(Iterator::class); $dummyIterator->toArray()->shouldBeCalled()->willReturn([['_id' => 1, 'relatedDummies' => [['_id' => 2]]]]); - $dummyAggregationBuilder->execute()->shouldBeCalled()->willReturn($dummyIterator->reveal()); + $dummyAggregationBuilder->execute([])->shouldBeCalled()->willReturn($dummyIterator->reveal()); $classMetadataProphecy = $this->prophesize(ClassMetadata::class); $classMetadataProphecy->hasAssociation('relatedDummies')->willReturn(true)->shouldBeCalled(); @@ -201,7 +217,7 @@ public function testGetSubSubresourceItem() $rIterator = $this->prophesize(Iterator::class); $rIterator->toArray()->shouldBeCalled()->willReturn([['_id' => 1, 'thirdLevel' => [['_id' => 3]]]]); - $rAggregationBuilder->execute()->shouldBeCalled()->willReturn($rIterator->reveal()); + $rAggregationBuilder->execute([])->shouldBeCalled()->willReturn($rIterator->reveal()); $rClassMetadataProphecy = $this->prophesize(ClassMetadata::class); $rClassMetadataProphecy->hasAssociation('thirdLevel')->shouldBeCalled()->willReturn(true); @@ -223,7 +239,7 @@ public function testGetSubSubresourceItem() $iterator = $this->prophesize(Iterator::class); $iterator->current()->shouldBeCalled()->willReturn($result); - $aggregationBuilder->execute()->shouldBeCalled()->willReturn($iterator->reveal()); + $aggregationBuilder->execute([])->shouldBeCalled()->willReturn($iterator->reveal()); $aggregationBuilder->hydrate(ThirdLevel::class)->shouldBeCalled()->willReturn($aggregationBuilder); $repositoryProphecy = $this->prophesize(DocumentRepository::class); @@ -234,15 +250,114 @@ public function testGetSubSubresourceItem() $managerRegistryProphecy->getManagerForClass(ThirdLevel::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); + $this->resourceMetadataFactoryProphecy->create(ThirdLevel::class)->willReturn(new ResourceMetadata()); + [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers, RelatedDummy::class => $identifiers]); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); $context = ['property' => 'thirdLevel', 'identifiers' => [['id', Dummy::class], ['relatedDummies', RelatedDummy::class]], 'collection' => false, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; $this->assertEquals($result, $dataProvider->getSubresource(ThirdLevel::class, ['id' => ['id' => 1], 'relatedDummies' => ['id' => 1]], $context)); } + public function testGetSubSubresourceItemWithExecuteOptions() + { + $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); + $identifiers = ['id']; + + // First manager (Dummy) + $dummyAggregationBuilder = $this->prophesize(Builder::class); + $dummyLookup = $this->prophesize(Lookup::class); + $dummyLookup->alias('relatedDummies')->shouldBeCalled(); + $dummyAggregationBuilder->lookup('relatedDummies')->shouldBeCalled()->willReturn($dummyLookup->reveal()); + + $dummyMatch = $this->prophesize(AggregationMatch::class); + $dummyMatch->equals(1)->shouldBeCalled(); + $dummyMatch->field('id')->shouldBeCalled()->willReturn($dummyMatch); + $dummyAggregationBuilder->match()->shouldBeCalled()->willReturn($dummyMatch->reveal()); + + $dummyIterator = $this->prophesize(Iterator::class); + $dummyIterator->toArray()->shouldBeCalled()->willReturn([['_id' => 1, 'relatedDummies' => [['_id' => 2]]]]); + $dummyAggregationBuilder->execute(['allowDiskUse' => true])->shouldBeCalled()->willReturn($dummyIterator->reveal()); + + $classMetadataProphecy = $this->prophesize(ClassMetadata::class); + $classMetadataProphecy->hasAssociation('relatedDummies')->willReturn(true)->shouldBeCalled(); + + $dummyManagerProphecy = $this->prophesize(DocumentManager::class); + $dummyManagerProphecy->createAggregationBuilder(Dummy::class)->shouldBeCalled()->willReturn($dummyAggregationBuilder->reveal()); + $dummyManagerProphecy->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadataProphecy->reveal()); + + $managerRegistryProphecy->getManagerForClass(Dummy::class)->shouldBeCalled()->willReturn($dummyManagerProphecy->reveal()); + + // Second manager (RelatedDummy) + $rAggregationBuilder = $this->prophesize(Builder::class); + $rLookup = $this->prophesize(Lookup::class); + $rLookup->alias('thirdLevel')->shouldBeCalled(); + $rAggregationBuilder->lookup('thirdLevel')->shouldBeCalled()->willReturn($rLookup->reveal()); + + $rMatch = $this->prophesize(AggregationMatch::class); + $rMatch->equals(1)->shouldBeCalled(); + $rMatch->field('id')->shouldBeCalled()->willReturn($rMatch); + $previousRMatch = $this->prophesize(AggregationMatch::class); + $previousRMatch->in([2])->shouldBeCalled(); + $previousRMatch->field('_id')->shouldBeCalled()->willReturn($previousRMatch); + $rAggregationBuilder->match()->shouldBeCalled()->willReturn($rMatch->reveal(), $previousRMatch->reveal()); + + $rIterator = $this->prophesize(Iterator::class); + $rIterator->toArray()->shouldBeCalled()->willReturn([['_id' => 1, 'thirdLevel' => [['_id' => 3]]]]); + $rAggregationBuilder->execute(['allowDiskUse' => true])->shouldBeCalled()->willReturn($rIterator->reveal()); + + $rClassMetadataProphecy = $this->prophesize(ClassMetadata::class); + $rClassMetadataProphecy->hasAssociation('thirdLevel')->shouldBeCalled()->willReturn(true); + + $rDummyManagerProphecy = $this->prophesize(DocumentManager::class); + $rDummyManagerProphecy->createAggregationBuilder(RelatedDummy::class)->shouldBeCalled()->willReturn($rAggregationBuilder->reveal()); + $rDummyManagerProphecy->getClassMetadata(RelatedDummy::class)->shouldBeCalled()->willReturn($rClassMetadataProphecy->reveal()); + + $managerRegistryProphecy->getManagerForClass(RelatedDummy::class)->shouldBeCalled()->willReturn($rDummyManagerProphecy->reveal()); + + $result = new \stdClass(); + // Origin manager (ThirdLevel) + $aggregationBuilder = $this->prophesize(Builder::class); + + $match = $this->prophesize(AggregationMatch::class); + $match->in([3])->shouldBeCalled(); + $match->field('_id')->shouldBeCalled()->willReturn($match); + $aggregationBuilder->match()->shouldBeCalled()->willReturn($match); + + $iterator = $this->prophesize(Iterator::class); + $iterator->current()->shouldBeCalled()->willReturn($result); + $aggregationBuilder->execute(['allowDiskUse' => true])->shouldBeCalled()->willReturn($iterator->reveal()); + $aggregationBuilder->hydrate(ThirdLevel::class)->shouldBeCalled()->willReturn($aggregationBuilder); + + $repositoryProphecy = $this->prophesize(DocumentRepository::class); + $repositoryProphecy->createAggregationBuilder()->shouldBeCalled()->willReturn($aggregationBuilder->reveal()); + + $managerProphecy = $this->prophesize(DocumentManager::class); + $managerProphecy->getRepository(ThirdLevel::class)->shouldBeCalled()->willReturn($repositoryProphecy->reveal()); + + $managerRegistryProphecy->getManagerForClass(ThirdLevel::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); + + $this->resourceMetadataFactoryProphecy->create(ThirdLevel::class)->willReturn(new ResourceMetadata( + 'ThirdLevel', + null, + null, + null, + null, + null, + ['third_level_operation_name' => ['doctrine_mongodb' => ['execute_options' => ['allowDiskUse' => true]]]] + )); + + [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers, RelatedDummy::class => $identifiers]); + + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + + $context = ['property' => 'thirdLevel', 'identifiers' => [['id', Dummy::class], ['relatedDummies', RelatedDummy::class]], 'collection' => false, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; + + $this->assertEquals($result, $dataProvider->getSubresource(ThirdLevel::class, ['id' => ['id' => 1], 'relatedDummies' => ['id' => 1]], $context, 'third_level_operation_name')); + } + public function testGetSubresourceOneToOneOwningRelation() { // RelatedOwningDummy OneToOne Dummy @@ -277,16 +392,18 @@ public function testGetSubresourceOneToOneOwningRelation() $iterator->toArray()->shouldBeCalled()->willReturn([['_id' => 1, 'ownedDummy' => [['_id' => 3]]]]); $result = new \stdClass(); $iterator->current()->shouldBeCalled()->willReturn($result); - $aggregationBuilder->execute()->shouldBeCalled()->willReturn($iterator->reveal()); + $aggregationBuilder->execute([])->shouldBeCalled()->willReturn($iterator->reveal()); $aggregationBuilder->hydrate(RelatedOwningDummy::class)->shouldBeCalled()->willReturn($aggregationBuilder); $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); $managerRegistryProphecy->getManagerForClass(RelatedOwningDummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); $managerRegistryProphecy->getManagerForClass(Dummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); + $this->resourceMetadataFactoryProphecy->create(RelatedOwningDummy::class)->willReturn(new ResourceMetadata()); + [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers]); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); $context = ['property' => 'ownedDummy', 'identifiers' => [['id', Dummy::class]], 'collection' => false, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; @@ -324,12 +441,14 @@ public function testAggregationResultExtension() $iterator = $this->prophesize(Iterator::class); $iterator->toArray()->shouldBeCalled()->willReturn([['_id' => 1, 'relatedDummies' => [['_id' => 3]]]]); - $aggregationBuilder->execute()->shouldBeCalled()->willReturn($iterator->reveal()); + $aggregationBuilder->execute([])->shouldBeCalled()->willReturn($iterator->reveal()); $managerRegistryProphecy = $this->prophesize(ManagerRegistry::class); $managerRegistryProphecy->getManagerForClass(RelatedDummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); $managerRegistryProphecy->getManagerForClass(Dummy::class)->shouldBeCalled()->willReturn($managerProphecy->reveal()); + $this->resourceMetadataFactoryProphecy->create(RelatedDummy::class)->willReturn(new ResourceMetadata()); + [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers]); $extensionProphecy = $this->prophesize(AggregationResultCollectionExtensionInterface::class); @@ -337,7 +456,7 @@ public function testAggregationResultExtension() $extensionProphecy->supportsResult(RelatedDummy::class, null, Argument::type('array'))->willReturn(true)->shouldBeCalled(); $extensionProphecy->getResult($aggregationBuilder, RelatedDummy::class, null, Argument::type('array'))->willReturn([])->shouldBeCalled(); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory, [$extensionProphecy->reveal()]); $context = ['property' => 'relatedDummies', 'identifiers' => [['id', Dummy::class]], 'collection' => true, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; @@ -360,7 +479,7 @@ public function testCannotCreateQueryBuilder() [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers]); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); $dataProvider->getSubresource(Dummy::class, ['id' => 1], []); } @@ -374,7 +493,7 @@ public function testThrowResourceClassNotSupportedException() [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers]); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); $dataProvider->getSubresource(Dummy::class, ['id' => 1], []); } @@ -404,7 +523,7 @@ public function testGetSubresourceCollectionItem() $rIterator = $this->prophesize(Iterator::class); $rIterator->current()->shouldBeCalled()->willReturn($result); - $rAggregationBuilder->execute()->shouldBeCalled()->willReturn($rIterator->reveal()); + $rAggregationBuilder->execute([])->shouldBeCalled()->willReturn($rIterator->reveal()); $rAggregationBuilder->hydrate(RelatedDummy::class)->shouldBeCalled()->willReturn($rAggregationBuilder); $aggregationBuilder = $this->prophesize(Builder::class); @@ -414,9 +533,11 @@ public function testGetSubresourceCollectionItem() $rDummyManagerProphecy->getRepository(RelatedDummy::class)->shouldBeCalled()->willReturn($repositoryProphecy->reveal()); + $this->resourceMetadataFactoryProphecy->create(RelatedDummy::class)->willReturn(new ResourceMetadata()); + [$propertyNameCollectionFactory, $propertyMetadataFactory] = $this->getMetadataProphecies([Dummy::class => $identifiers, RelatedDummy::class => $identifiers]); - $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); + $dataProvider = new SubresourceDataProvider($managerRegistryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $propertyNameCollectionFactory, $propertyMetadataFactory); $context = ['property' => 'id', 'identifiers' => [['id', Dummy::class, true], ['relatedDummies', RelatedDummy::class, true]], 'collection' => false, IdentifierConverterInterface::HAS_IDENTIFIER_CONVERTER => true]; diff --git a/tests/Bridge/Doctrine/Orm/Filter/SearchFilterTest.php b/tests/Bridge/Doctrine/Orm/Filter/SearchFilterTest.php index da08bedb8d0..1dbb23905dd 100644 --- a/tests/Bridge/Doctrine/Orm/Filter/SearchFilterTest.php +++ b/tests/Bridge/Doctrine/Orm/Filter/SearchFilterTest.php @@ -390,7 +390,7 @@ 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)', $this->alias, Dummy::class), [ 'name_p1' => [ 'CaSE', @@ -400,7 +400,7 @@ public function provideApplyTestData(): array $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)', $this->alias, Dummy::class), [ 'name_p1' => [ 'case', @@ -420,43 +420,107 @@ public function provideApplyTestData(): array $filterFactory, ], 'partial' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1, \'%%\')', $this->alias, Dummy::class), - ['name_p1' => 'partial'], + sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0, \'%%\')', $this->alias, Dummy::class), + ['name_p1_0' => 'partial'], $filterFactory, ], 'partial (case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1, \'%%\'))', $this->alias, Dummy::class), - ['name_p1' => 'partial'], + sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0, \'%%\'))', $this->alias, Dummy::class), + ['name_p1_0' => 'partial'], + $filterFactory, + ], + 'partial (multiple values)' => [ + sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(\'%%\', :name_p1_1, \'%%\')', $this->alias, Dummy::class), + [ + 'name_p1_0' => 'CaSE', + 'name_p1_1' => 'SENSitive', + ], + $filterFactory, + ], + 'partial (multiple values; case insensitive)' => [ + sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_1, \'%%\'))', $this->alias, Dummy::class), + [ + 'name_p1_0' => 'case', + 'name_p1_1' => 'insensitive', + ], $filterFactory, ], 'start' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1, \'%%\')', $this->alias, Dummy::class), - ['name_p1' => 'partial'], + sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1_0, \'%%\')', $this->alias, Dummy::class), + ['name_p1_0' => 'partial'], $filterFactory, ], 'start (case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1, \'%%\'))', $this->alias, Dummy::class), - ['name_p1' => 'partial'], + sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\'))', $this->alias, Dummy::class), + ['name_p1_0' => 'partial'], + $filterFactory, + ], + 'start (multiple values)' => [ + sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(:name_p1_1, \'%%\')', $this->alias, Dummy::class), + [ + 'name_p1_0' => 'CaSE', + 'name_p1_1' => 'SENSitive', + ], + $filterFactory, + ], + 'start (multiple values; case insensitive)' => [ + sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_1, \'%%\'))', $this->alias, Dummy::class), + [ + 'name_p1_0' => 'case', + 'name_p1_1' => 'insensitive', + ], $filterFactory, ], 'end' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1)', $this->alias, Dummy::class), - ['name_p1' => 'partial'], + sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0)', $this->alias, Dummy::class), + ['name_p1_0' => 'partial'], $filterFactory, ], 'end (case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1))', $this->alias, Dummy::class), - ['name_p1' => 'partial'], + sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0))', $this->alias, Dummy::class), + ['name_p1_0' => 'partial'], + $filterFactory, + ], + 'end (multiple values)' => [ + sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(\'%%\', :name_p1_0) OR %1$s.name LIKE CONCAT(\'%%\', :name_p1_1)', $this->alias, Dummy::class), + [ + 'name_p1_0' => 'CaSE', + 'name_p1_1' => 'SENSitive', + ], + $filterFactory, + ], + 'end (multiple values; case insensitive)' => [ + sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_0)) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%%\', :name_p1_1))', $this->alias, Dummy::class), + [ + 'name_p1_0' => 'case', + 'name_p1_1' => 'insensitive', + ], $filterFactory, ], 'word_start' => [ - sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1, \'%%\') OR %1$s.name LIKE CONCAT(\'%% \', :name_p1, \'%%\')', $this->alias, Dummy::class), - ['name_p1' => 'partial'], + sprintf('SELECT %s FROM %s %1$s WHERE %1$s.name LIKE CONCAT(:name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(\'%% \', :name_p1_0, \'%%\')', $this->alias, Dummy::class), + ['name_p1_0' => 'partial'], $filterFactory, ], 'word_start (case insensitive)' => [ - sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%% \', :name_p1, \'%%\'))', $this->alias, Dummy::class), - ['name_p1' => 'partial'], + sprintf('SELECT %s FROM %s %1$s WHERE LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%% \', :name_p1_0, \'%%\'))', $this->alias, Dummy::class), + ['name_p1_0' => 'partial'], + $filterFactory, + ], + 'word_start (multiple values)' => [ + sprintf('SELECT %s FROM %s %1$s WHERE (%1$s.name LIKE CONCAT(:name_p1_0, \'%%\') OR %1$s.name LIKE CONCAT(\'%% \', :name_p1_0, \'%%\')) OR (%1$s.name LIKE CONCAT(:name_p1_1, \'%%\') OR %1$s.name LIKE CONCAT(\'%% \', :name_p1_1, \'%%\'))', $this->alias, Dummy::class), + [ + 'name_p1_0' => 'CaSE', + 'name_p1_1' => 'SENSitive', + ], + $filterFactory, + ], + 'word_start (multiple values; case insensitive)' => [ + sprintf('SELECT %s FROM %s %1$s WHERE (LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_0, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%% \', :name_p1_0, \'%%\'))) OR (LOWER(%1$s.name) LIKE LOWER(CONCAT(:name_p1_1, \'%%\')) OR LOWER(%1$s.name) LIKE LOWER(CONCAT(\'%% \', :name_p1_1, \'%%\')))', $this->alias, Dummy::class), + [ + 'name_p1_0' => 'case', + 'name_p1_1' => 'insensitive', + ], $filterFactory, ], 'invalid value for relation' => [ @@ -483,7 +547,7 @@ 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) AND relatedDummies_a1.id = :relatedDummies_p2', $this->alias, Dummy::class), [ 'relatedDummy_p1' => [1, 2], 'relatedDummies_p2' => 1, diff --git a/tests/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactoryTest.php b/tests/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactoryTest.php index c8a15857ff8..ca9fbe6a3eb 100644 --- a/tests/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactoryTest.php +++ b/tests/Bridge/Doctrine/Orm/Metadata/Property/DoctrineOrmPropertyMetadataFactoryTest.php @@ -17,6 +17,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyPropertyWithDefaultValue; use ApiPlatform\Core\Tests\ProphecyTrait; use Doctrine\ORM\Mapping\ClassMetadataInfo; use Doctrine\Persistence\ManagerRegistry; @@ -76,6 +77,7 @@ public function testCreateIsWritable() $classMetadata = $this->prophesize(ClassMetadataInfo::class); $classMetadata->getIdentifier()->shouldBeCalled()->willReturn(['id']); + $classMetadata->getFieldNames()->shouldBeCalled()->willReturn([]); $objectManager = $this->prophesize(ObjectManager::class); $objectManager->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadata->reveal()); @@ -91,6 +93,31 @@ public function testCreateIsWritable() $this->assertEquals($doctrinePropertyMetadata->isWritable(), false); } + public function testCreateWithDefaultOption() + { + $propertyMetadata = new PropertyMetadata(); + + $propertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactory->create(DummyPropertyWithDefaultValue::class, 'dummyDefaultOption', [])->shouldBeCalled()->willReturn($propertyMetadata); + + $classMetadata = new ClassMetadataInfo(DummyPropertyWithDefaultValue::class); + $classMetadata->fieldMappings = [ + 'dummyDefaultOption' => ['options' => ['default' => 'default value']], + ]; + + $objectManager = $this->prophesize(ObjectManager::class); + $objectManager->getClassMetadata(DummyPropertyWithDefaultValue::class)->shouldBeCalled()->willReturn($classMetadata); + + $managerRegistry = $this->prophesize(ManagerRegistry::class); + $managerRegistry->getManagerForClass(DummyPropertyWithDefaultValue::class)->shouldBeCalled()->willReturn($objectManager->reveal()); + + $doctrineOrmPropertyMetadataFactory = new DoctrineOrmPropertyMetadataFactory($managerRegistry->reveal(), $propertyMetadataFactory->reveal()); + + $doctrinePropertyMetadata = $doctrineOrmPropertyMetadataFactory->create(DummyPropertyWithDefaultValue::class, 'dummyDefaultOption'); + + $this->assertEquals($doctrinePropertyMetadata->getDefault(), 'default value'); + } + public function testCreateClassMetadataInfo() { $propertyMetadata = new PropertyMetadata(); @@ -101,6 +128,7 @@ public function testCreateClassMetadataInfo() $classMetadata = $this->prophesize(ClassMetadataInfo::class); $classMetadata->getIdentifier()->shouldBeCalled()->willReturn(['id']); $classMetadata->isIdentifierNatural()->shouldBeCalled()->willReturn(true); + $classMetadata->getFieldNames()->shouldBeCalled()->willReturn([]); $objectManager = $this->prophesize(ObjectManager::class); $objectManager->getClassMetadata(Dummy::class)->shouldBeCalled()->willReturn($classMetadata->reveal()); diff --git a/tests/Bridge/Elasticsearch/DataProvider/PaginatorTest.php b/tests/Bridge/Elasticsearch/DataProvider/PaginatorTest.php index 9db9e1b60a2..a9516d21a66 100644 --- a/tests/Bridge/Elasticsearch/DataProvider/PaginatorTest.php +++ b/tests/Bridge/Elasticsearch/DataProvider/PaginatorTest.php @@ -154,7 +154,7 @@ public function testGetItemsPerPage() public function testGetIterator() { // set local cache - iterator_to_array($this->paginator); // @phpstan-ignore-line + iterator_to_array($this->paginator); self::assertEquals( array_map( diff --git a/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php b/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php index f75d280bf49..ae1c6d3391d 100644 --- a/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php +++ b/tests/Bridge/Symfony/Bundle/Action/SwaggerUiActionTest.php @@ -22,6 +22,7 @@ use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\Routing\Generator\UrlGenerator; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -32,6 +33,7 @@ */ class SwaggerUiActionTest extends TestCase { + use ExpectDeprecationTrait; use ProphecyTrait; public const SPEC = [ @@ -43,9 +45,11 @@ class SwaggerUiActionTest extends TestCase /** * @dataProvider getInvokeParameters + * @group legacy */ public function testInvoke(Request $request, $twigProphecy) { + $this->expectDeprecation('The use of "ApiPlatform\Core\Bridge\Symfony\Bundle\Action\SwaggerUiAction" is deprecated since API Platform 2.6, use "ApiPlatform\Core\Bridge\Symfony\Bundle\SwaggerUi\SwaggerUiAction" instead.'); $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); $resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection(['Foo', 'Bar']))->shouldBeCalled(); @@ -81,6 +85,7 @@ public function getInvokeParameters() 'graphqlEnabled' => false, 'graphiQlEnabled' => false, 'graphQlPlaygroundEnabled' => false, + 'assetPackage' => null, 'swagger_data' => [ 'url' => '/url', 'spec' => self::SPEC, @@ -101,7 +106,7 @@ public function getInvokeParameters() 'path' => '/fs', 'method' => 'get', ], - ])->shouldBeCalled(); + ])->shouldBeCalled()->willReturn(''); $twigItemProphecy = $this->prophesize(TwigEnvironment::class); $twigItemProphecy->render('@ApiPlatform/SwaggerUi/index.html.twig', [ @@ -114,6 +119,7 @@ public function getInvokeParameters() 'graphqlEnabled' => false, 'graphiQlEnabled' => false, 'graphQlPlaygroundEnabled' => false, + 'assetPackage' => null, 'swagger_data' => [ 'url' => '/url', 'spec' => self::SPEC, @@ -134,7 +140,7 @@ public function getInvokeParameters() 'path' => '/fs/{id}', 'method' => 'get', ], - ])->shouldBeCalled(); + ])->shouldBeCalled()->willReturn(''); return [ [new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'get']), $twigCollectionProphecy], @@ -145,9 +151,11 @@ public function getInvokeParameters() /** * @dataProvider getDoNotRunCurrentRequestParameters + * @group legacy */ public function testDoNotRunCurrentRequest(Request $request) { + $this->expectDeprecation('The use of "ApiPlatform\Core\Bridge\Symfony\Bundle\Action\SwaggerUiAction" is deprecated since API Platform 2.6, use "ApiPlatform\Core\Bridge\Symfony\Bundle\SwaggerUi\SwaggerUiAction" instead.'); $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); $resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection(['Foo', 'Bar']))->shouldBeCalled(); @@ -168,6 +176,7 @@ public function testDoNotRunCurrentRequest(Request $request) 'graphqlEnabled' => false, 'graphiQlEnabled' => false, 'graphQlPlaygroundEnabled' => false, + 'assetPackage' => null, 'swagger_data' => [ 'url' => '/url', 'spec' => self::SPEC, @@ -182,7 +191,7 @@ public function testDoNotRunCurrentRequest(Request $request) 'scopes' => [], ], ], - ])->shouldBeCalled(); + ])->shouldBeCalled()->willReturn(''); $urlGeneratorProphecy = $this->prophesize(UrlGenerator::class); $urlGeneratorProphecy->generate('api_doc', ['format' => 'json'])->willReturn('/url')->shouldBeCalled(); diff --git a/tests/Bridge/Symfony/Bundle/ApiPlatformBundleTest.php b/tests/Bridge/Symfony/Bundle/ApiPlatformBundleTest.php index 6015d0d0b56..ce92910072d 100644 --- a/tests/Bridge/Symfony/Bundle/ApiPlatformBundleTest.php +++ b/tests/Bridge/Symfony/Bundle/ApiPlatformBundleTest.php @@ -16,6 +16,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\DataProviderPass; +use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\DeprecateMercurePublisherPass; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\ElasticsearchClientPass; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\FilterPass; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\GraphQlMutationResolverPass; @@ -45,6 +46,7 @@ public function testBuild() $containerProphecy->addCompilerPass(Argument::type(GraphQlTypePass::class))->shouldBeCalled(); $containerProphecy->addCompilerPass(Argument::type(GraphQlQueryResolverPass::class))->shouldBeCalled(); $containerProphecy->addCompilerPass(Argument::type(GraphQlMutationResolverPass::class))->shouldBeCalled(); + $containerProphecy->addCompilerPass(Argument::type(DeprecateMercurePublisherPass::class))->shouldBeCalled(); $containerProphecy->addCompilerPass(Argument::type(MetadataAwareNameConverterPass::class))->shouldBeCalled(); $bundle = new ApiPlatformBundle(); diff --git a/tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php b/tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php new file mode 100644 index 00000000000..a6767e8e786 --- /dev/null +++ b/tests/Bridge/Symfony/Bundle/Command/OpenApiCommandTest.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Symfony\Bundle\Command; + +use ApiPlatform\Core\OpenApi\OpenApi; +use Symfony\Bundle\FrameworkBundle\Console\Application; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\Console\Tester\ApplicationTester; +use Symfony\Component\Yaml\Exception\ParseException; +use Symfony\Component\Yaml\Yaml; + +/** + * @author Amrouche Hamza + */ +class OpenApiCommandTest extends KernelTestCase +{ + /** + * @var ApplicationTester + */ + private $tester; + + protected function setUp(): void + { + self::bootKernel(); + + $application = new Application(static::$kernel); + $application->setCatchExceptions(false); + $application->setAutoExit(false); + + $this->tester = new ApplicationTester($application); + } + + public function testExecute() + { + $this->tester->run(['command' => 'api:openapi:export']); + + $this->assertJson($this->tester->getDisplay()); + } + + public function testExecuteWithYaml() + { + $this->tester->run(['command' => 'api:openapi:export', '--yaml' => true]); + + $result = $this->tester->getDisplay(); + $this->assertYaml($result); + + $expected = <<assertStringContainsString(str_replace(PHP_EOL, "\n", $expected), $result, 'nested object should be present.'); + + $expected = <<assertStringContainsString(str_replace(PHP_EOL, "\n", $expected), $result, 'arrays should be correctly formatted.'); + $this->assertStringContainsString('openapi: '.OpenApi::VERSION, $result); + + $expected = <<assertStringContainsString(str_replace(PHP_EOL, "\n", $expected), $result, 'multiline formatting must be preserved (using literal style).'); + } + + public function testWriteToFile() + { + /** @var string $tmpFile */ + $tmpFile = tempnam(sys_get_temp_dir(), 'test_write_to_file'); + + $this->tester->run(['command' => 'api:openapi:export', '--output' => $tmpFile]); + + $this->assertJson((string) @file_get_contents($tmpFile)); + @unlink($tmpFile); + } + + /** + * @param string $data + */ + private function assertYaml($data) + { + try { + Yaml::parse($data); + } catch (ParseException $exception) { + $this->fail('Is not valid YAML: '.$exception->getMessage()); + } + $this->addToAssertionCount(1); + } +} diff --git a/tests/Bridge/Symfony/Bundle/Command/SwaggerCommandTest.php b/tests/Bridge/Symfony/Bundle/Command/SwaggerCommandTest.php index f5b0dc7ff79..1d13b14306a 100644 --- a/tests/Bridge/Symfony/Bundle/Command/SwaggerCommandTest.php +++ b/tests/Bridge/Symfony/Bundle/Command/SwaggerCommandTest.php @@ -41,6 +41,10 @@ protected function setUp(): void $this->tester = new ApplicationTester($application); } + /** + * @group legacy + * @expectedDeprecation The command "api:swagger:export" is deprecated for the spec version 3 use "api:openapi:export". + */ public function testExecuteWithAliasVersion3() { $this->tester->run(['command' => 'api:swagger:export', '--spec-version' => 3]); @@ -48,13 +52,10 @@ public function testExecuteWithAliasVersion3() $this->assertJson($this->tester->getDisplay()); } - public function testExecuteOpenApiVersion2() - { - $this->tester->run(['command' => 'api:openapi:export']); - - $this->assertJson($this->tester->getDisplay()); - } - + /** + * @group legacy + * @expectedDeprecation The command "api:swagger:export" is deprecated for the spec version 3 use "api:openapi:export". + */ public function testExecuteWithYamlVersion3() { $this->tester->run(['command' => 'api:swagger:export', '--yaml' => true, '--spec-version' => 3]); @@ -94,20 +95,11 @@ public function testExecuteWithYamlVersion3() $this->assertStringContainsString(str_replace(PHP_EOL, "\n", $expected), $result, 'multiline formatting must be preserved (using literal style).'); } - public function testExecuteOpenApiVersion2WithYaml() - { - $this->tester->run(['command' => 'api:openapi:export', '--yaml' => true]); - - $result = $this->tester->getDisplay(); - $this->assertYaml($result); - $this->assertStringContainsString("swagger: '2.0'", $result); - } - public function testExecuteWithBadArguments() { $this->expectException(InvalidOptionException::class); $this->expectExceptionMessage('This tool only supports versions 2, 3 of the OpenAPI specification ("foo" given).'); - $this->tester->run(['command' => 'api:openapi:export', '--spec-version' => 'foo', '--yaml' => true]); + $this->tester->run(['command' => 'api:swagger:export', '--spec-version' => 'foo', '--yaml' => true]); } public function testWriteToFile() diff --git a/tests/Bridge/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php b/tests/Bridge/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php index 20ea4ff61c2..4875acf1b0c 100644 --- a/tests/Bridge/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php +++ b/tests/Bridge/Symfony/Bundle/DataCollector/RequestDataCollectorTest.php @@ -30,6 +30,7 @@ use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\DummyEntity; use ApiPlatform\Core\Tests\ProphecyTrait; +use PackageVersions\Versions; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\ParameterBag; @@ -213,6 +214,27 @@ public function testWithResourceWithTraceables() } } + public function testVersionCollection() + { + $this->apiResourceClassWillReturn(DummyEntity::class); + + $dataCollector = new RequestDataCollector( + $this->metadataFactory->reveal(), + $this->filterLocator->reveal(), + $this->getUsedCollectionDataProvider(), + $this->getUsedItemDataProvider(), + $this->getUsedSubresourceDataProvider(), + $this->getUsedPersister() + ); + + $dataCollector->collect( + $this->request->reveal(), + $this->response + ); + + $this->assertSame(null !== $dataCollector->getVersion(), class_exists(Versions::class)); + } + private function apiResourceClassWillReturn($data, $context = []) { $this->attributes->get('_api_resource_class')->shouldBeCalled()->willReturn($data); diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php index 1376d38586d..4bcfeebb310 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ApiPlatformExtensionTest.php @@ -55,14 +55,19 @@ use ApiPlatform\Core\Bridge\Elasticsearch\DataProvider\Filter\TermFilter; use ApiPlatform\Core\Bridge\Elasticsearch\Metadata\Document\Factory\DocumentMetadataFactoryInterface; use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\ApiPlatformExtension; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRestrictionMetadataInterface; +use ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface; use ApiPlatform\Core\DataPersister\DataPersisterInterface; use ApiPlatform\Core\DataProvider\CollectionDataProviderInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; use ApiPlatform\Core\DataProvider\Pagination; +use ApiPlatform\Core\DataProvider\PaginationOptions; use ApiPlatform\Core\DataProvider\SubresourceDataProviderInterface; +use ApiPlatform\Core\DataTransformer\DataTransformerInitializerInterface; use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Exception\FilterValidationException; use ApiPlatform\Core\Exception\InvalidArgumentException; +use ApiPlatform\Core\GraphQl\Error\ErrorHandlerInterface; use ApiPlatform\Core\GraphQl\Resolver\MutationResolverInterface; use ApiPlatform\Core\GraphQl\Resolver\QueryCollectionResolverInterface; use ApiPlatform\Core\GraphQl\Resolver\QueryItemResolverInterface; @@ -74,6 +79,9 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface; +use ApiPlatform\Core\OpenApi\Options; +use ApiPlatform\Core\OpenApi\Serializer\OpenApiNormalizer; use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; use ApiPlatform\Core\Serializer\Filter\GroupFilter; use ApiPlatform\Core\Serializer\Filter\PropertyFilter; @@ -103,6 +111,7 @@ use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Serializer\Exception\ExceptionInterface; +use Symfony\Component\Uid\AbstractUid; /** * @group resource-hog @@ -144,6 +153,10 @@ class ApiPlatformExtensionTest extends TestCase 'doctrine_mongodb_odm' => [ 'enabled' => false, ], + 'defaults' => [ + 'attributes' => [], + 'stateless' => true, + ], ]]; private $extension; @@ -302,8 +315,10 @@ public function testDisableGraphQl() $containerBuilderProphecy->setDefinition('api_platform.graphql.action.entrypoint', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.action.graphiql', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.action.graphql_playground', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.error_handler', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.resolver.factory.collection', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.resolver.factory.item_mutation', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.resolver.factory.item_subscription', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.resolver.factory.item', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.resolver.stage.read', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.resolver.stage.security', Argument::type(Definition::class))->shouldNotBeCalled(); @@ -329,7 +344,15 @@ public function testDisableGraphQl() $containerBuilderProphecy->setDefinition('api_platform.graphql.type_converter', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.query_resolver_locator', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.mutation_resolver_locator', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.normalizer.error', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.normalizer.validation_exception', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.normalizer.http_exception', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.normalizer.runtime_exception', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.subscription.subscription_manager', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.subscription.subscription_identifier_generator', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.cache.subscription', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.graphql.command.export_command', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.graphql.subscription.mercure_iri_generator', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setParameter('api_platform.graphql.enabled', true)->shouldNotBeCalled(); $containerBuilderProphecy->setParameter('api_platform.graphql.enabled', false)->shouldBeCalled(); $containerBuilderProphecy->setParameter('api_platform.graphql.default_ide', 'graphiql')->shouldNotBeCalled(); @@ -343,6 +366,8 @@ public function testDisableGraphQl() $containerBuilderProphecy->setParameter('api_platform.graphql.graphql_playground.enabled', false)->shouldBeCalled(); $containerBuilderProphecy->registerForAutoconfiguration(GraphQlTypeInterface::class)->shouldNotBeCalled(); $this->childDefinitionProphecy->addTag('api_platform.graphql.type')->shouldNotBeCalled(); + $containerBuilderProphecy->registerForAutoconfiguration(ErrorHandlerInterface::class)->shouldNotBeCalled(); + $this->childDefinitionProphecy->addTag('api_platform.graphql.error_handler')->shouldNotBeCalled(); $containerBuilderProphecy->registerForAutoconfiguration(QueryItemResolverInterface::class)->shouldNotBeCalled(); $containerBuilderProphecy->registerForAutoconfiguration(QueryCollectionResolverInterface::class)->shouldNotBeCalled(); $this->childDefinitionProphecy->addTag('api_platform.graphql.query_resolver')->shouldNotBeCalled(); @@ -554,7 +579,7 @@ private function runDisableDoctrineTests() $containerBuilderProphecy->setDefinition('api_platform.doctrine.orm.range_filter', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.doctrine.orm.search_filter', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.doctrine.orm.subresource_data_provider', Argument::type(Definition::class))->shouldNotBeCalled(); - $containerBuilderProphecy->setDefinition('api_platform.doctrine.listener.mercure.publish', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.doctrine.orm.listener.mercure.publish', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setAlias(EagerLoadingExtension::class, 'api_platform.doctrine.orm.query_extension.eager_loading')->shouldNotBeCalled(); $containerBuilderProphecy->setAlias(FilterExtension::class, 'api_platform.doctrine.orm.query_extension.filter')->shouldNotBeCalled(); $containerBuilderProphecy->setAlias(FilterEagerLoadingExtension::class, 'api_platform.doctrine.orm.query_extension.filter_eager_loading')->shouldNotBeCalled(); @@ -567,6 +592,7 @@ private function runDisableDoctrineTests() $containerBuilderProphecy->setAlias(BooleanFilter::class, 'api_platform.doctrine.orm.boolean_filter')->shouldNotBeCalled(); $containerBuilderProphecy->setAlias(NumericFilter::class, 'api_platform.doctrine.orm.numeric_filter')->shouldNotBeCalled(); $containerBuilderProphecy->setAlias(ExistsFilter::class, 'api_platform.doctrine.orm.exists_filter')->shouldNotBeCalled(); + $containerBuilderProphecy->setAlias('api_platform.doctrine.listener.mercure.publish', 'api_platform.doctrine.orm.listener.mercure.publish')->shouldNotBeCalled(); $containerBuilder = $containerBuilderProphecy->reveal(); $config = self::DEFAULT_CONFIG; @@ -606,6 +632,7 @@ public function testDisableDoctrineMongoDbOdm() $containerBuilderProphecy->setDefinition('api_platform.doctrine_mongodb.odm.range_filter', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.doctrine_mongodb.odm.search_filter', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setDefinition('api_platform.doctrine_mongodb.odm.subresource_data_provider', Argument::type(Definition::class))->shouldNotBeCalled(); + $containerBuilderProphecy->setDefinition('api_platform.doctrine_mongodb.odm.listener.mercure.publish', Argument::type(Definition::class))->shouldNotBeCalled(); $containerBuilderProphecy->setAlias(MongoDbOdmFilterExtension::class, 'api_platform.doctrine_mongodb.odm.aggregation_extension.filter')->shouldNotBeCalled(); $containerBuilderProphecy->setAlias(MongoDbOdmOrderExtension::class, 'api_platform.doctrine_mongodb.odm.aggregation_extension.order')->shouldNotBeCalled(); $containerBuilderProphecy->setAlias(MongoDbOdmPaginationExtension::class, 'api_platform.doctrine_mongodb.odm.aggregation_extension.pagination')->shouldNotBeCalled(); @@ -657,6 +684,7 @@ public function testEnableElasticsearch() $containerBuilderProphecy->registerForAutoconfiguration(RequestBodySearchCollectionExtensionInterface::class)->willReturn($this->childDefinitionProphecy)->shouldBeCalled(); $containerBuilderProphecy->setParameter('api_platform.elasticsearch.hosts', ['http://elasticsearch:9200'])->shouldBeCalled(); $containerBuilderProphecy->setParameter('api_platform.elasticsearch.mapping', [])->shouldBeCalled(); + $containerBuilderProphecy->setParameter('api_platform.defaults', ['attributes' => ['stateless' => true]])->shouldBeCalled(); $config = self::DEFAULT_CONFIG; $config['api_platform']['elasticsearch'] = [ @@ -806,8 +834,12 @@ private function getPartialContainerBuilderProphecy($configuration = null) 'api_platform.http_cache.shared_max_age' => null, 'api_platform.http_cache.vary' => ['Accept'], 'api_platform.http_cache.public' => null, + 'api_platform.http_cache.invalidation.max_header_length' => 7500, + 'api_platform.asset_package' => null, + 'api_platform.defaults' => ['attributes' => ['stateless' => true]], 'api_platform.enable_entrypoint' => true, 'api_platform.enable_docs' => true, + 'api_platform.url_generation_strategy' => 1, ]; $pagination = [ @@ -878,12 +910,10 @@ private function getPartialContainerBuilderProphecy($configuration = null) 'api_platform.listener.view.write', 'api_platform.metadata.extractor.xml', 'api_platform.metadata.property.metadata_factory.cached', - 'api_platform.metadata.property.metadata_factory.inherited', 'api_platform.metadata.property.metadata_factory.property_info', 'api_platform.metadata.property.metadata_factory.serializer', 'api_platform.metadata.property.metadata_factory.xml', 'api_platform.metadata.property.name_collection_factory.cached', - 'api_platform.metadata.property.name_collection_factory.inherited', 'api_platform.metadata.property.name_collection_factory.property_info', 'api_platform.metadata.property.name_collection_factory.xml', 'api_platform.metadata.resource.metadata_factory.cached', @@ -894,6 +924,7 @@ private function getPartialContainerBuilderProphecy($configuration = null) 'api_platform.metadata.resource.metadata_factory.xml', 'api_platform.metadata.resource.name_collection_factory.cached', 'api_platform.metadata.resource.name_collection_factory.xml', + 'api_platform.metadata.property.metadata_factory.default_property', 'api_platform.negotiator', 'api_platform.operation_method_resolver', 'api_platform.operation_path_resolver.custom', @@ -902,6 +933,7 @@ private function getPartialContainerBuilderProphecy($configuration = null) 'api_platform.operation_path_resolver.generator', 'api_platform.operation_path_resolver.underscore', 'api_platform.pagination', + 'api_platform.pagination_options', 'api_platform.path_segment_name_generator.underscore', 'api_platform.path_segment_name_generator.dash', 'api_platform.resource_class_resolver', @@ -921,6 +953,11 @@ private function getPartialContainerBuilderProphecy($configuration = null) 'api_platform.subresource_operation_factory.cached', ]; + if (class_exists(AbstractUid::class)) { + $definitions[] = 'api_platform.identifier.symfony_ulid_normalizer'; + $definitions[] = 'api_platform.identifier.symfony_uuid_normalizer'; + } + foreach ($definitions as $definition) { $containerBuilderProphecy->setDefinition($definition, Argument::type(Definition::class))->shouldBeCalled(); } @@ -961,6 +998,7 @@ private function getPartialContainerBuilderProphecy($configuration = null) SerializerContextBuilderInterface::class => 'api_platform.serializer.context_builder', SubresourceDataProviderInterface::class => 'api_platform.subresource_data_provider', UrlGeneratorInterface::class => 'api_platform.router', + PaginationOptions::class => 'api_platform.pagination_options', ]; foreach ($aliases as $alias => $service) { @@ -1023,6 +1061,10 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); $this->childDefinitionProphecy->addTag('api_platform.graphql.type')->shouldBeCalledTimes(1); + $containerBuilderProphecy->registerForAutoconfiguration(ErrorHandlerInterface::class) + ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); + $this->childDefinitionProphecy->addTag('api_platform.graphql.error_handler')->shouldBeCalledTimes(1); + $containerBuilderProphecy->registerForAutoconfiguration(QueryItemResolverInterface::class) ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); $containerBuilderProphecy->registerForAutoconfiguration(QueryCollectionResolverInterface::class) @@ -1045,6 +1087,14 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); $this->childDefinitionProphecy->setBindings(['$requestStack' => null])->shouldBeCalledTimes(1); + $containerBuilderProphecy->registerForAutoconfiguration(ValidationGroupsGeneratorInterface::class) + ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); + $this->childDefinitionProphecy->addTag('api_platform.validation_groups_generator')->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); + + $containerBuilderProphecy->registerForAutoconfiguration(PropertySchemaRestrictionMetadataInterface::class) + ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); + $this->childDefinitionProphecy->addTag('api_platform.metadata.property_schema_restriction')->shouldBeCalledTimes(1); + if (\in_array('odm', $doctrineIntegrationsToLoad, true)) { $containerBuilderProphecy->registerForAutoconfiguration(AggregationItemExtensionInterface::class) ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); @@ -1061,7 +1111,11 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo $containerBuilderProphecy->registerForAutoconfiguration(DataTransformerInterface::class) ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); - $this->childDefinitionProphecy->addTag('api_platform.data_transformer')->shouldBeCalledTimes(1); + + $containerBuilderProphecy->registerForAutoconfiguration(DataTransformerInitializerInterface::class) + ->willReturn($this->childDefinitionProphecy)->shouldBeCalledTimes(1); + + $this->childDefinitionProphecy->addTag('api_platform.data_transformer')->shouldBeCalledTimes(2); $containerBuilderProphecy->addResource(Argument::type(DirectoryResource::class))->shouldBeCalled(); @@ -1073,6 +1127,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.oauth.flow' => 'application', 'api_platform.oauth.tokenUrl' => '/oauth/v2/token', 'api_platform.oauth.authorizationUrl' => '/oauth/v2/auth', + 'api_platform.oauth.refreshUrl' => '/oauth/v2/refresh', 'api_platform.oauth.scopes' => [], 'api_platform.enable_swagger_ui' => true, 'api_platform.enable_re_doc' => true, @@ -1085,6 +1140,14 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.resource_class_directories' => Argument::type('array'), 'api_platform.validator.serialize_payload_fields' => [], 'api_platform.elasticsearch.enabled' => false, + 'api_platform.asset_package' => null, + 'api_platform.defaults' => ['attributes' => ['stateless' => true]], + 'api_platform.openapi.termsOfService' => null, + 'api_platform.openapi.contact.name' => null, + 'api_platform.openapi.contact.url' => null, + 'api_platform.openapi.contact.email' => null, + 'api_platform.openapi.license.name' => null, + 'api_platform.openapi.license.url' => null, ]; if ($hasSwagger) { @@ -1108,7 +1171,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo $definitions = [ 'api_platform.data_collector.request', 'api_platform.doctrine.listener.http_cache.purge', - 'api_platform.doctrine.listener.mercure.publish', + 'api_platform.doctrine.orm.listener.mercure.publish', 'api_platform.doctrine.orm.boolean_filter', 'api_platform.doctrine.orm.collection_data_provider', 'api_platform.doctrine.orm.data_persister', @@ -1132,6 +1195,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.graphql.action.entrypoint', 'api_platform.graphql.action.graphiql', 'api_platform.graphql.action.graphql_playground', + 'api_platform.graphql.error_handler', 'api_platform.graphql.executor', 'api_platform.graphql.type_builder', 'api_platform.graphql.fields_builder', @@ -1140,6 +1204,7 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.graphql.resolver.factory.item', 'api_platform.graphql.resolver.factory.collection', 'api_platform.graphql.resolver.factory.item_mutation', + 'api_platform.graphql.resolver.factory.item_subscription', 'api_platform.graphql.resolver.stage.read', 'api_platform.graphql.resolver.stage.security', 'api_platform.graphql.resolver.stage.security_post_denormalize', @@ -1148,6 +1213,10 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.graphql.resolver.stage.write', 'api_platform.graphql.resolver.stage.validate', 'api_platform.graphql.resolver.resource_field', + 'api_platform.graphql.normalizer.error', + 'api_platform.graphql.normalizer.validation_exception', + 'api_platform.graphql.normalizer.http_exception', + 'api_platform.graphql.normalizer.runtime_exception', 'api_platform.graphql.iterable_type', 'api_platform.graphql.upload_type', 'api_platform.graphql.type_locator', @@ -1159,7 +1228,11 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.graphql.normalizer.item', 'api_platform.graphql.normalizer.object', 'api_platform.graphql.serializer.context_builder', + 'api_platform.graphql.subscription.subscription_manager', + 'api_platform.graphql.subscription.subscription_identifier_generator', + 'api_platform.graphql.cache.subscription', 'api_platform.graphql.command.export_command', + 'api_platform.graphql.subscription.mercure_iri_generator', 'api_platform.hal.encoder', 'api_platform.hal.normalizer.collection', 'api_platform.hal.normalizer.entrypoint', @@ -1177,6 +1250,9 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.metadata.extractor.yaml', 'api_platform.metadata.property.metadata_factory.annotation', 'api_platform.metadata.property.metadata_factory.validator', + 'api_platform.metadata.property_schema.length_restriction', + 'api_platform.metadata.property_schema.regex_restriction', + 'api_platform.metadata.property_schema.format_restriction', 'api_platform.metadata.property.metadata_factory.yaml', 'api_platform.metadata.property.name_collection_factory.yaml', 'api_platform.metadata.resource.filter_metadata_factory.annotation', @@ -1194,11 +1270,13 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo 'api_platform.swagger.action.ui', 'api_platform.swagger.listener.ui', 'api_platform.validator', + 'api_platform.validator.query_parameter_validator', 'test.api_platform.client', ]; if (\in_array('odm', $doctrineIntegrationsToLoad, true)) { $definitions = array_merge($definitions, [ + 'api_platform.doctrine_mongodb.odm.listener.mercure.publish', 'api_platform.doctrine_mongodb.odm.aggregation_extension.filter', 'api_platform.doctrine_mongodb.odm.aggregation_extension.order', 'api_platform.doctrine_mongodb.odm.aggregation_extension.pagination', @@ -1233,6 +1311,13 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo $definitions[] = 'api_platform.json_schema.type_factory'; $definitions[] = 'api_platform.json_schema.schema_factory'; $definitions[] = 'api_platform.json_schema.json_schema_generate_command'; + $definitions[] = 'api_platform.openapi.options'; + $definitions[] = 'api_platform.openapi.normalizer'; + $definitions[] = 'api_platform.openapi.normalizer.api_gateway'; + $definitions[] = 'api_platform.openapi.factory'; + $definitions[] = 'api_platform.openapi.command'; + $definitions[] = 'api_platform.swagger_ui.context'; + $definitions[] = 'api_platform.swagger_ui.action'; } // has jsonld @@ -1303,6 +1388,10 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo $aliases += [ TypeFactoryInterface::class => 'api_platform.json_schema.type_factory', SchemaFactoryInterface::class => 'api_platform.json_schema.schema_factory', + Options::class => 'api_platform.openapi.options', + OpenApiNormalizer::class => 'api_platform.openapi.normalizer', + OpenApiFactoryInterface::class => 'api_platform.openapi.factory', + 'api_platform.swagger_ui.listener' => 'api_platform.swagger.listener.ui', ]; } @@ -1316,7 +1405,15 @@ private function getBaseContainerBuilderProphecy(array $doctrineIntegrationsToLo $definitionDummy = $this->prophesize(Definition::class); $containerBuilderProphecy->removeDefinition('api_platform.cache_warmer.cache_pool_clearer')->will(function () {}); $containerBuilderProphecy->getDefinition('api_platform.mercure.listener.response.add_link_header')->willReturn($definitionDummy); - $containerBuilderProphecy->getDefinition('api_platform.doctrine.listener.mercure.publish')->willReturn($definitionDummy); + $containerBuilderProphecy->getDefinition('api_platform.doctrine.orm.listener.mercure.publish')->willReturn($definitionDummy); + $containerBuilderProphecy->getDefinition('api_platform.doctrine_mongodb.odm.listener.mercure.publish')->willReturn($definitionDummy); + $containerBuilderProphecy->getDefinition('api_platform.graphql.subscription.mercure_iri_generator')->willReturn($definitionDummy); + $this->childDefinitionProphecy->setPublic(true)->will(function () {}); + + $containerBuilderProphecy->getDefinition(Argument::type('string')) + ->willReturn($this->prophesize(Definition::class)->reveal()); + $containerBuilderProphecy->getAlias(Argument::type('string')) + ->willReturn($this->prophesize(Alias::class)->reveal()); $containerBuilderProphecy->getDefinition(Argument::type('string')) ->willReturn($this->prophesize(Definition::class)->reveal()); diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DeprecateMercurePublisherPassTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DeprecateMercurePublisherPassTest.php new file mode 100644 index 00000000000..e83c0f6bb1d --- /dev/null +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/Compiler/DeprecateMercurePublisherPassTest.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\Bridge\Symfony\Bundle\DependencyInjection\Compiler; + +use ApiPlatform\Core\Bridge\Symfony\Bundle\DependencyInjection\Compiler\DeprecateMercurePublisherPass; +use ApiPlatform\Core\Tests\ProphecyTrait; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Config\Definition\BaseNode; +use Symfony\Component\DependencyInjection\Alias; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; + +final class DeprecateMercurePublisherPassTest extends TestCase +{ + use ProphecyTrait; + + public function testProcess() + { + $deprecateMercurePublisherPass = new DeprecateMercurePublisherPass(); + + $this->assertInstanceOf(CompilerPassInterface::class, $deprecateMercurePublisherPass); + + $containerBuilderProphecy = $this->prophesize(ContainerBuilder::class); + $aliasProphecy = $this->prophesize(Alias::class); + + $containerBuilderProphecy + ->setAlias('api_platform.doctrine.listener.mercure.publish', 'api_platform.doctrine.orm.listener.mercure.publish') + ->willReturn($aliasProphecy->reveal()) + ->shouldBeCalled(); + + $setDeprecatedArgs = method_exists(BaseNode::class, 'getDeprecation') + ? ['api-platform/core', '2.6', 'Using "%alias_id%" service is deprecated since API Platform 2.6. Use "api_platform.doctrine.orm.listener.mercure.publish" instead.'] + : ['Using "%alias_id%" service is deprecated since API Platform 2.6. Use "api_platform.doctrine.orm.listener.mercure.publish" instead.']; + + $aliasProphecy + ->setDeprecated(...$setDeprecatedArgs) + ->willReturn($aliasProphecy->reveal()) + ->shouldBeCalled(); + + $deprecateMercurePublisherPass->process($containerBuilderProphecy->reveal()); + } +} diff --git a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php index e8c07bfb8b1..d00b136fee8 100644 --- a/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php +++ b/tests/Bridge/Symfony/Bundle/DependencyInjection/ConfigurationTest.php @@ -144,6 +144,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'flow' => 'application', 'tokenUrl' => '/oauth/v2/token', 'authorizationUrl' => '/oauth/v2/auth', + 'refreshUrl' => '/oauth/v2/refresh', 'scopes' => [], ], 'swagger' => [ @@ -182,6 +183,7 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm 'enabled' => false, 'varnish_urls' => [], 'request_options' => [], + 'max_header_length' => 7500, ], 'etag' => true, 'max_age' => null, @@ -204,6 +206,19 @@ private function runDefaultConfigTests(array $doctrineIntegrationsToLoad = ['orm ], 'allow_plain_identifiers' => false, 'resource_class_directories' => [], + 'asset_package' => null, + 'openapi' => [ + 'contact' => [ + 'name' => null, + 'url' => null, + 'email' => null, + ], + 'termsOfService' => null, + 'license' => [ + 'name' => null, + 'url' => null, + ], + ], ], $config); } diff --git a/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php b/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php new file mode 100644 index 00000000000..f75315d9712 --- /dev/null +++ b/tests/Bridge/Symfony/Bundle/SwaggerUi/SwaggerUiActionTest.php @@ -0,0 +1,217 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Symfony\Bundle\SwaggerUi; + +use ApiPlatform\Core\Bridge\Symfony\Bundle\SwaggerUi\SwaggerUiAction; +use ApiPlatform\Core\Bridge\Symfony\Bundle\SwaggerUi\SwaggerUiContext; +use ApiPlatform\Core\Documentation\DocumentationInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface; +use ApiPlatform\Core\OpenApi\Model\Info; +use ApiPlatform\Core\OpenApi\Model\Paths; +use ApiPlatform\Core\OpenApi\OpenApi; +use ApiPlatform\Core\OpenApi\Options; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\Routing\Generator\UrlGenerator; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; +use Twig\Environment as TwigEnvironment; + +/** + * @author Antoine Bluchet + */ +class SwaggerUiActionTest extends TestCase +{ + public const SPEC = [ + 'paths' => [ + '/fs' => ['get' => ['operationId' => 'getFCollection']], + '/fs/{id}' => ['get' => ['operationId' => 'getFItem']], + ], + ]; + + /** + * @dataProvider getInvokeParameters + */ + public function testInvoke(Request $request, $twigProphecy) + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata('F'))->shouldBeCalled(); + + $normalizerProphecy = $this->prophesize(NormalizerInterface::class); + $normalizerProphecy->normalize(Argument::type(DocumentationInterface::class), 'json', Argument::type('array'))->willReturn(self::SPEC)->shouldBeCalled(); + + $urlGeneratorProphecy = $this->prophesize(UrlGenerator::class); + $urlGeneratorProphecy->generate('api_doc', ['format' => 'json'])->willReturn('/url')->shouldBeCalled(); + + $openApiFactoryProphecy = $this->prophesize(OpenApiFactoryInterface::class); + $openApiFactoryProphecy->__invoke(Argument::type('array'))->willReturn(new OpenApi(new Info('title', '1.0.0'), [], new Paths()))->shouldBeCalled(); + + $action = new SwaggerUiAction( + $resourceMetadataFactoryProphecy->reveal(), + $twigProphecy->reveal(), + $urlGeneratorProphecy->reveal(), + $normalizerProphecy->reveal(), + $openApiFactoryProphecy->reveal(), + new Options('title', '', '1.0.0'), + new SwaggerUiContext(), + ['jsonld' => ['application/ld+json']] + ); + $action($request); + } + + public function getInvokeParameters() + { + $twigCollectionProphecy = $this->prophesize(TwigEnvironment::class); + $twigCollectionProphecy->render('@ApiPlatform/SwaggerUi/index.html.twig', [ + 'title' => 'title', + 'description' => '', + 'formats' => ['jsonld' => ['application/ld+json']], + 'showWebby' => true, + 'swaggerUiEnabled' => false, + 'reDocEnabled' => false, + 'graphqlEnabled' => false, + 'graphiQlEnabled' => false, + 'graphQlPlaygroundEnabled' => false, + 'assetPackage' => null, + 'swagger_data' => [ + 'url' => '/url', + 'spec' => self::SPEC, + 'oauth' => [ + 'enabled' => false, + 'clientId' => '', + 'clientSecret' => '', + 'type' => '', + 'flow' => '', + 'tokenUrl' => '', + 'authorizationUrl' => '', + 'scopes' => [], + ], + 'shortName' => 'F', + 'operationId' => 'getFCollection', + 'id' => null, + 'queryParameters' => [], + 'path' => '/fs', + 'method' => 'get', + ], + ])->shouldBeCalled()->willReturn(''); + + $twigItemProphecy = $this->prophesize(TwigEnvironment::class); + $twigItemProphecy->render('@ApiPlatform/SwaggerUi/index.html.twig', [ + 'title' => 'title', + 'description' => '', + 'formats' => ['jsonld' => ['application/ld+json']], + 'swaggerUiEnabled' => false, + 'showWebby' => true, + 'reDocEnabled' => false, + 'graphqlEnabled' => false, + 'graphiQlEnabled' => false, + 'graphQlPlaygroundEnabled' => false, + 'assetPackage' => null, + 'swagger_data' => [ + 'url' => '/url', + 'spec' => self::SPEC, + 'oauth' => [ + 'enabled' => false, + 'clientId' => null, + 'clientSecret' => null, + 'type' => '', + 'flow' => '', + 'tokenUrl' => '', + 'authorizationUrl' => '', + 'scopes' => [], + ], + 'shortName' => 'F', + 'operationId' => 'getFItem', + 'id' => null, + 'queryParameters' => [], + 'path' => '/fs/{id}', + 'method' => 'get', + ], + ])->shouldBeCalled()->willReturn(''); + + return [ + [new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'get']), $twigCollectionProphecy], + [new Request([], [], ['_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get']), $twigItemProphecy], + [new Request([], [], ['_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get'], [], [], ['REQUEST_URI' => '/docs', 'SCRIPT_FILENAME' => '/docs']), $twigItemProphecy], + ]; + } + + /** + * @dataProvider getDoNotRunCurrentRequestParameters + */ + public function testDoNotRunCurrentRequest(Request $request) + { + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata()); + + $normalizerProphecy = $this->prophesize(NormalizerInterface::class); + $normalizerProphecy->normalize(Argument::type(DocumentationInterface::class), 'json', Argument::type('array'))->willReturn(self::SPEC)->shouldBeCalled(); + + $twigProphecy = $this->prophesize(TwigEnvironment::class); + $twigProphecy->render('@ApiPlatform/SwaggerUi/index.html.twig', [ + 'title' => 'title', + 'description' => '', + 'formats' => ['jsonld' => ['application/ld+json']], + 'showWebby' => true, + 'swaggerUiEnabled' => false, + 'reDocEnabled' => false, + 'graphqlEnabled' => false, + 'graphiQlEnabled' => false, + 'graphQlPlaygroundEnabled' => false, + 'assetPackage' => null, + 'swagger_data' => [ + 'url' => '/url', + 'spec' => self::SPEC, + 'oauth' => [ + 'enabled' => false, + 'clientId' => '', + 'clientSecret' => '', + 'type' => '', + 'flow' => '', + 'tokenUrl' => '', + 'authorizationUrl' => '', + 'scopes' => [], + ], + ], + ])->shouldBeCalled()->willReturn(''); + + $urlGeneratorProphecy = $this->prophesize(UrlGenerator::class); + $urlGeneratorProphecy->generate('api_doc', ['format' => 'json'])->willReturn('/url')->shouldBeCalled(); + + $openApiFactoryProphecy = $this->prophesize(OpenApiFactoryInterface::class); + $openApiFactoryProphecy->__invoke(Argument::type('array'))->willReturn(new OpenApi(new Info('title', '1.0.0'), [], new Paths()))->shouldBeCalled(); + + $action = new SwaggerUiAction( + $resourceMetadataFactoryProphecy->reveal(), + $twigProphecy->reveal(), + $urlGeneratorProphecy->reveal(), + $normalizerProphecy->reveal(), + $openApiFactoryProphecy->reveal(), + new Options('title', '', '1.0.0'), + new SwaggerUiContext(), + ['jsonld' => ['application/ld+json']] + ); + $action($request); + } + + public function getDoNotRunCurrentRequestParameters(): iterable + { + $nonSafeRequest = new Request([], [], ['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'post']); + $nonSafeRequest->setMethod('POST'); + yield [$nonSafeRequest]; + yield [new Request()]; + } +} diff --git a/tests/Bridge/Symfony/Bundle/Twig/ApiPlatformProfilerPanelTest.php b/tests/Bridge/Symfony/Bundle/Twig/ApiPlatformProfilerPanelTest.php index 9ff6806e6d9..3ea58ad0f04 100644 --- a/tests/Bridge/Symfony/Bundle/Twig/ApiPlatformProfilerPanelTest.php +++ b/tests/Bridge/Symfony/Bundle/Twig/ApiPlatformProfilerPanelTest.php @@ -81,7 +81,7 @@ public function testDebugBarContentNotResourceClass() // Check extra info content $this->assertStringContainsString('sf-toolbar-status-default', $block->attr('class'), 'The toolbar block should have the default color.'); - $this->assertSame('Not an API Platform resource', $block->filter('.sf-toolbar-info-piece span')->html()); + $this->assertSame('Not an API Platform resource', $block->filterXPath('//div[@class="sf-toolbar-info-piece"][./b[contains(., "Resource Class")]]/span')->html()); } public function testDebugBarContent() @@ -99,7 +99,7 @@ public function testDebugBarContent() // Check extra info content $this->assertStringContainsString('sf-toolbar-status-default', $block->attr('class'), 'The toolbar block should have the default color.'); - $this->assertSame('mongodb' === $this->env ? DocumentDummy::class : Dummy::class, $block->filter('.sf-toolbar-info-piece span')->html()); + $this->assertSame('mongodb' === $this->env ? DocumentDummy::class : Dummy::class, $block->filterXPath('//div[@class="sf-toolbar-info-piece"][./b[contains(., "Resource Class")]]/span')->html()); } public function testProfilerGeneralLayoutNotResourceClass() diff --git a/tests/Bridge/Symfony/Identifier/Normalizer/UlidNormalizerTest.php b/tests/Bridge/Symfony/Identifier/Normalizer/UlidNormalizerTest.php new file mode 100644 index 00000000000..ca716a07b9f --- /dev/null +++ b/tests/Bridge/Symfony/Identifier/Normalizer/UlidNormalizerTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Symfony\Identifier\Normalizer; + +use ApiPlatform\Core\Bridge\Symfony\Identifier\Normalizer\UlidNormalizer; +use ApiPlatform\Core\Exception\InvalidIdentifierException; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Uid\AbstractUid; +use Symfony\Component\Uid\Ulid; + +final class UlidNormalizerTest extends TestCase +{ + protected function setUp(): void + { + if (!class_exists(AbstractUid::class)) { + $this->markTestSkipped(); + } + } + + public function testDenormalizeUlid() + { + $ulid = new Ulid(); + $normalizer = new UlidNormalizer(); + $this->assertTrue($normalizer->supportsDenormalization($ulid->__toString(), Ulid::class)); + $this->assertEquals($ulid, $normalizer->denormalize($ulid->__toString(), Ulid::class)); + } + + public function testNoSupportDenormalizeUlid() + { + $ulid = 'notanulid'; + $normalizer = new UlidNormalizer(); + $this->assertFalse($normalizer->supportsDenormalization($ulid, '')); + } + + public function testFailDenormalizeUlid() + { + $this->expectException(InvalidIdentifierException::class); + + $ulid = 'notanulid'; + $normalizer = new UlidNormalizer(); + $this->assertTrue($normalizer->supportsDenormalization($ulid, Ulid::class)); + $normalizer->denormalize($ulid, Ulid::class); + } + + public function testDoNotSupportNotString() + { + $ulid = new Ulid(); + $normalizer = new UlidNormalizer(); + $this->assertFalse($normalizer->supportsDenormalization($ulid, Ulid::class)); + } +} diff --git a/tests/Bridge/Symfony/Identifier/Normalizer/UuidNormalizerTest.php b/tests/Bridge/Symfony/Identifier/Normalizer/UuidNormalizerTest.php new file mode 100644 index 00000000000..aff588ae090 --- /dev/null +++ b/tests/Bridge/Symfony/Identifier/Normalizer/UuidNormalizerTest.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Bridge\Symfony\Identifier\Normalizer; + +use ApiPlatform\Core\Bridge\Symfony\Identifier\Normalizer\UuidNormalizer; +use ApiPlatform\Core\Exception\InvalidIdentifierException; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Uid\AbstractUid; +use Symfony\Component\Uid\Uuid; + +final class UuidNormalizerTest extends TestCase +{ + protected function setUp(): void + { + if (!class_exists(AbstractUid::class)) { + $this->markTestSkipped(); + } + } + + public function testDenormalizeUuid() + { + $uuid = Uuid::v4(); + $normalizer = new UuidNormalizer(); + $this->assertTrue($normalizer->supportsDenormalization($uuid->__toString(), Uuid::class)); + $this->assertEquals($uuid, $normalizer->denormalize($uuid->__toString(), Uuid::class)); + } + + public function testNoSupportDenormalizeUuid() + { + $uuid = 'notanuuid'; + $normalizer = new UuidNormalizer(); + $this->assertFalse($normalizer->supportsDenormalization($uuid, '')); + } + + public function testFailDenormalizeUuid() + { + $this->expectException(InvalidIdentifierException::class); + + $uuid = 'notanuuid'; + $normalizer = new UuidNormalizer(); + $this->assertTrue($normalizer->supportsDenormalization($uuid, Uuid::class)); + $normalizer->denormalize($uuid, Uuid::class); + } + + public function testDoNotSupportNotString() + { + $uuid = Uuid::v4(); + $normalizer = new UuidNormalizer(); + $this->assertFalse($normalizer->supportsDenormalization($uuid, Uuid::class)); + } +} diff --git a/tests/Bridge/Symfony/Messenger/ContextStampTest.php b/tests/Bridge/Symfony/Messenger/ContextStampTest.php new file mode 100644 index 00000000000..0c2cbddf74f --- /dev/null +++ b/tests/Bridge/Symfony/Messenger/ContextStampTest.php @@ -0,0 +1,35 @@ + + * + * For 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\Messenger; + +use ApiPlatform\Core\Bridge\Symfony\Messenger\ContextStamp; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Messenger\Stamp\StampInterface; + +/** + * @author Sergii Pavlenko + */ +class ContextStampTest extends TestCase +{ + public function testConstruct() + { + $this->assertInstanceOf(StampInterface::class, new ContextStamp()); + } + + public function testGetContext() + { + $contextStamp = new ContextStamp(); + $this->assertIsArray($contextStamp->getContext()); + } +} diff --git a/tests/Bridge/Symfony/Messenger/DataPersisterTest.php b/tests/Bridge/Symfony/Messenger/DataPersisterTest.php index 002041aad81..30b9a9110d0 100644 --- a/tests/Bridge/Symfony/Messenger/DataPersisterTest.php +++ b/tests/Bridge/Symfony/Messenger/DataPersisterTest.php @@ -13,8 +13,10 @@ namespace ApiPlatform\Core\Tests\Bridge\Symfony\Messenger; +use ApiPlatform\Core\Bridge\Symfony\Messenger\ContextStamp; use ApiPlatform\Core\Bridge\Symfony\Messenger\DataPersister; use ApiPlatform\Core\Bridge\Symfony\Messenger\RemoveStamp; +use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface; use ApiPlatform\Core\Exception\ResourceClassNotFoundException; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; @@ -25,7 +27,6 @@ use Prophecy\Argument; use Symfony\Component\Messenger\Envelope; use Symfony\Component\Messenger\MessageBusInterface; -use Symfony\Component\Messenger\Stamp\HandledStamp; /** * @author Kévin Dunglas @@ -39,7 +40,7 @@ public function testSupport() $metadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $metadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => true])); - $dataPersister = new DataPersister($metadataFactoryProphecy->reveal(), $this->prophesize(MessageBusInterface::class)->reveal()); + $dataPersister = new DataPersister($metadataFactoryProphecy->reveal(), $this->prophesize(MessageBusInterface::class)->reveal(), $this->prophesize(ContextAwareDataPersisterInterface::class)->reveal()); $this->assertTrue($dataPersister->supports(new Dummy())); } @@ -49,19 +50,39 @@ public function testSupportWithContext() $metadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => true])); $metadataFactoryProphecy->create(DummyCar::class)->willThrow(new ResourceClassNotFoundException()); - $dataPersister = new DataPersister($metadataFactoryProphecy->reveal(), $this->prophesize(MessageBusInterface::class)->reveal()); + $dataPersister = new DataPersister($metadataFactoryProphecy->reveal(), $this->prophesize(MessageBusInterface::class)->reveal(), $this->prophesize(ContextAwareDataPersisterInterface::class)->reveal()); $this->assertTrue($dataPersister->supports(new DummyCar(), ['resource_class' => Dummy::class])); $this->assertFalse($dataPersister->supports(new DummyCar())); } + public function testSupportWithContextAndMessengerDispatched() + { + $metadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $metadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => true])); + $metadataFactoryProphecy->create(DummyCar::class)->willThrow(new ResourceClassNotFoundException()); + + $dataPersister = new DataPersister($metadataFactoryProphecy->reveal(), $this->prophesize(MessageBusInterface::class)->reveal(), $this->prophesize(ContextAwareDataPersisterInterface::class)->reveal()); + $this->assertFalse($dataPersister->supports(new DummyCar(), ['resource_class' => Dummy::class, 'messenger_dispatched' => true])); + } + public function testPersist() { $dummy = new Dummy(); + $metadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); + $metadataFactory->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => true])); + $metadataFactory->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => 'input'])); + $metadataFactory->create(DummyCar::class)->willThrow(new ResourceClassNotFoundException()); + + $chainDataPersister = $this->prophesize(ContextAwareDataPersisterInterface::class); + $chainDataPersister->persist($dummy, ['messenger_dispatched' => true])->shouldNotBeCalled(); + $messageBus = $this->prophesize(MessageBusInterface::class); - $messageBus->dispatch($dummy)->willReturn(new Envelope($dummy))->shouldBeCalled(); + $messageBus->dispatch(Argument::that(function (Envelope $envelope) use ($dummy) { + return $dummy === $envelope->getMessage() && null !== $envelope->last(ContextStamp::class); + }))->willReturn(new Envelope($dummy))->shouldBeCalled(); - $dataPersister = new DataPersister($this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), $messageBus->reveal()); + $dataPersister = new DataPersister($metadataFactory->reveal(), $messageBus->reveal(), $chainDataPersister->reveal()); $this->assertSame($dummy, $dataPersister->persist($dummy)); } @@ -69,32 +90,92 @@ public function testRemove() { $dummy = new Dummy(); + $metadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); + $metadataFactory->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => true])); + $metadataFactory->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => 'input'])); + $metadataFactory->create(DummyCar::class)->willThrow(new ResourceClassNotFoundException()); + + $chainDataPersister = $this->prophesize(ContextAwareDataPersisterInterface::class); + $chainDataPersister->remove($dummy, ['messenger_dispatched' => true])->shouldNotBeCalled(); + $messageBus = $this->prophesize(MessageBusInterface::class); $messageBus->dispatch(Argument::that(function (Envelope $envelope) use ($dummy) { return $dummy === $envelope->getMessage() && null !== $envelope->last(RemoveStamp::class); }))->willReturn(new Envelope($dummy))->shouldBeCalled(); - $dataPersister = new DataPersister($this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), $messageBus->reveal()); + $dataPersister = new DataPersister($metadataFactory->reveal(), $messageBus->reveal(), $chainDataPersister->reveal()); $dataPersister->remove($dummy); } - public function testHandle() + public function testPersistWithHandOver() + { + $dummy = new Dummy(); + + $metadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); + $metadataFactory->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => 'persist'])); + $metadataFactory->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => ['persist', 'input']])); + $metadataFactory->create(DummyCar::class)->willThrow(new ResourceClassNotFoundException()); + + $chainDataPersister = $this->prophesize(ContextAwareDataPersisterInterface::class); + $chainDataPersister->persist($dummy, ['messenger_dispatched' => true])->willReturn($dummy)->shouldBeCalled(); + + $messageBus = $this->prophesize(MessageBusInterface::class); + $messageBus->dispatch(Argument::that(function (Envelope $envelope) use ($dummy) { + return $dummy === $envelope->getMessage() && null !== $envelope->last(ContextStamp::class); + }))->willReturn(new Envelope($dummy))->shouldBeCalled(); + + $dataPersister = new DataPersister($metadataFactory->reveal(), $messageBus->reveal(), $chainDataPersister->reveal()); + $this->assertSame($dummy, $dataPersister->persist($dummy)); + } + + public function testPersistWithExceptionOnHandOver() { $dummy = new Dummy(); + $metadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); + $metadataFactory->create(DummyCar::class)->willThrow(new ResourceClassNotFoundException()); + $metadataFactory->create(Dummy::class)->willThrow(new ResourceClassNotFoundException()); + $metadataFactory->create(Dummy::class)->willThrow(new ResourceClassNotFoundException()); + + $chainDataPersister = $this->prophesize(ContextAwareDataPersisterInterface::class); + $chainDataPersister->persist(Argument::any(), Argument::any())->shouldNotBeCalled(); + $messageBus = $this->prophesize(MessageBusInterface::class); - $messageBus->dispatch($dummy)->willReturn((new Envelope($dummy))->with(new HandledStamp($dummy, 'DummyHandler::__invoke')))->shouldBeCalled(); + $messageBus->dispatch(Argument::that(function (Envelope $envelope) use ($dummy) { + return $dummy === $envelope->getMessage() && null !== $envelope->last(ContextStamp::class); + }))->willReturn(new Envelope($dummy))->shouldBeCalled(); - $dataPersister = new DataPersister($this->prophesize(ResourceMetadataFactoryInterface::class)->reveal(), $messageBus->reveal()); + $dataPersister = new DataPersister($metadataFactory->reveal(), $messageBus->reveal(), $chainDataPersister->reveal()); $this->assertSame($dummy, $dataPersister->persist($dummy)); } + public function testRemoveWithHandOver() + { + $dummy = new Dummy(); + + $metadataFactory = $this->prophesize(ResourceMetadataFactoryInterface::class); + $metadataFactory->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => 'persist'])); + $metadataFactory->create(Dummy::class)->willReturn(new ResourceMetadata(null, null, null, null, null, ['messenger' => ['persist', 'input']])); + $metadataFactory->create(DummyCar::class)->willThrow(new ResourceClassNotFoundException()); + + $chainDataPersister = $this->prophesize(ContextAwareDataPersisterInterface::class); + $chainDataPersister->remove($dummy, ['messenger_dispatched' => true])->shouldBeCalled(); + + $messageBus = $this->prophesize(MessageBusInterface::class); + $messageBus->dispatch(Argument::that(function (Envelope $envelope) use ($dummy) { + return $dummy === $envelope->getMessage() && null !== $envelope->last(RemoveStamp::class); + }))->willReturn(new Envelope($dummy))->shouldBeCalled(); + + $dataPersister = new DataPersister($metadataFactory->reveal(), $messageBus->reveal(), $chainDataPersister->reveal()); + $dataPersister->remove($dummy); + } + public function testSupportWithGraphqlContext() { $metadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $metadataFactoryProphecy->create(Dummy::class)->willReturn((new ResourceMetadata(null, null, null, null, null, []))->withGraphQl(['create' => ['messenger' => 'input']])); - $dataPersister = new DataPersister($metadataFactoryProphecy->reveal(), $this->prophesize(MessageBusInterface::class)->reveal()); + $dataPersister = new DataPersister($metadataFactoryProphecy->reveal(), $this->prophesize(MessageBusInterface::class)->reveal(), $this->prophesize(ContextAwareDataPersisterInterface::class)->reveal()); $this->assertTrue($dataPersister->supports(new DummyCar(), ['resource_class' => Dummy::class, 'graphql_operation_name' => 'create'])); } } diff --git a/tests/Bridge/Symfony/Messenger/DataTransformerTest.php b/tests/Bridge/Symfony/Messenger/DataTransformerTest.php index 13e7819130d..82b529d9188 100644 --- a/tests/Bridge/Symfony/Messenger/DataTransformerTest.php +++ b/tests/Bridge/Symfony/Messenger/DataTransformerTest.php @@ -28,6 +28,9 @@ class DataTransformerTest extends TestCase { use ProphecyTrait; + /** + * @dataProvider getMessengerAttribute + */ public function testSupport() { $metadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); @@ -37,6 +40,9 @@ public function testSupport() $this->assertTrue($dataTransformer->supportsTransformation([], Dummy::class, ['input' => ['class' => 'smth']])); } + /** + * @dataProvider getMessengerAttribute + */ public function testSupportWithinRequest() { $metadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); @@ -64,6 +70,9 @@ public function testNoSupportWithinRequest() $this->assertFalse($dataTransformer->supportsTransformation([], Dummy::class, ['input' => ['class' => 'smth'], 'operation_type' => OperationType::ITEM, 'item_operation_name' => 'foo'])); } + /** + * @dataProvider getMessengerAttribute + */ public function testNoSupportWithoutInput() { $metadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); @@ -73,6 +82,9 @@ public function testNoSupportWithoutInput() $this->assertFalse($dataTransformer->supportsTransformation([], Dummy::class, [])); } + /** + * @dataProvider getMessengerAttribute + */ public function testNoSupportWithObject() { $metadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); @@ -90,6 +102,9 @@ public function testTransform() $this->assertSame($dummy, $dataTransformer->transform($dummy, Dummy::class)); } + /** + * @dataProvider getMessengerAttribute + */ public function testSupportWithGraphqlContext() { $metadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); @@ -97,4 +112,15 @@ public function testSupportWithGraphqlContext() $dataTransformer = new DataTransformer($metadataFactoryProphecy->reveal()); $this->assertTrue($dataTransformer->supportsTransformation([], Dummy::class, ['input' => ['class' => 'smth'], 'graphql_operation_name' => 'create'])); } + + public function getMessengerAttribute() + { + yield [ + 'input', + ]; + + yield [ + ['persist', 'input'], + ]; + } } diff --git a/tests/Bridge/Symfony/Routing/ApiLoaderTest.php b/tests/Bridge/Symfony/Routing/ApiLoaderTest.php index b5d464d903e..629b4bd2995 100644 --- a/tests/Bridge/Symfony/Routing/ApiLoaderTest.php +++ b/tests/Bridge/Symfony/Routing/ApiLoaderTest.php @@ -52,21 +52,25 @@ public function testApiLoader() $resourceMetadata = $resourceMetadata->withShortName('dummy'); //default operation based on OperationResourceMetadataFactory $resourceMetadata = $resourceMetadata->withItemOperations([ - 'get' => ['method' => 'GET', 'requirements' => ['id' => '\d+'], 'defaults' => ['my_default' => 'default_value', '_controller' => 'should_not_be_overriden']], - 'put' => ['method' => 'PUT'], - 'delete' => ['method' => 'DELETE'], + 'get' => ['method' => 'GET', 'requirements' => ['id' => '\d+'], 'defaults' => ['my_default' => 'default_value', '_controller' => 'should_not_be_overriden'], 'stateless' => null], + 'put' => ['method' => 'PUT', 'stateless' => null], + 'delete' => ['method' => 'DELETE', 'stateless' => null], ]); //custom operations $resourceMetadata = $resourceMetadata->withCollectionOperations([ - 'my_op' => ['method' => 'GET', 'controller' => 'some.service.name', 'requirements' => ['_format' => 'a valid format'], 'defaults' => ['my_default' => 'default_value'], 'condition' => "request.headers.get('User-Agent') matches '/firefox/i'"], //with controller - 'my_second_op' => ['method' => 'POST', 'options' => ['option' => 'option_value'], 'host' => '{subdomain}.api-platform.com', 'schemes' => ['https']], //without controller, takes the default one - 'my_path_op' => ['method' => 'GET', 'path' => 'some/custom/path'], //custom path + 'my_op' => ['method' => 'GET', 'controller' => 'some.service.name', 'requirements' => ['_format' => 'a valid format'], 'defaults' => ['my_default' => 'default_value'], 'condition' => "request.headers.get('User-Agent') matches '/firefox/i'", 'stateless' => null], //with controller + 'my_second_op' => ['method' => 'POST', 'options' => ['option' => 'option_value'], 'host' => '{subdomain}.api-platform.com', 'schemes' => ['https'], 'stateless' => null], //without controller, takes the default one + 'my_path_op' => ['method' => 'GET', 'path' => 'some/custom/path', 'stateless' => null], //custom path + 'my_stateless_op' => ['method' => 'GET', 'stateless' => true], + ]); + $resourceMetadata = $resourceMetadata->withSubresourceOperations([ + 'subresources_get_subresource' => ['stateless' => true], ]); $routeCollection = $this->getApiLoaderWithResourceMetadata($resourceMetadata)->load(null); $this->assertEquals( - $this->getRoute('/dummies/{id}.{_format}', 'api_platform.action.get_item', DummyEntity::class, 'get', ['GET'], false, ['id' => '\d+'], ['my_default' => 'default_value']), + $this->getRoute('/dummies/{id}.{_format}', 'api_platform.action.get_item', DummyEntity::class, 'get', ['GET'], false, ['id' => '\d+'], ['my_default' => 'default_value', '_stateless' => null]), $routeCollection->get('api_dummies_get_item') ); @@ -81,12 +85,12 @@ public function testApiLoader() ); $this->assertEquals( - $this->getRoute('/dummies.{_format}', 'some.service.name', DummyEntity::class, 'my_op', ['GET'], true, ['_format' => 'a valid format'], ['my_default' => 'default_value'], [], '', [], "request.headers.get('User-Agent') matches '/firefox/i'"), + $this->getRoute('/dummies.{_format}', 'some.service.name', DummyEntity::class, 'my_op', ['GET'], true, ['_format' => 'a valid format'], ['my_default' => 'default_value', '_stateless' => null], [], '', [], "request.headers.get('User-Agent') matches '/firefox/i'"), $routeCollection->get('api_dummies_my_op_collection') ); $this->assertEquals( - $this->getRoute('/dummies.{_format}', 'api_platform.action.post_collection', DummyEntity::class, 'my_second_op', ['POST'], true, [], [], ['option' => 'option_value'], '{subdomain}.api-platform.com', ['https']), + $this->getRoute('/dummies.{_format}', 'api_platform.action.post_collection', DummyEntity::class, 'my_second_op', ['POST'], true, [], ['_stateless' => null], ['option' => 'option_value'], '{subdomain}.api-platform.com', ['https']), $routeCollection->get('api_dummies_my_second_op_collection') ); @@ -96,7 +100,12 @@ public function testApiLoader() ); $this->assertEquals( - $this->getSubresourceRoute('/dummies/{id}/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_dummies_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => [['id', DummyEntity::class, true]], 'collection' => true, 'operationId' => 'api_dummies_subresources_get_subresource']), + $this->getRoute('/dummies.{_format}', 'api_platform.action.get_collection', DummyEntity::class, 'my_stateless_op', ['GET'], true, [], ['_stateless' => true]), + $routeCollection->get('api_dummies_my_stateless_op_collection') + ); + + $this->assertEquals( + $this->getSubresourceRoute('/dummies/{id}/subresources.{_format}', 'api_platform.action.get_subresource', RelatedDummyEntity::class, 'api_dummies_subresources_get_subresource', ['property' => 'subresource', 'identifiers' => [['id', DummyEntity::class, true]], 'collection' => true, 'operationId' => 'api_dummies_subresources_get_subresource'], [], ['_stateless' => true]), $routeCollection->get('api_dummies_subresources_get_subresource') ); } @@ -106,16 +115,16 @@ public function testApiLoaderWithPrefix() $resourceMetadata = new ResourceMetadata(); $resourceMetadata = $resourceMetadata->withShortName('dummy'); $resourceMetadata = $resourceMetadata->withItemOperations([ - 'get' => ['method' => 'GET', 'requirements' => ['id' => '\d+'], 'defaults' => ['my_default' => 'default_value', '_controller' => 'should_not_be_overriden']], - 'put' => ['method' => 'PUT'], - 'delete' => ['method' => 'DELETE'], + 'get' => ['method' => 'GET', 'requirements' => ['id' => '\d+'], 'defaults' => ['my_default' => 'default_value', '_controller' => 'should_not_be_overriden'], 'stateless' => null], + 'put' => ['method' => 'PUT', 'stateless' => null], + 'delete' => ['method' => 'DELETE', 'stateless' => null], ]); $resourceMetadata = $resourceMetadata->withAttributes(['route_prefix' => '/foobar-prefix']); $routeCollection = $this->getApiLoaderWithResourceMetadata($resourceMetadata)->load(null); $this->assertEquals( - $this->getRoute('/foobar-prefix/dummies/{id}.{_format}', 'api_platform.action.get_item', DummyEntity::class, 'get', ['GET'], false, ['id' => '\d+'], ['my_default' => 'default_value']), + $this->getRoute('/foobar-prefix/dummies/{id}.{_format}', 'api_platform.action.get_item', DummyEntity::class, 'get', ['GET'], false, ['id' => '\d+'], ['my_default' => 'default_value', '_stateless' => null]), $routeCollection->get('api_dummies_get_item') ); @@ -142,7 +151,7 @@ public function testNoMethodApiLoader() ]); $resourceMetadata = $resourceMetadata->withCollectionOperations([ - 'get' => ['method' => 'GET'], + 'get' => ['method' => 'GET', 'stateless' => null], ]); $this->getApiLoaderWithResourceMetadata($resourceMetadata)->load(null); @@ -156,11 +165,11 @@ public function testWrongMethodApiLoader() $resourceMetadata = $resourceMetadata->withShortName('dummy'); $resourceMetadata = $resourceMetadata->withItemOperations([ - 'post' => ['method' => 'POST'], + 'post' => ['method' => 'POST', 'stateless' => null], ]); $resourceMetadata = $resourceMetadata->withCollectionOperations([ - 'get' => ['method' => 'GET'], + 'get' => ['method' => 'GET', 'stateless' => null], ]); $this->getApiLoaderWithResourceMetadata($resourceMetadata)->load(null); @@ -178,14 +187,14 @@ public function testRecursiveSubresource() $resourceMetadata = new ResourceMetadata(); $resourceMetadata = $resourceMetadata->withShortName('dummy'); $resourceMetadata = $resourceMetadata->withItemOperations([ - 'get' => ['method' => 'GET'], - 'put' => ['method' => 'PUT'], - 'delete' => ['method' => 'DELETE'], + 'get' => ['method' => 'GET', 'stateless' => null], + 'put' => ['method' => 'PUT', 'stateless' => null], + 'delete' => ['method' => 'DELETE', 'stateless' => null], ]); $resourceMetadata = $resourceMetadata->withCollectionOperations([ - 'my_op' => ['method' => 'GET', 'controller' => 'some.service.name'], //with controller - 'my_second_op' => ['method' => 'POST'], //without controller, takes the default one - 'my_path_op' => ['method' => 'GET', 'path' => 'some/custom/path'], //custom path + 'my_op' => ['method' => 'GET', 'controller' => 'some.service.name', 'stateless' => null], //with controller + 'my_second_op' => ['method' => 'POST', 'stateless' => null], //without controller, takes the default one + 'my_path_op' => ['method' => 'GET', 'path' => 'some/custom/path', 'stateless' => null], //custom path ]); $routeCollection = $this->getApiLoaderWithResourceMetadata($resourceMetadata, true)->load(null); @@ -297,7 +306,7 @@ private function getApiLoaderWithResourceMetadata(ResourceMetadata $resourceMeta return new ApiLoader($kernelProphecy->reveal(), $resourceNameCollectionFactoryProphecy->reveal(), $resourceMetadataFactory, $operationPathResolver, $containerProphecy->reveal(), ['jsonld' => ['application/ld+json']], [], $subresourceOperationFactory, false, true, true, false, false); } - private function getRoute(string $path, string $controller, string $resourceClass, string $operationName, array $methods, bool $collection = false, array $requirements = [], array $extraDefaults = [], array $options = [], string $host = '', array $schemes = [], string $condition = ''): Route + private function getRoute(string $path, string $controller, string $resourceClass, string $operationName, array $methods, bool $collection = false, array $requirements = [], array $extraDefaults = ['_stateless' => null], array $options = [], string $host = '', array $schemes = [], string $condition = ''): Route { return new Route( $path, @@ -316,7 +325,7 @@ private function getRoute(string $path, string $controller, string $resourceClas ); } - private function getSubresourceRoute(string $path, string $controller, string $resourceClass, string $operationName, array $context, array $requirements = []): Route + private function getSubresourceRoute(string $path, string $controller, string $resourceClass, string $operationName, array $context, array $requirements = [], array $extraDefaults = ['_stateless' => null]): Route { return new Route( $path, @@ -326,7 +335,7 @@ private function getSubresourceRoute(string $path, string $controller, string $r '_api_resource_class' => $resourceClass, '_api_subresource_operation_name' => $operationName, '_api_subresource_context' => $context, - ], + ] + $extraDefaults, $requirements, [], '', diff --git a/tests/Bridge/Symfony/Routing/IriConverterTest.php b/tests/Bridge/Symfony/Routing/IriConverterTest.php index 51dfb6dffe5..dbf51036f9b 100644 --- a/tests/Bridge/Symfony/Routing/IriConverterTest.php +++ b/tests/Bridge/Symfony/Routing/IriConverterTest.php @@ -27,6 +27,8 @@ use ApiPlatform\Core\Identifier\IdentifierConverterInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; use ApiPlatform\Core\Tests\ProphecyTrait; @@ -148,6 +150,21 @@ public function testGetIriFromResourceClass() $this->assertEquals($converter->getIriFromResourceClass(Dummy::class), '/dummies'); } + public function testGetIriFromResourceClassAbsoluteUrl() + { + $routeNameResolverProphecy = $this->prophesize(RouteNameResolverInterface::class); + $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::COLLECTION)->willReturn('dummies'); + + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->generate('dummies', [], UrlGeneratorInterface::ABS_URL)->willReturn('http://example.com/dummies'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata('', '', '', [], [], ['url_generation_strategy' => UrlGeneratorInterface::ABS_URL])); + + $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy, null, null, null, $resourceMetadataFactoryProphecy->reveal()); + $this->assertEquals($converter->getIriFromResourceClass(Dummy::class), 'http://example.com/dummies'); + } + public function testNotAbleToGenerateGetIriFromResourceClass() { $this->expectException(InvalidArgumentException::class); @@ -202,6 +219,21 @@ public function testGetItemIriFromResourceClass() $this->assertEquals($converter->getItemIriFromResourceClass(Dummy::class, ['id' => 1]), '/dummies/1'); } + public function testGetItemIriFromResourceClassAbsoluteUrl() + { + $routeNameResolverProphecy = $this->prophesize(RouteNameResolverInterface::class); + $routeNameResolverProphecy->getRouteName(Dummy::class, OperationType::ITEM)->willReturn('api_dummies_get_item'); + + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->generate('api_dummies_get_item', ['id' => 1], UrlGeneratorInterface::ABS_URL)->willReturn('http://example.com/dummies/1'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata('', '', '', [], [], ['url_generation_strategy' => UrlGeneratorInterface::ABS_URL])); + + $converter = $this->getIriConverter($routerProphecy, $routeNameResolverProphecy, null, null, null, $resourceMetadataFactoryProphecy->reveal()); + $this->assertEquals($converter->getItemIriFromResourceClass(Dummy::class, ['id' => 1]), 'http://example.com/dummies/1'); + } + public function testNotAbleToGenerateGetItemIriFromResourceClass() { $this->expectException(InvalidArgumentException::class); @@ -342,7 +374,7 @@ private function getResourceClassResolver() return $resourceClassResolver->reveal(); } - private function getIriConverter($routerProphecy = null, $routeNameResolverProphecy = null, $itemDataProviderProphecy = null, $subresourceDataProviderProphecy = null, $identifierConverterProphecy = null) + private function getIriConverter($routerProphecy = null, $routeNameResolverProphecy = null, $itemDataProviderProphecy = null, $subresourceDataProviderProphecy = null, $identifierConverterProphecy = null, ResourceMetadataFactoryInterface $resourceMetadataFactory = null) { $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); @@ -369,7 +401,9 @@ private function getIriConverter($routerProphecy = null, $routeNameResolverProph null, new IdentifiersExtractor($propertyNameCollectionFactory, $propertyMetadataFactory, null, $this->getResourceClassResolver()), $subresourceDataProviderProphecy ? $subresourceDataProviderProphecy->reveal() : null, - $identifierConverterProphecy ? $identifierConverterProphecy->reveal() : null + $identifierConverterProphecy ? $identifierConverterProphecy->reveal() : null, + null, + $resourceMetadataFactory ); } } diff --git a/tests/Bridge/Symfony/Routing/RouterTest.php b/tests/Bridge/Symfony/Routing/RouterTest.php index 260ed27d55c..69318e33617 100644 --- a/tests/Bridge/Symfony/Routing/RouterTest.php +++ b/tests/Bridge/Symfony/Routing/RouterTest.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Tests\Bridge\Symfony\Routing; +use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\Bridge\Symfony\Routing\Router; use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; @@ -62,6 +63,24 @@ public function testGenerate() $this->assertSame('/bar', $router->generate('foo')); } + public function testGenerateWithDefaultStrategy() + { + $mockedRouter = $this->prophesize(RouterInterface::class); + $mockedRouter->generate('foo', [], UrlGeneratorInterface::ABS_URL)->willReturn('/bar')->shouldBeCalled(); + + $router = new Router($mockedRouter->reveal(), UrlGeneratorInterface::ABS_URL); + $this->assertSame('/bar', $router->generate('foo')); + } + + public function testGenerateWithStrategy() + { + $mockedRouter = $this->prophesize(RouterInterface::class); + $mockedRouter->generate('foo', [], UrlGeneratorInterface::ABS_URL)->willReturn('/bar')->shouldBeCalled(); + + $router = new Router($mockedRouter->reveal()); + $this->assertSame('/bar', $router->generate('foo', [], UrlGeneratorInterface::ABS_URL)); + } + public function testMatch() { $context = new RequestContext('/app_dev.php', 'GET', 'localhost', 'https'); diff --git a/tests/Bridge/Symfony/Validator/EventListener/ValidationExceptionListenerTest.php b/tests/Bridge/Symfony/Validator/EventListener/ValidationExceptionListenerTest.php index 7151e553f36..b1128b477c0 100644 --- a/tests/Bridge/Symfony/Validator/EventListener/ValidationExceptionListenerTest.php +++ b/tests/Bridge/Symfony/Validator/EventListener/ValidationExceptionListenerTest.php @@ -57,7 +57,7 @@ public function testValidationException() $response = $event->getResponse(); $this->assertInstanceOf(Response::class, $response); $this->assertSame($exceptionJson, $response->getContent()); - $this->assertSame(Response::HTTP_BAD_REQUEST, $response->getStatusCode()); + $this->assertSame(Response::HTTP_UNPROCESSABLE_ENTITY, $response->getStatusCode()); $this->assertSame('application/ld+json; charset=utf-8', $response->headers->get('Content-Type')); $this->assertSame('nosniff', $response->headers->get('X-Content-Type-Options')); $this->assertSame('deny', $response->headers->get('X-Frame-Options')); diff --git a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php index a6187e7824e..181b9ff14df 100644 --- a/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php +++ b/tests/Bridge/Symfony/Validator/Metadata/Property/ValidatorPropertyMetadataFactoryTest.php @@ -13,6 +13,9 @@ namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator\Metadata\Property; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaFormat; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaLengthRestriction; +use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\Restriction\PropertySchemaRegexRestriction; use ApiPlatform\Core\Bridge\Symfony\Validator\Metadata\Property\ValidatorPropertyMetadataFactory; use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; @@ -21,6 +24,7 @@ use ApiPlatform\Core\Tests\ProphecyTrait; use Doctrine\Common\Annotations\AnnotationReader; use PHPUnit\Framework\TestCase; +use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Validator\Mapping\ClassMetadata; use Symfony\Component\Validator\Mapping\Factory\MetadataFactoryInterface; use Symfony\Component\Validator\Mapping\Loader\AnnotationLoader; @@ -51,7 +55,11 @@ public function testCreateWithPropertyWithRequiredConstraints() $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class)->willReturn($this->validatorClassMetadata)->shouldBeCalled(); - $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory($validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal()); + $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [] + ); $resultedPropertyMetadata = $validatorPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummy'); $this->assertEquals($expectedPropertyMetadata, $resultedPropertyMetadata); @@ -69,7 +77,11 @@ public function testCreateWithPropertyWithNotRequiredConstraints() $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class)->willReturn($this->validatorClassMetadata)->shouldBeCalled(); - $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory($validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal()); + $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [] + ); $resultedPropertyMetadata = $validatorPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummyDate'); $this->assertEquals($expectedPropertyMetadata, $resultedPropertyMetadata); @@ -86,7 +98,11 @@ public function testCreateWithPropertyWithoutConstraints() $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class)->willReturn($this->validatorClassMetadata)->shouldBeCalled(); - $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory($validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal()); + $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [] + ); $resultedPropertyMetadata = $validatorPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummyId'); $this->assertEquals($expectedPropertyMetadata, $resultedPropertyMetadata); @@ -103,7 +119,11 @@ public function testCreateWithPropertyWithRightValidationGroupsAndRequiredConstr $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class)->willReturn($this->validatorClassMetadata)->shouldBeCalled(); - $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory($validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal()); + $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [] + ); $resultedPropertyMetadata = $validatorPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummyGroup', ['validation_groups' => ['dummy']]); $this->assertEquals($expectedPropertyMetadata, $resultedPropertyMetadata); @@ -120,7 +140,11 @@ public function testCreateWithPropertyWithBadValidationGroupsAndRequiredConstrai $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class)->willReturn($this->validatorClassMetadata)->shouldBeCalled(); - $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory($validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal()); + $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [] + ); $resultedPropertyMetadata = $validatorPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummyGroup', ['validation_groups' => ['ymmud']]); $this->assertEquals($expectedPropertyMetadata, $resultedPropertyMetadata); @@ -137,7 +161,11 @@ public function testCreateWithPropertyWithNonStringValidationGroupsAndRequiredCo $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class)->willReturn($this->validatorClassMetadata)->shouldBeCalled(); - $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory($validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal()); + $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [] + ); $resultedPropertyMetadata = $validatorPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummyGroup', ['validation_groups' => [1312]]); $this->assertEquals($expectedPropertyMetadata, $resultedPropertyMetadata); @@ -152,8 +180,13 @@ public function testCreateWithRequiredByDecorated() $decoratedPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummyDate', [])->willReturn($propertyMetadata)->shouldBeCalled(); $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); + $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class)->willReturn($this->validatorClassMetadata)->shouldBeCalled(); - $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory($validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal()); + $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [] + ); $resultedPropertyMetadata = $validatorPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummyDate'); $this->assertEquals($expectedPropertyMetadata, $resultedPropertyMetadata); @@ -189,11 +222,117 @@ public function testCreateWithPropertyWithValidationConstraints() $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); $validatorMetadataFactory->getMetadataFor(DummyIriWithValidationEntity::class)->willReturn($validatorClassMetadata)->shouldBeCalled(); - $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory($validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal()); + $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [] + ); foreach ($types as $property => $iri) { $resultedPropertyMetadata = $validatorPropertyMetadataFactory->create(DummyIriWithValidationEntity::class, $property); $this->assertSame($iri, $resultedPropertyMetadata->getIri()); } } + + public function testCreateWithPropertyLengthRestriction(): void + { + $validatorClassMetadata = new ClassMetadata(DummyValidatedEntity::class); + (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + + $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); + $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class) + ->willReturn($validatorClassMetadata) + ->shouldBeCalled(); + + $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $property = 'dummy'; + $decoratedPropertyMetadataFactory->create(DummyValidatedEntity::class, $property, [])->willReturn( + new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING)) + )->shouldBeCalled(); + + $lengthRestrictions = new PropertySchemaLengthRestriction(); + $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal(), [$lengthRestrictions] + ); + + $schema = $validatorPropertyMetadataFactory->create(DummyValidatedEntity::class, $property)->getSchema(); + $this->assertNotNull($schema); + $this->assertArrayHasKey('minLength', $schema); + $this->assertArrayHasKey('maxLength', $schema); + + $numberTypes = [Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_FLOAT]; + + foreach ($numberTypes as $type) { + $decoratedPropertyMetadataFactory->create(DummyValidatedEntity::class, $property, [])->willReturn( + new PropertyMetadata(new Type($type)) + )->shouldBeCalled(); + $validatorPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal(), [$lengthRestrictions] + ); + + $schema = $validatorPropertyMetadataFactory->create(DummyValidatedEntity::class, $property)->getSchema(); + $this->assertNotNull($schema); + $this->assertArrayHasKey('minimum', $schema); + $this->assertArrayHasKey('maximum', $schema); + } + } + + public function testCreateWithPropertyRegexRestriction(): void + { + $validatorClassMetadata = new ClassMetadata(DummyValidatedEntity::class); + (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + + $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); + $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class) + ->willReturn($validatorClassMetadata) + ->shouldBeCalled(); + + $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $decoratedPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummy', [])->willReturn( + new PropertyMetadata() + )->shouldBeCalled(); + + $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), $decoratedPropertyMetadataFactory->reveal(), + [new PropertySchemaRegexRestriction()] + ); + + $schema = $validationPropertyMetadataFactory->create(DummyValidatedEntity::class, 'dummy')->getSchema(); + $this->assertNotNull($schema); + $this->assertArrayHasKey('pattern', $schema); + $this->assertEquals('^dummy$', $schema['pattern']); + } + + public function testCreateWithPropertyFormatRestriction(): void + { + $validatorClassMetadata = new ClassMetadata(DummyValidatedEntity::class); + (new AnnotationLoader(new AnnotationReader()))->loadClassMetadata($validatorClassMetadata); + + $validatorMetadataFactory = $this->prophesize(MetadataFactoryInterface::class); + $validatorMetadataFactory->getMetadataFor(DummyValidatedEntity::class) + ->willReturn($validatorClassMetadata) + ->shouldBeCalled(); + $formats = [ + 'dummyEmail' => 'email', + 'dummyUuid' => 'uuid', + 'dummyIpv4' => 'ipv4', + 'dummyIpv6' => 'ipv6', + ]; + + foreach ($formats as $property => $format) { + $decoratedPropertyMetadataFactory = $this->prophesize(PropertyMetadataFactoryInterface::class); + $decoratedPropertyMetadataFactory->create(DummyValidatedEntity::class, $property, [])->willReturn( + new PropertyMetadata() + )->shouldBeCalled(); + $validationPropertyMetadataFactory = new ValidatorPropertyMetadataFactory( + $validatorMetadataFactory->reveal(), + $decoratedPropertyMetadataFactory->reveal(), + [new PropertySchemaFormat()] + ); + $schema = $validationPropertyMetadataFactory->create(DummyValidatedEntity::class, $property)->getSchema(); + $this->assertNotNull($schema); + $this->assertArrayHasKey('format', $schema); + $this->assertEquals($format, $schema['format']); + } + } } diff --git a/tests/Bridge/Symfony/Validator/ValidatorTest.php b/tests/Bridge/Symfony/Validator/ValidatorTest.php index 96dcb2d339b..db9fef5adc2 100644 --- a/tests/Bridge/Symfony/Validator/ValidatorTest.php +++ b/tests/Bridge/Symfony/Validator/ValidatorTest.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Tests\Bridge\Symfony\Validator; use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException; +use ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface; use ApiPlatform\Core\Bridge\Symfony\Validator\Validator; use ApiPlatform\Core\Tests\Fixtures\DummyEntity; use ApiPlatform\Core\Tests\ProphecyTrait; @@ -77,26 +78,51 @@ public function testGetGroupsFromCallable() }]); } - public function testGetGroupsFromService() + public function testValidateGetGroupsFromService(): void { $data = new DummyEntity(); $constraintViolationListProphecy = $this->prophesize(ConstraintViolationListInterface::class); + $symfonyValidatorProphecy = $this->prophesize(SymfonyValidatorInterface::class); - $symfonyValidatorProphecy->validate($data, null, ['a', 'b', 'c'])->willReturn($constraintViolationListProphecy->reveal())->shouldBeCalled(); - $symfonyValidator = $symfonyValidatorProphecy->reveal(); + $symfonyValidatorProphecy->validate($data, null, ['a', 'b', 'c'])->willReturn($constraintViolationListProphecy)->shouldBeCalled(); $containerProphecy = $this->prophesize(ContainerInterface::class); - $containerProphecy->has('groups_builder')->willReturn(true)->shouldBeCalled(); + $containerProphecy->has('groups_builder')->willReturn(true); + $containerProphecy->get('groups_builder')->willReturn(new class() implements ValidationGroupsGeneratorInterface { + public function __invoke($data): array + { + return $data instanceof DummyEntity ? ['a', 'b', 'c'] : []; + } + }); + + $validator = new Validator($symfonyValidatorProphecy->reveal(), $containerProphecy->reveal()); + $validator->validate(new DummyEntity(), ['groups' => 'groups_builder']); + } + + /** + * @group legacy + * @expectedDeprecation Using a public validation groups generator service not implementing "ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface" is deprecated since 2.6 and will be removed in 3.0. + */ + public function testValidateGetGroupsFromLegacyService(): void + { + $data = new DummyEntity(); + + $constraintViolationListProphecy = $this->prophesize(ConstraintViolationListInterface::class); + + $symfonyValidatorProphecy = $this->prophesize(SymfonyValidatorInterface::class); + $symfonyValidatorProphecy->validate($data, null, ['a', 'b', 'c'])->willReturn($constraintViolationListProphecy); + + $containerProphecy = $this->prophesize(ContainerInterface::class); + $containerProphecy->has('groups_builder')->willReturn(true); $containerProphecy->get('groups_builder')->willReturn(new class() { public function __invoke($data): array { return $data instanceof DummyEntity ? ['a', 'b', 'c'] : []; } - } - )->shouldBeCalled(); + }); - $validator = new Validator($symfonyValidator, $containerProphecy->reveal()); + $validator = new Validator($symfonyValidatorProphecy->reveal(), $containerProphecy->reveal()); $validator->validate(new DummyEntity(), ['groups' => 'groups_builder']); } diff --git a/tests/Documentation/Action/DocumentationActionTest.php b/tests/Documentation/Action/DocumentationActionTest.php index e0f0fe57346..4d6556d49ba 100644 --- a/tests/Documentation/Action/DocumentationActionTest.php +++ b/tests/Documentation/Action/DocumentationActionTest.php @@ -18,6 +18,10 @@ use ApiPlatform\Core\Documentation\Documentation; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface; +use ApiPlatform\Core\OpenApi\Model\Info; +use ApiPlatform\Core\OpenApi\Model\Paths; +use ApiPlatform\Core\OpenApi\OpenApi; use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -32,9 +36,14 @@ class DocumentationActionTest extends TestCase { use ProphecyTrait; - public function testyDocumentationAction(): void + /** + * @group legacy + * @expectedDeprecation Not passing an instance of "ApiPlatform\Core\OpenApi\Factory\OpenApiFactoryInterface" as 7th parameter of the constructor of "ApiPlatform\Core\Documentation\Action\DocumentationAction" is deprecated since API Platform 2.6 + */ + public function testDocumentationAction(): void { $requestProphecy = $this->prophesize(Request::class); + $requestProphecy->getRequestFormat()->willReturn('json'); $attributesProphecy = $this->prophesize(ParameterBagInterface::class); $queryProphecy = $this->prophesize(ParameterBag::class); $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); @@ -55,6 +64,7 @@ public function testyDocumentationAction(): void public function testLegacyDocumentationAction(): void { $requestProphecy = $this->prophesize(Request::class); + $requestProphecy->getRequestFormat()->willReturn('json'); $attributesProphecy = $this->prophesize(ParameterBagInterface::class); $queryProphecy = $this->prophesize(ParameterBag::class); $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); @@ -84,4 +94,50 @@ public function testDocumentationActionFormatDeprecation() $resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection(['dummies'])); new DocumentationAction($resourceNameCollectionFactoryProphecy->reveal(), '', '', '', ['formats' => ['jsonld' => 'application/ld+json']]); } + + public function testDocumentationActionV2(): void + { + $openApiFactoryProphecy = $this->prophesize(OpenApiFactoryInterface::class); + $requestProphecy = $this->prophesize(Request::class); + $requestProphecy->getRequestFormat()->willReturn('json'); + $attributesProphecy = $this->prophesize(ParameterBagInterface::class); + $queryProphecy = $this->prophesize(ParameterBag::class); + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection(['dummies'])); + $requestProphecy->attributes = $attributesProphecy->reveal(); + $requestProphecy->query = $queryProphecy->reveal(); + $requestProphecy->getBaseUrl()->willReturn('/api')->shouldBeCalledTimes(1); + $queryProphecy->getBoolean('api_gateway')->willReturn(true)->shouldBeCalledTimes(1); + $queryProphecy->getInt('spec_version', 2)->willReturn(2)->shouldBeCalledTimes(1); + $attributesProphecy->all()->willReturn(['_api_normalization_context' => ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true, 'spec_version' => 2]])->shouldBeCalledTimes(1); + $attributesProphecy->get('_api_normalization_context', [])->willReturn(['foo' => 'bar'])->shouldBeCalledTimes(1); + $attributesProphecy->set('_api_normalization_context', ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true, 'spec_version' => 2])->shouldBeCalledTimes(1); + + $documentation = new DocumentationAction($resourceNameCollectionFactoryProphecy->reveal(), 'My happy hippie api', 'lots of chocolate', '1.0.0', null, [2, 3], $openApiFactoryProphecy->reveal()); + $this->assertEquals(new Documentation(new ResourceNameCollection(['dummies']), 'My happy hippie api', 'lots of chocolate', '1.0.0'), $documentation($requestProphecy->reveal())); + } + + public function testDocumentationActionV3(): void + { + $openApi = new OpenApi(new Info('my api', '1.0.0'), [], new Paths()); + $openApiFactoryProphecy = $this->prophesize(OpenApiFactoryInterface::class); + $openApiFactoryProphecy->__invoke(Argument::any())->shouldBeCalled()->willReturn($openApi); + $requestProphecy = $this->prophesize(Request::class); + $requestProphecy->getRequestFormat()->willReturn('json'); + $attributesProphecy = $this->prophesize(ParameterBagInterface::class); + $queryProphecy = $this->prophesize(ParameterBag::class); + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection(['dummies'])); + $requestProphecy->attributes = $attributesProphecy->reveal(); + $requestProphecy->query = $queryProphecy->reveal(); + $requestProphecy->getBaseUrl()->willReturn('/api')->shouldBeCalledTimes(1); + $queryProphecy->getBoolean('api_gateway')->willReturn(true)->shouldBeCalledTimes(1); + $queryProphecy->getInt('spec_version', 2)->willReturn(3)->shouldBeCalledTimes(1); + $attributesProphecy->all()->willReturn(['_api_normalization_context' => ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true, 'spec_version' => 3]])->shouldBeCalledTimes(1); + $attributesProphecy->get('_api_normalization_context', [])->willReturn(['foo' => 'bar'])->shouldBeCalledTimes(1); + $attributesProphecy->set('_api_normalization_context', ['foo' => 'bar', 'base_url' => '/api', 'api_gateway' => true, 'spec_version' => 3])->shouldBeCalledTimes(1); + + $documentation = new DocumentationAction($resourceNameCollectionFactoryProphecy->reveal(), 'My happy hippie api', 'lots of chocolate', '1.0.0', null, [2, 3], $openApiFactoryProphecy->reveal()); + $this->assertInstanceOf(OpenApi::class, $documentation($requestProphecy->reveal())); + } } diff --git a/tests/Filter/QueryParameterValidateListenerTest.php b/tests/EventListener/QueryParameterValidateListenerTest.php similarity index 69% rename from tests/Filter/QueryParameterValidateListenerTest.php rename to tests/EventListener/QueryParameterValidateListenerTest.php index d540263ca8e..c77773bcb3d 100644 --- a/tests/Filter/QueryParameterValidateListenerTest.php +++ b/tests/EventListener/QueryParameterValidateListenerTest.php @@ -11,17 +11,16 @@ declare(strict_types=1); -namespace ApiPlatform\Core\Tests\Filter; +namespace ApiPlatform\Core\Tests\EventListener; -use ApiPlatform\Core\Api\FilterInterface; +use ApiPlatform\Core\EventListener\QueryParameterValidateListener; use ApiPlatform\Core\Exception\FilterValidationException; -use ApiPlatform\Core\Filter\QueryParameterValidateListener; +use ApiPlatform\Core\Filter\QueryParameterValidator; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; -use Psr\Container\ContainerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\RequestEvent; @@ -30,7 +29,7 @@ class QueryParameterValidateListenerTest extends TestCase use ProphecyTrait; private $testedInstance; - private $filterLocatorProphecy; + private $queryParameterValidor; /** * unsafe method should not use filter validations. @@ -63,8 +62,7 @@ public function testOnKernelRequestWithWrongFilter() $eventProphecy = $this->prophesize(RequestEvent::class); $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); - $this->filterLocatorProphecy->has('some_inexistent_filter')->shouldBeCalled(); - $this->filterLocatorProphecy->get('some_inexistent_filter')->shouldNotBeCalled(); + $this->queryParameterValidor->validateFilters(Dummy::class, ['some_inexistent_filter'], [])->shouldBeCalled(); $this->assertNull( $this->testedInstance->onKernelRequest($eventProphecy->reveal()) @@ -84,24 +82,10 @@ public function testOnKernelRequestWithRequiredFilterNotSet() $eventProphecy = $this->prophesize(RequestEvent::class); $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); - $this->filterLocatorProphecy - ->has('some_filter') + $this->queryParameterValidor + ->validateFilters(Dummy::class, ['some_filter'], []) ->shouldBeCalled() - ->willReturn(true); - $filterProphecy = $this->prophesize(FilterInterface::class); - $filterProphecy - ->getDescription(Dummy::class) - ->shouldBeCalled() - ->willReturn([ - 'required' => [ - 'required' => true, - ], - ]); - $this->filterLocatorProphecy - ->get('some_filter') - ->shouldBeCalled() - ->willReturn($filterProphecy->reveal()); - + ->willThrow(new FilterValidationException(['Query parameter "required" is required'])); $this->expectException(FilterValidationException::class); $this->expectExceptionMessage('Query parameter "required" is required'); $this->testedInstance->onKernelRequest($eventProphecy->reveal()); @@ -115,32 +99,21 @@ public function testOnKernelRequestWithRequiredFilter() $this->setUpWithFilters(['some_filter']); $request = new Request( - ['required' => 'foo'], [], - ['_api_resource_class' => Dummy::class, '_api_collection_operation_name' => 'get'] + [], + ['_api_resource_class' => Dummy::class, '_api_collection_operation_name' => 'get'], + [], + [], + ['QUERY_STRING' => 'required=foo'] ); $request->setMethod('GET'); $eventProphecy = $this->prophesize(RequestEvent::class); $eventProphecy->getRequest()->willReturn($request)->shouldBeCalled(); - $this->filterLocatorProphecy - ->has('some_filter') - ->shouldBeCalled() - ->willReturn(true); - $filterProphecy = $this->prophesize(FilterInterface::class); - $filterProphecy - ->getDescription(Dummy::class) - ->shouldBeCalled() - ->willReturn([ - 'required' => [ - 'required' => true, - ], - ]); - $this->filterLocatorProphecy - ->get('some_filter') - ->shouldBeCalled() - ->willReturn($filterProphecy->reveal()); + $this->queryParameterValidor + ->validateFilters(Dummy::class, ['some_filter'], ['required' => 'foo']) + ->shouldBeCalled(); $this->assertNull( $this->testedInstance->onKernelRequest($eventProphecy->reveal()) @@ -159,11 +132,11 @@ private function setUpWithFilters(array $filters = []) ]) ); - $this->filterLocatorProphecy = $this->prophesize(ContainerInterface::class); + $this->queryParameterValidor = $this->prophesize(QueryParameterValidator::class); $this->testedInstance = new QueryParameterValidateListener( $resourceMetadataFactoryProphecy->reveal(), - $this->filterLocatorProphecy->reveal() + $this->queryParameterValidor->reveal() ); } } diff --git a/tests/Filter/QueryParameterValidatorTest.php b/tests/Filter/QueryParameterValidatorTest.php new file mode 100644 index 00000000000..1c9e60f06f6 --- /dev/null +++ b/tests/Filter/QueryParameterValidatorTest.php @@ -0,0 +1,128 @@ + + * + * For 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\Filter; + +use ApiPlatform\Core\Api\FilterInterface; +use ApiPlatform\Core\Exception\FilterValidationException; +use ApiPlatform\Core\Filter\QueryParameterValidator; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; + +/** + * Class QueryParameterValidatorTest. + * + * @author Julien Deniau + */ +class QueryParameterValidatorTest extends TestCase +{ + private $testedInstance; + private $filterLocatorProphecy; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->filterLocatorProphecy = $this->prophesize(ContainerInterface::class); + + $this->testedInstance = new QueryParameterValidator( + $this->filterLocatorProphecy->reveal() + ); + } + + /** + * unsafe method should not use filter validations. + */ + public function testOnKernelRequestWithUnsafeMethod() + { + $request = []; + + $this->assertNull( + $this->testedInstance->validateFilters(Dummy::class, [], $request) + ); + } + + /** + * If the tested filter is non-existant, then nothing should append. + */ + public function testOnKernelRequestWithWrongFilter() + { + $request = []; + + $this->assertNull( + $this->testedInstance->validateFilters(Dummy::class, ['some_inexistent_filter'], $request) + ); + } + + /** + * if the required parameter is not set, throw an FilterValidationException. + */ + public function testOnKernelRequestWithRequiredFilterNotSet() + { + $request = []; + + $filterProphecy = $this->prophesize(FilterInterface::class); + $filterProphecy + ->getDescription(Dummy::class) + ->shouldBeCalled() + ->willReturn([ + 'required' => [ + 'required' => true, + ], + ]); + $this->filterLocatorProphecy + ->has('some_filter') + ->shouldBeCalled() + ->willReturn(true); + $this->filterLocatorProphecy + ->get('some_filter') + ->shouldBeCalled() + ->willReturn($filterProphecy->reveal()); + + $this->expectException(FilterValidationException::class); + $this->expectExceptionMessage('Query parameter "required" is required'); + $this->testedInstance->validateFilters(Dummy::class, ['some_filter'], $request); + } + + /** + * if the required parameter is set, no exception should be throwned. + */ + public function testOnKernelRequestWithRequiredFilter() + { + $request = ['required' => 'foo']; + + $this->filterLocatorProphecy + ->has('some_filter') + ->shouldBeCalled() + ->willReturn(true); + $filterProphecy = $this->prophesize(FilterInterface::class); + $filterProphecy + ->getDescription(Dummy::class) + ->shouldBeCalled() + ->willReturn([ + 'required' => [ + 'required' => true, + ], + ]); + $this->filterLocatorProphecy + ->get('some_filter') + ->shouldBeCalled() + ->willReturn($filterProphecy->reveal()); + + $this->assertNull( + $this->testedInstance->validateFilters(Dummy::class, ['some_filter'], $request) + ); + } +} diff --git a/tests/Filter/Validator/ArrayItemsTest.php b/tests/Filter/Validator/ArrayItemsTest.php new file mode 100644 index 00000000000..66aee6ee7e5 --- /dev/null +++ b/tests/Filter/Validator/ArrayItemsTest.php @@ -0,0 +1,198 @@ + + * + * For 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\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\ArrayItems; +use PHPUnit\Framework\TestCase; + +/** + * @author Julien Deniau + */ +class ArrayItemsTest extends TestCase +{ + public function testNonDefinedFilter() + { + $request = []; + $filter = new ArrayItems(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testEmptyQueryParameter() + { + $request = ['some_filter' => '']; + $filter = new ArrayItems(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testNonMatchingParameter() + { + $filter = new ArrayItems(); + + $filterDefinition = [ + 'swagger' => [ + 'maxItems' => 3, + 'minItems' => 2, + ], + ]; + + $request = ['some_filter' => ['foo', 'bar', 'bar', 'foo']]; + $this->assertEquals( + ['Query parameter "some_filter" must contain less than 3 values'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $request = ['some_filter' => ['foo']]; + $this->assertEquals( + ['Query parameter "some_filter" must contain more than 2 values'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testMatchingParameter() + { + $filter = new ArrayItems(); + + $filterDefinition = [ + 'swagger' => [ + 'maxItems' => 3, + 'minItems' => 2, + ], + ]; + + $request = ['some_filter' => ['foo', 'bar']]; + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $request = ['some_filter' => ['foo', 'bar', 'baz']]; + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testNonMatchingUniqueItems() + { + $filter = new ArrayItems(); + + $filterDefinition = [ + 'swagger' => [ + 'uniqueItems' => true, + ], + ]; + + $request = ['some_filter' => ['foo', 'bar', 'bar', 'foo']]; + $this->assertEquals( + ['Query parameter "some_filter" must contain unique values'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testMatchingUniqueItems() + { + $filter = new ArrayItems(); + + $filterDefinition = [ + 'swagger' => [ + 'uniqueItems' => true, + ], + ]; + + $request = ['some_filter' => ['foo', 'bar', 'baz']]; + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testSeparators() + { + $filter = new ArrayItems(); + + $filterDefinition = [ + 'swagger' => [ + 'maxItems' => 2, + 'uniqueItems' => true, + 'collectionFormat' => 'csv', + ], + ]; + + $request = ['some_filter' => 'foo,bar,bar']; + $this->assertEquals( + [ + 'Query parameter "some_filter" must contain less than 2 values', + 'Query parameter "some_filter" must contain unique values', + ], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition['swagger']['collectionFormat'] = 'ssv'; + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition['swagger']['collectionFormat'] = 'ssv'; + $request = ['some_filter' => 'foo bar bar']; + $this->assertEquals( + [ + 'Query parameter "some_filter" must contain less than 2 values', + 'Query parameter "some_filter" must contain unique values', + ], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition['swagger']['collectionFormat'] = 'tsv'; + $request = ['some_filter' => 'foo\tbar\tbar']; + $this->assertEquals( + [ + 'Query parameter "some_filter" must contain less than 2 values', + 'Query parameter "some_filter" must contain unique values', + ], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition['swagger']['collectionFormat'] = 'pipes'; + $request = ['some_filter' => 'foo|bar|bar']; + $this->assertEquals( + [ + 'Query parameter "some_filter" must contain less than 2 values', + 'Query parameter "some_filter" must contain unique values', + ], + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testSeparatorsUnknownSeparator() + { + $filter = new ArrayItems(); + + $filterDefinition = [ + 'swagger' => [ + 'maxItems' => 2, + 'uniqueItems' => true, + 'collectionFormat' => 'unknownFormat', + ], + ]; + $request = ['some_filter' => 'foo,bar,bar']; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown collection format unknownFormat'); + + $filter->validate('some_filter', $filterDefinition, $request); + } +} diff --git a/tests/Filter/Validator/BoundsTest.php b/tests/Filter/Validator/BoundsTest.php new file mode 100644 index 00000000000..50f2958ec43 --- /dev/null +++ b/tests/Filter/Validator/BoundsTest.php @@ -0,0 +1,177 @@ + + * + * For 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\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\Bounds; +use PHPUnit\Framework\TestCase; + +/** + * @author Julien Deniau + */ +class BoundsTest extends TestCase +{ + public function testNonDefinedFilter() + { + $filter = new Bounds(); + + $this->assertEmpty( + $filter->validate('some_filter', [], []) + ); + } + + public function testEmptyQueryParameter() + { + $filter = new Bounds(); + + $this->assertEmpty( + $filter->validate('some_filter', [], ['some_filter' => '']) + ); + } + + public function testNonMatchingMinimum() + { + $request = ['some_filter' => '9']; + $filter = new Bounds(); + + $filterDefinition = [ + 'swagger' => [ + 'minimum' => 10, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be greater than or equal to 10'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition = [ + 'swagger' => [ + 'minimum' => 10, + 'exclusiveMinimum' => false, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be greater than or equal to 10'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition = [ + 'swagger' => [ + 'minimum' => 9, + 'exclusiveMinimum' => true, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be greater than 9'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testMatchingMinimum() + { + $request = ['some_filter' => '10']; + $filter = new Bounds(); + + $filterDefinition = [ + 'swagger' => [ + 'minimum' => 10, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition = [ + 'swagger' => [ + 'minimum' => 9, + 'exclusiveMinimum' => false, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testNonMatchingMaximum() + { + $request = ['some_filter' => '11']; + $filter = new Bounds(); + + $filterDefinition = [ + 'swagger' => [ + 'maximum' => 10, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be less than or equal to 10'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition = [ + 'swagger' => [ + 'maximum' => 10, + 'exclusiveMaximum' => false, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be less than or equal to 10'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition = [ + 'swagger' => [ + 'maximum' => 9, + 'exclusiveMaximum' => true, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be less than 9'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testMatchingMaximum() + { + $request = ['some_filter' => '10']; + $filter = new Bounds(); + + $filterDefinition = [ + 'swagger' => [ + 'maximum' => 10, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + + $filterDefinition = [ + 'swagger' => [ + 'maximum' => 10, + 'exclusiveMaximum' => false, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + } +} diff --git a/tests/Filter/Validator/EnumTest.php b/tests/Filter/Validator/EnumTest.php new file mode 100644 index 00000000000..bd55f076a65 --- /dev/null +++ b/tests/Filter/Validator/EnumTest.php @@ -0,0 +1,72 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\Enum; +use PHPUnit\Framework\TestCase; + +/** + * @author Julien Deniau + */ +class EnumTest extends TestCase +{ + public function testNonDefinedFilter() + { + $filter = new Enum(); + + $this->assertEmpty( + $filter->validate('some_filter', [], []) + ); + } + + public function testEmptyQueryParameter() + { + $filter = new Enum(); + + $this->assertEmpty( + $filter->validate('some_filter', [], ['some_filter' => '']) + ); + } + + public function testNonMatchingParameter() + { + $filter = new Enum(); + + $filterDefinition = [ + 'swagger' => [ + 'enum' => ['foo', 'bar'], + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must be one of "foo, bar"'], + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'foobar']) + ); + } + + public function testMatchingParameter() + { + $filter = new Enum(); + + $filterDefinition = [ + 'swagger' => [ + 'enum' => ['foo', 'bar'], + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'foo']) + ); + } +} diff --git a/tests/Filter/Validator/LengthTest.php b/tests/Filter/Validator/LengthTest.php new file mode 100644 index 00000000000..d9f36b3500c --- /dev/null +++ b/tests/Filter/Validator/LengthTest.php @@ -0,0 +1,139 @@ + + * + * For 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\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\Length; +use PHPUnit\Framework\TestCase; + +/** + * @author Julien Deniau + */ +class LengthTest extends TestCase +{ + public function testNonDefinedFilter() + { + $filter = new Length(); + + $this->assertEmpty( + $filter->validate('some_filter', [], []) + ); + } + + public function testEmptyQueryParameter() + { + $filter = new Length(); + + $this->assertEmpty( + $filter->validate('some_filter', [], ['some_filter' => '']) + ); + } + + public function testNonMatchingParameter() + { + $filter = new Length(); + + $filterDefinition = [ + 'swagger' => [ + 'minLength' => 3, + 'maxLength' => 5, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" length must be greater than or equal to 3'], + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'ab']) + ); + + $this->assertEquals( + ['Query parameter "some_filter" length must be lower than or equal to 5'], + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'abcdef']) + ); + } + + public function testNonMatchingParameterWithOnlyOneDefinition() + { + $filter = new Length(); + + $filterDefinition = [ + 'swagger' => [ + 'minLength' => 3, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" length must be greater than or equal to 3'], + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'ab']) + ); + + $filterDefinition = [ + 'swagger' => [ + 'maxLength' => 5, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" length must be lower than or equal to 5'], + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'abcdef']) + ); + } + + public function testMatchingParameter() + { + $filter = new Length(); + + $filterDefinition = [ + 'swagger' => [ + 'minLength' => 3, + 'maxLength' => 5, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'abc']) + ); + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'abcd']) + ); + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'abcde']) + ); + } + + public function testMatchingParameterWithOneDefinition() + { + $filter = new Length(); + + $filterDefinition = [ + 'swagger' => [ + 'minLength' => 3, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'abc']) + ); + + $filterDefinition = [ + 'swagger' => [ + 'maxLength' => 5, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, ['some_filter' => 'abcde']) + ); + } +} diff --git a/tests/Filter/Validator/MultipleOfTest.php b/tests/Filter/Validator/MultipleOfTest.php new file mode 100644 index 00000000000..f313cd3cba2 --- /dev/null +++ b/tests/Filter/Validator/MultipleOfTest.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\MultipleOf; +use PHPUnit\Framework\TestCase; + +/** + * @author Julien Deniau + */ +class MultipleOfTest extends TestCase +{ + public function testNonDefinedFilter() + { + $filter = new MultipleOf(); + + $this->assertEmpty( + $filter->validate('some_filter', [], []) + ); + } + + public function testEmptyQueryParameter() + { + $request = ['some_filter' => '']; + $filter = new MultipleOf(); + + $this->assertEmpty( + $filter->validate('some_filter', [], $request) + ); + } + + public function testNonMatchingParameter() + { + $request = ['some_filter' => '8']; + $filter = new MultipleOf(); + + $filterDefinition = [ + 'swagger' => [ + 'multipleOf' => 3, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must multiple of 3'], + $filter->validate('some_filter', $filterDefinition, $request) + ); + } + + public function testMatchingParameter() + { + $request = ['some_filter' => '8']; + $filter = new MultipleOf(); + + $filterDefinition = [ + 'swagger' => [ + 'multipleOf' => 4, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $filterDefinition, $request) + ); + } +} diff --git a/tests/Filter/Validator/PatternTest.php b/tests/Filter/Validator/PatternTest.php new file mode 100644 index 00000000000..18f58aff86c --- /dev/null +++ b/tests/Filter/Validator/PatternTest.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\Pattern; +use PHPUnit\Framework\TestCase; + +/** + * @author Julien Deniau + */ +class PatternTest extends TestCase +{ + public function testNonDefinedFilter() + { + $filter = new Pattern(); + + $this->assertEmpty( + $filter->validate('some_filter', [], []) + ); + } + + public function testFilterWithEmptyValue() + { + $filter = new Pattern(); + + $explicitFilterDefinition = [ + 'swagger' => [ + 'pattern' => '/foo/', + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $explicitFilterDefinition, ['some_filter' => '']) + ); + + $weirdParameter = new \stdClass(); + $weirdParameter->foo = 'non string value should not exists'; + $this->assertEmpty( + $filter->validate('some_filter', $explicitFilterDefinition, ['some_filter' => $weirdParameter]) + ); + } + + public function testFilterWithZeroAsParameter() + { + $filter = new Pattern(); + + $explicitFilterDefinition = [ + 'swagger' => [ + 'pattern' => '/foo/', + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must match pattern /foo/'], + $filter->validate('some_filter', $explicitFilterDefinition, ['some_filter' => '0']) + ); + } + + public function testFilterWithNonMatchingValue() + { + $filter = new Pattern(); + + $explicitFilterDefinition = [ + 'swagger' => [ + 'pattern' => '/foo/', + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" must match pattern /foo/'], + $filter->validate('some_filter', $explicitFilterDefinition, ['some_filter' => 'bar']) + ); + } + + public function testFilterWithNonchingValue() + { + $filter = new Pattern(); + + $explicitFilterDefinition = [ + 'swagger' => [ + 'pattern' => '/foo \d+/', + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $explicitFilterDefinition, ['some_filter' => 'this is a foo '.random_int(0, 10).' and it should match']) + ); + } +} diff --git a/tests/Filter/Validator/RequiredTest.php b/tests/Filter/Validator/RequiredTest.php new file mode 100644 index 00000000000..fa7cd89ed97 --- /dev/null +++ b/tests/Filter/Validator/RequiredTest.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Filter\Validator; + +use ApiPlatform\Core\Filter\Validator\Required; +use PHPUnit\Framework\TestCase; + +/** + * Class RequiredTest. + * + * @author Julien Deniau + */ +class RequiredTest extends TestCase +{ + public function testNonRequiredFilter() + { + $request = []; + $filter = new Required(); + + $this->assertEmpty( + $filter->validate('some_filter', [], []) + ); + + $this->assertEmpty( + $filter->validate('some_filter', ['required' => false], $request) + ); + } + + public function testRequiredFilterNotInQuery() + { + $request = []; + $filter = new Required(); + + $this->assertEquals( + ['Query parameter "some_filter" is required'], + $filter->validate('some_filter', ['required' => true], $request) + ); + } + + public function testRequiredFilterIsPresent() + { + $request = ['some_filter' => 'some_value']; + $filter = new Required(); + + $this->assertEmpty( + $filter->validate('some_filter', ['required' => true], $request) + ); + } + + public function testEmptyValueNotAllowed() + { + $request = ['some_filter' => '']; + $filter = new Required(); + + $explicitFilterDefinition = [ + 'required' => true, + 'swagger' => [ + 'allowEmptyValue' => false, + ], + ]; + + $this->assertEquals( + ['Query parameter "some_filter" does not allow empty value'], + $filter->validate('some_filter', $explicitFilterDefinition, $request) + ); + + $implicitFilterDefinition = [ + 'required' => true, + ]; + + $this->assertEquals( + ['Query parameter "some_filter" does not allow empty value'], + $filter->validate('some_filter', $implicitFilterDefinition, $request) + ); + } + + public function testEmptyValueAllowed() + { + $request = ['some_filter' => '']; + $filter = new Required(); + + $explicitFilterDefinition = [ + 'required' => true, + 'swagger' => [ + 'allowEmptyValue' => true, + ], + ]; + + $this->assertEmpty( + $filter->validate('some_filter', $explicitFilterDefinition, $request) + ); + } +} diff --git a/tests/Fixtures/DummyMercurePublisher.php b/tests/Fixtures/DummyMercurePublisher.php index efed15a6900..d2e8b94d206 100644 --- a/tests/Fixtures/DummyMercurePublisher.php +++ b/tests/Fixtures/DummyMercurePublisher.php @@ -17,8 +17,20 @@ class DummyMercurePublisher { + private $updates = []; + public function __invoke(Update $update): string { + $this->updates[] = $update; + return 'dummy'; } + + /** + * @return array + */ + public function getUpdates(): array + { + return $this->updates; + } } diff --git a/tests/Fixtures/DummyValidatedEntity.php b/tests/Fixtures/DummyValidatedEntity.php index 8314774b654..81d3e4b852d 100644 --- a/tests/Fixtures/DummyValidatedEntity.php +++ b/tests/Fixtures/DummyValidatedEntity.php @@ -31,9 +31,39 @@ class DummyValidatedEntity * @var string A dummy * * @Assert\NotBlank + * @Assert\Length(max="4", min="10") + * @Assert\Regex(pattern="^dummy$") */ public $dummy; + /** + * @var string + * + * @Assert\Email + */ + public $dummyEmail; + + /** + * @var string + * + * @Assert\Uuid + */ + public $dummyUuid; + + /** + * @var string + * + * @Assert\Ip + */ + public $dummyIpv4; + + /** + * @var string + * + * @Assert\Ip(version="6") + */ + public $dummyIpv6; + /** * @var \DateTimeInterface A dummy date * diff --git a/tests/Fixtures/FileConfigurations/resources.xml b/tests/Fixtures/FileConfigurations/resources.xml index 217167532f6..f12ed385eaa 100644 --- a/tests/Fixtures/FileConfigurations/resources.xml +++ b/tests/Fixtures/FileConfigurations/resources.xml @@ -53,6 +53,7 @@ hydra:Operation File config Dummy + true - + + + + + + * + * For 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\DataTransformerInitializerInterface; +use ApiPlatform\Core\Serializer\AbstractItemNormalizer; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\InitializeInput as InitializeInputDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\InitializeInputDto; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\InitializeInput; + +final class InitializeInputDataTransformer implements DataTransformerInitializerInterface +{ + /** + * {@inheritdoc} + */ + public function transform($object, string $to, array $context = []) + { + /** @var InitializeInputDto */ + $data = $object; + + /** @var InitializeInput|InitializeInputDocument */ + $resourceObject = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] ?? new $context['resource_class'](); + $resourceObject->name = $data->name; + + return $resourceObject; + } + + /** + * {@inheritdoc} + */ + public function initialize(string $inputClass, array $context = []) + { + $currentResource = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] ?? null; + if (!$currentResource) { + return new InitializeInputDto(); + } + + $dto = new InitializeInputDto(); + $dto->manager = $currentResource->manager; + + return $dto; + } + + /** + * {@inheritdoc} + */ + public function supportsTransformation($data, string $to, array $context = []): bool + { + return (InitializeInput::class === $to || InitializeInputDocument::class === $to) && InitializeInputDto::class === ($context['input']['class'] ?? null); + } +} diff --git a/tests/Fixtures/TestBundle/DataTransformer/MessengerWithArrayDtoDataTransformer.php b/tests/Fixtures/TestBundle/DataTransformer/MessengerWithArrayDtoDataTransformer.php new file mode 100644 index 00000000000..0e3f497f981 --- /dev/null +++ b/tests/Fixtures/TestBundle/DataTransformer/MessengerWithArrayDtoDataTransformer.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer; + +use ApiPlatform\Core\DataTransformer\DataTransformerInterface; +use ApiPlatform\Core\Serializer\AbstractItemNormalizer; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\MessengerWithArray as MessengerWithArrayDocument; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\MessengerInput; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\MessengerWithArray as MessengerWithArrayEntity; + +final class MessengerWithArrayDtoDataTransformer implements DataTransformerInterface +{ + /** + * {@inheritdoc} + */ + public function transform($object, string $to, array $context = []) + { + /** @var MessengerInput */ + $data = $object; + + $resourceObject = $context[AbstractItemNormalizer::OBJECT_TO_POPULATE] ?? new $context['resource_class'](); + $resourceObject->name = $data->var; + + return $resourceObject; + } + + /** + * {@inheritdoc} + */ + public function supportsTransformation($object, string $to, array $context = []): bool + { + return \in_array($to, [MessengerWithArrayEntity::class, MessengerWithArrayDocument::class], true) && null !== ($context['input']['class'] ?? null); + } +} diff --git a/tests/Fixtures/TestBundle/Document/AbsoluteUrlDummy.php b/tests/Fixtures/TestBundle/Document/AbsoluteUrlDummy.php new file mode 100644 index 00000000000..6997f3ab80a --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/AbsoluteUrlDummy.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * @ApiResource(urlGenerationStrategy=UrlGeneratorInterface::ABS_URL) + * @ODM\Document + */ +class AbsoluteUrlDummy +{ + /** + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + private $id; + + /** + * @ODM\ReferenceOne(targetDocument=AbsoluteUrlRelationDummy::class, inversedBy="absoluteUrlDummies", storeAs="id") + */ + public $absoluteUrlRelationDummy; + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Document/AbsoluteUrlRelationDummy.php b/tests/Fixtures/TestBundle/Document/AbsoluteUrlRelationDummy.php new file mode 100644 index 00000000000..968c628c227 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/AbsoluteUrlRelationDummy.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Annotation\ApiSubresource; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * @ApiResource(urlGenerationStrategy=UrlGeneratorInterface::ABS_URL) + * @ODM\Document + */ +class AbsoluteUrlRelationDummy +{ + /** + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + private $id; + + /** + * @ODM\ReferenceMany(targetDocument=AbsoluteUrlDummy::class, mappedBy="absoluteUrlRelationDummy") + * @ApiSubresource + */ + public $absoluteUrlDummies; + + public function __construct() + { + $this->absoluteUrlDummies = new ArrayCollection(); + } + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Document/ContainNonResource.php b/tests/Fixtures/TestBundle/Document/ContainNonResource.php index 960c2b7449c..f3823cb7934 100644 --- a/tests/Fixtures/TestBundle/Document/ContainNonResource.php +++ b/tests/Fixtures/TestBundle/Document/ContainNonResource.php @@ -25,7 +25,7 @@ * * @ApiResource( * normalizationContext={ - * "groups"="contain_non_resource", + * "groups"={"contain_non_resource"}, * }, * ) * diff --git a/tests/Fixtures/TestBundle/Document/Dummy.php b/tests/Fixtures/TestBundle/Document/Dummy.php index f29baa22e40..471dc3a2cd6 100644 --- a/tests/Fixtures/TestBundle/Document/Dummy.php +++ b/tests/Fixtures/TestBundle/Document/Dummy.php @@ -27,6 +27,11 @@ * @author Alexandre Delplace * * @ApiResource(attributes={ + * "doctrine_mongodb"={ + * "execute_options"={ + * "allowDiskUse"=true + * } + * }, * "filters"={ * "my_dummy.mongodb.boolean", * "my_dummy.mongodb.date", diff --git a/tests/Fixtures/TestBundle/Document/DummyCar.php b/tests/Fixtures/TestBundle/Document/DummyCar.php index df23c2cfe9a..34af7e629e9 100644 --- a/tests/Fixtures/TestBundle/Document/DummyCar.php +++ b/tests/Fixtures/TestBundle/Document/DummyCar.php @@ -29,7 +29,7 @@ * itemOperations={"get"={"swagger_context"={"tags"={}}, "openapi_context"={"tags"={}}}, "put", "delete"}, * attributes={ * "sunset"="2050-01-01", - * "normalization_context"={"groups"="colors"} + * "normalization_context"={"groups"={"colors"}} * } * ) * @ODM\Document @@ -110,6 +110,16 @@ class DummyCar */ private $availableAt; + /** + * @var string + * + * @Serializer\Groups({"colors"}) + * @Serializer\SerializedName("carBrand") + * + * @ODM\Field + */ + private $brand = 'DummyBrand'; + public function __construct() { $this->colors = new ArrayCollection(); @@ -191,4 +201,14 @@ public function setAvailableAt(\DateTime $availableAt) { $this->availableAt = $availableAt; } + + public function getBrand(): string + { + return $this->brand; + } + + public function setBrand(string $brand): void + { + $this->brand = $brand; + } } diff --git a/tests/Fixtures/TestBundle/Document/DummyMercure.php b/tests/Fixtures/TestBundle/Document/DummyMercure.php index 25177c85bd1..5a4b9cc23e5 100644 --- a/tests/Fixtures/TestBundle/Document/DummyMercure.php +++ b/tests/Fixtures/TestBundle/Document/DummyMercure.php @@ -28,4 +28,19 @@ class DummyMercure * @ODM\Id(strategy="INCREMENT", type="integer") */ public $id; + + /** + * @ODM\Field(type="string") + */ + public $name; + + /** + * @ODM\Field(type="string") + */ + public $description; + + /** + * @ODM\ReferenceOne(targetDocument=RelatedDummy::class, storeAs="id", nullable=true) + */ + public $relatedDummy; } diff --git a/tests/Fixtures/TestBundle/Document/FilterValidator.php b/tests/Fixtures/TestBundle/Document/FilterValidator.php index 722ac9f849f..1756965196a 100644 --- a/tests/Fixtures/TestBundle/Document/FilterValidator.php +++ b/tests/Fixtures/TestBundle/Document/FilterValidator.php @@ -15,6 +15,13 @@ use ApiPlatform\Core\Annotation\ApiProperty; use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\ArrayItemsFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\BoundsFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\EnumFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\LengthFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\MultipleOfFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\PatternFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredAllowEmptyFilter; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredFilter; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; @@ -26,7 +33,14 @@ * * @ApiResource(attributes={ * "filters"={ - * RequiredFilter::class + * ArrayItemsFilter::class, + * BoundsFilter::class, + * EnumFilter::class, + * LengthFilter::class, + * MultipleOfFilter::class, + * PatternFilter::class, + * RequiredFilter::class, + * RequiredAllowEmptyFilter::class * } * }) * @ODM\Document diff --git a/tests/Fixtures/TestBundle/Document/Foo.php b/tests/Fixtures/TestBundle/Document/Foo.php index d159b9362bb..385cacc8f23 100644 --- a/tests/Fixtures/TestBundle/Document/Foo.php +++ b/tests/Fixtures/TestBundle/Document/Foo.php @@ -30,6 +30,11 @@ * "collection_query"={"pagination_enabled"=false}, * "create", * "delete" + * }, + * collectionOperations={ + * "get", + * "get_desc_custom"={"method"="GET", "path"="custom_collection_desc_foos", "order"={"name"="DESC"}}, + * "get_asc_custom"={"method"="GET", "path"="custom_collection_asc_foos", "order"={ "name"="ASC"}}, * } * ) * @ODM\Document diff --git a/tests/Fixtures/TestBundle/Document/FooDummy.php b/tests/Fixtures/TestBundle/Document/FooDummy.php index 2cb5ef90f57..e29794ceb44 100644 --- a/tests/Fixtures/TestBundle/Document/FooDummy.php +++ b/tests/Fixtures/TestBundle/Document/FooDummy.php @@ -21,9 +21,14 @@ * * @author Vincent Chalamon * - * @ApiResource(attributes={ - * "order"={"dummy.name"} - * }) + * @ApiResource( + * attributes={ + * "order"={"dummy.name"} + * }, + * graphql={ + * "collection_query"={"pagination_type"="page"} + * } + * ) * @ODM\Document */ class FooDummy diff --git a/tests/Fixtures/TestBundle/Document/InitializeInput.php b/tests/Fixtures/TestBundle/Document/InitializeInput.php new file mode 100644 index 00000000000..3171097a946 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/InitializeInput.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\InitializeInputDto; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * @ApiResource(input=InitializeInputDto::class) + * @ODM\Document + */ +class InitializeInput +{ + /** + * @ODM\Id(strategy="NONE", type="integer") + */ + public $id; + + /** + * @ODM\Field + */ + public $manager; + + /** + * @ODM\Field + */ + public $name; +} diff --git a/tests/Fixtures/TestBundle/Document/IriOnlyDummy.php b/tests/Fixtures/TestBundle/Document/IriOnlyDummy.php new file mode 100644 index 00000000000..b278a71fd20 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/IriOnlyDummy.php @@ -0,0 +1,62 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * Dummy with iri_only. + * + * @author Pierre Thibaudeau + * + * @ApiResource( + * normalizationContext={ + * "iri_only"=true, + * "jsonld_embed_context"=true + * } + * ) + * @ODM\Document + */ +class IriOnlyDummy +{ + /** + * @var int The id + * + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + private $id; + + /** + * @var string + * + * @ODM\Field(type="string") + */ + private $foo; + + public function getId(): int + { + return $this->id; + } + + public function getFoo(): string + { + return $this->foo; + } + + public function setFoo(string $foo): void + { + $this->foo = $foo; + } +} diff --git a/tests/Fixtures/TestBundle/Document/MessengerWithArray.php b/tests/Fixtures/TestBundle/Document/MessengerWithArray.php new file mode 100644 index 00000000000..81dc5482192 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/MessengerWithArray.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiProperty; +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\MessengerInput; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * @ApiResource(messenger={"persist", "input"}, input=MessengerInput::class) + * @ODM\Document + */ +class MessengerWithArray +{ + /** + * @var int|null + * + * @ApiProperty(identifier=true) + * + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + public $id; + + /** + * @var string|null + * + * @ODM\Field + */ + public $name; +} diff --git a/tests/Fixtures/TestBundle/Document/MessengerWithPersist.php b/tests/Fixtures/TestBundle/Document/MessengerWithPersist.php new file mode 100644 index 00000000000..bc8b317cc60 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/MessengerWithPersist.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiProperty; +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * @ApiResource(messenger="persist") + * @ODM\Document + */ +class MessengerWithPersist +{ + /** + * @var int|null + * + * @ApiProperty(identifier=true) + * + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + public $id; + + /** + * @var string|null + * + * @ODM\Field + */ + public $name; +} diff --git a/tests/Fixtures/TestBundle/Document/NetworkPathDummy.php b/tests/Fixtures/TestBundle/Document/NetworkPathDummy.php new file mode 100644 index 00000000000..1026bd41402 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/NetworkPathDummy.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * @ApiResource(urlGenerationStrategy=UrlGeneratorInterface::NET_PATH) + * @ODM\Document + */ +class NetworkPathDummy +{ + /** + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + private $id; + + /** + * @ODM\ReferenceOne(targetDocument=NetworkPathRelationDummy::class, inversedBy="networkPathDummies", storeAs="id") + */ + public $networkPathRelationDummy; + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Document/NetworkPathRelationDummy.php b/tests/Fixtures/TestBundle/Document/NetworkPathRelationDummy.php new file mode 100644 index 00000000000..05331574484 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/NetworkPathRelationDummy.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Annotation\ApiSubresource; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * @ApiResource(urlGenerationStrategy=UrlGeneratorInterface::NET_PATH) + * @ODM\Document + */ +class NetworkPathRelationDummy +{ + /** + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + private $id; + + /** + * @ODM\ReferenceMany(targetDocument=NetworkPathDummy::class, mappedBy="networkPathRelationDummy") + * @ApiSubresource + */ + public $networkPathDummies; + + public function __construct() + { + $this->networkPathDummies = new ArrayCollection(); + } + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Document/PatchDummyRelation.php b/tests/Fixtures/TestBundle/Document/PatchDummyRelation.php new file mode 100644 index 00000000000..e9368e1b6e5 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/PatchDummyRelation.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; +use Symfony\Component\Serializer\Annotation\Groups; + +/** + * @author Kévin Dunglas + * + * @ApiResource( + * attributes={ + * "normalization_context"={"groups"={"chicago"}}, + * "denormalization_context"={"groups"={"chicago"}}, + * }, + * itemOperations={ + * "get", + * "patch"={"input_formats"={"json"={"application/merge-patch+json"}, "jsonapi"}} + * } + * ) + * @ODM\Document + */ +class PatchDummyRelation +{ + /** + * @ODM\Id(strategy="INCREMENT", type="integer") + */ + public $id; + + /** + * @ODM\ReferenceOne(targetDocument=RelatedDummy::class) + * @Groups({"chicago"}) + */ + protected $related; + + public function getRelated() + { + return $this->related; + } + + public function setRelated(RelatedDummy $relatedDummy) + { + $this->related = $relatedDummy; + } +} diff --git a/tests/Fixtures/TestBundle/Document/SecuredDummy.php b/tests/Fixtures/TestBundle/Document/SecuredDummy.php index bc932469381..6820d13b0f5 100644 --- a/tests/Fixtures/TestBundle/Document/SecuredDummy.php +++ b/tests/Fixtures/TestBundle/Document/SecuredDummy.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document; +use ApiPlatform\Core\Annotation\ApiProperty; use ApiPlatform\Core\Annotation\ApiResource; use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; use Symfony\Component\Validator\Constraints as Assert; @@ -26,7 +27,7 @@ * @ApiResource( * attributes={"security"="is_granted('ROLE_USER')"}, * collectionOperations={ - * "get", + * "get"={"security"="is_granted('ROLE_USER') or is_granted('ROLE_ADMIN')"}, * "get_from_data_provider_generator"={ * "method"="GET", * "path"="custom_data_provider_generator", @@ -72,6 +73,14 @@ class SecuredDummy */ private $description = ''; + /** + * @var string The dummy secret property, only readable/writable by specific users + * + * @ODM\Field + * @ApiProperty(security="is_granted('ROLE_ADMIN')") + */ + private $adminOnlyProperty = ''; + /** * @var string The owner * @@ -105,6 +114,16 @@ public function setDescription(string $description) $this->description = $description; } + public function getAdminOnlyProperty(): ?string + { + return $this->adminOnlyProperty; + } + + public function setAdminOnlyProperty(?string $adminOnlyProperty) + { + $this->adminOnlyProperty = $adminOnlyProperty; + } + public function getOwner(): string { return $this->owner; diff --git a/tests/Fixtures/TestBundle/Document/UrlEncodedId.php b/tests/Fixtures/TestBundle/Document/UrlEncodedId.php new file mode 100644 index 00000000000..31ebbf31bb5 --- /dev/null +++ b/tests/Fixtures/TestBundle/Document/UrlEncodedId.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Document; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ODM\MongoDB\Mapping\Annotations as ODM; + +/** + * @author Daniel West + * + * Resource with an ID that will be URL encoded + * + * @ODM\Document + * + * @ApiResource( + * itemOperations={ + * "get"={ + * "method"="GET", + * "requirements"={"id"=".+"} + * } + * } + * ) + */ +class UrlEncodedId +{ + /** + * @ODM\Id(strategy="none") + */ + private $id = '%encode:id'; + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Dto/InitializeInputDto.php b/tests/Fixtures/TestBundle/Dto/InitializeInputDto.php new file mode 100644 index 00000000000..a7fa3c8dc94 --- /dev/null +++ b/tests/Fixtures/TestBundle/Dto/InitializeInputDto.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto; + +class InitializeInputDto +{ + /** + * @var string + */ + public $name; + + /** + * @var string + */ + public $manager; +} diff --git a/tests/Fixtures/TestBundle/Entity/AbsoluteUrlDummy.php b/tests/Fixtures/TestBundle/Entity/AbsoluteUrlDummy.php new file mode 100644 index 00000000000..cff27c3ac83 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/AbsoluteUrlDummy.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ApiResource(urlGenerationStrategy=UrlGeneratorInterface::ABS_URL) + * @ORM\Entity + */ +class AbsoluteUrlDummy +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\ManyToOne(targetEntity="AbsoluteUrlRelationDummy", inversedBy="absoluteUrlDummies") + */ + public $absoluteUrlRelationDummy; + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/AbsoluteUrlRelationDummy.php b/tests/Fixtures/TestBundle/Entity/AbsoluteUrlRelationDummy.php new file mode 100644 index 00000000000..34090ee2bc9 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/AbsoluteUrlRelationDummy.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Annotation\ApiSubresource; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ApiResource(urlGenerationStrategy=UrlGeneratorInterface::ABS_URL) + * @ORM\Entity + */ +class AbsoluteUrlRelationDummy +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\OneToMany(targetEntity="AbsoluteUrlDummy", mappedBy="absoluteUrlRelationDummy") + * @ApiSubresource + */ + public $absoluteUrlDummies; + + public function __construct() + { + $this->absoluteUrlDummies = new ArrayCollection(); + } + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/ContainNonResource.php b/tests/Fixtures/TestBundle/Entity/ContainNonResource.php index 484a4e83474..619e2ed8ccc 100644 --- a/tests/Fixtures/TestBundle/Entity/ContainNonResource.php +++ b/tests/Fixtures/TestBundle/Entity/ContainNonResource.php @@ -25,7 +25,7 @@ * * @ApiResource( * normalizationContext={ - * "groups"="contain_non_resource", + * "groups"={"contain_non_resource"}, * }, * ) * diff --git a/tests/Fixtures/TestBundle/Entity/DummyCar.php b/tests/Fixtures/TestBundle/Entity/DummyCar.php index a6bb99575fe..ad9241e0174 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyCar.php +++ b/tests/Fixtures/TestBundle/Entity/DummyCar.php @@ -29,7 +29,7 @@ * itemOperations={"get"={"swagger_context"={"tags"={}}, "openapi_context"={"tags"={}}}, "put", "delete"}, * attributes={ * "sunset"="2050-01-01", - * "normalization_context"={"groups"="colors"} + * "normalization_context"={"groups"={"colors"}} * } * ) * @ORM\Entity @@ -115,6 +115,16 @@ class DummyCar */ private $availableAt; + /** + * @var string + * + * @Serializer\Groups({"colors"}) + * @Serializer\SerializedName("carBrand") + * + * @ORM\Column + */ + private $brand = 'DummyBrand'; + public function __construct() { $this->colors = new ArrayCollection(); @@ -199,4 +209,14 @@ public function setAvailableAt(\DateTime $availableAt) { $this->availableAt = $availableAt; } + + public function getBrand(): string + { + return $this->brand; + } + + public function setBrand(string $brand): void + { + $this->brand = $brand; + } } diff --git a/tests/Fixtures/TestBundle/Entity/DummyMercure.php b/tests/Fixtures/TestBundle/Entity/DummyMercure.php index a6190fba197..ebd7eb920a4 100644 --- a/tests/Fixtures/TestBundle/Entity/DummyMercure.php +++ b/tests/Fixtures/TestBundle/Entity/DummyMercure.php @@ -26,7 +26,23 @@ class DummyMercure { /** * @ORM\Id - * @ORM\Column(type="string") + * @ORM\Column(type="integer") + * @ORM\GeneratedValue(strategy="AUTO") */ public $id; + + /** + * @ORM\Column + */ + public $name; + + /** + * @ORM\Column + */ + public $description; + + /** + * @ORM\ManyToOne(targetEntity="RelatedDummy") + */ + public $relatedDummy; } diff --git a/tests/Fixtures/TestBundle/Entity/DummyPhp8.php b/tests/Fixtures/TestBundle/Entity/DummyPhp8.php new file mode 100644 index 00000000000..28d3f91d5ae --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyPhp8.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiProperty; +use ApiPlatform\Core\Annotation\ApiResource; + +#[ApiResource(description: "Hey PHP 8")] +class DummyPhp8 +{ + #[ApiProperty(identifier: true, description: 'the identifier')] + public $id; + + #[ApiProperty(description: 'a foo')] + public function getFoo(): int + { + return 0; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/DummyPropertyWithDefaultValue.php b/tests/Fixtures/TestBundle/Entity/DummyPropertyWithDefaultValue.php new file mode 100644 index 00000000000..2c7323165bd --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/DummyPropertyWithDefaultValue.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; + +/** + * DummyPropertyWithDefaultValue. + * + * @ORM\Entity + * + * @ApiResource(attributes={ + * "normalization_context"={"groups"={"dummy_read"}}, + * "denormalization_context"={"groups"={"dummy_write"}} + * }) + */ +class DummyPropertyWithDefaultValue +{ + /** + * @var int + * + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue(strategy="AUTO") + * + * @Groups("dummy_read") + */ + private $id; + + /** + * @var string + * + * @ORM\Column(nullable=true) + * + * @Groups({"dummy_read", "dummy_write"}) + */ + public $foo = 'foo'; + + /** + * @var string A dummy with a Doctrine default options + * + * @ORM\Column(options={"default"="default value"}) + */ + public $dummyDefaultOption; + + /** + * @return int + */ + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/FilterValidator.php b/tests/Fixtures/TestBundle/Entity/FilterValidator.php index 118050a9b8f..eaa62b26345 100644 --- a/tests/Fixtures/TestBundle/Entity/FilterValidator.php +++ b/tests/Fixtures/TestBundle/Entity/FilterValidator.php @@ -15,6 +15,13 @@ use ApiPlatform\Core\Annotation\ApiProperty; use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\ArrayItemsFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\BoundsFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\EnumFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\LengthFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\MultipleOfFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\PatternFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredAllowEmptyFilter; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredFilter; use Doctrine\ORM\Mapping as ORM; @@ -25,7 +32,14 @@ * * @ApiResource(attributes={ * "filters"={ - * RequiredFilter::class + * ArrayItemsFilter::class, + * BoundsFilter::class, + * EnumFilter::class, + * LengthFilter::class, + * MultipleOfFilter::class, + * PatternFilter::class, + * RequiredFilter::class, + * RequiredAllowEmptyFilter::class * } * }) * @ORM\Entity diff --git a/tests/Fixtures/TestBundle/Entity/Foo.php b/tests/Fixtures/TestBundle/Entity/Foo.php index dd970d4f639..6368d0bf8e0 100644 --- a/tests/Fixtures/TestBundle/Entity/Foo.php +++ b/tests/Fixtures/TestBundle/Entity/Foo.php @@ -30,6 +30,11 @@ * "collection_query"={"pagination_enabled"=false}, * "create", * "delete" + * }, + * collectionOperations={ + * "get", + * "get_desc_custom"={"method"="GET", "path"="custom_collection_desc_foos", "order"={"name"="DESC"}}, + * "get_asc_custom"={"method"="GET", "path"="custom_collection_asc_foos", "order"={ "name"="ASC"}}, * } * ) * @ORM\Entity diff --git a/tests/Fixtures/TestBundle/Entity/FooDummy.php b/tests/Fixtures/TestBundle/Entity/FooDummy.php index a06658a5707..83bb02a55f2 100644 --- a/tests/Fixtures/TestBundle/Entity/FooDummy.php +++ b/tests/Fixtures/TestBundle/Entity/FooDummy.php @@ -21,9 +21,14 @@ * * @author Vincent Chalamon * - * @ApiResource(attributes={ - * "order"={"dummy.name"} - * }) + * @ApiResource( + * attributes={ + * "order"={"dummy.name"} + * }, + * graphql={ + * "collection_query"={"pagination_type"="page"} + * } + * ) * @ORM\Entity */ class FooDummy diff --git a/tests/Fixtures/TestBundle/Entity/InitializeInput.php b/tests/Fixtures/TestBundle/Entity/InitializeInput.php new file mode 100644 index 00000000000..07e77f7d486 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/InitializeInput.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\InitializeInputDto; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ApiResource(input=InitializeInputDto::class) + * @ORM\Entity + */ +class InitializeInput +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + */ + public $id; + + /** + * @ORM\Column + */ + public $manager; + + /** + * @ORM\Column + */ + public $name; +} diff --git a/tests/Fixtures/TestBundle/Entity/IriOnlyDummy.php b/tests/Fixtures/TestBundle/Entity/IriOnlyDummy.php new file mode 100644 index 00000000000..ffb6cbd8d6b --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/IriOnlyDummy.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +/** + * Dummy with iri_only. + * + * @author Pierre Thibaudeau + * + * @ApiResource( + * normalizationContext={ + * "iri_only"=true, + * "jsonld_embed_context"=true + * } + * ) + * @ORM\Entity + */ +class IriOnlyDummy +{ + /** + * @var int The id + * + * @ORM\Column(type="integer") + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + */ + private $id; + + /** + * @var string + * + * @ORM\Column(type="string") + */ + private $foo; + + public function getId(): int + { + return $this->id; + } + + public function getFoo(): string + { + return $this->foo; + } + + public function setFoo(string $foo): void + { + $this->foo = $foo; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/MessengerWithArray.php b/tests/Fixtures/TestBundle/Entity/MessengerWithArray.php new file mode 100644 index 00000000000..44035beb686 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/MessengerWithArray.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiProperty; +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\MessengerInput; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ApiResource(messenger={"persist", "input"}, input=MessengerInput::class) + * @ORM\Entity + */ +class MessengerWithArray +{ + /** + * @var int|null + * + * @ApiProperty(identifier=true) + * + * @ORM\Column(type="integer") + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + */ + public $id; + + /** + * @var string|null + * + * @ORM\Column(type="string") + */ + public $name; +} diff --git a/tests/Fixtures/TestBundle/Entity/MessengerWithPersist.php b/tests/Fixtures/TestBundle/Entity/MessengerWithPersist.php new file mode 100644 index 00000000000..b0c1716d10c --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/MessengerWithPersist.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiProperty; +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ApiResource(messenger="persist") + * @ORM\Entity + */ +class MessengerWithPersist +{ + /** + * @var int|null + * + * @ApiProperty(identifier=true) + * + * @ORM\Column(type="integer") + * @ORM\Id + * @ORM\GeneratedValue(strategy="AUTO") + */ + public $id; + + /** + * @var string|null + * + * @ORM\Column(type="string") + */ + public $name; +} diff --git a/tests/Fixtures/TestBundle/Entity/NetworkPathDummy.php b/tests/Fixtures/TestBundle/Entity/NetworkPathDummy.php new file mode 100644 index 00000000000..29f7e8ea043 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/NetworkPathDummy.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ApiResource(urlGenerationStrategy=UrlGeneratorInterface::NET_PATH) + * @ORM\Entity + */ +class NetworkPathDummy +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\ManyToOne(targetEntity="NetworkPathRelationDummy", inversedBy="networkPathDummies") + */ + public $networkPathRelationDummy; + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/NetworkPathRelationDummy.php b/tests/Fixtures/TestBundle/Entity/NetworkPathRelationDummy.php new file mode 100644 index 00000000000..cfd35391c6d --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/NetworkPathRelationDummy.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use ApiPlatform\Core\Annotation\ApiSubresource; +use ApiPlatform\Core\Api\UrlGeneratorInterface; +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\ORM\Mapping as ORM; + +/** + * @ApiResource(urlGenerationStrategy=UrlGeneratorInterface::NET_PATH) + * @ORM\Entity + */ +class NetworkPathRelationDummy +{ + /** + * @ORM\Id + * @ORM\GeneratedValue + * @ORM\Column(type="integer") + */ + private $id; + + /** + * @ORM\OneToMany(targetEntity="NetworkPathDummy", mappedBy="networkPathRelationDummy") + * @ApiSubresource + */ + public $networkPathDummies; + + public function __construct() + { + $this->networkPathDummies = new ArrayCollection(); + } + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/PatchDummyRelation.php b/tests/Fixtures/TestBundle/Entity/PatchDummyRelation.php new file mode 100644 index 00000000000..aa830e506cc --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/PatchDummyRelation.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Serializer\Annotation\Groups; + +/** + * @author Kévin Dunglas + * + * @ApiResource( + * attributes={ + * "normalization_context"={"groups"={"chicago"}}, + * "denormalization_context"={"groups"={"chicago"}}, + * }, + * itemOperations={ + * "get", + * "patch"={"input_formats"={"json"={"application/merge-patch+json"}, "jsonapi"}} + * } + * ) + * @ORM\Entity + */ +class PatchDummyRelation +{ + /** + * @ORM\Id + * @ORM\Column(type="integer") + * @ORM\GeneratedValue(strategy="AUTO") + */ + public $id; + + /** + * @ORM\ManyToOne(targetEntity="RelatedDummy") + * @Groups({"chicago"}) + */ + protected $related; + + public function getRelated() + { + return $this->related; + } + + public function setRelated(RelatedDummy $relatedDummy) + { + $this->related = $relatedDummy; + } +} diff --git a/tests/Fixtures/TestBundle/Entity/SecuredDummy.php b/tests/Fixtures/TestBundle/Entity/SecuredDummy.php index f01d909c33c..19ffc2cc816 100644 --- a/tests/Fixtures/TestBundle/Entity/SecuredDummy.php +++ b/tests/Fixtures/TestBundle/Entity/SecuredDummy.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; +use ApiPlatform\Core\Annotation\ApiProperty; use ApiPlatform\Core\Annotation\ApiResource; use Doctrine\ORM\Mapping as ORM; use Symfony\Component\Validator\Constraints as Assert; @@ -25,7 +26,7 @@ * @ApiResource( * attributes={"security"="is_granted('ROLE_USER')"}, * collectionOperations={ - * "get", + * "get"={"security"="is_granted('ROLE_USER') or is_granted('ROLE_ADMIN')"}, * "get_from_data_provider_generator"={ * "method"="GET", * "path"="custom_data_provider_generator", @@ -73,6 +74,14 @@ class SecuredDummy */ private $description = ''; + /** + * @var string The dummy secret property, only readable/writable by specific users + * + * @ORM\Column + * @ApiProperty(security="is_granted('ROLE_ADMIN')") + */ + private $adminOnlyProperty = ''; + /** * @var string The owner * @@ -106,6 +115,16 @@ public function setDescription(string $description) $this->description = $description; } + public function getAdminOnlyProperty(): ?string + { + return $this->adminOnlyProperty; + } + + public function setAdminOnlyProperty(?string $adminOnlyProperty) + { + $this->adminOnlyProperty = $adminOnlyProperty; + } + public function getOwner(): string { return $this->owner; diff --git a/tests/Fixtures/TestBundle/Entity/UrlEncodedId.php b/tests/Fixtures/TestBundle/Entity/UrlEncodedId.php new file mode 100644 index 00000000000..6ff5a65da87 --- /dev/null +++ b/tests/Fixtures/TestBundle/Entity/UrlEncodedId.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity; + +use ApiPlatform\Core\Annotation\ApiResource; +use Doctrine\ORM\Mapping as ORM; + +/** + * @author Daniel West + * + * Resource with an ID that will be URL encoded + * + * @ORM\Entity + * + * @ApiResource( + * itemOperations={ + * "get"={ + * "method"="GET", + * "requirements"={"id"=".+"} + * } + * } + * ) + */ +class UrlEncodedId +{ + /** + * @ORM\Column(type="string") + * @ORM\Id + */ + private $id = '%encode:id'; + + public function getId() + { + return $this->id; + } +} diff --git a/tests/Fixtures/TestBundle/Filter/ArrayItemsFilter.php b/tests/Fixtures/TestBundle/Filter/ArrayItemsFilter.php new file mode 100644 index 00000000000..848eaa34104 --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/ArrayItemsFilter.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Common\PropertyHelperTrait; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class ArrayItemsFilter extends AbstractFilter +{ + use PropertyHelperTrait; + + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'csv-min-2' => [ + 'property' => 'csv-min-2', + 'type' => 'array', + 'required' => false, + 'swagger' => [ + 'minItems' => 2, + ], + ], + 'csv-max-3' => [ + 'property' => 'csv-max-3', + 'type' => 'array', + 'required' => false, + 'swagger' => [ + 'maxItems' => 3, + ], + ], + 'ssv-min-2' => [ + 'property' => 'ssv-min-2', + 'type' => 'array', + 'required' => false, + 'swagger' => [ + 'collectionFormat' => 'ssv', + 'minItems' => 2, + ], + ], + 'tsv-min-2' => [ + 'property' => 'tsv-min-2', + 'type' => 'array', + 'required' => false, + 'swagger' => [ + 'collectionFormat' => 'tsv', + 'minItems' => 2, + ], + ], + 'pipes-min-2' => [ + 'property' => 'pipes-min-2', + 'type' => 'array', + 'required' => false, + 'swagger' => [ + 'collectionFormat' => 'pipes', + 'minItems' => 2, + ], + ], + 'csv-uniques' => [ + 'property' => 'csv-uniques', + 'type' => 'array', + 'required' => false, + 'swagger' => [ + 'uniqueItems' => true, + ], + ], + ]; + } +} diff --git a/tests/Fixtures/TestBundle/Filter/BoundsFilter.php b/tests/Fixtures/TestBundle/Filter/BoundsFilter.php new file mode 100644 index 00000000000..9c3cca1984a --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/BoundsFilter.php @@ -0,0 +1,69 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Common\PropertyHelperTrait; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class BoundsFilter extends AbstractFilter +{ + use PropertyHelperTrait; + + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'maximum' => [ + 'property' => 'maximum', + 'type' => 'float', + 'required' => false, + 'swagger' => [ + 'maximum' => 10, + ], + ], + 'exclusiveMaximum' => [ + 'property' => 'maximum', + 'type' => 'float', + 'required' => false, + 'swagger' => [ + 'maximum' => 10, + 'exclusiveMaximum' => true, + ], + ], + 'minimum' => [ + 'property' => 'minimum', + 'type' => 'float', + 'required' => false, + 'swagger' => [ + 'minimum' => 5, + ], + ], + 'exclusiveMinimum' => [ + 'property' => 'exclusiveMinimum', + 'type' => 'float', + 'required' => false, + 'swagger' => [ + 'minimum' => 5, + 'exclusiveMinimum' => true, + ], + ], + ]; + } +} diff --git a/tests/Fixtures/TestBundle/Filter/EnumFilter.php b/tests/Fixtures/TestBundle/Filter/EnumFilter.php new file mode 100644 index 00000000000..1c4bd33fa27 --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/EnumFilter.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Common\PropertyHelperTrait; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class EnumFilter extends AbstractFilter +{ + use PropertyHelperTrait; + + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'enum' => [ + 'property' => 'enum', + 'type' => 'string', + 'required' => false, + 'swagger' => [ + 'enum' => ['in-enum', 'mune-ni'], + ], + ], + ]; + } +} diff --git a/tests/Fixtures/TestBundle/Filter/LengthFilter.php b/tests/Fixtures/TestBundle/Filter/LengthFilter.php new file mode 100644 index 00000000000..6d44799156f --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/LengthFilter.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Common\PropertyHelperTrait; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class LengthFilter extends AbstractFilter +{ + use PropertyHelperTrait; + + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'max-length-3' => [ + 'property' => 'max-length-3', + 'type' => 'string', + 'required' => false, + 'swagger' => [ + 'maxLength' => 3, + ], + ], + 'min-length-3' => [ + 'property' => 'min-length-3', + 'type' => 'string', + 'required' => false, + 'swagger' => [ + 'minLength' => 3, + ], + ], + ]; + } +} diff --git a/tests/Fixtures/TestBundle/Filter/MultipleOfFilter.php b/tests/Fixtures/TestBundle/Filter/MultipleOfFilter.php new file mode 100644 index 00000000000..4918188526e --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/MultipleOfFilter.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Common\PropertyHelperTrait; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class MultipleOfFilter extends AbstractFilter +{ + use PropertyHelperTrait; + + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'multiple-of' => [ + 'property' => 'multiple-of', + 'type' => 'float', + 'required' => false, + 'swagger' => [ + 'multipleOf' => 2, + ], + ], + ]; + } +} diff --git a/tests/Fixtures/TestBundle/Filter/PatternFilter.php b/tests/Fixtures/TestBundle/Filter/PatternFilter.php new file mode 100644 index 00000000000..1cb136520d6 --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/PatternFilter.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Common\PropertyHelperTrait; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class PatternFilter extends AbstractFilter +{ + use PropertyHelperTrait; + + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'pattern' => [ + 'property' => 'pattern', + 'type' => 'string', + 'required' => false, + 'swagger' => [ + 'pattern' => '/^(pattern|nrettap)$/', + ], + ], + ]; + } +} diff --git a/tests/Fixtures/TestBundle/Filter/RequiredAllowEmptyFilter.php b/tests/Fixtures/TestBundle/Filter/RequiredAllowEmptyFilter.php new file mode 100644 index 00000000000..8727b754d89 --- /dev/null +++ b/tests/Fixtures/TestBundle/Filter/RequiredAllowEmptyFilter.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter; + +use ApiPlatform\Core\Bridge\Doctrine\Common\PropertyHelperTrait; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Filter\AbstractFilter; +use ApiPlatform\Core\Bridge\Doctrine\Orm\Util\QueryNameGeneratorInterface; +use Doctrine\ORM\QueryBuilder; + +class RequiredAllowEmptyFilter extends AbstractFilter +{ + use PropertyHelperTrait; + + protected function filterProperty(string $property, $value, QueryBuilder $queryBuilder, QueryNameGeneratorInterface $queryNameGenerator, string $resourceClass, string $operationName = null) + { + } + + // This function is only used to hook in documentation generators (supported by Swagger and Hydra) + public function getDescription(string $resourceClass): array + { + return [ + 'required-allow-empty' => [ + 'property' => 'required-allow-empty', + 'type' => 'string', + 'required' => true, + 'swagger' => [ + 'allowEmptyValue' => true, + ], + ], + ]; + } +} diff --git a/tests/Fixtures/TestBundle/GraphQl/Type/TypeConverter.php b/tests/Fixtures/TestBundle/GraphQl/Type/TypeConverter.php index 69f4e86457c..39b440ff7ee 100644 --- a/tests/Fixtures/TestBundle/GraphQl/Type/TypeConverter.php +++ b/tests/Fixtures/TestBundle/GraphQl/Type/TypeConverter.php @@ -36,7 +36,7 @@ public function __construct(TypeConverterInterface $defaultTypeConverter) /** * {@inheritdoc} */ - public function convertType(Type $type, bool $input, ?string $queryName, ?string $mutationName, string $resourceClass, string $rootResource, ?string $property, int $depth) + public function convertType(Type $type, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, string $resourceClass, string $rootResource, ?string $property, int $depth) { if ('dummyDate' === $property && \in_array($rootResource, [Dummy::class, DummyDocument::class], true) @@ -46,7 +46,7 @@ public function convertType(Type $type, bool $input, ?string $queryName, ?string return 'DateTime'; } - return $this->defaultTypeConverter->convertType($type, $input, $queryName, $mutationName, $resourceClass, $rootResource, $property, $depth); + return $this->defaultTypeConverter->convertType($type, $input, $queryName, $mutationName, $subscriptionName, $resourceClass, $rootResource, $property, $depth); } /** diff --git a/tests/Fixtures/TestBundle/Resources/config/serialization/abstract_dummy.yml b/tests/Fixtures/TestBundle/Resources/config/serialization/abstract_dummy.yml index a6f77e6325f..72d4b1ac29c 100644 --- a/tests/Fixtures/TestBundle/Resources/config/serialization/abstract_dummy.yml +++ b/tests/Fixtures/TestBundle/Resources/config/serialization/abstract_dummy.yml @@ -1,11 +1,11 @@ ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\AbstractDummy: - discriminator_map: - type_property: discr - mapping: - concrete: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\ConcreteDummy' + discriminator_map: + type_property: discr + mapping: + concrete: ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\ConcreteDummy ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\AbstractDummy: - discriminator_map: - type_property: discr - mapping: - concrete: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\ConcreteDummy' \ No newline at end of file + discriminator_map: + type_property: discr + mapping: + concrete: ApiPlatform\Core\Tests\Fixtures\TestBundle\Document\ConcreteDummy diff --git a/tests/Fixtures/TestBundle/Validator/DummyValidationGroupsGenerator.php b/tests/Fixtures/TestBundle/Validator/DummyValidationGroupsGenerator.php index 84044757cff..0f9f414d83c 100644 --- a/tests/Fixtures/TestBundle/Validator/DummyValidationGroupsGenerator.php +++ b/tests/Fixtures/TestBundle/Validator/DummyValidationGroupsGenerator.php @@ -13,11 +13,15 @@ namespace ApiPlatform\Core\Tests\Fixtures\TestBundle\Validator; +use ApiPlatform\Core\Bridge\Symfony\Validator\ValidationGroupsGeneratorInterface; use Symfony\Component\Validator\Constraints\GroupSequence; -class DummyValidationGroupsGenerator +final class DummyValidationGroupsGenerator implements ValidationGroupsGeneratorInterface { - public function __invoke() + /** + * {@inheritdoc} + */ + public function __invoke($object): GroupSequence { return new GroupSequence(['b', 'a']); } diff --git a/tests/Fixtures/app/config/config_common.yml b/tests/Fixtures/app/config/config_common.yml index c011f1e3faf..9eb3b87cfdc 100644 --- a/tests/Fixtures/app/config/config_common.yml +++ b/tests/Fixtures/app/config/config_common.yml @@ -10,6 +10,11 @@ framework: profiler: enabled: true collect: false + messenger: + default_bus: messenger.bus.default + buses: + messenger.bus.default: + default_middleware: allow_no_handlers router: utf8: true @@ -73,10 +78,18 @@ api_platform: http_cache: invalidation: enabled: true - max_age: 60 - shared_max_age: 3600 - vary: ['Accept', 'Cookie'] - public: true + defaults: + pagination_client_enabled: true + pagination_client_items_per_page: true + pagination_client_partial: true + pagination_items_per_page: 3 + order: 'ASC' + cache_headers: + max_age: 60 + shared_max_age: 3600 + vary: ['Accept', 'Cookie'] + public: true + stateless: true parameters: container.autowiring.strict_mode: true @@ -138,12 +151,40 @@ services: arguments: ['@doctrine'] tags: ['api_platform.filter'] + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\RequiredAllowEmptyFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] + + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\BoundsFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] + + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\LengthFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] + + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\PatternFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] + ApiPlatform\Core\Tests\Fixtures\TestBundle\Controller\: resource: '../../TestBundle/Controller' autowire: true autoconfigure: true tags: ['controller.service_arguments'] + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\EnumFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] + + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\MultipleOfFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] + + ApiPlatform\Core\Tests\Fixtures\TestBundle\Filter\ArrayItemsFilter: + arguments: [ '@doctrine' ] + tags: [ 'api_platform.filter' ] + app.config_dummy_resource.action: class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\Action\ConfigCustom' arguments: ['@api_platform.item_data_provider'] @@ -206,6 +247,8 @@ services: mercure.hub.default.publisher: class: ApiPlatform\Core\Tests\Fixtures\DummyMercurePublisher + public: true + tags: ['messenger.message_handler'] app.serializer.normalizer.override_documentation: class: ApiPlatform\Core\Tests\Fixtures\TestBundle\Serializer\Normalizer\OverrideDocumentationNormalizer @@ -225,6 +268,12 @@ services: tags: - { name: 'api_platform.data_transformer' } + app.data_transformer.messenger_with_array_dto: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer\MessengerWithArrayDtoDataTransformer' + public: false + tags: + - { name: 'api_platform.data_transformer' } + app.data_transformer.custom_output_dto_fallback_same_class: class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer\OutputDtoSameClassTransformer' public: false @@ -261,6 +310,12 @@ services: tags: - { name: 'api_platform.data_transformer' } + app.data_transformer.initialize_input: + class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\DataTransformer\InitializeInputDataTransformer' + public: false + tags: + - { name: 'api_platform.data_transformer' } + app.messenger_handler.messenger_with_response: class: 'ApiPlatform\Core\Tests\Fixtures\TestBundle\MessengerHandler\MessengerWithResponseHandler' public: false diff --git a/tests/GraphQl/Action/EntrypointActionTest.php b/tests/GraphQl/Action/EntrypointActionTest.php index 049d42578b3..76cc5251271 100644 --- a/tests/GraphQl/Action/EntrypointActionTest.php +++ b/tests/GraphQl/Action/EntrypointActionTest.php @@ -16,7 +16,10 @@ use ApiPlatform\Core\GraphQl\Action\EntrypointAction; use ApiPlatform\Core\GraphQl\Action\GraphiQlAction; use ApiPlatform\Core\GraphQl\Action\GraphQlPlaygroundAction; +use ApiPlatform\Core\GraphQl\Error\ErrorHandler; use ApiPlatform\Core\GraphQl\ExecutorInterface; +use ApiPlatform\Core\GraphQl\Serializer\Exception\ErrorNormalizer; +use ApiPlatform\Core\GraphQl\Serializer\Exception\HttpExceptionNormalizer; use ApiPlatform\Core\GraphQl\Type\SchemaBuilderInterface; use ApiPlatform\Core\Tests\ProphecyTrait; use GraphQL\Error\DebugFlag; @@ -29,6 +32,7 @@ use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\RouterInterface; +use Symfony\Component\Serializer\Serializer; use Twig\Environment as TwigEnvironment; /** @@ -41,7 +45,7 @@ class EntrypointActionTest extends TestCase /** * Hack to avoid transient failing test because of Date header. */ - private function assertEqualsWithoutDateHeader(JsonResponse $expected, Response $actual) + private function assertEqualsWithoutDateHeader(JsonResponse $expected, Response $actual): void { $expected->headers->remove('Date'); $actual->headers->remove('Date'); @@ -57,18 +61,18 @@ public function testGetHtmlAction(): void $this->assertInstanceOf(Response::class, $mockedEntrypoint($request)); } - public function testGetAction() + public function testGetAction(): void { - $request = new Request(['query' => 'graphqlQuery', 'variables' => '["graphqlVariable"]', 'operation' => 'graphqlOperationName']); + $request = new Request(['query' => 'graphqlQuery', 'variables' => '["graphqlVariable"]', 'operationName' => 'graphqlOperationName']); $request->setRequestFormat('json'); $mockedEntrypoint = $this->getEntrypointAction(); $this->assertEqualsWithoutDateHeader(new JsonResponse(['GraphQL']), $mockedEntrypoint($request)); } - public function testPostRawAction() + public function testPostRawAction(): void { - $request = new Request(['variables' => '["graphqlVariable"]', 'operation' => 'graphqlOperationName'], [], [], [], [], [], 'graphqlQuery'); + $request = new Request(['variables' => '["graphqlVariable"]', 'operationName' => 'graphqlOperationName'], [], [], [], [], [], 'graphqlQuery'); $request->setFormat('graphql', 'application/graphql'); $request->setMethod('POST'); $request->headers->set('Content-Type', 'application/graphql'); @@ -77,9 +81,9 @@ public function testPostRawAction() $this->assertEqualsWithoutDateHeader(new JsonResponse(['GraphQL']), $mockedEntrypoint($request)); } - public function testPostJsonAction() + public function testPostJsonAction(): void { - $request = new Request([], [], [], [], [], [], '{"query": "graphqlQuery", "variables": "[\"graphqlVariable\"]", "operation": "graphqlOperationName"}'); + $request = new Request([], [], [], [], [], [], '{"query": "graphqlQuery", "variables": "[\"graphqlVariable\"]", "operationName": "graphqlOperationName"}'); $request->setMethod('POST'); $request->headers->set('Content-Type', 'application/json'); $mockedEntrypoint = $this->getEntrypointAction(); @@ -90,7 +94,7 @@ public function testPostJsonAction() /** * @dataProvider multipartRequestProvider */ - public function testMultipartRequestAction(?string $operations, ?string $map, array $files, array $variables, Response $expectedResponse) + public function testMultipartRequestAction(?string $operations, ?string $map, array $files, array $variables, Response $expectedResponse): void { $requestParams = []; if ($operations) { @@ -120,14 +124,14 @@ public function multipartRequestProvider(): array return [ 'upload a single file' => [ - '{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}', + '{"query": "graphqlQuery", "variables": {"file": null}, "operationName": "graphqlOperationName"}', '{"file": ["variables.file"]}', ['file' => $file], ['file' => $file], new JsonResponse(['GraphQL']), ], 'upload multiple files' => [ - '{"query": "graphqlQuery", "variables": {"files": [null, null, null]}, "operation": "graphqlOperationName"}', + '{"query": "graphqlQuery", "variables": {"files": [null, null, null]}, "operationName": "graphqlOperationName"}', '{"0": ["variables.files.0"], "1": ["variables.files.1"], "2": ["variables.files.2"]}', [ '0' => $file, @@ -148,82 +152,82 @@ public function multipartRequestProvider(): array '{"file": ["variables.file"]}', ['file' => $file], ['file' => $file], - new Response('{"errors":[{"message":"GraphQL multipart request does not respect the specification.","extensions":{"category":"user"}}]}'), + new Response('{"errors":[{"message":"GraphQL multipart request does not respect the specification.","extensions":{"category":"user","status":400}}]}'), ], 'upload without providing map' => [ - '{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}', + '{"query": "graphqlQuery", "variables": {"file": null}, "operationName": "graphqlOperationName"}', null, ['file' => $file], ['file' => null], - new Response('{"errors":[{"message":"GraphQL multipart request does not respect the specification.","extensions":{"category":"user"}}]}'), + new Response('{"errors":[{"message":"GraphQL multipart request does not respect the specification.","extensions":{"category":"user","status":400}}]}'), ], 'upload with invalid json' => [ '{invalid}', '{"file": ["file"]}', ['file' => $file], ['file' => null], - new Response('{"errors":[{"message":"GraphQL data is not valid JSON.","extensions":{"category":"user"}}]}'), + new Response('{"errors":[{"message":"GraphQL data is not valid JSON.","extensions":{"category":"user","status":400}}]}'), ], 'upload with invalid map JSON' => [ - '{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}', + '{"query": "graphqlQuery", "variables": {"file": null}, "operationName": "graphqlOperationName"}', '{invalid}', ['file' => $file], ['file' => null], - new Response('{"errors":[{"message":"GraphQL multipart request map is not valid JSON.","extensions":{"category":"user"}}]}'), + new Response('{"errors":[{"message":"GraphQL multipart request map is not valid JSON.","extensions":{"category":"user","status":400}}]}'), ], 'upload with no file' => [ - '{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}', + '{"query": "graphqlQuery", "variables": {"file": null}, "operationName": "graphqlOperationName"}', '{"file": ["file"]}', [], ['file' => null], - new Response('{"errors":[{"message":"GraphQL multipart request file has not been sent correctly.","extensions":{"category":"user"}}]}'), + new Response('{"errors":[{"message":"GraphQL multipart request file has not been sent correctly.","extensions":{"category":"user","status":400}}]}'), ], 'upload with wrong map' => [ - '{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}', + '{"query": "graphqlQuery", "variables": {"file": null}, "operationName": "graphqlOperationName"}', '{"file": ["file"]}', ['file' => $file], ['file' => null], - new Response('{"errors":[{"message":"GraphQL multipart request path in map is invalid.","extensions":{"category":"user"}}]}'), + new Response('{"errors":[{"message":"GraphQL multipart request path in map is invalid.","extensions":{"category":"user","status":400}}]}'), ], 'upload when variable path does not exist' => [ - '{"query": "graphqlQuery", "variables": {"file": null}, "operation": "graphqlOperationName"}', + '{"query": "graphqlQuery", "variables": {"file": null}, "operationName": "graphqlOperationName"}', '{"file": ["variables.wrong"]}', ['file' => $file], ['file' => null], - new Response('{"errors":[{"message":"GraphQL multipart request path in map does not match the variables.","extensions":{"category":"user"}}]}'), + new Response('{"errors":[{"message":"GraphQL multipart request path in map does not match the variables.","extensions":{"category":"user","status":400}}]}'), ], ]; } - public function testBadContentTypePostAction() + public function testBadContentTypePostAction(): void { $request = new Request(); $request->setMethod('POST'); $request->headers->set('Content-Type', 'application/xml'); $mockedEntrypoint = $this->getEntrypointAction(); - $this->assertEquals(400, $mockedEntrypoint($request)->getStatusCode()); - $this->assertEquals('{"errors":[{"message":"GraphQL query is not valid.","extensions":{"category":"user"}}]}', $mockedEntrypoint($request)->getContent()); + $this->assertEquals(200, $mockedEntrypoint($request)->getStatusCode()); + $this->assertEquals('{"errors":[{"message":"GraphQL query is not valid.","extensions":{"category":"user","status":400}}]}', $mockedEntrypoint($request)->getContent()); } - public function testBadMethodAction() + public function testBadMethodAction(): void { $request = new Request(); $request->setMethod('PUT'); $mockedEntrypoint = $this->getEntrypointAction(); - $this->assertEquals(400, $mockedEntrypoint($request)->getStatusCode()); - $this->assertEquals('{"errors":[{"message":"GraphQL query is not valid.","extensions":{"category":"user"}}]}', $mockedEntrypoint($request)->getContent()); + $this->assertEquals(200, $mockedEntrypoint($request)->getStatusCode()); + $this->assertEquals('{"errors":[{"message":"GraphQL query is not valid.","extensions":{"category":"user","status":400}}]}', $mockedEntrypoint($request)->getContent()); } - public function testBadVariablesAction() + public function testBadVariablesAction(): void { - $request = new Request(['query' => 'graphqlQuery', 'variables' => 'graphqlVariable', 'operation' => 'graphqlOperationName']); + $request = new Request(['query' => 'graphqlQuery', 'variables' => 'graphqlVariable', 'operationName' => 'graphqlOperationName']); $request->setRequestFormat('json'); $mockedEntrypoint = $this->getEntrypointAction(); - $this->assertEquals(400, $mockedEntrypoint($request)->getStatusCode()); - $this->assertEquals('{"errors":[{"message":"GraphQL variables are not valid JSON.","extensions":{"category":"user"}}]}', $mockedEntrypoint($request)->getContent()); + $this->assertEquals(200, $mockedEntrypoint($request)->getStatusCode()); + $this->assertEquals('{"errors":[{"message":"GraphQL variables are not valid JSON.","extensions":{"category":"user","status":400}}]}', $mockedEntrypoint($request)->getContent()); } private function getEntrypointAction(array $variables = ['graphqlVariable']): EntrypointAction @@ -232,8 +236,16 @@ private function getEntrypointAction(array $variables = ['graphqlVariable']): En $schemaBuilderProphecy = $this->prophesize(SchemaBuilderInterface::class); $schemaBuilderProphecy->getSchema()->willReturn($schema->reveal()); + $normalizer = new Serializer([ + new HttpExceptionNormalizer(), + new ErrorNormalizer(), + ]); + $errorHandler = new ErrorHandler(); + $executionResultProphecy = $this->prophesize(ExecutionResult::class); $executionResultProphecy->toArray(DebugFlag::NONE)->willReturn(['GraphQL']); + $executionResultProphecy->setErrorFormatter([$normalizer, 'normalize'])->willReturn($executionResultProphecy); + $executionResultProphecy->setErrorsHandler($errorHandler)->willReturn($executionResultProphecy); $executorProphecy = $this->prophesize(ExecutorInterface::class); $executorProphecy->executeQuery(Argument::is($schema->reveal()), 'graphqlQuery', null, null, $variables, 'graphqlOperationName')->willReturn($executionResultProphecy->reveal()); @@ -245,6 +257,6 @@ private function getEntrypointAction(array $variables = ['graphqlVariable']): En $graphiQlAction = new GraphiQlAction($twigProphecy->reveal(), $routerProphecy->reveal(), true); $graphQlPlaygroundAction = new GraphQlPlaygroundAction($twigProphecy->reveal(), $routerProphecy->reveal(), true); - return new EntrypointAction($schemaBuilderProphecy->reveal(), $executorProphecy->reveal(), $graphiQlAction, $graphQlPlaygroundAction, false, true, true, 'graphiql'); + return new EntrypointAction($schemaBuilderProphecy->reveal(), $executorProphecy->reveal(), $graphiQlAction, $graphQlPlaygroundAction, $normalizer, $errorHandler, false, true, true, 'graphiql'); } } diff --git a/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php b/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php index 6cef95bb6f8..a4d464f6770 100644 --- a/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php +++ b/tests/GraphQl/Resolver/Factory/CollectionResolverFactoryTest.php @@ -77,7 +77,7 @@ public function testResolve(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => false]; $request = new Request(); $attributesParameterBagProphecy = $this->prophesize(ParameterBag::class); @@ -141,7 +141,7 @@ public function testResolveBadReadStageCollection(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => false]; $readStageCollection = new \stdClass(); $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageCollection); @@ -160,7 +160,7 @@ public function testResolveCustom(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => true, 'is_mutation' => false, 'is_subscription' => false]; $readStageCollection = [new \stdClass()]; $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageCollection); diff --git a/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php b/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php index 54d571c1b4e..4b16857a4cf 100644 --- a/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php +++ b/tests/GraphQl/Resolver/Factory/ItemMutationResolverFactoryTest.php @@ -25,7 +25,6 @@ use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\ProphecyTrait; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -85,7 +84,7 @@ public function testResolve(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true, 'is_subscription' => false]; $readStageItem = new \stdClass(); $readStageItem->field = 'read'; @@ -153,7 +152,7 @@ public function testResolveBadReadStageItem(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true, 'is_subscription' => false]; $readStageItem = []; $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem); @@ -172,7 +171,7 @@ public function testResolveNullDeserializeStageItem(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true, 'is_subscription' => false]; $readStageItem = new \stdClass(); $readStageItem->field = 'read'; @@ -213,7 +212,7 @@ public function testResolveDelete(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true, 'is_subscription' => false]; $readStageItem = new \stdClass(); $readStageItem->field = 'read'; @@ -253,7 +252,7 @@ public function testResolveCustom(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true, 'is_subscription' => false]; $readStageItem = new \stdClass(); $readStageItem->field = 'read'; @@ -305,7 +304,7 @@ public function testResolveCustomBadItem(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => true, 'is_subscription' => false]; $readStageItem = new \stdClass(); $readStageItem->field = 'read'; @@ -324,7 +323,7 @@ public function testResolveCustomBadItem(): void return $customItem; }); - $this->expectException(Error::class); + $this->expectException(\LogicException::class); $this->expectExceptionMessage('Custom mutation resolver "query_resolver_id" has to return an item of class shortName but returned an item of class Dummy.'); ($this->itemMutationResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info); diff --git a/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php b/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php index 6fe6886418d..69cc1b31576 100644 --- a/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php +++ b/tests/GraphQl/Resolver/Factory/ItemResolverFactoryTest.php @@ -22,7 +22,6 @@ use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\ProphecyTrait; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; @@ -77,7 +76,7 @@ public function testResolve(?string $resourceClass, string $determinedResourceCl $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => false]; $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem); @@ -127,7 +126,7 @@ public function testResolveBadReadStageItem(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => false]; $readStageItem = []; $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem); @@ -146,12 +145,12 @@ public function testResolveNoResourceNoItem(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => false]; $readStageItem = null; $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem); - $this->expectException(Error::class); + $this->expectException(\UnexpectedValueException::class); $this->expectExceptionMessage('Resource class cannot be determined.'); ($this->itemResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info); @@ -165,12 +164,12 @@ public function testResolveBadItem(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => false]; $readStageItem = new \stdClass(); $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem); - $this->expectException(Error::class); + $this->expectException(\UnexpectedValueException::class); $this->expectExceptionMessage('Resolver only handles items of class Dummy but retrieved item is of class stdClass.'); ($this->itemResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info); @@ -184,7 +183,7 @@ public function testResolveCustom(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => false]; $readStageItem = new \stdClass(); $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem); @@ -225,7 +224,7 @@ public function testResolveCustomBadItem(): void $source = ['source']; $args = ['args']; $info = $this->prophesize(ResolveInfo::class)->reveal(); - $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false]; + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => false]; $readStageItem = new \stdClass(); $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem); @@ -239,7 +238,7 @@ public function testResolveCustomBadItem(): void return $customItem; }); - $this->expectException(Error::class); + $this->expectException(\UnexpectedValueException::class); $this->expectExceptionMessage('Custom query resolver "query_resolver_id" has to return an item of class stdClass but returned an item of class Dummy.'); ($this->itemResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info); diff --git a/tests/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactoryTest.php b/tests/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactoryTest.php new file mode 100644 index 00000000000..2d6240366a3 --- /dev/null +++ b/tests/GraphQl/Resolver/Factory/ItemSubscriptionResolverFactoryTest.php @@ -0,0 +1,199 @@ + + * + * For 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\GraphQl\Resolver\Factory; + +use ApiPlatform\Core\GraphQl\Resolver\Factory\ItemSubscriptionResolverFactory; +use ApiPlatform\Core\GraphQl\Resolver\Stage\ReadStageInterface; +use ApiPlatform\Core\GraphQl\Resolver\Stage\SecurityStageInterface; +use ApiPlatform\Core\GraphQl\Resolver\Stage\SerializeStageInterface; +use ApiPlatform\Core\GraphQl\Subscription\MercureSubscriptionIriGeneratorInterface; +use ApiPlatform\Core\GraphQl\Subscription\SubscriptionManagerInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use GraphQL\Type\Definition\ResolveInfo; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; + +/** + * @author Alan Poulain + */ +class ItemSubscriptionResolverFactoryTest extends TestCase +{ + private $itemSubscriptionResolverFactory; + private $readStageProphecy; + private $securityStageProphecy; + private $serializeStageProphecy; + private $resourceMetadataFactoryProphecy; + private $subscriptionManagerProphecy; + private $mercureSubscriptionIriGeneratorProphecy; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->readStageProphecy = $this->prophesize(ReadStageInterface::class); + $this->securityStageProphecy = $this->prophesize(SecurityStageInterface::class); + $this->serializeStageProphecy = $this->prophesize(SerializeStageInterface::class); + $this->resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $this->subscriptionManagerProphecy = $this->prophesize(SubscriptionManagerInterface::class); + $this->mercureSubscriptionIriGeneratorProphecy = $this->prophesize(MercureSubscriptionIriGeneratorInterface::class); + + $this->itemSubscriptionResolverFactory = new ItemSubscriptionResolverFactory( + $this->readStageProphecy->reveal(), + $this->securityStageProphecy->reveal(), + $this->serializeStageProphecy->reveal(), + $this->resourceMetadataFactoryProphecy->reveal(), + $this->subscriptionManagerProphecy->reveal(), + $this->mercureSubscriptionIriGeneratorProphecy->reveal() + ); + } + + public function testResolve(): void + { + $resourceClass = 'stdClass'; + $rootClass = 'rootClass'; + $operationName = 'update'; + $source = ['source']; + $args = ['args']; + $info = $this->prophesize(ResolveInfo::class)->reveal(); + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + + $readStageItem = new \stdClass(); + $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem); + + $this->securityStageProphecy->__invoke($resourceClass, $operationName, $resolverContext + [ + 'extra_variables' => [ + 'object' => $readStageItem, + ], + ])->shouldBeCalled(); + + $serializeStageData = ['serialized']; + $this->serializeStageProphecy->__invoke($readStageItem, $resourceClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($serializeStageData); + + $subscriptionId = 'subscriptionId'; + $this->subscriptionManagerProphecy->retrieveSubscriptionId($resolverContext, $serializeStageData)->shouldBeCalled()->willReturn($subscriptionId); + + $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn((new ResourceMetadata())->withAttributes(['mercure' => true])); + + $mercureUrl = 'mercure-url'; + $this->mercureSubscriptionIriGeneratorProphecy->generateMercureUrl($subscriptionId)->shouldBeCalled()->willReturn($mercureUrl); + + $this->assertSame($serializeStageData + ['mercureUrl' => $mercureUrl], ($this->itemSubscriptionResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info)); + } + + public function testResolveNullResourceClass(): void + { + $resourceClass = null; + $rootClass = 'rootClass'; + $operationName = 'update'; + $source = ['source']; + $args = ['args']; + $info = $this->prophesize(ResolveInfo::class)->reveal(); + + $this->assertNull(($this->itemSubscriptionResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info)); + } + + public function testResolveNullOperationName(): void + { + $resourceClass = 'stdClass'; + $rootClass = 'rootClass'; + $operationName = null; + $source = ['source']; + $args = ['args']; + $info = $this->prophesize(ResolveInfo::class)->reveal(); + + $this->assertNull(($this->itemSubscriptionResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info)); + } + + public function testResolveBadReadStageItem(): void + { + $resourceClass = 'stdClass'; + $rootClass = 'rootClass'; + $operationName = 'update'; + $source = ['source']; + $args = ['args']; + $info = $this->prophesize(ResolveInfo::class)->reveal(); + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + + $readStageItem = []; + $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->shouldBeCalled()->willReturn($readStageItem); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Item from read stage should be a nullable object.'); + + ($this->itemSubscriptionResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info); + } + + public function testResolveNoSubscriptionId(): void + { + $resourceClass = 'stdClass'; + $rootClass = 'rootClass'; + $operationName = 'update'; + $source = ['source']; + $args = ['args']; + $info = $this->prophesize(ResolveInfo::class)->reveal(); + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + + $readStageItem = new \stdClass(); + $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->willReturn($readStageItem); + + $serializeStageData = ['serialized']; + $this->serializeStageProphecy->__invoke($readStageItem, $resourceClass, $operationName, $resolverContext)->willReturn($serializeStageData); + + $this->subscriptionManagerProphecy->retrieveSubscriptionId($resolverContext, $serializeStageData)->willReturn(null); + + $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn((new ResourceMetadata())->withAttributes(['mercure' => true])); + + $this->mercureSubscriptionIriGeneratorProphecy->generateMercureUrl(Argument::any())->shouldNotBeCalled(); + + $this->assertSame($serializeStageData, ($this->itemSubscriptionResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info)); + } + + public function testResolveNoMercureSubscriptionIriGenerator(): void + { + $resourceClass = 'stdClass'; + $rootClass = 'rootClass'; + $operationName = 'update'; + $source = ['source']; + $args = ['args']; + $info = $this->prophesize(ResolveInfo::class)->reveal(); + $resolverContext = ['source' => $source, 'args' => $args, 'info' => $info, 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + + $readStageItem = new \stdClass(); + $this->readStageProphecy->__invoke($resourceClass, $rootClass, $operationName, $resolverContext)->willReturn($readStageItem); + + $serializeStageData = ['serialized']; + $this->serializeStageProphecy->__invoke($readStageItem, $resourceClass, $operationName, $resolverContext)->willReturn($serializeStageData); + + $subscriptionId = 'subscriptionId'; + $this->subscriptionManagerProphecy->retrieveSubscriptionId($resolverContext, $serializeStageData)->willReturn($subscriptionId); + + $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn((new ResourceMetadata())->withAttributes(['mercure' => true])); + + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('Cannot use Mercure for subscriptions when MercureBundle is not installed. Try running "composer require mercure".'); + + $itemSubscriptionResolverFactory = new ItemSubscriptionResolverFactory( + $this->readStageProphecy->reveal(), + $this->securityStageProphecy->reveal(), + $this->serializeStageProphecy->reveal(), + $this->resourceMetadataFactoryProphecy->reveal(), + $this->subscriptionManagerProphecy->reveal(), + null + ); + + ($itemSubscriptionResolverFactory)($resourceClass, $rootClass, $operationName)($source, $args, null, $info); + } +} diff --git a/tests/GraphQl/Resolver/Stage/ReadStageTest.php b/tests/GraphQl/Resolver/Stage/ReadStageTest.php index f0efe7a82bf..981d3a56693 100644 --- a/tests/GraphQl/Resolver/Stage/ReadStageTest.php +++ b/tests/GraphQl/Resolver/Stage/ReadStageTest.php @@ -23,15 +23,17 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\ProphecyTrait; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; +use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; /** * @author Alan Poulain */ class ReadStageTest extends TestCase { + use ExpectDeprecationTrait; use ProphecyTrait; /** @var ReadStage */ @@ -104,6 +106,7 @@ public function testApplyItem(?string $identifier, $item, bool $throwNotFound, $ $context = [ 'is_collection' => false, 'is_mutation' => false, + 'is_subscription' => false, 'args' => ['id' => $identifier], 'info' => $info, ]; @@ -135,18 +138,19 @@ public function itemProvider(): array } /** - * @dataProvider itemMutationProvider + * @dataProvider itemMutationOrSubscriptionProvider * * @param object|null $item * @param object|null $expectedResult */ - public function testApplyMutation(string $resourceClass, ?string $identifier, $item, bool $throwNotFound, $expectedResult, ?string $expectedExceptionClass = null, ?string $expectedExceptionMessage = null): void + public function testApplyMutationOrSubscription(bool $isMutation, bool $isSubscription, string $resourceClass, ?string $identifier, $item, bool $throwNotFound, $expectedResult, ?string $expectedExceptionClass = null, ?string $expectedExceptionMessage = null): void { $operationName = 'create'; $info = $this->prophesize(ResolveInfo::class)->reveal(); $context = [ 'is_collection' => false, - 'is_mutation' => true, + 'is_mutation' => $isMutation, + 'is_subscription' => $isSubscription, 'args' => ['input' => ['id' => $identifier]], 'info' => $info, ]; @@ -171,15 +175,17 @@ public function testApplyMutation(string $resourceClass, ?string $identifier, $i $this->assertSame($expectedResult, $result); } - public function itemMutationProvider(): array + public function itemMutationOrSubscriptionProvider(): array { $item = new \stdClass(); return [ - 'no identifier' => ['myResource', null, $item, false, null], - 'identifier' => ['stdClass', 'identifier', $item, false, $item], - 'identifier bad item' => ['myResource', 'identifier', $item, false, $item, Error::class, 'Item "identifier" did not match expected type "shortName".'], - 'identifier not found' => ['myResource', 'identifier_not_found', $item, true, null, Error::class, 'Item "identifier_not_found" not found.'], + 'no identifier' => [true, false, 'myResource', null, $item, false, null], + 'identifier' => [true, false, 'stdClass', 'identifier', $item, false, $item], + 'identifier bad item' => [true, false, 'myResource', 'identifier', $item, false, $item, \UnexpectedValueException::class, 'Item "identifier" did not match expected type "shortName".'], + 'identifier not found' => [true, false, 'myResource', 'identifier_not_found', $item, true, null, NotFoundHttpException::class, 'Item "identifier_not_found" not found.'], + 'no identifier (subscription)' => [false, true, 'myResource', null, $item, false, null], + 'identifier (subscription)' => [false, true, 'stdClass', 'identifier', $item, false, $item], ]; } @@ -196,6 +202,7 @@ public function testApplyCollection(array $args, ?string $rootClass, ?array $sou $context = [ 'is_collection' => true, 'is_mutation' => false, + 'is_subscription' => false, 'args' => $args, 'info' => $info, 'source' => $source, @@ -225,6 +232,13 @@ public function collectionProvider(): array ['filter_list' => 'filtered', 'filter_field_list' => ['filtered1', 'filtered2'], 'filter.list' => 'filtered', 'filter_field' => ['filtered1', 'filtered2'], 'filter.field' => ['filtered1', 'filtered2']], [], ], + 'with array filter syntax' => [ + ['filter' => [['filterArg1' => 'filterValue1'], ['filterArg2' => 'filterValue2']]], + 'myResource', + null, + ['filter' => ['filterArg1' => 'filterValue1', 'filterArg2' => 'filterValue2']], + [], + ], 'with subresource' => [ [], 'myResource', @@ -234,4 +248,36 @@ public function collectionProvider(): array ], ]; } + + /** + * @group legacy + */ + public function testApplyCollectionWithDeprecatedFilterSyntax(): void + { + $operationName = 'collection_query'; + $resourceClass = 'myResource'; + $info = $this->prophesize(ResolveInfo::class)->reveal(); + $fieldName = 'subresource'; + $info->fieldName = $fieldName; + $context = [ + 'is_collection' => true, + 'is_mutation' => false, + 'is_subscription' => false, + 'args' => ['filter' => [['filterArg1' => 'filterValue1', 'filterArg2' => 'filterValue2']]], + 'info' => $info, + 'source' => null, + ]; + $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadata()); + + $normalizationContext = ['normalization' => true]; + $this->serializerContextBuilderProphecy->create($resourceClass, $operationName, $context, true)->shouldBeCalled()->willReturn($normalizationContext); + + $this->collectionDataProviderProphecy->getCollection($resourceClass, $operationName, $normalizationContext + ['filters' => ['filter' => ['filterArg1' => 'filterValue1', 'filterArg2' => 'filterValue2']]])->willReturn([]); + + $this->expectDeprecation('The filter syntax "filter: {filterArg1: "filterValue1", filterArg2: "filterValue2"}" is deprecated since API Platform 2.6, use the following syntax instead: "filter: [{filterArg1: "filterValue1"}, {filterArg2: "filterValue2"}]".'); + + $result = ($this->readStage)($resourceClass, 'myResource', $operationName, $context); + + $this->assertSame([], $result); + } } diff --git a/tests/GraphQl/Resolver/Stage/SecurityPostDenormalizeStageTest.php b/tests/GraphQl/Resolver/Stage/SecurityPostDenormalizeStageTest.php index d187dd1dd92..32fcdf211c2 100644 --- a/tests/GraphQl/Resolver/Stage/SecurityPostDenormalizeStageTest.php +++ b/tests/GraphQl/Resolver/Stage/SecurityPostDenormalizeStageTest.php @@ -18,10 +18,10 @@ use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; use ApiPlatform\Core\Tests\ProphecyTrait; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; /** * @author Alan Poulain @@ -110,7 +110,7 @@ public function testNotGranted(): void $info = $this->prophesize(ResolveInfo::class)->reveal(); - $this->expectException(Error::class); + $this->expectException(AccessDeniedHttpException::class); $this->expectExceptionMessage('Access Denied.'); ($this->securityPostDenormalizeStage)($resourceClass, 'item_query', [ diff --git a/tests/GraphQl/Resolver/Stage/SecurityStageTest.php b/tests/GraphQl/Resolver/Stage/SecurityStageTest.php index c41df872284..bc15b7203f7 100644 --- a/tests/GraphQl/Resolver/Stage/SecurityStageTest.php +++ b/tests/GraphQl/Resolver/Stage/SecurityStageTest.php @@ -18,10 +18,10 @@ use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; use ApiPlatform\Core\Tests\ProphecyTrait; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Prophecy\Argument; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; /** * @author Alan Poulain @@ -91,7 +91,7 @@ public function testNotGranted(): void $info = $this->prophesize(ResolveInfo::class)->reveal(); - $this->expectException(Error::class); + $this->expectException(AccessDeniedHttpException::class); $this->expectExceptionMessage('Access Denied.'); ($this->securityStage)($resourceClass, 'item_query', [ diff --git a/tests/GraphQl/Resolver/Stage/SerializeStageTest.php b/tests/GraphQl/Resolver/Stage/SerializeStageTest.php index d0ddd0be4d7..baf6e3b0836 100644 --- a/tests/GraphQl/Resolver/Stage/SerializeStageTest.php +++ b/tests/GraphQl/Resolver/Stage/SerializeStageTest.php @@ -21,7 +21,6 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\ProphecyTrait; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -68,10 +67,11 @@ public function testApplyDisabled(array $context, bool $paginationEnabled, ?arra public function applyDisabledProvider(): array { return [ - 'item' => [['is_collection' => false, 'is_mutation' => false], false, null], - 'collection with pagination' => [['is_collection' => true, 'is_mutation' => false], true, ['totalCount' => 0., 'edges' => [], 'pageInfo' => ['startCursor' => null, 'endCursor' => null, 'hasNextPage' => false, 'hasPreviousPage' => false]]], - 'collection without pagination' => [['is_collection' => true, 'is_mutation' => false], false, []], - 'mutation' => [['is_collection' => false, 'is_mutation' => true], false, ['clientMutationId' => null]], + 'item' => [['is_collection' => false, 'is_mutation' => false, 'is_subscription' => false], false, null], + 'collection with pagination' => [['is_collection' => true, 'is_mutation' => false, 'is_subscription' => false], true, ['totalCount' => 0., 'edges' => [], 'pageInfo' => ['startCursor' => null, 'endCursor' => null, 'hasNextPage' => false, 'hasPreviousPage' => false]]], + 'collection without pagination' => [['is_collection' => true, 'is_mutation' => false, 'is_subscription' => false], false, []], + 'mutation' => [['is_collection' => false, 'is_mutation' => true, 'is_subscription' => false], false, ['clientMutationId' => null]], + 'subscription' => [['is_collection' => false, 'is_mutation' => false, 'is_subscription' => true], false, ['clientSubscriptionId' => null]], ]; } @@ -103,10 +103,11 @@ public function applyProvider(): array ]; return [ - 'item' => [new \stdClass(), 'item_query', $defaultContext + ['is_collection' => false, 'is_mutation' => false], false, ['normalized_item']], - 'collection without pagination' => [[new \stdClass(), new \stdClass()], 'collection_query', $defaultContext + ['is_collection' => true, 'is_mutation' => false], false, [['normalized_item'], ['normalized_item']]], - 'mutation' => [new \stdClass(), 'create', array_merge($defaultContext, ['args' => ['input' => ['clientMutationId' => 'clientMutationId']], 'is_collection' => false, 'is_mutation' => true]), false, ['shortName' => ['normalized_item'], 'clientMutationId' => 'clientMutationId']], - 'delete mutation' => [new \stdClass(), 'delete', array_merge($defaultContext, ['args' => ['input' => ['id' => 4]], 'is_collection' => false, 'is_mutation' => true]), false, ['shortName' => ['id' => 4], 'clientMutationId' => null]], + 'item' => [new \stdClass(), 'item_query', $defaultContext + ['is_collection' => false, 'is_mutation' => false, 'is_subscription' => false], false, ['normalized_item']], + 'collection without pagination' => [[new \stdClass(), new \stdClass()], 'collection_query', $defaultContext + ['is_collection' => true, 'is_mutation' => false, 'is_subscription' => false], false, [['normalized_item'], ['normalized_item']]], + 'mutation' => [new \stdClass(), 'create', array_merge($defaultContext, ['args' => ['input' => ['clientMutationId' => 'clientMutationId']], 'is_collection' => false, 'is_mutation' => true, 'is_subscription' => false]), false, ['shortName' => ['normalized_item'], 'clientMutationId' => 'clientMutationId']], + 'delete mutation' => [new \stdClass(), 'delete', array_merge($defaultContext, ['args' => ['input' => ['id' => '/iri/4']], 'is_collection' => false, 'is_mutation' => true, 'is_subscription' => false]), false, ['shortName' => ['id' => '/iri/4'], 'clientMutationId' => null]], + 'subscription' => [new \stdClass(), 'update', array_merge($defaultContext, ['args' => ['input' => ['clientSubscriptionId' => 'clientSubscriptionId']], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]), false, ['shortName' => ['normalized_item'], 'clientSubscriptionId' => 'clientSubscriptionId']], ]; } @@ -120,6 +121,7 @@ public function testApplyCollectionWithPagination(iterable $collection, array $a $context = [ 'is_collection' => true, 'is_mutation' => false, + 'is_subscription' => false, 'args' => $args, 'info' => $this->prophesize(ResolveInfo::class)->reveal(), ]; @@ -143,15 +145,15 @@ public function testApplyCollectionWithPagination(iterable $collection, array $a public function applyCollectionWithPaginationProvider(): array { return [ - 'not paginator' => [[], [], null, Error::class, 'Collection returned by the collection data provider must implement ApiPlatform\Core\DataProvider\PaginatorInterface'], + 'not paginator' => [[], [], null, \LogicException::class, 'Collection returned by the collection data provider must implement ApiPlatform\Core\DataProvider\PaginatorInterface'], 'empty paginator' => [new ArrayPaginator([], 0, 0), [], ['totalCount' => 0., 'edges' => [], 'pageInfo' => ['startCursor' => null, 'endCursor' => null, 'hasNextPage' => false, 'hasPreviousPage' => false]]], 'paginator' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 0, 2), [], ['totalCount' => 3., 'edges' => [['node' => ['normalized_item'], 'cursor' => 'MA=='], ['node' => ['normalized_item'], 'cursor' => 'MQ==']], 'pageInfo' => ['startCursor' => 'MA==', 'endCursor' => 'MQ==', 'hasNextPage' => true, 'hasPreviousPage' => false]]], 'paginator with after cursor' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 1, 2), ['after' => 'MA=='], ['totalCount' => 3., 'edges' => [['node' => ['normalized_item'], 'cursor' => 'MQ=='], ['node' => ['normalized_item'], 'cursor' => 'Mg==']], 'pageInfo' => ['startCursor' => 'MQ==', 'endCursor' => 'Mg==', 'hasNextPage' => false, 'hasPreviousPage' => true]]], - 'paginator with bad after cursor' => [new ArrayPaginator([], 0, 0), ['after' => '-'], null, Error::class, 'Cursor - is invalid'], - 'paginator with empty after cursor' => [new ArrayPaginator([], 0, 0), ['after' => ''], null, Error::class, 'Empty cursor is invalid'], + 'paginator with bad after cursor' => [new ArrayPaginator([], 0, 0), ['after' => '-'], null, \UnexpectedValueException::class, 'Cursor - is invalid'], + 'paginator with empty after cursor' => [new ArrayPaginator([], 0, 0), ['after' => ''], null, \UnexpectedValueException::class, 'Empty cursor is invalid'], 'paginator with before cursor' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 1, 1), ['before' => 'Mg=='], ['totalCount' => 3., 'edges' => [['node' => ['normalized_item'], 'cursor' => 'MQ==']], 'pageInfo' => ['startCursor' => 'MQ==', 'endCursor' => 'MQ==', 'hasNextPage' => true, 'hasPreviousPage' => true]]], - 'paginator with bad before cursor' => [new ArrayPaginator([], 0, 0), ['before' => '-'], null, Error::class, 'Cursor - is invalid'], - 'paginator with empty before cursor' => [new ArrayPaginator([], 0, 0), ['before' => ''], null, Error::class, 'Empty cursor is invalid'], + 'paginator with bad before cursor' => [new ArrayPaginator([], 0, 0), ['before' => '-'], null, \UnexpectedValueException::class, 'Cursor - is invalid'], + 'paginator with empty before cursor' => [new ArrayPaginator([], 0, 0), ['before' => ''], null, \UnexpectedValueException::class, 'Empty cursor is invalid'], 'paginator with last' => [new ArrayPaginator([new \stdClass(), new \stdClass(), new \stdClass()], 1, 2), ['last' => 2], ['totalCount' => 3., 'edges' => [['node' => ['normalized_item'], 'cursor' => 'MQ=='], ['node' => ['normalized_item'], 'cursor' => 'Mg==']], 'pageInfo' => ['startCursor' => 'MQ==', 'endCursor' => 'Mg==', 'hasNextPage' => false, 'hasPreviousPage' => true]]], ]; } @@ -160,7 +162,7 @@ public function testApplyBadNormalizedData(): void { $operationName = 'item_query'; $resourceClass = 'myResource'; - $context = ['is_collection' => false, 'is_mutation' => false, 'args' => [], 'info' => $this->prophesize(ResolveInfo::class)->reveal()]; + $context = ['is_collection' => false, 'is_mutation' => false, 'is_subscription' => false, 'args' => [], 'info' => $this->prophesize(ResolveInfo::class)->reveal()]; $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadata()); $normalizationContext = ['normalization' => true]; @@ -168,7 +170,7 @@ public function testApplyBadNormalizedData(): void $this->normalizerProphecy->normalize(Argument::type('stdClass'), ItemNormalizer::FORMAT, $normalizationContext)->willReturn(new \stdClass()); - $this->expectException(Error::class); + $this->expectException(\UnexpectedValueException::class); $this->expectExceptionMessage('Expected serialized data to be a nullable array.'); ($this->createSerializeStage(false))(new \stdClass(), $resourceClass, $operationName, $context); diff --git a/tests/GraphQl/Resolver/Stage/ValidateStageTest.php b/tests/GraphQl/Resolver/Stage/ValidateStageTest.php index fe27e927587..3aa9c3ea987 100644 --- a/tests/GraphQl/Resolver/Stage/ValidateStageTest.php +++ b/tests/GraphQl/Resolver/Stage/ValidateStageTest.php @@ -19,7 +19,6 @@ use ApiPlatform\Core\Tests\ProphecyTrait; use ApiPlatform\Core\Validator\Exception\ValidationException; use ApiPlatform\Core\Validator\ValidatorInterface; -use GraphQL\Error\Error; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -95,7 +94,7 @@ public function testApplyNotValidated(): void $object = new \stdClass(); $this->validatorProphecy->validate($object, ['groups' => $validationGroups])->shouldBeCalled()->willThrow(new ValidationException()); - $this->expectException(Error::class); + $this->expectException(ValidationException::class); ($this->validateStage)($object, $resourceClass, $operationName, $context); } diff --git a/tests/GraphQl/Resolver/Util/IdentifierTraitTest.php b/tests/GraphQl/Resolver/Util/IdentifierTraitTest.php new file mode 100644 index 00000000000..b32b6209a56 --- /dev/null +++ b/tests/GraphQl/Resolver/Util/IdentifierTraitTest.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\GraphQl\Resolver\Util; + +use ApiPlatform\Core\GraphQl\Resolver\Util\IdentifierTrait; +use PHPUnit\Framework\TestCase; + +/** + * @author Alan Poulain + */ +class IdentifierTraitTest extends TestCase +{ + private function getIdentifierTraitImplementation() + { + return new class() { + use IdentifierTrait { + IdentifierTrait::getIdentifierFromContext as public; + } + }; + } + + public function testGetIdentifierFromQueryContext(): void + { + $identifierTrait = $this->getIdentifierTraitImplementation(); + + $this->assertEquals('foo', $identifierTrait->getIdentifierFromContext(['args' => ['id' => 'foo'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => false])); + } + + public function testGetIdentifierFromMutationContext(): void + { + $identifierTrait = $this->getIdentifierTraitImplementation(); + + $this->assertEquals('foo', $identifierTrait->getIdentifierFromContext(['args' => ['input' => ['id' => 'foo']], 'is_collection' => false, 'is_mutation' => true, 'is_subscription' => false])); + } + + public function testGetIdentifierFromSubscriptionContext(): void + { + $identifierTrait = $this->getIdentifierTraitImplementation(); + + $this->assertEquals('foo', $identifierTrait->getIdentifierFromContext(['args' => ['input' => ['id' => 'foo']], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true])); + } +} diff --git a/tests/GraphQl/Serializer/Exception/ErrorNormalizerTest.php b/tests/GraphQl/Serializer/Exception/ErrorNormalizerTest.php new file mode 100644 index 00000000000..ccceabc9e5a --- /dev/null +++ b/tests/GraphQl/Serializer/Exception/ErrorNormalizerTest.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\GraphQl\Serializer\Exception; + +use ApiPlatform\Core\GraphQl\Serializer\Exception\ErrorNormalizer; +use GraphQL\Error\Error; +use PHPUnit\Framework\TestCase; + +/** + * @author Alan Poulain + */ +class ErrorNormalizerTest extends TestCase +{ + private $errorNormalizer; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + parent::setUp(); + + $this->errorNormalizer = new ErrorNormalizer(); + } + + public function testNormalize(): void + { + $errorMessage = 'test message'; + $error = new Error($errorMessage); + + $normalizedError = $this->errorNormalizer->normalize($error); + $this->assertSame($errorMessage, $normalizedError['message']); + $this->assertSame(Error::CATEGORY_GRAPHQL, $normalizedError['extensions']['category']); + } + + public function testSupportsNormalization(): void + { + $error = new Error('test message'); + + $this->assertTrue($this->errorNormalizer->supportsNormalization($error)); + } +} diff --git a/tests/GraphQl/Serializer/Exception/HttpExceptionNormalizerTest.php b/tests/GraphQl/Serializer/Exception/HttpExceptionNormalizerTest.php new file mode 100644 index 00000000000..c25f10e096d --- /dev/null +++ b/tests/GraphQl/Serializer/Exception/HttpExceptionNormalizerTest.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\GraphQl\Serializer\Exception; + +use ApiPlatform\Core\GraphQl\Serializer\Exception\HttpExceptionNormalizer; +use GraphQL\Error\Error; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; + +/** + * @author Alan Poulain + */ +class HttpExceptionNormalizerTest extends TestCase +{ + private $httpExceptionNormalizer; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + parent::setUp(); + + $this->httpExceptionNormalizer = new HttpExceptionNormalizer(); + } + + /** + * @dataProvider exceptionProvider + */ + public function testNormalize(HttpException $exception, string $expectedExceptionMessage, int $expectedStatus, string $expectedCategory): void + { + $error = new Error('test message', null, null, [], null, $exception); + + $normalizedError = $this->httpExceptionNormalizer->normalize($error); + $this->assertSame($expectedExceptionMessage, $normalizedError['message']); + $this->assertSame($expectedStatus, $normalizedError['extensions']['status']); + $this->assertSame($expectedCategory, $normalizedError['extensions']['category']); + } + + public function exceptionProvider(): array + { + $exceptionMessage = 'exception message'; + + return [ + 'client error' => [new BadRequestHttpException($exceptionMessage), $exceptionMessage, 400, 'user'], + 'server error' => [new ServiceUnavailableHttpException(null, $exceptionMessage), $exceptionMessage, 503, Error::CATEGORY_INTERNAL], + ]; + } + + public function testSupportsNormalization(): void + { + $exception = new BadRequestHttpException(); + $error = new Error('test message', null, null, [], null, $exception); + + $this->assertTrue($this->httpExceptionNormalizer->supportsNormalization($error)); + } +} diff --git a/tests/GraphQl/Serializer/Exception/RuntimeExceptionNormalizerTest.php b/tests/GraphQl/Serializer/Exception/RuntimeExceptionNormalizerTest.php new file mode 100644 index 00000000000..89cf4b4e8df --- /dev/null +++ b/tests/GraphQl/Serializer/Exception/RuntimeExceptionNormalizerTest.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\GraphQl\Serializer\Exception; + +use ApiPlatform\Core\GraphQl\Serializer\Exception\RuntimeExceptionNormalizer; +use GraphQL\Error\Error; +use PHPUnit\Framework\TestCase; + +/** + * @author Alan Poulain + */ +class RuntimeExceptionNormalizerTest extends TestCase +{ + private $runtimeExceptionNormalizer; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + parent::setUp(); + + $this->runtimeExceptionNormalizer = new RuntimeExceptionNormalizer(); + } + + public function testNormalize(): void + { + $exceptionMessage = 'exception message'; + $exception = new \RuntimeException($exceptionMessage); + $error = new Error('test message', null, null, [], null, $exception); + + $normalizedError = $this->runtimeExceptionNormalizer->normalize($error); + $this->assertSame($exceptionMessage, $normalizedError['message']); + $this->assertSame(Error::CATEGORY_INTERNAL, $normalizedError['extensions']['category']); + } + + public function testSupportsNormalization(): void + { + $exception = new \RuntimeException(); + $error = new Error('test message', null, null, [], null, $exception); + + $this->assertTrue($this->runtimeExceptionNormalizer->supportsNormalization($error)); + } +} diff --git a/tests/GraphQl/Serializer/Exception/ValidationExceptionNormalizerTest.php b/tests/GraphQl/Serializer/Exception/ValidationExceptionNormalizerTest.php new file mode 100644 index 00000000000..bef54b2fbf7 --- /dev/null +++ b/tests/GraphQl/Serializer/Exception/ValidationExceptionNormalizerTest.php @@ -0,0 +1,73 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\GraphQl\Serializer\Exception; + +use ApiPlatform\Core\Bridge\Symfony\Validator\Exception\ValidationException; +use ApiPlatform\Core\GraphQl\Serializer\Exception\ValidationExceptionNormalizer; +use GraphQL\Error\Error; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationList; + +/** + * @author Mahmood Bazdar + */ +class ValidationExceptionNormalizerTest extends TestCase +{ + private $validationExceptionNormalizer; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + parent::setUp(); + + $this->validationExceptionNormalizer = new ValidationExceptionNormalizer(); + } + + public function testNormalize(): void + { + $exceptionMessage = 'exception message'; + $exception = new ValidationException(new ConstraintViolationList([ + new ConstraintViolation('message 1', '', [], '', 'field 1', 'invalid'), + new ConstraintViolation('message 2', '', [], '', 'field 2', 'invalid'), + ]), $exceptionMessage); + $error = new Error('test message', null, null, [], null, $exception); + + $normalizedError = $this->validationExceptionNormalizer->normalize($error); + $this->assertSame($exceptionMessage, $normalizedError['message']); + $this->assertSame(422, $normalizedError['extensions']['status']); + $this->assertSame('user', $normalizedError['extensions']['category']); + $this->assertArrayHasKey('violations', $normalizedError['extensions']); + $this->assertSame([ + [ + 'path' => 'field 1', + 'message' => 'message 1', + ], + [ + 'path' => 'field 2', + 'message' => 'message 2', + ], + ], $normalizedError['extensions']['violations']); + } + + public function testSupportsNormalization(): void + { + $exception = new ValidationException(new ConstraintViolationList([])); + $error = new Error('test message', null, null, [], null, $exception); + + $this->assertTrue($this->validationExceptionNormalizer->supportsNormalization($error)); + } +} diff --git a/tests/GraphQl/Serializer/ItemNormalizerTest.php b/tests/GraphQl/Serializer/ItemNormalizerTest.php index 4d2d3346018..2ce80292c7a 100644 --- a/tests/GraphQl/Serializer/ItemNormalizerTest.php +++ b/tests/GraphQl/Serializer/ItemNormalizerTest.php @@ -126,6 +126,57 @@ public function testNormalize() ])); } + public function testNormalizeNoResolverData(): void + { + $dummy = new Dummy(); + $dummy->setName('hello'); + + $propertyNameCollection = new PropertyNameCollection(['name']); + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn($propertyNameCollection); + + $propertyMetadata = new PropertyMetadata(null, null, true); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', [])->willReturn($propertyMetadata); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($dummy)->willReturn('/dummies/1'); + + $identifiersExtractorProphecy = $this->prophesize(IdentifiersExtractorInterface::class); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('hello', ItemNormalizer::FORMAT, Argument::type('array'))->willReturn('hello'); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $identifiersExtractorProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + null, + null, + null, + null, + false, + null, + [], + null + ); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + 'name' => 'hello', + ]; + $this->assertEquals($expected, $normalizer->normalize($dummy, ItemNormalizer::FORMAT, [ + 'resources' => [], + 'no_resolver_data' => true, + ])); + } + public function testDenormalize() { $context = ['resource_class' => Dummy::class, 'api_allow_update' => true]; diff --git a/tests/GraphQl/Serializer/SerializerContextBuilderTest.php b/tests/GraphQl/Serializer/SerializerContextBuilderTest.php index 465df371d96..5dd421f6688 100644 --- a/tests/GraphQl/Serializer/SerializerContextBuilderTest.php +++ b/tests/GraphQl/Serializer/SerializerContextBuilderTest.php @@ -20,6 +20,8 @@ use ApiPlatform\Core\Tests\ProphecyTrait; use GraphQL\Type\Definition\ResolveInfo; use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; /** * @author Alan Poulain @@ -39,24 +41,32 @@ protected function setUp(): void { $this->resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $this->serializerContextBuilder = new SerializerContextBuilder( - $this->resourceMetadataFactoryProphecy->reveal(), - new CustomConverter() - ); + $this->serializerContextBuilder = $this->buildSerializerContextBuilder(); + } + + private function buildSerializerContextBuilder(?AdvancedNameConverterInterface $advancedNameConverter = null): SerializerContextBuilder + { + return new SerializerContextBuilder($this->resourceMetadataFactoryProphecy->reveal(), $advancedNameConverter ?? new CustomConverter()); } /** * @dataProvider createNormalizationContextProvider */ - public function testCreateNormalizationContext(?string $resourceClass, string $operationName, array $fields, array $expectedContext, bool $isMutation, ?string $expectedExceptionClass = null, ?string $expectedExceptionMessage = null): void + public function testCreateNormalizationContext(?string $resourceClass, string $operationName, array $fields, bool $isMutation, bool $isSubscription, bool $noInfo, array $expectedContext, ?AdvancedNameConverterInterface $advancedNameConverter = null, ?string $expectedExceptionClass = null, ?string $expectedExceptionMessage = null): void { - $resolveInfoProphecy = $this->prophesize(ResolveInfo::class); - $resolveInfoProphecy->getFieldSelection(PHP_INT_MAX)->willReturn($fields); $resolverContext = [ - 'info' => $resolveInfoProphecy->reveal(), 'is_mutation' => $isMutation, + 'is_subscription' => $isSubscription, ]; + if ($noInfo) { + $resolverContext['fields'] = $fields; + } else { + $resolveInfoProphecy = $this->prophesize(ResolveInfo::class); + $resolveInfoProphecy->getFieldSelection(PHP_INT_MAX)->willReturn($fields); + $resolverContext['info'] = $resolveInfoProphecy->reveal(); + } + $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn( (new ResourceMetadata('shortName')) ->withGraphql([ @@ -73,51 +83,86 @@ public function testCreateNormalizationContext(?string $resourceClass, string $o $this->expectExceptionMessage($expectedExceptionMessage); } - $context = $this->serializerContextBuilder->create($resourceClass, $operationName, $resolverContext, true); + $serializerContextBuilder = $this->serializerContextBuilder; + if ($advancedNameConverter) { + $serializerContextBuilder = $this->buildSerializerContextBuilder($advancedNameConverter); + } + + $context = $serializerContextBuilder->create($resourceClass, $operationName, $resolverContext, true); $this->assertSame($expectedContext, $context); } public function createNormalizationContextProvider(): array { + $advancedNameConverter = $this->prophesize(AdvancedNameConverterInterface::class); + $advancedNameConverter->denormalize('field', 'myResource', null, Argument::type('array'))->willReturn('denormalizedField'); + return [ 'nominal' => [ $resourceClass = 'myResource', $operationName = 'item_query', ['_id' => 3, 'field' => 'foo'], + false, + false, + false, [ 'groups' => ['normalization_group'], 'resource_class' => $resourceClass, 'graphql_operation_name' => $operationName, + 'input' => ['class' => 'inputClass'], + 'output' => ['class' => 'outputClass'], 'attributes' => [ 'id' => 3, 'field' => 'foo', ], + ], + ], + 'nominal with advanced name converter' => [ + $resourceClass = 'myResource', + $operationName = 'item_query', + ['_id' => 3, 'field' => 'foo'], + false, + false, + false, + [ + 'groups' => ['normalization_group'], + 'resource_class' => $resourceClass, + 'graphql_operation_name' => $operationName, 'input' => ['class' => 'inputClass'], 'output' => ['class' => 'outputClass'], + 'attributes' => [ + 'id' => 3, + 'denormalizedField' => 'foo', + ], ], - false, + $advancedNameConverter->reveal(), ], 'nominal collection' => [ $resourceClass = 'myResource', $operationName = 'collection_query', ['edges' => ['node' => ['nodeField' => 'baz']]], + false, + false, + false, [ 'groups' => ['normalization_group'], 'resource_class' => $resourceClass, 'graphql_operation_name' => $operationName, + 'input' => ['class' => 'inputClass'], + 'output' => ['class' => 'outputClass'], 'attributes' => [ 'nodeField' => 'baz', ], - 'input' => ['class' => 'inputClass'], - 'output' => ['class' => 'outputClass'], ], - false, ], 'no resource class' => [ $resourceClass = null, $operationName = 'item_query', ['related' => ['_id' => 9]], + false, + false, + false, [ 'resource_class' => $resourceClass, 'graphql_operation_name' => $operationName, @@ -125,33 +170,57 @@ public function createNormalizationContextProvider(): array 'related' => ['id' => 9], ], ], - false, ], 'mutation' => [ $resourceClass = 'myResource', $operationName = 'create', ['shortName' => ['_id' => 7, 'related' => ['field' => 'bar']]], + true, + false, + false, [ 'groups' => ['normalization_group'], 'resource_class' => $resourceClass, 'graphql_operation_name' => $operationName, + 'input' => ['class' => 'inputClass'], + 'output' => ['class' => 'outputClass'], 'attributes' => [ 'id' => 7, 'related' => ['field' => 'bar'], ], - 'input' => ['class' => 'inputClass'], - 'output' => ['class' => 'outputClass'], ], - true, ], 'mutation without resource class' => [ $resourceClass = null, $operationName = 'create', ['shortName' => ['_id' => 7, 'related' => ['field' => 'bar']]], - [], true, + false, + false, + [], + null, \LogicException::class, - 'ResourceMetadata should always exist for a mutation.', + 'ResourceMetadata should always exist for a mutation or a subscription.', + ], + 'subscription (using fields in context)' => [ + $resourceClass = 'myResource', + $operationName = 'update', + ['shortName' => ['_id' => 7, 'related' => ['field' => 'bar']]], + false, + true, + true, + [ + 'groups' => ['normalization_group'], + 'resource_class' => $resourceClass, + 'graphql_operation_name' => $operationName, + 'no_resolver_data' => true, + 'input' => ['class' => 'inputClass'], + 'output' => ['class' => 'outputClass'], + 'attributes' => [ + 'id' => 7, + 'related' => ['field' => 'bar'], + ], + ], ], ]; } diff --git a/tests/GraphQl/Subscription/MercureSubscriptionIriGeneratorTest.php b/tests/GraphQl/Subscription/MercureSubscriptionIriGeneratorTest.php new file mode 100644 index 00000000000..f2d1d835160 --- /dev/null +++ b/tests/GraphQl/Subscription/MercureSubscriptionIriGeneratorTest.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\GraphQl\Subscription; + +use ApiPlatform\Core\GraphQl\Subscription\MercureSubscriptionIriGenerator; +use PHPUnit\Framework\TestCase; +use Symfony\Component\Routing\RequestContext; + +/** + * @author Alan Poulain + */ +class MercureSubscriptionIriGeneratorTest extends TestCase +{ + private $requestContext; + private $hubUrl; + private $mercureSubscriptionIriGenerator; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->requestContext = new RequestContext('', 'GET', 'example.com'); + $this->hubUrl = 'https://demo.mercure.rocks/hub'; + $this->mercureSubscriptionIriGenerator = new MercureSubscriptionIriGenerator($this->requestContext, $this->hubUrl); + } + + public function testGenerateTopicIri(): void + { + $this->assertSame('http://example.com/subscriptions/subscription-id', $this->mercureSubscriptionIriGenerator->generateTopicIri('subscription-id')); + } + + public function testGenerateDefaultTopicIri(): void + { + $mercureSubscriptionIriGenerator = new MercureSubscriptionIriGenerator(new RequestContext('', 'GET', '', ''), $this->hubUrl); + + $this->assertSame('https://api-platform.com/subscriptions/subscription-id', $mercureSubscriptionIriGenerator->generateTopicIri('subscription-id')); + } + + public function testGenerateMercureUrl(): void + { + $this->assertSame("$this->hubUrl?topic=http://example.com/subscriptions/subscription-id", $this->mercureSubscriptionIriGenerator->generateMercureUrl('subscription-id')); + } +} diff --git a/tests/GraphQl/Subscription/SubscriptionIdentifierGeneratorTest.php b/tests/GraphQl/Subscription/SubscriptionIdentifierGeneratorTest.php new file mode 100644 index 00000000000..9dcf85d8616 --- /dev/null +++ b/tests/GraphQl/Subscription/SubscriptionIdentifierGeneratorTest.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\GraphQl\Subscription; + +use ApiPlatform\Core\GraphQl\Subscription\SubscriptionIdentifierGenerator; +use PHPUnit\Framework\TestCase; + +/** + * @author Alan Poulain + */ +class SubscriptionIdentifierGeneratorTest extends TestCase +{ + private $subscriptionIdentifierGenerator; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->subscriptionIdentifierGenerator = new SubscriptionIdentifierGenerator(); + } + + public function testGenerateSubscriptionIdentifier(): void + { + $this->assertSame('bf861b4e0edd7766ff61da90c60fdceef2618b595a3628901921d4d8eca555d0', $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier([ + 'dummyMercure' => [ + 'id' => true, + 'name' => true, + 'relatedDummy' => [ + 'name' => true, + ], + ], + ])); + } + + public function testGenerateSubscriptionIdentifierFieldsNotIncluded(): void + { + $subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier([ + 'dummyMercure' => [ + 'id' => true, + 'name' => true, + 'relatedDummy' => [ + 'name' => true, + ], + ], + ]); + + $subscriptionId2 = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier([ + 'dummyMercure' => [ + 'id' => true, + 'name' => true, + 'relatedDummy' => [ + 'name' => true, + ], + ], + 'mercureUrl' => true, + 'clientSubscriptionId' => true, + ]); + + $this->assertSame($subscriptionId, $subscriptionId2); + } + + public function testDifferentGeneratedSubscriptionIdentifiers(): void + { + $subscriptionId = $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier([ + 'dummyMercure' => [ + 'id' => true, + 'name' => true, + 'relatedDummy' => [ + 'name' => true, + ], + ], + ]); + + $this->assertNotSame($subscriptionId, $this->subscriptionIdentifierGenerator->generateSubscriptionIdentifier([ + 'dummyMercure' => [ + 'id' => true, + 'relatedDummy' => [ + 'name' => true, + ], + ], + ])); + } +} diff --git a/tests/GraphQl/Subscription/SubscriptionManagerTest.php b/tests/GraphQl/Subscription/SubscriptionManagerTest.php new file mode 100644 index 00000000000..5b35497c1f7 --- /dev/null +++ b/tests/GraphQl/Subscription/SubscriptionManagerTest.php @@ -0,0 +1,194 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\GraphQl\Subscription; + +use ApiPlatform\Core\Api\IriConverterInterface; +use ApiPlatform\Core\GraphQl\Resolver\Stage\SerializeStageInterface; +use ApiPlatform\Core\GraphQl\Subscription\SubscriptionIdentifierGeneratorInterface; +use ApiPlatform\Core\GraphQl\Subscription\SubscriptionManager; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use GraphQL\Type\Definition\ResolveInfo; +use PHPUnit\Framework\TestCase; +use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; + +/** + * @author Alan Poulain + */ +class SubscriptionManagerTest extends TestCase +{ + private $subscriptionsCacheProphecy; + private $subscriptionIdentifierGeneratorProphecy; + private $serializeStageProphecy; + private $iriConverterProphecy; + private $subscriptionManager; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + $this->subscriptionsCacheProphecy = $this->prophesize(CacheItemPoolInterface::class); + $this->subscriptionIdentifierGeneratorProphecy = $this->prophesize(SubscriptionIdentifierGeneratorInterface::class); + $this->serializeStageProphecy = $this->prophesize(SerializeStageInterface::class); + $this->iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $this->subscriptionManager = new SubscriptionManager($this->subscriptionsCacheProphecy->reveal(), $this->subscriptionIdentifierGeneratorProphecy->reveal(), $this->serializeStageProphecy->reveal(), $this->iriConverterProphecy->reveal()); + } + + public function testRetrieveSubscriptionIdNoIdentifier(): void + { + $info = $this->prophesize(ResolveInfo::class); + $info->getFieldSelection(PHP_INT_MAX)->willReturn([]); + + $context = ['args' => [], 'info' => $info->reveal(), 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + + $this->assertNull($this->subscriptionManager->retrieveSubscriptionId($context, null)); + } + + public function testRetrieveSubscriptionIdNoHit(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fields']; + $infoProphecy->getFieldSelection(PHP_INT_MAX)->willReturn($fields); + + $context = ['args' => ['input' => ['id' => '/foos/34']], 'info' => $infoProphecy->reveal(), 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + $result = ['result', 'clientSubscriptionId' => 'client-subscription-id']; + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(false); + $subscriptionId = 'subscriptionId'; + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields)->willReturn($subscriptionId); + $cacheItemProphecy->set([[$subscriptionId, $fields, ['result']]])->shouldBeCalled(); + $this->subscriptionsCacheProphecy->getItem('_foos_34')->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); + + $this->assertSame($subscriptionId, $this->subscriptionManager->retrieveSubscriptionId($context, $result)); + } + + public function testRetrieveSubscriptionIdHitNotCached(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fields']; + $infoProphecy->getFieldSelection(PHP_INT_MAX)->willReturn($fields); + + $context = ['args' => ['input' => ['id' => '/foos/34']], 'info' => $infoProphecy->reveal(), 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + $result = ['result', 'clientSubscriptionId' => 'client-subscription-id']; + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(true); + $cachedSubscriptions = [ + ['subscriptionIdFoo', ['fieldsFoo'], ['resultFoo']], + ['subscriptionIdBar', ['fieldsBar'], ['resultBar']], + ]; + $cacheItemProphecy->get()->willReturn($cachedSubscriptions); + $subscriptionId = 'subscriptionId'; + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields)->willReturn($subscriptionId); + $cacheItemProphecy->set(array_merge($cachedSubscriptions, [[$subscriptionId, $fields, ['result']]]))->shouldBeCalled(); + $this->subscriptionsCacheProphecy->getItem('_foos_34')->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + $this->subscriptionsCacheProphecy->save($cacheItemProphecy->reveal())->shouldBeCalled(); + + $this->assertSame($subscriptionId, $this->subscriptionManager->retrieveSubscriptionId($context, $result)); + } + + public function testRetrieveSubscriptionIdHitCached(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = ['fieldsBar']; + $infoProphecy->getFieldSelection(PHP_INT_MAX)->willReturn($fields); + + $context = ['args' => ['input' => ['id' => '/foos/34']], 'info' => $infoProphecy->reveal(), 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + $result = ['result']; + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(true); + $cacheItemProphecy->get()->willReturn([ + ['subscriptionIdFoo', ['fieldsFoo'], ['resultFoo']], + ['subscriptionIdBar', ['fieldsBar'], ['resultBar']], + ]); + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields)->shouldNotBeCalled(); + $this->subscriptionsCacheProphecy->getItem('_foos_34')->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + + $this->assertSame('subscriptionIdBar', $this->subscriptionManager->retrieveSubscriptionId($context, $result)); + } + + public function testRetrieveSubscriptionIdHitCachedDifferentFieldsOrder(): void + { + $infoProphecy = $this->prophesize(ResolveInfo::class); + $fields = [ + 'third' => true, + 'second' => [ + 'second' => true, + 'third' => true, + 'first' => true, + ], + 'first' => true, + ]; + $infoProphecy->getFieldSelection(PHP_INT_MAX)->willReturn($fields); + + $context = ['args' => ['input' => ['id' => '/foos/34']], 'info' => $infoProphecy->reveal(), 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true]; + $result = ['result']; + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(true); + $cacheItemProphecy->get()->willReturn([ + ['subscriptionIdFoo', [ + 'first' => true, + 'second' => [ + 'first' => true, + 'second' => true, + 'third' => true, + ], + 'third' => true, + ], ['resultFoo']], + ['subscriptionIdBar', ['fieldsBar'], ['resultBar']], + ]); + $this->subscriptionIdentifierGeneratorProphecy->generateSubscriptionIdentifier($fields)->shouldNotBeCalled(); + $this->subscriptionsCacheProphecy->getItem('_foos_34')->shouldBeCalled()->willReturn($cacheItemProphecy->reveal()); + + $this->assertSame('subscriptionIdFoo', $this->subscriptionManager->retrieveSubscriptionId($context, $result)); + } + + public function testGetPushPayloadsNoHit(): void + { + $object = new Dummy(); + + $this->iriConverterProphecy->getIriFromItem($object)->willReturn('/dummies/2'); + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(false); + $this->subscriptionsCacheProphecy->getItem('_dummies_2')->willReturn($cacheItemProphecy->reveal()); + + $this->assertSame([], $this->subscriptionManager->getPushPayloads($object)); + } + + public function testGetPushPayloadsHit(): void + { + $object = new Dummy(); + + $this->iriConverterProphecy->getIriFromItem($object)->willReturn('/dummies/2'); + + $cacheItemProphecy = $this->prophesize(CacheItemInterface::class); + $cacheItemProphecy->isHit()->willReturn(true); + $cacheItemProphecy->get()->willReturn([ + ['subscriptionIdFoo', ['fieldsFoo'], ['resultFoo']], + ['subscriptionIdBar', ['fieldsBar'], ['resultBar']], + ]); + $this->subscriptionsCacheProphecy->getItem('_dummies_2')->willReturn($cacheItemProphecy->reveal()); + + $this->serializeStageProphecy->__invoke($object, Dummy::class, 'update', ['fields' => ['fieldsFoo'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true])->willReturn(['newResultFoo', 'clientSubscriptionId' => 'client-subscription-id']); + $this->serializeStageProphecy->__invoke($object, Dummy::class, 'update', ['fields' => ['fieldsBar'], 'is_collection' => false, 'is_mutation' => false, 'is_subscription' => true])->willReturn(['resultBar', 'clientSubscriptionId' => 'client-subscription-id']); + + $this->assertSame([['subscriptionIdFoo', ['newResultFoo']]], $this->subscriptionManager->getPushPayloads($object)); + } +} diff --git a/tests/GraphQl/Type/FieldsBuilderTest.php b/tests/GraphQl/Type/FieldsBuilderTest.php index 9e5481ba178..df2f13f055a 100644 --- a/tests/GraphQl/Type/FieldsBuilderTest.php +++ b/tests/GraphQl/Type/FieldsBuilderTest.php @@ -31,6 +31,7 @@ use ApiPlatform\Core\Tests\ProphecyTrait; use GraphQL\Type\Definition\InputObjectType; use GraphQL\Type\Definition\InterfaceType; +use GraphQL\Type\Definition\ListOfType; use GraphQL\Type\Definition\NonNull; use GraphQL\Type\Definition\ObjectType; use GraphQL\Type\Definition\Type as GraphQLType; @@ -39,6 +40,7 @@ use Prophecy\Prophecy\ObjectProphecy; use Psr\Container\ContainerInterface; use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; /** * @author Alan Poulain @@ -74,6 +76,9 @@ class FieldsBuilderTest extends TestCase /** @var ObjectProphecy */ private $itemMutationResolverFactoryProphecy; + /** @var ObjectProphecy */ + private $itemSubscriptionResolverFactoryProphecy; + /** @var ObjectProphecy */ private $filterLocatorProphecy; @@ -94,8 +99,14 @@ protected function setUp(): void $this->itemResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); $this->collectionResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); $this->itemMutationResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); + $this->itemSubscriptionResolverFactoryProphecy = $this->prophesize(ResolverFactoryInterface::class); $this->filterLocatorProphecy = $this->prophesize(ContainerInterface::class); - $this->fieldsBuilder = new FieldsBuilder($this->propertyNameCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $this->typesContainerProphecy->reveal(), $this->typeBuilderProphecy->reveal(), $this->typeConverterProphecy->reveal(), $this->itemResolverFactoryProphecy->reveal(), $this->collectionResolverFactoryProphecy->reveal(), $this->itemMutationResolverFactoryProphecy->reveal(), $this->filterLocatorProphecy->reveal(), new Pagination($this->resourceMetadataFactoryProphecy->reveal()), new CustomConverter(), '__'); + $this->fieldsBuilder = $this->buildFieldsBuilder(); + } + + private function buildFieldsBuilder(?AdvancedNameConverterInterface $advancedNameConverter = null): FieldsBuilder + { + return new FieldsBuilder($this->propertyNameCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $this->typesContainerProphecy->reveal(), $this->typeBuilderProphecy->reveal(), $this->typeConverterProphecy->reveal(), $this->itemResolverFactoryProphecy->reveal(), $this->collectionResolverFactoryProphecy->reveal(), $this->itemMutationResolverFactoryProphecy->reveal(), $this->itemSubscriptionResolverFactoryProphecy->reveal(), $this->filterLocatorProphecy->reveal(), new Pagination($this->resourceMetadataFactoryProphecy->reveal()), $advancedNameConverter ?? new CustomConverter(), '__'); } public function testGetNodeQueryFields(): void @@ -127,7 +138,7 @@ public function testGetNodeQueryFields(): void */ public function testGetItemQueryFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $queryName, array $configuration, ?GraphQLType $graphqlType, ?callable $resolver, array $expectedQueryFields): void { - $this->typeConverterProphecy->convertType(Argument::type(Type::class), false, $queryName, null, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); + $this->typeConverterProphecy->convertType(Argument::type(Type::class), false, $queryName, null, null, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); $this->typeConverterProphecy->resolveType(Argument::type('string'))->willReturn(GraphQLType::string()); $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false); $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn($resourceMetadata); @@ -142,11 +153,11 @@ public function itemQueryFieldsProvider(): array { return [ 'no resource field configuration' => ['resourceClass', new ResourceMetadata(), 'action', [], null, null, []], - 'nominal standard type case with deprecation reason' => ['resourceClass', (new ResourceMetadata('ShortName'))->withGraphql(['action' => ['deprecation_reason' => 'not useful']]), 'action', [], GraphQLType::string(), null, + 'nominal standard type case with deprecation reason and description' => ['resourceClass', (new ResourceMetadata('ShortName'))->withGraphql(['action' => ['deprecation_reason' => 'not useful', 'description' => 'Custom description.']]), 'action', [], GraphQLType::string(), null, [ 'actionShortName' => [ 'type' => GraphQLType::string(), - 'description' => null, + 'description' => 'Custom description.', 'args' => [ 'id' => ['type' => GraphQLType::nonNull(GraphQLType::id())], ], @@ -206,10 +217,10 @@ public function itemQueryFieldsProvider(): array */ public function testGetCollectionQueryFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $queryName, array $configuration, ?GraphQLType $graphqlType, ?callable $resolver, array $expectedQueryFields): void { - $this->typeConverterProphecy->convertType(Argument::type(Type::class), false, $queryName, null, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); + $this->typeConverterProphecy->convertType(Argument::type(Type::class), false, $queryName, null, null, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); $this->typeConverterProphecy->resolveType(Argument::type('string'))->willReturn(GraphQLType::string()); $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(true); - $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType)->willReturn($graphqlType); + $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $queryName)->willReturn($graphqlType); $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn($resourceMetadata); $this->collectionResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $queryName)->willReturn($resolver); $this->filterLocatorProphecy->has('my_filter')->willReturn(true); @@ -223,8 +234,8 @@ public function testGetCollectionQueryFields(string $resourceClass, ResourceMeta $this->filterLocatorProphecy->get('my_filter')->willReturn($filterProphecy->reveal()); $this->typesContainerProphecy->has('ShortNameFilter_dateField')->willReturn(false); $this->typesContainerProphecy->has('ShortNameFilter_parent__child')->willReturn(false); - $this->typesContainerProphecy->set('ShortNameFilter_dateField', Argument::type(InputObjectType::class)); - $this->typesContainerProphecy->set('ShortNameFilter_parent__child', Argument::type(InputObjectType::class)); + $this->typesContainerProphecy->set('ShortNameFilter_dateField', Argument::type(ListOfType::class)); + $this->typesContainerProphecy->set('ShortNameFilter_parent__child', Argument::type(ListOfType::class)); $queryFields = $this->fieldsBuilder->getCollectionQueryFields($resourceClass, $resourceMetadata, $queryName, $configuration); @@ -235,12 +246,12 @@ public function collectionQueryFieldsProvider(): array { return [ 'no resource field configuration' => ['resourceClass', new ResourceMetadata(), 'action', [], null, null, []], - 'nominal collection case with deprecation reason' => ['resourceClass', (new ResourceMetadata('ShortName'))->withGraphql(['action' => ['deprecation_reason' => 'not useful']]), 'action', [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function () { + 'nominal collection case with deprecation reason and description' => ['resourceClass', (new ResourceMetadata('ShortName'))->withGraphql(['action' => ['deprecation_reason' => 'not useful', 'description' => 'Custom description.']]), 'action', [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function () { }, [ 'actionShortNames' => [ 'type' => $graphqlType, - 'description' => null, + 'description' => 'Custom description.', 'args' => [ 'first' => [ 'type' => GraphQLType::int(), @@ -289,8 +300,8 @@ public function collectionQueryFieldsProvider(): array ], 'boolField' => $graphqlType, 'boolField_list' => GraphQLType::listOf($graphqlType), - 'parent__child' => new InputObjectType(['name' => 'ShortNameFilter_parent__child', 'fields' => ['related__nested' => $graphqlType]]), - 'dateField' => new InputObjectType(['name' => 'ShortNameFilter_dateField', 'fields' => ['before' => $graphqlType]]), + 'parent__child' => GraphQLType::listOf(new InputObjectType(['name' => 'ShortNameFilter_parent__child', 'fields' => ['related__nested' => $graphqlType]])), + 'dateField' => GraphQLType::listOf(new InputObjectType(['name' => 'ShortNameFilter_dateField', 'fields' => ['before' => $graphqlType]])), ], 'resolve' => $resolver, 'deprecationReason' => null, @@ -328,21 +339,41 @@ public function collectionQueryFieldsProvider(): array ], ], ], + 'collection with page-based pagination enabled' => ['resourceClass', (new ResourceMetadata('ShortName', null, null, null, null, ['pagination_type' => 'page']))->withGraphql(['action' => ['filters' => ['my_filter']]]), 'action', [], $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), $resolver = function () { + }, + [ + 'actionShortNames' => [ + 'type' => $graphqlType, + 'description' => null, + 'args' => [ + 'page' => [ + 'type' => GraphQLType::int(), + 'description' => 'Returns the current page.', + ], + 'boolField' => $graphqlType, + 'boolField_list' => GraphQLType::listOf($graphqlType), + 'parent__child' => GraphQLType::listOf(new InputObjectType(['name' => 'ShortNameFilter_parent__child', 'fields' => ['related__nested' => $graphqlType]])), + 'dateField' => GraphQLType::listOf(new InputObjectType(['name' => 'ShortNameFilter_dateField', 'fields' => ['before' => $graphqlType]])), + ], + 'resolve' => $resolver, + 'deprecationReason' => null, + ], + ], + ], ]; } /** * @dataProvider mutationFieldsProvider */ - public function testGetMutationFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $mutationName, GraphQLType $graphqlType, bool $isTypeCollection, ?callable $mutationResolver, ?callable $collectionResolver, array $expectedMutationFields): void + public function testGetMutationFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $mutationName, GraphQLType $graphqlType, GraphQLType $inputGraphqlType, ?callable $mutationResolver, array $expectedMutationFields): void { - $this->typeConverterProphecy->convertType(Argument::type(Type::class), false, null, $mutationName, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); - $this->typeConverterProphecy->convertType(Argument::type(Type::class), true, null, $mutationName, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); - $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn($isTypeCollection); - $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType)->willReturn($graphqlType); - $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn(new ResourceMetadata()); - $this->collectionResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, null)->willReturn($collectionResolver); - $this->itemMutationResolverFactoryProphecy->__invoke($resourceClass, null, $mutationName)->willReturn($mutationResolver); + $this->typeConverterProphecy->convertType(Argument::type(Type::class), false, null, $mutationName, null, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); + $this->typeConverterProphecy->convertType(Argument::type(Type::class), true, null, $mutationName, null, $resourceClass, $resourceClass, null, 0)->willReturn($inputGraphqlType); + $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false); + $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $mutationName)->willReturn($graphqlType); + $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn($resourceMetadata); + $this->itemMutationResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $mutationName)->willReturn($mutationResolver); $mutationFields = $this->fieldsBuilder->getMutationFields($resourceClass, $resourceMetadata, $mutationName); @@ -352,15 +383,15 @@ public function testGetMutationFields(string $resourceClass, ResourceMetadata $r public function mutationFieldsProvider(): array { return [ - 'nominal case with deprecation reason' => ['resourceClass', (new ResourceMetadata('ShortName'))->withGraphql(['action' => ['deprecation_reason' => 'not useful']]), 'action', GraphQLType::string(), false, $mutationResolver = function () { - }, null, + 'nominal case with deprecation reason' => ['resourceClass', (new ResourceMetadata('ShortName'))->withGraphql(['action' => ['deprecation_reason' => 'not useful']]), 'action', $graphqlType = new ObjectType(['name' => 'mutation']), $inputGraphqlType = new ObjectType(['name' => 'input']), $mutationResolver = function () { + }, [ 'actionShortName' => [ - 'type' => GraphQLType::string(), + 'type' => $graphqlType, 'description' => 'Actions a ShortName.', 'args' => [ 'input' => [ - 'type' => GraphQLType::string(), + 'type' => $inputGraphqlType, 'description' => null, 'args' => [], 'resolve' => null, @@ -372,22 +403,87 @@ public function mutationFieldsProvider(): array ], ], ], - 'wrapped collection type' => ['resourceClass', new ResourceMetadata('ShortName'), 'action', $graphqlType = GraphQLType::listOf(new ObjectType(['name' => 'collection'])), true, null, $collectionResolver = function () { + 'custom description' => ['resourceClass', (new ResourceMetadata('ShortName'))->withGraphql(['action' => ['description' => 'Custom description.']]), 'action', $graphqlType = new ObjectType(['name' => 'mutation']), $inputGraphqlType = new ObjectType(['name' => 'input']), $mutationResolver = function () { }, [ 'actionShortName' => [ 'type' => $graphqlType, - 'description' => 'Actions a ShortName.', + 'description' => 'Custom description.', + 'args' => [ + 'input' => [ + 'type' => $inputGraphqlType, + 'description' => null, + 'args' => [], + 'resolve' => null, + 'deprecationReason' => null, + ], + ], + 'resolve' => $mutationResolver, + 'deprecationReason' => null, + ], + ], + ], + ]; + } + + /** + * @dataProvider subscriptionFieldsProvider + */ + public function testGetSubscriptionFields(string $resourceClass, ResourceMetadata $resourceMetadata, string $subscriptionName, GraphQLType $graphqlType, GraphQLType $inputGraphqlType, ?callable $subscriptionResolver, array $expectedSubscriptionFields): void + { + $this->typeConverterProphecy->convertType(Argument::type(Type::class), false, null, null, $subscriptionName, $resourceClass, $resourceClass, null, 0)->willReturn($graphqlType); + $this->typeConverterProphecy->convertType(Argument::type(Type::class), true, null, null, $subscriptionName, $resourceClass, $resourceClass, null, 0)->willReturn($inputGraphqlType); + $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false); + $this->typeBuilderProphecy->getResourcePaginatedCollectionType($graphqlType, $resourceClass, $subscriptionName)->willReturn($graphqlType); + $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn($resourceMetadata); + $this->itemSubscriptionResolverFactoryProphecy->__invoke($resourceClass, $resourceClass, $subscriptionName)->willReturn($subscriptionResolver); + + $subscriptionFields = $this->fieldsBuilder->getSubscriptionFields($resourceClass, $resourceMetadata, $subscriptionName); + + $this->assertEquals($expectedSubscriptionFields, $subscriptionFields); + } + + public function subscriptionFieldsProvider(): array + { + return [ + 'mercure not enabled' => ['resourceClass', new ResourceMetadata('ShortName'), 'action', new ObjectType(['name' => 'subscription']), new ObjectType(['name' => 'input']), null, [], + ], + 'nominal case with deprecation reason' => ['resourceClass', (new ResourceMetadata('ShortName'))->withAttributes(['mercure' => true])->withGraphql(['action' => ['deprecation_reason' => 'not useful']]), 'action', $graphqlType = new ObjectType(['name' => 'subscription']), $inputGraphqlType = new ObjectType(['name' => 'input']), $subscriptionResolver = function () { + }, + [ + 'actionShortNameSubscribe' => [ + 'type' => $graphqlType, + 'description' => 'Subscribes to the action event of a ShortName.', 'args' => [ 'input' => [ - 'type' => GraphQLType::listOf($graphqlType), + 'type' => $inputGraphqlType, + 'description' => null, + 'args' => [], + 'resolve' => null, + 'deprecationReason' => 'not useful', + ], + ], + 'resolve' => $subscriptionResolver, + 'deprecationReason' => 'not useful', + ], + ], + ], + 'custom description' => ['resourceClass', (new ResourceMetadata('ShortName'))->withAttributes(['mercure' => true])->withGraphql(['action' => ['description' => 'Custom description.']]), 'action', $graphqlType = new ObjectType(['name' => 'subscription']), $inputGraphqlType = new ObjectType(['name' => 'input']), $subscriptionResolver = function () { + }, + [ + 'actionShortNameSubscribe' => [ + 'type' => $graphqlType, + 'description' => 'Custom description.', + 'args' => [ + 'input' => [ + 'type' => $inputGraphqlType, 'description' => null, 'args' => [], 'resolve' => null, 'deprecationReason' => null, ], ], - 'resolve' => $collectionResolver, + 'resolve' => $subscriptionResolver, 'deprecationReason' => null, ], ], @@ -398,30 +494,37 @@ public function mutationFieldsProvider(): array /** * @dataProvider resourceObjectTypeFieldsProvider */ - public function testGetResourceObjectTypeFields(string $resourceClass, ResourceMetadata $resourceMetadata, array $properties, bool $input, ?string $queryName, ?string $mutationName, ?array $ioMetadata, array $expectedResourceObjectTypeFields): void + public function testGetResourceObjectTypeFields(string $resourceClass, ResourceMetadata $resourceMetadata, array $properties, bool $input, ?string $queryName, ?string $mutationName, ?string $subscriptionName, ?array $ioMetadata, array $expectedResourceObjectTypeFields, ?AdvancedNameConverterInterface $advancedNameConverter = null): void { $this->propertyNameCollectionFactoryProphecy->create($resourceClass)->willReturn(new PropertyNameCollection(array_keys($properties))); foreach ($properties as $propertyName => $propertyMetadata) { - $this->propertyMetadataFactoryProphecy->create($resourceClass, $propertyName, ['graphql_operation_name' => $queryName ?? $mutationName])->willReturn($propertyMetadata); - $this->propertyMetadataFactoryProphecy->create($resourceClass, $propertyName, ['graphql_operation_name' => $queryName ?? $mutationName])->willReturn($propertyMetadata); - $this->typeConverterProphecy->convertType(new Type(Type::BUILTIN_TYPE_NULL), Argument::type('bool'), $queryName, null, '', $resourceClass, $propertyName, 1)->willReturn(null); - $this->typeConverterProphecy->convertType(new Type(Type::BUILTIN_TYPE_CALLABLE), Argument::type('bool'), $queryName, null, '', $resourceClass, $propertyName, 1)->willReturn('NotRegisteredType'); - $this->typeConverterProphecy->convertType(Argument::type(Type::class), Argument::type('bool'), $queryName, null, '', $resourceClass, $propertyName, 1)->willReturn(GraphQLType::string()); - $this->typeConverterProphecy->convertType(Argument::type(Type::class), Argument::type('bool'), null, $mutationName, '', $resourceClass, $propertyName, 1)->willReturn(GraphQLType::string()); - $this->typeConverterProphecy->convertType(Argument::type(Type::class), true, null, $mutationName, 'subresourceClass', $propertyName, 1)->willReturn(GraphQLType::string()); + $this->propertyMetadataFactoryProphecy->create($resourceClass, $propertyName, ['graphql_operation_name' => $queryName ?? $mutationName ?? $subscriptionName])->willReturn($propertyMetadata); + $this->typeConverterProphecy->convertType(new Type(Type::BUILTIN_TYPE_NULL), Argument::type('bool'), $queryName, null, null, '', $resourceClass, $propertyName, 1)->willReturn(null); + $this->typeConverterProphecy->convertType(new Type(Type::BUILTIN_TYPE_CALLABLE), Argument::type('bool'), $queryName, null, null, '', $resourceClass, $propertyName, 1)->willReturn('NotRegisteredType'); + $this->typeConverterProphecy->convertType(Argument::type(Type::class), Argument::type('bool'), $queryName, null, null, '', $resourceClass, $propertyName, 1)->willReturn(GraphQLType::string()); + $this->typeConverterProphecy->convertType(Argument::type(Type::class), Argument::type('bool'), null, $mutationName, null, '', $resourceClass, $propertyName, 1)->willReturn(GraphQLType::string()); + $this->typeConverterProphecy->convertType(Argument::type(Type::class), Argument::type('bool'), null, null, $subscriptionName, '', $resourceClass, $propertyName, 1)->willReturn(GraphQLType::string()); + $this->typeConverterProphecy->convertType(Argument::type(Type::class), true, null, $mutationName, null, 'subresourceClass', $propertyName, 1)->willReturn(GraphQLType::string()); } $this->typesContainerProphecy->has('NotRegisteredType')->willReturn(false); $this->typesContainerProphecy->all()->willReturn([]); $this->typeBuilderProphecy->isCollection(Argument::type(Type::class))->willReturn(false); $this->resourceMetadataFactoryProphecy->create('subresourceClass')->willReturn(new ResourceMetadata()); - $resourceObjectTypeFields = $this->fieldsBuilder->getResourceObjectTypeFields($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, 0, $ioMetadata); + $fieldsBuilder = $this->fieldsBuilder; + if ($advancedNameConverter) { + $fieldsBuilder = $this->buildFieldsBuilder($advancedNameConverter); + } + $resourceObjectTypeFields = $fieldsBuilder->getResourceObjectTypeFields($resourceClass, $resourceMetadata, $input, $queryName, $mutationName, $subscriptionName, 0, $ioMetadata); $this->assertEquals($expectedResourceObjectTypeFields, $resourceObjectTypeFields); } public function resourceObjectTypeFieldsProvider(): array { + $advancedNameConverter = $this->prophesize(AdvancedNameConverterInterface::class); + $advancedNameConverter->normalize('field', 'resourceClass')->willReturn('normalizedField'); + return [ 'query' => ['resourceClass', new ResourceMetadata(), [ @@ -430,7 +533,7 @@ public function resourceObjectTypeFieldsProvider(): array 'propertyNotReadable' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, false), 'nameConverted' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), null, true, false), ], - false, 'item_query', null, null, + false, 'item_query', null, null, null, [ 'id' => [ 'type' => GraphQLType::nonNull(GraphQLType::id()), @@ -451,12 +554,31 @@ public function resourceObjectTypeFieldsProvider(): array ], ], ], + 'query with advanced name converter' => ['resourceClass', new ResourceMetadata(), + [ + 'field' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), null, true, false), + ], + false, 'item_query', null, null, null, + [ + 'id' => [ + 'type' => GraphQLType::nonNull(GraphQLType::id()), + ], + 'normalizedField' => [ + 'type' => GraphQLType::nonNull(GraphQLType::string()), + 'description' => null, + 'args' => [], + 'resolve' => null, + 'deprecationReason' => null, + ], + ], + $advancedNameConverter->reveal(), + ], 'query input' => ['resourceClass', new ResourceMetadata(), [ 'property' => new PropertyMetadata(), 'propertyBool' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, false), ], - true, 'item_query', null, null, + true, 'item_query', null, null, null, [ 'id' => [ 'type' => GraphQLType::nonNull(GraphQLType::id()), @@ -476,7 +598,7 @@ public function resourceObjectTypeFieldsProvider(): array 'propertyBool' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, true), 'propertyReadable' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, true, true), ], - false, null, 'mutation', null, + false, null, 'mutation', null, null, [ 'id' => [ 'type' => GraphQLType::nonNull(GraphQLType::id()), @@ -497,7 +619,7 @@ public function resourceObjectTypeFieldsProvider(): array 'propertySubresource' => (new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, true))->withSubresource(new SubresourceMetadata('subresourceClass')), 'id' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT), null, false, true), ], - true, null, 'mutation', null, + true, null, 'mutation', null, null, [ 'id' => [ 'type' => GraphQLType::nonNull(GraphQLType::id()), @@ -530,7 +652,7 @@ public function resourceObjectTypeFieldsProvider(): array [ 'propertyBool' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, true), ], - true, null, 'delete', null, + true, null, 'delete', null, null, [ 'id' => [ 'type' => GraphQLType::nonNull(GraphQLType::id()), @@ -542,7 +664,7 @@ public function resourceObjectTypeFieldsProvider(): array [ 'propertyBool' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, true), ], - true, null, 'create', null, + true, null, 'create', null, null, [ 'propertyBool' => [ 'type' => GraphQLType::nonNull(GraphQLType::string()), @@ -558,7 +680,7 @@ public function resourceObjectTypeFieldsProvider(): array [ 'propertyBool' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, true), ], - true, null, 'update', null, + true, null, 'update', null, null, [ 'id' => [ 'type' => GraphQLType::nonNull(GraphQLType::id()), @@ -573,17 +695,52 @@ public function resourceObjectTypeFieldsProvider(): array 'clientMutationId' => GraphQLType::string(), ], ], + 'subscription non input' => ['resourceClass', new ResourceMetadata(), + [ + 'property' => new PropertyMetadata(), + 'propertyBool' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, true), + 'propertyReadable' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, true, true), + ], + false, null, null, 'subscription', null, + [ + 'id' => [ + 'type' => GraphQLType::nonNull(GraphQLType::id()), + ], + 'propertyReadable' => [ + 'type' => GraphQLType::nonNull(GraphQLType::string()), + 'description' => null, + 'args' => [], + 'resolve' => null, + 'deprecationReason' => null, + ], + ], + ], + 'subscription input' => ['resourceClass', new ResourceMetadata(), + [ + 'property' => new PropertyMetadata(), + 'propertyBool' => (new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), 'propertyBool description', false, true))->withAttributes(['deprecation_reason' => 'not useful']), + 'propertySubresource' => (new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, true))->withSubresource(new SubresourceMetadata('subresourceClass')), + 'id' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT), null, false, true), + ], + true, null, null, 'subscription', null, + [ + 'id' => [ + 'type' => GraphQLType::nonNull(GraphQLType::id()), + ], + 'clientSubscriptionId' => GraphQLType::string(), + ], + ], 'null io metadata non input' => ['resourceClass', new ResourceMetadata(), [ 'propertyBool' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, true), ], - false, null, 'update', ['class' => null], [], + false, null, 'update', null, ['class' => null], [], ], 'null io metadata input' => ['resourceClass', new ResourceMetadata(), [ 'propertyBool' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_BOOL), null, false, true), ], - true, null, 'update', ['class' => null], + true, null, 'update', null, ['class' => null], [ 'clientMutationId' => GraphQLType::string(), ], @@ -593,7 +750,7 @@ public function resourceObjectTypeFieldsProvider(): array 'propertyInvalidType' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_NULL), null, true, false), 'propertyNotRegisteredType' => new PropertyMetadata(new Type(Type::BUILTIN_TYPE_CALLABLE), null, true, false), ], - false, 'item_query', null, null, + false, 'item_query', null, null, null, [ 'id' => [ 'type' => GraphQLType::nonNull(GraphQLType::id()), diff --git a/tests/GraphQl/Type/SchemaBuilderTest.php b/tests/GraphQl/Type/SchemaBuilderTest.php index ba324ba9446..f61d556b7e3 100644 --- a/tests/GraphQl/Type/SchemaBuilderTest.php +++ b/tests/GraphQl/Type/SchemaBuilderTest.php @@ -68,7 +68,7 @@ protected function setUp(): void /** * @dataProvider schemaProvider */ - public function testGetSchema(string $resourceClass, ResourceMetadata $resourceMetadata, ObjectType $expectedQueryType, ?ObjectType $expectedMutationType): void + public function testGetSchema(string $resourceClass, ResourceMetadata $resourceMetadata, ObjectType $expectedQueryType, ?ObjectType $expectedMutationType, ?ObjectType $expectedSubscriptionType): void { $type = $this->prophesize(GraphQLType::class)->reveal(); $type->name = 'MyType'; @@ -84,6 +84,8 @@ public function testGetSchema(string $resourceClass, ResourceMetadata $resourceM $this->fieldsBuilderProphecy->getItemQueryFields($resourceClass, $resourceMetadata, 'custom_item_query', ['item_query' => 'item_query_resolver'])->willReturn(['custom_item_query' => ['custom_item_query_fields']]); $this->fieldsBuilderProphecy->getCollectionQueryFields($resourceClass, $resourceMetadata, 'custom_collection_query', ['collection_query' => 'collection_query_resolver'])->willReturn(['custom_collection_query' => ['custom_collection_query_fields']]); $this->fieldsBuilderProphecy->getMutationFields($resourceClass, $resourceMetadata, 'mutation')->willReturn(['mutation' => ['mutation_fields']]); + $this->fieldsBuilderProphecy->getMutationFields($resourceClass, $resourceMetadata, 'update')->willReturn(['mutation' => ['mutation_fields']]); + $this->fieldsBuilderProphecy->getSubscriptionFields($resourceClass, $resourceMetadata, 'update')->willReturn(['subscription' => ['subscription_fields']]); $this->resourceNameCollectionFactoryProphecy->create()->willReturn(new ResourceNameCollection([$resourceClass])); $this->resourceMetadataFactoryProphecy->create($resourceClass)->willReturn($resourceMetadata); @@ -91,6 +93,7 @@ public function testGetSchema(string $resourceClass, ResourceMetadata $resourceM $schema = $this->schemaBuilder->getSchema(); $this->assertEquals($expectedQueryType, $schema->getQueryType()); $this->assertEquals($expectedMutationType, $schema->getMutationType()); + $this->assertEquals($expectedSubscriptionType, $schema->getSubscriptionType()); $this->assertEquals($type, $schema->getType('MyType')); $this->assertEquals($typeFoo, $schema->getType('Foo')); } @@ -104,7 +107,7 @@ public function schemaProvider(): array 'fields' => [ 'node' => ['node_fields'], ], - ]), null, + ]), null, null, ], 'item query' => ['resourceClass', (new ResourceMetadata())->withGraphql(['item_query' => []]), new ObjectType([ @@ -113,7 +116,7 @@ public function schemaProvider(): array 'node' => ['node_fields'], 'query' => ['query_fields'], ], - ]), null, + ]), null, null, ], 'collection query' => ['resourceClass', (new ResourceMetadata())->withGraphql(['collection_query' => []]), new ObjectType([ @@ -122,7 +125,7 @@ public function schemaProvider(): array 'node' => ['node_fields'], 'query' => ['query_fields'], ], - ]), null, + ]), null, null, ], 'custom item query' => ['resourceClass', (new ResourceMetadata())->withGraphql(['custom_item_query' => ['item_query' => 'item_query_resolver']]), new ObjectType([ @@ -131,7 +134,7 @@ public function schemaProvider(): array 'node' => ['node_fields'], 'custom_item_query' => ['custom_item_query_fields'], ], - ]), null, + ]), null, null, ], 'custom collection query' => ['resourceClass', (new ResourceMetadata())->withGraphql(['custom_collection_query' => ['collection_query' => 'collection_query_resolver']]), new ObjectType([ @@ -140,7 +143,7 @@ public function schemaProvider(): array 'node' => ['node_fields'], 'custom_collection_query' => ['custom_collection_query_fields'], ], - ]), null, + ]), null, null, ], 'mutation' => ['resourceClass', (new ResourceMetadata())->withGraphql(['mutation' => []]), new ObjectType([ @@ -155,6 +158,27 @@ public function schemaProvider(): array 'mutation' => ['mutation_fields'], ], ]), + null, + ], + 'subscription' => ['resourceClass', (new ResourceMetadata())->withGraphql(['update' => []]), + new ObjectType([ + 'name' => 'Query', + 'fields' => [ + 'node' => ['node_fields'], + ], + ]), + new ObjectType([ + 'name' => 'Mutation', + 'fields' => [ + 'mutation' => ['mutation_fields'], + ], + ]), + new ObjectType([ + 'name' => 'Subscription', + 'fields' => [ + 'subscription' => ['subscription_fields'], + ], + ]), ], ]; } diff --git a/tests/GraphQl/Type/TypeBuilderTest.php b/tests/GraphQl/Type/TypeBuilderTest.php index da1c77fb0e0..b6d50e25af3 100644 --- a/tests/GraphQl/Type/TypeBuilderTest.php +++ b/tests/GraphQl/Type/TypeBuilderTest.php @@ -13,10 +13,12 @@ namespace ApiPlatform\Core\Tests\GraphQl\Type; +use ApiPlatform\Core\DataProvider\Pagination; use ApiPlatform\Core\GraphQl\Serializer\ItemNormalizer; use ApiPlatform\Core\GraphQl\Type\FieldsBuilderInterface; use ApiPlatform\Core\GraphQl\Type\TypeBuilder; use ApiPlatform\Core\GraphQl\Type\TypesContainerInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\ProphecyTrait; @@ -49,6 +51,9 @@ class TypeBuilderTest extends TestCase /** @var ObjectProphecy */ private $fieldsBuilderLocatorProphecy; + /** @var ObjectProphecy */ + private $resourceMetadataFactoryProphecy; + /** @var TypeBuilder */ private $typeBuilder; @@ -61,7 +66,13 @@ protected function setUp(): void $this->defaultFieldResolver = function () { }; $this->fieldsBuilderLocatorProphecy = $this->prophesize(ContainerInterface::class); - $this->typeBuilder = new TypeBuilder($this->typesContainerProphecy->reveal(), $this->defaultFieldResolver, $this->fieldsBuilderLocatorProphecy->reveal()); + $this->resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $this->typeBuilder = new TypeBuilder( + $this->typesContainerProphecy->reveal(), + $this->defaultFieldResolver, + $this->fieldsBuilderLocatorProphecy->reveal(), + new Pagination($this->resourceMetadataFactoryProphecy->reveal()) + ); } public function testGetResourceObjectType(): void @@ -73,7 +84,7 @@ public function testGetResourceObjectType(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, 'item_query', null); + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, 'item_query', null, null); $this->assertSame('shortName', $resourceObjectType->name); $this->assertSame('description', $resourceObjectType->description); $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); @@ -81,7 +92,7 @@ public function testGetResourceObjectType(): void $this->assertArrayHasKey('fields', $resourceObjectType->config); $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); - $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, false, 'item_query', null, 0, null)->shouldBeCalled(); + $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, false, 'item_query', null, null, 0, null)->shouldBeCalled(); $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); $resourceObjectType->config['fields'](); } @@ -96,7 +107,7 @@ public function testGetResourceObjectTypeOutputClass(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, 'item_query', null); + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, 'item_query', null, null); $this->assertSame('shortName', $resourceObjectType->name); $this->assertSame('description', $resourceObjectType->description); $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); @@ -104,7 +115,7 @@ public function testGetResourceObjectTypeOutputClass(): void $this->assertArrayHasKey('fields', $resourceObjectType->config); $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); - $fieldsBuilderProphecy->getResourceObjectTypeFields('outputClass', $resourceMetadata, false, 'item_query', null, 0, ['class' => 'outputClass'])->shouldBeCalled(); + $fieldsBuilderProphecy->getResourceObjectTypeFields('outputClass', $resourceMetadata, false, 'item_query', null, null, 0, ['class' => 'outputClass'])->shouldBeCalled(); $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); $resourceObjectType->config['fields'](); } @@ -125,7 +136,7 @@ public function testGetResourceObjectTypeQuerySerializationGroups(string $itemSe $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, $queryName, null); + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, $queryName, null, null); $this->assertSame($shortName, $resourceObjectType->name); } @@ -162,7 +173,7 @@ public function testGetResourceObjectTypeInput(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var NonNull $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, true, null, 'custom'); + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, true, null, 'custom', null); /** @var InputObjectType $wrappedType */ $wrappedType = $resourceObjectType->getWrappedType(); $this->assertInstanceOf(InputObjectType::class, $wrappedType); @@ -172,7 +183,7 @@ public function testGetResourceObjectTypeInput(): void $this->assertArrayHasKey('fields', $wrappedType->config); $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); - $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, true, null, 'custom', 0, null)->shouldBeCalled(); + $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, true, null, 'custom', null, 0, null)->shouldBeCalled(); $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); $wrappedType->config['fields'](); } @@ -187,7 +198,7 @@ public function testGetResourceObjectTypeCustomMutationInputArgs(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var NonNull $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, true, null, 'custom'); + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, true, null, 'custom', null); /** @var InputObjectType $wrappedType */ $wrappedType = $resourceObjectType->getWrappedType(); $this->assertInstanceOf(InputObjectType::class, $wrappedType); @@ -197,7 +208,7 @@ public function testGetResourceObjectTypeCustomMutationInputArgs(): void $this->assertArrayHasKey('fields', $wrappedType->config); $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); - $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, true, null, 'custom', 0, null) + $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, true, null, 'custom', null, 0, null) ->shouldBeCalled()->willReturn(['clientMutationId' => GraphQLType::string()]); $fieldsBuilderProphecy->resolveResourceArgs([], 'custom', 'shortName')->shouldBeCalled(); $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); @@ -213,7 +224,7 @@ public function testGetResourceObjectTypeMutation(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, null, 'create'); + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, null, 'create', null); $this->assertSame('createShortNamePayload', $resourceObjectType->name); $this->assertSame('description', $resourceObjectType->description); $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); @@ -244,7 +255,7 @@ public function testGetResourceObjectTypeMutationWrappedType(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, null, 'create'); + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, null, 'create', null); $this->assertSame('createShortNamePayload', $resourceObjectType->name); $this->assertSame('description', $resourceObjectType->description); $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); @@ -270,7 +281,7 @@ public function testGetResourceObjectTypeMutationWrappedType(): void $this->assertArrayHasKey('fields', $wrappedType->config); $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); - $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, false, null, 'create', 0, null)->shouldBeCalled(); + $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, false, null, 'create', null, 0, null)->shouldBeCalled(); $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); $wrappedType->config['fields'](); } @@ -284,7 +295,7 @@ public function testGetResourceObjectTypeMutationNested(): void $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); /** @var ObjectType $resourceObjectType */ - $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, null, 'create', false, 1); + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, null, 'create', null, false, 1); $this->assertSame('createShortNameNestedPayload', $resourceObjectType->name); $this->assertSame('description', $resourceObjectType->description); $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); @@ -292,7 +303,103 @@ public function testGetResourceObjectTypeMutationNested(): void $this->assertArrayHasKey('fields', $resourceObjectType->config); $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); - $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, false, null, 'create', 1, null)->shouldBeCalled(); + $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, false, null, 'create', null, 1, null)->shouldBeCalled(); + $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); + $resourceObjectType->config['fields'](); + } + + public function testGetResourceObjectTypeSubscription(): void + { + $resourceMetadata = (new ResourceMetadata('shortName', 'description'))->withAttributes(['mercure' => true]); + $this->typesContainerProphecy->has('updateShortNameSubscriptionPayload')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('updateShortNameSubscriptionPayload', Argument::type(ObjectType::class))->shouldBeCalled(); + $this->typesContainerProphecy->has('Node')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); + + /** @var ObjectType $resourceObjectType */ + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, null, null, 'update'); + $this->assertSame('updateShortNameSubscriptionPayload', $resourceObjectType->name); + $this->assertSame('description', $resourceObjectType->description); + $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); + $this->assertArrayHasKey('interfaces', $resourceObjectType->config); + $this->assertSame([], $resourceObjectType->config['interfaces']); + $this->assertArrayHasKey('fields', $resourceObjectType->config); + + // Recursive call (not using wrapped type) + $this->typesContainerProphecy->has('shortName')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('shortName', Argument::type(ObjectType::class))->shouldBeCalled(); + + $fieldsType = $resourceObjectType->config['fields'](); + $this->assertArrayHasKey('shortName', $fieldsType); + $this->assertArrayHasKey('clientSubscriptionId', $fieldsType); + $this->assertArrayHasKey('mercureUrl', $fieldsType); + $this->assertSame(GraphQLType::string(), $fieldsType['clientSubscriptionId']); + $this->assertSame(GraphQLType::string(), $fieldsType['mercureUrl']); + } + + public function testGetResourceObjectTypeSubscriptionWrappedType(): void + { + $resourceMetadata = (new ResourceMetadata('shortName', 'description')) + ->withGraphql([ + 'item_query' => ['normalization_context' => ['groups' => ['item_query']]], + 'update' => ['normalization_context' => ['groups' => ['update']]], + ]); + $this->typesContainerProphecy->has('updateShortNameSubscriptionPayload')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('updateShortNameSubscriptionPayload', Argument::type(ObjectType::class))->shouldBeCalled(); + $this->typesContainerProphecy->has('Node')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); + + /** @var ObjectType $resourceObjectType */ + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, null, null, 'update'); + $this->assertSame('updateShortNameSubscriptionPayload', $resourceObjectType->name); + $this->assertSame('description', $resourceObjectType->description); + $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); + $this->assertArrayHasKey('interfaces', $resourceObjectType->config); + $this->assertSame([], $resourceObjectType->config['interfaces']); + $this->assertArrayHasKey('fields', $resourceObjectType->config); + + // Recursive call (using wrapped type) + $this->typesContainerProphecy->has('updateShortNameSubscriptionPayloadData')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('updateShortNameSubscriptionPayloadData', Argument::type(ObjectType::class))->shouldBeCalled(); + + $fieldsType = $resourceObjectType->config['fields'](); + $this->assertArrayHasKey('shortName', $fieldsType); + $this->assertArrayHasKey('clientSubscriptionId', $fieldsType); + $this->assertArrayNotHasKey('mercureUrl', $fieldsType); + $this->assertSame(GraphQLType::string(), $fieldsType['clientSubscriptionId']); + + /** @var ObjectType $wrappedType */ + $wrappedType = $fieldsType['shortName']; + $this->assertSame('updateShortNameSubscriptionPayloadData', $wrappedType->name); + $this->assertSame('description', $wrappedType->description); + $this->assertSame($this->defaultFieldResolver, $wrappedType->resolveFieldFn); + $this->assertArrayHasKey('interfaces', $wrappedType->config); + $this->assertArrayHasKey('fields', $wrappedType->config); + + $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); + $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, false, null, null, 'update', 0, null)->shouldBeCalled(); + $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); + $wrappedType->config['fields'](); + } + + public function testGetResourceObjectTypeSubscriptionNested(): void + { + $resourceMetadata = (new ResourceMetadata('shortName', 'description'))->withAttributes(['mercure' => true]); + $this->typesContainerProphecy->has('updateShortNameSubscriptionNestedPayload')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('updateShortNameSubscriptionNestedPayload', Argument::type(ObjectType::class))->shouldBeCalled(); + $this->typesContainerProphecy->has('Node')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('Node', Argument::type(InterfaceType::class))->shouldBeCalled(); + + /** @var ObjectType $resourceObjectType */ + $resourceObjectType = $this->typeBuilder->getResourceObjectType('resourceClass', $resourceMetadata, false, null, null, 'update', false, 1); + $this->assertSame('updateShortNameSubscriptionNestedPayload', $resourceObjectType->name); + $this->assertSame('description', $resourceObjectType->description); + $this->assertSame($this->defaultFieldResolver, $resourceObjectType->resolveFieldFn); + $this->assertArrayHasKey('interfaces', $resourceObjectType->config); + $this->assertArrayHasKey('fields', $resourceObjectType->config); + + $fieldsBuilderProphecy = $this->prophesize(FieldsBuilderInterface::class); + $fieldsBuilderProphecy->getResourceObjectTypeFields('resourceClass', $resourceMetadata, false, null, null, 'update', 1, null)->shouldBeCalled(); $this->fieldsBuilderLocatorProphecy->get('api_platform.graphql.fields_builder')->shouldBeCalled()->willReturn($fieldsBuilderProphecy->reveal()); $resourceObjectType->config['fields'](); } @@ -319,15 +426,24 @@ public function testGetNodeInterface(): void $this->assertSame(GraphQLType::string(), $resolvedType); } - public function testGetResourcePaginatedCollectionType(): void + public function testCursorBasedGetResourcePaginatedCollectionType(): void { $this->typesContainerProphecy->has('StringConnection')->shouldBeCalled()->willReturn(false); $this->typesContainerProphecy->set('StringConnection', Argument::type(ObjectType::class))->shouldBeCalled(); $this->typesContainerProphecy->set('StringEdge', Argument::type(ObjectType::class))->shouldBeCalled(); $this->typesContainerProphecy->set('StringPageInfo', Argument::type(ObjectType::class))->shouldBeCalled(); + $this->resourceMetadataFactoryProphecy->create('StringResourceClass')->shouldBeCalled()->willReturn(new ResourceMetadata( + null, + null, + null, + null, + null, + ['pagination_type' => 'cursor'] + )); + /** @var ObjectType $resourcePaginatedCollectionType */ - $resourcePaginatedCollectionType = $this->typeBuilder->getResourcePaginatedCollectionType(GraphQLType::string()); + $resourcePaginatedCollectionType = $this->typeBuilder->getResourcePaginatedCollectionType(GraphQLType::string(), 'StringResourceClass', 'operationName'); $this->assertSame('StringConnection', $resourcePaginatedCollectionType->name); $this->assertSame('Connection for String.', $resourcePaginatedCollectionType->description); @@ -373,6 +489,48 @@ public function testGetResourcePaginatedCollectionType(): void $this->assertSame(GraphQLType::int(), $totalCountType->getWrappedType()); } + public function testPageBasedGetResourcePaginatedCollectionType(): void + { + $this->typesContainerProphecy->has('StringConnection')->shouldBeCalled()->willReturn(false); + $this->typesContainerProphecy->set('StringConnection', Argument::type(ObjectType::class))->shouldBeCalled(); + $this->typesContainerProphecy->set('StringPaginationInfo', Argument::type(ObjectType::class))->shouldBeCalled(); + + $this->resourceMetadataFactoryProphecy->create('StringResourceClass')->shouldBeCalled()->willReturn(new ResourceMetadata( + null, + null, + null, + null, + null, + ['pagination_type' => 'page'] + )); + + /** @var ObjectType $resourcePaginatedCollectionType */ + $resourcePaginatedCollectionType = $this->typeBuilder->getResourcePaginatedCollectionType(GraphQLType::string(), 'StringResourceClass', 'operationName'); + $this->assertSame('StringConnection', $resourcePaginatedCollectionType->name); + $this->assertSame('Connection for String.', $resourcePaginatedCollectionType->description); + + $resourcePaginatedCollectionTypeFields = $resourcePaginatedCollectionType->getFields(); + $this->assertArrayHasKey('collection', $resourcePaginatedCollectionTypeFields); + $this->assertArrayHasKey('paginationInfo', $resourcePaginatedCollectionTypeFields); + + /** @var NonNull $paginationInfoType */ + $paginationInfoType = $resourcePaginatedCollectionTypeFields['paginationInfo']->getType(); + /** @var ObjectType $wrappedType */ + $wrappedType = $paginationInfoType->getWrappedType(); + $this->assertSame('StringPaginationInfo', $wrappedType->name); + $this->assertSame('Information about the pagination.', $wrappedType->description); + $paginationInfoObjectTypeFields = $wrappedType->getFields(); + $this->assertArrayHasKey('itemsPerPage', $paginationInfoObjectTypeFields); + $this->assertArrayHasKey('lastPage', $paginationInfoObjectTypeFields); + $this->assertArrayHasKey('totalCount', $paginationInfoObjectTypeFields); + $this->assertInstanceOf(NonNull::class, $paginationInfoObjectTypeFields['itemsPerPage']->getType()); + $this->assertSame(GraphQLType::int(), $paginationInfoObjectTypeFields['itemsPerPage']->getType()->getWrappedType()); + $this->assertInstanceOf(NonNull::class, $paginationInfoObjectTypeFields['lastPage']->getType()); + $this->assertSame(GraphQLType::int(), $paginationInfoObjectTypeFields['lastPage']->getType()->getWrappedType()); + $this->assertInstanceOf(NonNull::class, $paginationInfoObjectTypeFields['totalCount']->getType()); + $this->assertSame(GraphQLType::int(), $paginationInfoObjectTypeFields['totalCount']->getType()->getWrappedType()); + } + /** * @dataProvider typesProvider */ diff --git a/tests/GraphQl/Type/TypeConverterTest.php b/tests/GraphQl/Type/TypeConverterTest.php index 3b90d6bdc80..d9548b79cd7 100644 --- a/tests/GraphQl/Type/TypeConverterTest.php +++ b/tests/GraphQl/Type/TypeConverterTest.php @@ -66,7 +66,7 @@ public function testConvertType(Type $type, bool $input, int $depth, $expectedGr { $this->typeBuilderProphecy->isCollection($type)->willReturn(false); - $graphqlType = $this->typeConverter->convertType($type, $input, null, null, 'resourceClass', 'rootClass', null, $depth); + $graphqlType = $this->typeConverter->convertType($type, $input, null, null, null, 'resourceClass', 'rootClass', null, $depth); $this->assertEquals($expectedGraphqlType, $graphqlType); } @@ -95,7 +95,7 @@ public function testConvertTypeNoGraphQlResourceMetadata(): void $this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(false); $this->resourceMetadataFactoryProphecy->create('dummy')->shouldBeCalled()->willReturn(new ResourceMetadata()); - $graphqlType = $this->typeConverter->convertType($type, false, null, null, 'resourceClass', 'rootClass', null, 0); + $graphqlType = $this->typeConverter->convertType($type, false, null, null, null, 'resourceClass', 'rootClass', null, 0); $this->assertNull($graphqlType); } @@ -109,7 +109,7 @@ public function testConvertTypeNodeResource(): void $this->expectException(\UnexpectedValueException::class); $this->expectExceptionMessage('A "Node" resource cannot be used with GraphQL because the type is already used by the Relay specification.'); - $this->typeConverter->convertType($type, false, null, null, 'resourceClass', 'rootClass', null, 0); + $this->typeConverter->convertType($type, false, null, null, null, 'resourceClass', 'rootClass', null, 0); } public function testConvertTypeResourceClassNotFound(): void @@ -119,7 +119,7 @@ public function testConvertTypeResourceClassNotFound(): void $this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(false); $this->resourceMetadataFactoryProphecy->create('dummy')->shouldBeCalled()->willThrow(new ResourceClassNotFoundException()); - $graphqlType = $this->typeConverter->convertType($type, false, null, null, 'resourceClass', 'rootClass', null, 0); + $graphqlType = $this->typeConverter->convertType($type, false, null, null, null, 'resourceClass', 'rootClass', null, 0); $this->assertNull($graphqlType); } @@ -132,9 +132,9 @@ public function testConvertTypeResource(Type $type, ObjectType $expectedGraphqlT $this->typeBuilderProphecy->isCollection($type)->shouldBeCalled()->willReturn(true); $this->resourceMetadataFactoryProphecy->create('dummyValue')->shouldBeCalled()->willReturn($graphqlResourceMetadata); - $this->typeBuilderProphecy->getResourceObjectType('dummyValue', $graphqlResourceMetadata, false, null, null, false, 0)->shouldBeCalled()->willReturn($expectedGraphqlType); + $this->typeBuilderProphecy->getResourceObjectType('dummyValue', $graphqlResourceMetadata, false, null, null, null, false, 0)->shouldBeCalled()->willReturn($expectedGraphqlType); - $graphqlType = $this->typeConverter->convertType($type, false, null, null, 'resourceClass', 'rootClass', null, 0); + $graphqlType = $this->typeConverter->convertType($type, false, null, null, null, 'resourceClass', 'rootClass', null, 0); $this->assertEquals($expectedGraphqlType, $graphqlType); } diff --git a/tests/Hal/Serializer/CollectionNormalizerTest.php b/tests/Hal/Serializer/CollectionNormalizerTest.php index d64930461f8..78727046afc 100644 --- a/tests/Hal/Serializer/CollectionNormalizerTest.php +++ b/tests/Hal/Serializer/CollectionNormalizerTest.php @@ -17,6 +17,8 @@ use ApiPlatform\Core\DataProvider\PaginatorInterface; use ApiPlatform\Core\DataProvider\PartialPaginatorInterface; use ApiPlatform\Core\Hal\Serializer\CollectionNormalizer; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; @@ -31,7 +33,8 @@ class CollectionNormalizerTest extends TestCase public function testSupportsNormalize() { $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); - $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page'); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); $this->assertTrue($normalizer->supportsNormalization([], CollectionNormalizer::FORMAT)); $this->assertTrue($normalizer->supportsNormalization(new \ArrayObject(), CollectionNormalizer::FORMAT)); @@ -44,11 +47,12 @@ public function testNormalizeApiSubLevel() { $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass()->shouldNotBeCalled(); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $itemNormalizer = $this->prophesize(NormalizerInterface::class); $itemNormalizer->normalize('bar', null, ['api_sub_level' => true])->willReturn(22); - $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page'); + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); $normalizer->setNormalizer($itemNormalizer->reveal()); $this->assertEquals(['foo' => 22], $normalizer->normalize(['foo' => 'bar'], null, ['api_sub_level' => true])); @@ -137,13 +141,16 @@ private function normalizePaginator($partial = false) $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass($paginatorProphecy, 'Foo')->willReturn('Foo'); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata()); + $itemNormalizer = $this->prophesize(NormalizerInterface::class); $itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [ 'api_sub_level' => true, 'resource_class' => 'Foo', ])->willReturn(['_links' => ['self' => '/me'], 'name' => 'Kévin']); - $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page'); + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); $normalizer->setNormalizer($itemNormalizer->reveal()); return $normalizer->normalize($paginatorProphecy->reveal(), CollectionNormalizer::FORMAT, [ diff --git a/tests/HttpCache/EventListener/AddHeadersListenerTest.php b/tests/HttpCache/EventListener/AddHeadersListenerTest.php index 3a55f9556b5..c44d708827b 100644 --- a/tests/HttpCache/EventListener/AddHeadersListenerTest.php +++ b/tests/HttpCache/EventListener/AddHeadersListenerTest.php @@ -110,11 +110,11 @@ public function testAddHeaders() $factory = $this->prophesize(ResourceMetadataFactoryInterface::class); $factory->create(Dummy::class)->willReturn(new ResourceMetadata())->shouldBeCalled(); - $listener = new AddHeadersListener(true, 100, 200, ['Accept', 'Accept-Encoding'], true, $factory->reveal()); + $listener = new AddHeadersListener(true, 100, 200, ['Accept', 'Accept-Encoding'], true, $factory->reveal(), 15, 30); $listener->onKernelResponse($event); $this->assertSame('"9893532233caff98cd083a116b013c0b"', $response->getEtag()); - $this->assertSame('max-age=100, public, s-maxage=200', $response->headers->get('Cache-Control')); + $this->assertSame('max-age=100, public, s-maxage=200, stale-if-error=30, stale-while-revalidate=15', $response->headers->get('Cache-Control')); $this->assertSame(['Accept', 'Cookie', 'Accept-Encoding'], $response->getVary()); } @@ -136,11 +136,11 @@ public function testDoNotSetHeaderWhenAlreadySet() $factory = $this->prophesize(ResourceMetadataFactoryInterface::class); $factory->create(Dummy::class)->willReturn(new ResourceMetadata())->shouldBeCalled(); - $listener = new AddHeadersListener(true, 100, 200, ['Accept', 'Accept-Encoding'], true, $factory->reveal()); + $listener = new AddHeadersListener(true, 100, 200, ['Accept', 'Accept-Encoding'], true, $factory->reveal(), 15, 30); $listener->onKernelResponse($event); $this->assertSame('"etag"', $response->getEtag()); - $this->assertSame('max-age=300, public, s-maxage=400', $response->headers->get('Cache-Control')); + $this->assertSame('max-age=300, public, s-maxage=400, stale-if-error=30, stale-while-revalidate=15', $response->headers->get('Cache-Control')); $this->assertSame(['Accept', 'Cookie', 'Accept-Encoding'], $response->getVary()); } @@ -154,14 +154,14 @@ public function testSetHeadersFromResourceMetadata() $response ); - $metadata = new ResourceMetadata(null, null, null, null, null, ['cache_headers' => ['max_age' => 123, 'shared_max_age' => 456, 'vary' => ['Vary-1', 'Vary-2']]]); + $metadata = new ResourceMetadata(null, null, null, null, null, ['cache_headers' => ['max_age' => 123, 'shared_max_age' => 456, 'stale_while_revalidate' => 928, 'stale_if_error' => 70, 'vary' => ['Vary-1', 'Vary-2']]]); $factory = $this->prophesize(ResourceMetadataFactoryInterface::class); $factory->create(Dummy::class)->willReturn($metadata)->shouldBeCalled(); - $listener = new AddHeadersListener(true, 100, 200, ['Accept', 'Accept-Encoding'], true, $factory->reveal()); + $listener = new AddHeadersListener(true, 100, 200, ['Accept', 'Accept-Encoding'], true, $factory->reveal(), 15, 30); $listener->onKernelResponse($event); - $this->assertSame('max-age=123, public, s-maxage=456', $response->headers->get('Cache-Control')); + $this->assertSame('max-age=123, public, s-maxage=456, stale-if-error=70, stale-while-revalidate=928', $response->headers->get('Cache-Control')); $this->assertSame(['Vary-1', 'Vary-2'], $response->getVary()); } diff --git a/tests/HttpCache/VarnishPurgerTest.php b/tests/HttpCache/VarnishPurgerTest.php index 009eca680c9..551dbb63f52 100644 --- a/tests/HttpCache/VarnishPurgerTest.php +++ b/tests/HttpCache/VarnishPurgerTest.php @@ -40,9 +40,20 @@ public function testPurge() $clientProphecy2->request('BAN', '', ['headers' => ['ApiPlatform-Ban-Regex' => '(^|\,)/foo($|\,)']])->willReturn(new Response())->shouldBeCalled(); $clientProphecy2->request('BAN', '', ['headers' => ['ApiPlatform-Ban-Regex' => '((^|\,)/foo($|\,))|((^|\,)/bar($|\,))']])->willReturn(new Response())->shouldBeCalled(); + $clientProphecy3 = $this->prophesize(ClientInterface::class); + $clientProphecy3->request('BAN', '', ['headers' => ['ApiPlatform-Ban-Regex' => '(^|\,)/foo($|\,)']])->willReturn(new Response())->shouldBeCalled(); + $clientProphecy3->request('BAN', '', ['headers' => ['ApiPlatform-Ban-Regex' => '(^|\,)/bar($|\,)']])->willReturn(new Response())->shouldBeCalled(); + + $clientProphecy4 = $this->prophesize(ClientInterface::class); + $clientProphecy4->request('BAN', '', ['headers' => ['ApiPlatform-Ban-Regex' => '(^|\,)/foo($|\,)']])->willReturn(new Response())->shouldBeCalled(); + $clientProphecy4->request('BAN', '', ['headers' => ['ApiPlatform-Ban-Regex' => '(^|\,)/bar($|\,)']])->willReturn(new Response())->shouldBeCalled(); + $purger = new VarnishPurger([$clientProphecy1->reveal(), $clientProphecy2->reveal()]); $purger->purge(['/foo']); $purger->purge(['/foo' => '/foo', '/bar' => '/bar']); + + $purger = new VarnishPurger([$clientProphecy3->reveal(), $clientProphecy4->reveal()], 5); + $purger->purge(['/foo' => '/foo', '/bar' => '/bar']); } public function testEmptyTags() diff --git a/tests/Hydra/JsonSchema/SchemaFactoryTest.php b/tests/Hydra/JsonSchema/SchemaFactoryTest.php index acbd7114e8d..f80caf4dcb9 100644 --- a/tests/Hydra/JsonSchema/SchemaFactoryTest.php +++ b/tests/Hydra/JsonSchema/SchemaFactoryTest.php @@ -57,7 +57,7 @@ public function testBuildSchema(): void $resultSchema = $this->schemaFactory->buildSchema(Dummy::class); $this->assertTrue($resultSchema->isDefined()); - $this->assertEquals(Dummy::class.':jsonld', $resultSchema->getRootDefinitionKey()); + $this->assertEquals(str_replace('\\', '.', Dummy::class).'.jsonld', $resultSchema->getRootDefinitionKey()); } public function testCustomFormatBuildSchema(): void @@ -65,7 +65,7 @@ public function testCustomFormatBuildSchema(): void $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'json'); $this->assertTrue($resultSchema->isDefined()); - $this->assertEquals(Dummy::class, $resultSchema->getRootDefinitionKey()); + $this->assertEquals(str_replace('\\', '.', Dummy::class), $resultSchema->getRootDefinitionKey()); } public function testHasRootDefinitionKeyBuildSchema(): void @@ -74,7 +74,7 @@ public function testHasRootDefinitionKeyBuildSchema(): void $definitions = $resultSchema->getDefinitions(); $rootDefinitionKey = $resultSchema->getRootDefinitionKey(); - $this->assertEquals(Dummy::class.':jsonld', $rootDefinitionKey); + $this->assertEquals(str_replace('\\', '.', Dummy::class).'.jsonld', $rootDefinitionKey); $this->assertArrayHasKey($rootDefinitionKey, $definitions); $this->assertArrayHasKey('properties', $definitions[$rootDefinitionKey]); $properties = $resultSchema['definitions'][$rootDefinitionKey]['properties']; @@ -86,6 +86,7 @@ public function testHasRootDefinitionKeyBuildSchema(): void public function testSchemaTypeBuildSchema(): void { $resultSchema = $this->schemaFactory->buildSchema(Dummy::class, 'jsonld', Schema::TYPE_OUTPUT, OperationType::COLLECTION); + $definitionName = str_replace('\\', '.', Dummy::class).'.jsonld'; $this->assertNull($resultSchema->getRootDefinitionKey()); $this->assertArrayHasKey('properties', $resultSchema); @@ -93,7 +94,7 @@ public function testSchemaTypeBuildSchema(): void $this->assertArrayHasKey('hydra:totalItems', $resultSchema['properties']); $this->assertArrayHasKey('hydra:view', $resultSchema['properties']); $this->assertArrayHasKey('hydra:search', $resultSchema['properties']); - $properties = $resultSchema['definitions'][Dummy::class.':jsonld']['properties']; + $properties = $resultSchema['definitions'][$definitionName]['properties']; $this->assertArrayNotHasKey('@context', $properties); $this->assertArrayHasKey('@type', $properties); $this->assertArrayHasKey('@id', $properties); @@ -106,7 +107,7 @@ public function testSchemaTypeBuildSchema(): void $this->assertArrayHasKey('hydra:totalItems', $resultSchema['properties']); $this->assertArrayHasKey('hydra:view', $resultSchema['properties']); $this->assertArrayHasKey('hydra:search', $resultSchema['properties']); - $properties = $resultSchema['definitions'][Dummy::class.':jsonld']['properties']; + $properties = $resultSchema['definitions'][$definitionName]['properties']; $this->assertArrayNotHasKey('@context', $properties); $this->assertArrayHasKey('@type', $properties); $this->assertArrayHasKey('@id', $properties); diff --git a/tests/Hydra/Serializer/CollectionNormalizerTest.php b/tests/Hydra/Serializer/CollectionNormalizerTest.php index a8782746ffa..352d5e60cb3 100644 --- a/tests/Hydra/Serializer/CollectionNormalizerTest.php +++ b/tests/Hydra/Serializer/CollectionNormalizerTest.php @@ -334,4 +334,109 @@ private function normalizePaginator($partial = false) 'resource_class' => 'Foo', ]); } + + public function testNormalizeIriOnlyResourceCollection(): void + { + $fooOne = new Foo(); + $fooOne->id = 1; + $fooOne->bar = 'baz'; + + $fooThree = new Foo(); + $fooThree->id = 3; + $fooThree->bar = 'bzz'; + + $data = [$fooOne, $fooThree]; + + $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); + $contextBuilderProphecy->getResourceContextUri(Foo::class)->willReturn('/contexts/Foo'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($data, Foo::class)->willReturn(Foo::class); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResourceClass(Foo::class)->willReturn('/foos'); + $iriConverterProphecy->getIriFromItem($fooOne)->willReturn('/foos/1'); + $iriConverterProphecy->getIriFromItem($fooThree)->willReturn('/foos/3'); + + $delegateNormalizerProphecy = $this->prophesize(NormalizerInterface::class); + + $normalizer = new CollectionNormalizer($contextBuilderProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $iriConverterProphecy->reveal()); + $normalizer->setNormalizer($delegateNormalizerProphecy->reveal()); + + $actual = $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ + 'collection_operation_name' => 'get', + 'iri_only' => true, + 'resource_class' => Foo::class, + ]); + + $this->assertSame([ + '@context' => '/contexts/Foo', + '@id' => '/foos', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + '/foos/1', + '/foos/3', + ], + 'hydra:totalItems' => 2, + ], $actual); + } + + public function testNormalizeIriOnlyEmbedContextResourceCollection(): void + { + $fooOne = new Foo(); + $fooOne->id = 1; + $fooOne->bar = 'baz'; + + $fooThree = new Foo(); + $fooThree->id = 3; + $fooThree->bar = 'bzz'; + + $data = [$fooOne, $fooThree]; + + $contextBuilderProphecy = $this->prophesize(ContextBuilderInterface::class); + $contextBuilderProphecy->getResourceContext(Foo::class)->willReturn([ + '@vocab' => 'http://localhost:8080/docs.jsonld#', + 'hydra' => 'http://www.w3.org/ns/hydra/core#', + 'hydra:member' => [ + '@type' => '@id', + ], + ]); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($data, Foo::class)->willReturn(Foo::class); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromResourceClass(Foo::class)->willReturn('/foos'); + $iriConverterProphecy->getIriFromItem($fooOne)->willReturn('/foos/1'); + $iriConverterProphecy->getIriFromItem($fooThree)->willReturn('/foos/3'); + + $delegateNormalizerProphecy = $this->prophesize(NormalizerInterface::class); + + $normalizer = new CollectionNormalizer($contextBuilderProphecy->reveal(), $resourceClassResolverProphecy->reveal(), $iriConverterProphecy->reveal()); + $normalizer->setNormalizer($delegateNormalizerProphecy->reveal()); + + $actual = $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ + 'collection_operation_name' => 'get', + 'iri_only' => true, + 'jsonld_embed_context' => true, + 'resource_class' => Foo::class, + ]); + + $this->assertSame([ + '@context' => [ + '@vocab' => 'http://localhost:8080/docs.jsonld#', + 'hydra' => 'http://www.w3.org/ns/hydra/core#', + 'hydra:member' => [ + '@type' => '@id', + ], + ], + '@id' => '/foos', + '@type' => 'hydra:Collection', + 'hydra:member' => [ + '/foos/1', + '/foos/3', + ], + 'hydra:totalItems' => 2, + ], $actual); + } } diff --git a/tests/Hydra/Serializer/ConstraintViolationNormalizerTest.php b/tests/Hydra/Serializer/ConstraintViolationNormalizerTest.php index 392c5ff43b2..2ad2080c01b 100644 --- a/tests/Hydra/Serializer/ConstraintViolationNormalizerTest.php +++ b/tests/Hydra/Serializer/ConstraintViolationNormalizerTest.php @@ -62,7 +62,7 @@ public function testNormalize(?array $fields, array $result) $constraint = new NotNull(); $constraint->payload = ['severity' => 'warning', 'anotherField2' => 'aValue']; $list = new ConstraintViolationList([ - new ConstraintViolation('a', 'b', [], 'c', 'd', 'e', null, null, $constraint), + new ConstraintViolation('a', 'b', [], 'c', 'd', 'e', null, 'f24bdbad0becef97a6887238aa58221c', $constraint), new ConstraintViolation('1', '2', [], '3', '4', '5'), ]); @@ -75,10 +75,12 @@ public function testNormalize(?array $fields, array $result) [ 'propertyPath' => '_d', 'message' => 'a', + 'code' => 'f24bdbad0becef97a6887238aa58221c', ], [ 'propertyPath' => '_4', 'message' => '1', + 'code' => null, ], ], ]; diff --git a/tests/Hydra/Serializer/EntrypointNormalizerTest.php b/tests/Hydra/Serializer/EntrypointNormalizerTest.php index a7aa76b7c09..4465dc3b63c 100644 --- a/tests/Hydra/Serializer/EntrypointNormalizerTest.php +++ b/tests/Hydra/Serializer/EntrypointNormalizerTest.php @@ -21,6 +21,7 @@ use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Metadata\Resource\ResourceNameCollection; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\FooDummy; use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; @@ -50,14 +51,16 @@ public function testSupportNormalization() public function testNormalize() { - $collection = new ResourceNameCollection([Dummy::class]); + $collection = new ResourceNameCollection([FooDummy::class, Dummy::class]); $entrypoint = new Entrypoint($collection); $factoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $factoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata('Dummy', null, null, null, ['get']))->shouldBeCalled(); + $factoryProphecy->create(FooDummy::class)->willReturn(new ResourceMetadata('FooDummy', null, null, null, ['get']))->shouldBeCalled(); $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); $iriConverterProphecy->getIriFromResourceClass(Dummy::class)->willReturn('/api/dummies')->shouldBeCalled(); + $iriConverterProphecy->getIriFromResourceClass(FooDummy::class)->willReturn('/api/foo_dummies')->shouldBeCalled(); $urlGeneratorProphecy = $this->prophesize(UrlGeneratorInterface::class); $urlGeneratorProphecy->generate('api_entrypoint')->willReturn('/api')->shouldBeCalled(); @@ -70,6 +73,7 @@ public function testNormalize() '@id' => '/api', '@type' => 'Entrypoint', 'dummy' => '/api/dummies', + 'fooDummy' => '/api/foo_dummies', ]; $this->assertEquals($expected, $normalizer->normalize($entrypoint, EntrypointNormalizer::FORMAT)); } diff --git a/tests/JsonApi/Serializer/CollectionNormalizerTest.php b/tests/JsonApi/Serializer/CollectionNormalizerTest.php index ace610c3522..c68118ffe1d 100644 --- a/tests/JsonApi/Serializer/CollectionNormalizerTest.php +++ b/tests/JsonApi/Serializer/CollectionNormalizerTest.php @@ -17,6 +17,8 @@ use ApiPlatform\Core\DataProvider\PaginatorInterface; use ApiPlatform\Core\DataProvider\PartialPaginatorInterface; use ApiPlatform\Core\JsonApi\Serializer\CollectionNormalizer; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\ProphecyTrait; use PHPUnit\Framework\TestCase; use Symfony\Component\Serializer\Exception\UnexpectedValueException; @@ -32,8 +34,9 @@ class CollectionNormalizerTest extends TestCase public function testSupportsNormalize() { $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); - $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page'); + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); $this->assertTrue($normalizer->supportsNormalization([], CollectionNormalizer::FORMAT)); $this->assertTrue($normalizer->supportsNormalization(new \ArrayObject(), CollectionNormalizer::FORMAT)); @@ -59,9 +62,13 @@ public function testNormalizePaginator() $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass($paginator, 'Foo')->willReturn('Foo'); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata()); + $itemNormalizer = $this->prophesize(NormalizerInterface::class); $itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [ 'request_uri' => '/foos?page=3', + 'uri' => 'http://example.com/foos?page=3', 'api_sub_level' => true, 'resource_class' => 'Foo', ])->willReturn([ @@ -75,7 +82,7 @@ public function testNormalizePaginator() ], ]); - $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page'); + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); $normalizer->setNormalizer($itemNormalizer->reveal()); $expected = [ @@ -105,6 +112,7 @@ public function testNormalizePaginator() $this->assertEquals($expected, $normalizer->normalize($paginator, CollectionNormalizer::FORMAT, [ 'request_uri' => '/foos?page=3', + 'uri' => 'http://example.com/foos?page=3', 'resource_class' => 'Foo', ])); } @@ -125,9 +133,13 @@ public function testNormalizePartialPaginator() $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass($paginator, 'Foo')->willReturn('Foo'); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata()); + $itemNormalizer = $this->prophesize(NormalizerInterface::class); $itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [ 'request_uri' => '/foos?page=3', + 'uri' => 'http://example.com/foos?page=3', 'api_sub_level' => true, 'resource_class' => 'Foo', ])->willReturn([ @@ -141,7 +153,7 @@ public function testNormalizePartialPaginator() ], ]); - $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page'); + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); $normalizer->setNormalizer($itemNormalizer->reveal()); $expected = [ @@ -168,6 +180,7 @@ public function testNormalizePartialPaginator() $this->assertEquals($expected, $normalizer->normalize($paginator, CollectionNormalizer::FORMAT, [ 'request_uri' => '/foos?page=3', + 'uri' => 'http://example.com/foos?page=3', 'resource_class' => 'Foo', ])); } @@ -178,10 +191,12 @@ public function testNormalizeArray() $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass($data, 'Foo')->willReturn('Foo'); - + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata()); $itemNormalizer = $this->prophesize(NormalizerInterface::class); $itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [ 'request_uri' => '/foos', + 'uri' => 'http://example.com/foos', 'api_sub_level' => true, 'resource_class' => 'Foo', ])->willReturn([ @@ -195,7 +210,7 @@ public function testNormalizeArray() ], ]); - $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page'); + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); $normalizer->setNormalizer($itemNormalizer->reveal()); $expected = [ @@ -215,6 +230,7 @@ public function testNormalizeArray() $this->assertEquals($expected, $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ 'request_uri' => '/foos', + 'uri' => 'http://example.com/foos', 'resource_class' => 'Foo', ])); } @@ -226,9 +242,13 @@ public function testNormalizeIncludedData() $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass($data, 'Foo')->willReturn('Foo'); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata()); + $itemNormalizer = $this->prophesize(NormalizerInterface::class); $itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [ 'request_uri' => '/foos', + 'uri' => 'http://example.com/foos', 'api_sub_level' => true, 'resource_class' => 'Foo', ])->willReturn([ @@ -252,7 +272,7 @@ public function testNormalizeIncludedData() ], ]); - $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page'); + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); $normalizer->setNormalizer($itemNormalizer->reveal()); $expected = [ @@ -282,6 +302,7 @@ public function testNormalizeIncludedData() $this->assertEquals($expected, $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ 'request_uri' => '/foos', + 'uri' => 'http://example.com/foos', 'resource_class' => 'Foo', ])); } @@ -296,18 +317,23 @@ public function testNormalizeWithoutDataKey() $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass($data, 'Foo')->willReturn('Foo'); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create('Foo')->willReturn(new ResourceMetadata()); + $itemNormalizer = $this->prophesize(NormalizerInterface::class); $itemNormalizer->normalize('foo', CollectionNormalizer::FORMAT, [ 'request_uri' => '/foos', + 'uri' => 'http://example.com/foos', 'api_sub_level' => true, 'resource_class' => 'Foo', ])->willReturn([]); - $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page'); + $normalizer = new CollectionNormalizer($resourceClassResolverProphecy->reveal(), 'page', $resourceMetadataFactoryProphecy->reveal()); $normalizer->setNormalizer($itemNormalizer->reveal()); $normalizer->normalize($data, CollectionNormalizer::FORMAT, [ 'request_uri' => '/foos', + 'uri' => 'http://example.com/foos', 'resource_class' => 'Foo', ]); } diff --git a/tests/JsonApi/Serializer/ErrorNormalizerTest.php b/tests/JsonApi/Serializer/ErrorNormalizerTest.php index c4bb2ced75e..dc9e68b4420 100644 --- a/tests/JsonApi/Serializer/ErrorNormalizerTest.php +++ b/tests/JsonApi/Serializer/ErrorNormalizerTest.php @@ -14,6 +14,7 @@ namespace ApiPlatform\Core\Tests\JsonApi\Serializer; use ApiPlatform\Core\JsonApi\Serializer\ErrorNormalizer; +use ApiPlatform\Core\Tests\Mock\Exception\ErrorCodeSerializable; use PHPUnit\Framework\TestCase; use Symfony\Component\Debug\Exception\FlattenException; use Symfony\Component\HttpFoundation\Response; @@ -61,6 +62,42 @@ public function testNormalize($status, $originalMessage, $debug) $this->assertEquals($expected, $normalizer->normalize($exception, ErrorNormalizer::FORMAT, ['statusCode' => $status])); } + public function testNormalizeAnExceptionWithCustomErrorCode(): void + { + $status = Response::HTTP_BAD_REQUEST; + $originalMessage = 'my-message'; + $debug = false; + + $normalizer = new ErrorNormalizer($debug); + $exception = new ErrorCodeSerializable($originalMessage); + + $expected = [ + 'title' => 'An error occurred', + 'description' => 'my-message', + 'code' => ErrorCodeSerializable::getErrorCode(), + ]; + + $this->assertEquals($expected, $normalizer->normalize($exception, ErrorNormalizer::FORMAT, ['statusCode' => $status])); + } + + public function testNormalizeAFlattenExceptionWithCustomErrorCode(): void + { + $status = Response::HTTP_BAD_REQUEST; + $originalMessage = 'my-message'; + $debug = false; + + $normalizer = new ErrorNormalizer($debug); + $exception = FlattenException::create(new ErrorCodeSerializable($originalMessage), $status); + + $expected = [ + 'title' => 'An error occurred', + 'description' => 'my-message', + 'code' => ErrorCodeSerializable::getErrorCode(), + ]; + + $this->assertEquals($expected, $normalizer->normalize($exception, ErrorNormalizer::FORMAT, ['statusCode' => $status])); + } + public function errorProvider() { return [ diff --git a/tests/JsonApi/Serializer/ItemNormalizerTest.php b/tests/JsonApi/Serializer/ItemNormalizerTest.php index c5fc2b85327..e56812ccd8f 100644 --- a/tests/JsonApi/Serializer/ItemNormalizerTest.php +++ b/tests/JsonApi/Serializer/ItemNormalizerTest.php @@ -98,7 +98,72 @@ public function testSupportNormalization() $this->assertFalse($normalizer->supportsNormalization($std, ItemNormalizer::FORMAT)); } - public function testNormalize() + public function testNormalize(): void + { + $dummy = new Dummy(); + $dummy->setId(10); + $dummy->setName('hello'); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn(new PropertyNameCollection(['id', 'name', '\bad_property'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', [])->willReturn(new PropertyMetadata(null, null, true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', [])->willReturn(new PropertyMetadata(null, null, true, null, null, null, null, true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, '\bad_property', [])->willReturn(new PropertyMetadata(null, null, true)); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($dummy)->willReturn('/dummies/10'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + $resourceClassResolverProphecy->getResourceClass($dummy, Dummy::class)->willReturn(Dummy::class); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->getValue($dummy, 'id')->willReturn(10); + $propertyAccessorProphecy->getValue($dummy, 'name')->willReturn('hello'); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn(new ResourceMetadata('Dummy', 'A dummy', '/dummy', null, null, ['id', 'name'])); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('hello', ItemNormalizer::FORMAT, Argument::type('array'))->willReturn('hello'); + $serializerProphecy->normalize(10, ItemNormalizer::FORMAT, Argument::type('array'))->willReturn(10); + $serializerProphecy->normalize(null, ItemNormalizer::FORMAT, Argument::type('array'))->willReturn(null); + + $normalizer = new ItemNormalizer( + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + new ReservedAttributeNameConverter(), + $resourceMetadataFactoryProphecy->reveal(), + [], + [] + ); + + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + 'data' => [ + 'type' => 'Dummy', + 'id' => '/dummies/10', + 'attributes' => [ + '_id' => 10, + 'name' => 'hello', + ], + ], + ]; + + $this->assertEquals($expected, $normalizer->normalize($dummy, ItemNormalizer::FORMAT)); + } + + /** + * @group legacy + */ + public function testNormalizeChildInheritedProperty(): void { $dummy = new Dummy(); $dummy->setId(10); diff --git a/tests/JsonLd/ContextBuilderTest.php b/tests/JsonLd/ContextBuilderTest.php index 4176bd5ee46..54103706cb8 100644 --- a/tests/JsonLd/ContextBuilderTest.php +++ b/tests/JsonLd/ContextBuilderTest.php @@ -70,6 +70,25 @@ public function testResourceContext() $this->assertEquals($expected, $contextBuilder->getResourceContext($this->entityClass)); } + public function testIriOnlyResourceContext() + { + $this->resourceMetadataFactoryProphecy->create($this->entityClass)->willReturn(new ResourceMetadata('DummyEntity', null, null, null, null, ['normalization_context' => ['iri_only' => true]])); + $this->propertyNameCollectionFactoryProphecy->create($this->entityClass)->willReturn(new PropertyNameCollection(['dummyPropertyA'])); + $this->propertyMetadataFactoryProphecy->create($this->entityClass, 'dummyPropertyA')->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'Dummy property A', true, true, true, true, false, false)); + + $contextBuilder = new ContextBuilder($this->resourceNameCollectionFactoryProphecy->reveal(), $this->resourceMetadataFactoryProphecy->reveal(), $this->propertyNameCollectionFactoryProphecy->reveal(), $this->propertyMetadataFactoryProphecy->reveal(), $this->urlGeneratorProphecy->reveal()); + + $expected = [ + '@vocab' => '#', + 'hydra' => 'http://www.w3.org/ns/hydra/core#', + 'hydra:member' => [ + '@type' => '@id', + ], + ]; + + $this->assertEquals($expected, $contextBuilder->getResourceContext($this->entityClass)); + } + public function testResourceContextWithJsonldContext() { $this->resourceMetadataFactoryProphecy->create($this->entityClass)->willReturn(new ResourceMetadata('DummyEntity')); diff --git a/tests/Metadata/Extractor/ExtractorTestCase.php b/tests/Metadata/Extractor/ExtractorTestCase.php index 4e64a87ec2c..46a6eb95cac 100644 --- a/tests/Metadata/Extractor/ExtractorTestCase.php +++ b/tests/Metadata/Extractor/ExtractorTestCase.php @@ -162,6 +162,7 @@ final public function testResourcesParametersResolution() { $containerProphecy = $this->prophesize(ContainerInterface::class); $containerProphecy->get('dummy_class')->willReturn(\ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy::class); + $containerProphecy->getParameter('dummy_related_owned_class')->willReturn(\ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedOwnedDummy::class); $containerProphecy->get('file_config_dummy_class')->willReturn(\ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\FileConfigDummy::class); $resources = $this->createExtractor([$this->getResourceWithParametersFile()], $containerProphecy->reveal())->getResources(); @@ -176,7 +177,11 @@ final public function testResourcesParametersResolution() 'subresourceOperations' => null, 'graphql' => null, 'attributes' => null, - 'properties' => null, + 'properties' => [ + 'relatedOwnedDummy' => [ + 'resourceClass' => \ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedOwnedDummy::class, + ], + ], ], '\ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyBis' => [ 'shortName' => null, @@ -288,6 +293,7 @@ final public function testResourcesParametersResolutionWithTheSymfonyContainer() { $containerProphecy = $this->prophesize(SymfonyContainerInterface::class); $containerProphecy->getParameter('dummy_class')->willReturn(\ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy::class); + $containerProphecy->getParameter('dummy_related_owned_class')->willReturn(\ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedOwnedDummy::class); $containerProphecy->getParameter('file_config_dummy_class')->willReturn(\ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\FileConfigDummy::class); $resources = $this->createExtractor([$this->getResourceWithParametersFile()], $containerProphecy->reveal())->getResources(); @@ -302,7 +308,11 @@ final public function testResourcesParametersResolutionWithTheSymfonyContainer() 'subresourceOperations' => null, 'graphql' => null, 'attributes' => null, - 'properties' => null, + 'properties' => [ + 'relatedOwnedDummy' => [ + 'resourceClass' => \ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedOwnedDummy::class, + ], + ], ], '\ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyBis' => [ 'shortName' => null, @@ -424,7 +434,11 @@ final public function testResourcesParametersResolutionWithoutAContainer() 'subresourceOperations' => null, 'graphql' => null, 'attributes' => null, - 'properties' => null, + 'properties' => [ + 'relatedOwnedDummy' => [ + 'resourceClass' => '%dummy_related_owned_class%', + ], + ], ], '%dummy_class%Bis' => [ 'shortName' => null, diff --git a/tests/Metadata/Property/Factory/AnnotationPropertyMetadataFactoryTest.php b/tests/Metadata/Property/Factory/AnnotationPropertyMetadataFactoryTest.php index 6da8905aee7..aa906801559 100644 --- a/tests/Metadata/Property/Factory/AnnotationPropertyMetadataFactoryTest.php +++ b/tests/Metadata/Property/Factory/AnnotationPropertyMetadataFactoryTest.php @@ -19,6 +19,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyPhp8; use ApiPlatform\Core\Tests\ProphecyTrait; use Doctrine\Common\Annotations\Reader; use PHPUnit\Framework\TestCase; @@ -50,6 +51,21 @@ public function testCreateProperty($reader, $decorated, string $description) $this->assertEquals(['foo' => 'bar'], $metadata->getAttributes()); } + /** + * @requires PHP 8.0 + */ + public function testCreateAttribute() + { + $factory = new AnnotationPropertyMetadataFactory(); + + $metadata = $factory->create(DummyPhp8::class, 'id'); + $this->assertTrue($metadata->isIdentifier()); + $this->assertSame('the identifier', $metadata->getDescription()); + + $metadata = $factory->create(DummyPhp8::class, 'foo'); + $this->assertSame('a foo', $metadata->getDescription()); + } + public function dependenciesProvider(): array { $annotation = new ApiProperty(); diff --git a/tests/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactoryTest.php b/tests/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactoryTest.php index c5e3280de4a..3aeccc4c767 100644 --- a/tests/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactoryTest.php +++ b/tests/Metadata/Property/Factory/AnnotationPropertyNameCollectionFactoryTest.php @@ -19,6 +19,7 @@ use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyPhp8; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\UpperCaseIdentifierDummy; use ApiPlatform\Core\Tests\ProphecyTrait; use Doctrine\Common\Annotations\Reader; @@ -66,6 +67,17 @@ public function dependenciesProvider(): array ]; } + /** + * @requires PHP 8.0 + */ + public function testCreateAttribute() + { + $factory = new AnnotationPropertyNameCollectionFactory(); + $metadata = $factory->create(DummyPhp8::class); + + $this->assertSame(['id', 'foo'], iterator_to_array($metadata)); + } + /** * @dataProvider upperCaseDependenciesProvider */ diff --git a/tests/Metadata/Property/Factory/DefaultPropertyMetadataFactoryTest.php b/tests/Metadata/Property/Factory/DefaultPropertyMetadataFactoryTest.php new file mode 100644 index 00000000000..4ad92c7a59f --- /dev/null +++ b/tests/Metadata/Property/Factory/DefaultPropertyMetadataFactoryTest.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Metadata\Property\Factory; + +use ApiPlatform\Core\Exception\PropertyNotFoundException; +use ApiPlatform\Core\Metadata\Property\Factory\DefaultPropertyMetadataFactory; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyPropertyWithDefaultValue; +use PHPUnit\Framework\TestCase; + +class DefaultPropertyMetadataFactoryTest extends TestCase +{ + public function testCreate() + { + $factory = new DefaultPropertyMetadataFactory(); + $metadata = $factory->create(DummyPropertyWithDefaultValue::class, 'foo'); + + $this->assertEquals($metadata->getDefault(), 'foo'); + } + + public function testClassDoesNotExist() + { + $factory = new DefaultPropertyMetadataFactory(); + $metadata = $factory->create('\DoNotExist', 'foo'); + + $this->assertEquals(new PropertyMetadata(), $metadata); + } + + public function testPropertyDoesNotExist() + { + $decoratedProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $decoratedProphecy->create(DummyPropertyWithDefaultValue::class, 'doNotExist', [])->willThrow(new PropertyNotFoundException()); + + $factory = new DefaultPropertyMetadataFactory($decoratedProphecy->reveal()); + $metadata = $factory->create(DummyPropertyWithDefaultValue::class, 'doNotExist'); + + $this->assertEquals(new PropertyMetadata(), $metadata); + } +} diff --git a/tests/Metadata/Property/Factory/InheritedPropertyMetadataFactoryTest.php b/tests/Metadata/Property/Factory/InheritedPropertyMetadataFactoryTest.php index a739b9eb067..6971e35e818 100644 --- a/tests/Metadata/Property/Factory/InheritedPropertyMetadataFactoryTest.php +++ b/tests/Metadata/Property/Factory/InheritedPropertyMetadataFactoryTest.php @@ -25,7 +25,7 @@ use Symfony\Component\PropertyInfo\Type; /** - * @author Antoine Bluchet + * @group legacy */ class InheritedPropertyMetadataFactoryTest extends TestCase { diff --git a/tests/Metadata/Property/Factory/InheritedPropertyNameCollectionFactoryTest.php b/tests/Metadata/Property/Factory/InheritedPropertyNameCollectionFactoryTest.php index 227fe5555a8..360be8a4c3a 100644 --- a/tests/Metadata/Property/Factory/InheritedPropertyNameCollectionFactoryTest.php +++ b/tests/Metadata/Property/Factory/InheritedPropertyNameCollectionFactoryTest.php @@ -24,24 +24,39 @@ use PHPUnit\Framework\TestCase; /** - * @author Antoine Bluchet + * @group legacy */ class InheritedPropertyNameCollectionFactoryTest extends TestCase { use ProphecyTrait; - public function testCreate() + public function testCreateOnParent() { $resourceNameCollectionFactory = $this->prophesize(ResourceNameCollectionFactoryInterface::class); $resourceNameCollectionFactory->create()->willReturn(new ResourceNameCollection([DummyTableInheritance::class, DummyTableInheritanceChild::class]))->shouldBeCalled(); $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); $propertyNameCollectionFactory->create(DummyTableInheritance::class, [])->willReturn(new PropertyNameCollection(['name']))->shouldBeCalled(); - $propertyNameCollectionFactory->create(DummyTableInheritanceChild::class, [])->willReturn(new PropertyNameCollection(['nickname', '169']))->shouldBeCalled(); + $propertyNameCollectionFactory->create(DummyTableInheritanceChild::class, [])->shouldNotBeCalled(); $factory = new InheritedPropertyNameCollectionFactory($resourceNameCollectionFactory->reveal(), $propertyNameCollectionFactory->reveal()); $metadata = $factory->create(DummyTableInheritance::class); - $this->assertSame((array) $metadata, (array) new PropertyNameCollection(['name', 'nickname', '169'])); + $this->assertSame((array) new PropertyNameCollection(['name']), (array) $metadata); + } + + public function testCreateOnChild() + { + $resourceNameCollectionFactory = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactory->create()->willReturn(new ResourceNameCollection([DummyTableInheritance::class, DummyTableInheritanceChild::class]))->shouldBeCalled(); + + $propertyNameCollectionFactory = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactory->create(DummyTableInheritance::class, [])->willReturn(new PropertyNameCollection(['name']))->shouldBeCalled(); + $propertyNameCollectionFactory->create(DummyTableInheritanceChild::class, [])->willReturn(new PropertyNameCollection(['nickname', '169']))->shouldBeCalled(); + + $factory = new InheritedPropertyNameCollectionFactory($resourceNameCollectionFactory->reveal(), $propertyNameCollectionFactory->reveal()); + $metadata = $factory->create(DummyTableInheritanceChild::class); + + $this->assertSame((array) new PropertyNameCollection(['nickname', '169', 'name']), (array) $metadata); } } diff --git a/tests/Metadata/Property/Factory/SerializerPropertyMetadataFactoryTest.php b/tests/Metadata/Property/Factory/SerializerPropertyMetadataFactoryTest.php index 322b93be5e9..0d571730e1f 100644 --- a/tests/Metadata/Property/Factory/SerializerPropertyMetadataFactoryTest.php +++ b/tests/Metadata/Property/Factory/SerializerPropertyMetadataFactoryTest.php @@ -31,9 +31,6 @@ use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface as SerializerClassMetadataFactoryInterface; use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; -/** - * @author Teoh Han Hui - */ class SerializerPropertyMetadataFactoryTest extends TestCase { use ProphecyTrait; @@ -140,7 +137,10 @@ public function groupsProvider(): array ]; } - public function testCreateInherited() + /** + * @group legacy + */ + public function testCreateInherited(): void { $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $resourceMetadataFactoryProphecy->create(DummyTableInheritanceChild::class)->willReturn(new ResourceMetadata()); diff --git a/tests/Metadata/Property/PropertyMetadataTest.php b/tests/Metadata/Property/PropertyMetadataTest.php index afee7f6adb6..d1c0d0a3add 100644 --- a/tests/Metadata/Property/PropertyMetadataTest.php +++ b/tests/Metadata/Property/PropertyMetadataTest.php @@ -82,6 +82,14 @@ public function testValueObject() $newMetadata = $metadata->withInitializable(true); $this->assertNotSame($metadata, $newMetadata); $this->assertTrue($newMetadata->isInitializable()); + + $newMetadata = $metadata->withDefault('foobar'); + $this->assertNotSame($metadata, $newMetadata); + $this->assertEquals('foobar', $newMetadata->getDefault()); + + $newMetadata = $metadata->withExample('foobarexample'); + $this->assertNotSame($metadata, $newMetadata); + $this->assertEquals('foobarexample', $newMetadata->getExample()); } public function testShouldReturnRequiredFalseWhenRequiredTrueIsSetButMaskedByWritableFalse() diff --git a/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php b/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php index 2bef85d45f1..caeb6fb60c6 100644 --- a/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php +++ b/tests/Metadata/Resource/Factory/AnnotationResourceMetadataFactoryTest.php @@ -19,6 +19,7 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyPhp8; use ApiPlatform\Core\Tests\ProphecyTrait; use Doctrine\Common\Annotations\Reader; use PHPUnit\Framework\TestCase; @@ -45,10 +46,57 @@ public function testCreate($reader, $decorated, string $expectedShortName, ?stri $this->assertEquals(['foo' => ['bar' => true]], $metadata->getItemOperations()); $this->assertEquals(['baz' => ['tab' => false]], $metadata->getCollectionOperations()); $this->assertEquals(['sub' => ['bus' => false]], $metadata->getSubresourceOperations()); - $this->assertEquals(['a' => 1, 'route_prefix' => '/foobar'], $metadata->getAttributes()); + $this->assertEquals(['a' => 1, 'route_prefix' => '/foobar', 'stateless' => false], $metadata->getAttributes()); $this->assertEquals(['foo' => 'bar'], $metadata->getGraphql()); } + /** + * @requires PHP 8.0 + */ + public function testCreateAttribute() + { + $factory = new AnnotationResourceMetadataFactory(); + $metadata = $factory->create(DummyPhp8::class); + + $this->assertSame('Hey PHP 8', $metadata->getDescription()); + } + + public function testCreateWithDefaults() + { + $defaults = [ + 'shortName' => 'Default shortname should not be ignored', + 'description' => 'CHANGEME!', + 'collection_operations' => ['get'], + 'item_operations' => ['get', 'put'], + 'attributes' => [ + 'pagination_items_per_page' => 4, + 'pagination_maximum_items_per_page' => 6, + 'stateless' => true, + ], + ]; + + $annotation = new ApiResource([ + 'itemOperations' => ['get', 'delete'], + 'attributes' => [ + 'pagination_client_enabled' => true, + 'pagination_maximum_items_per_page' => 10, + ], + ]); + $reader = $this->prophesize(Reader::class); + $reader->getClassAnnotation(Argument::type(\ReflectionClass::class), ApiResource::class)->willReturn($annotation)->shouldBeCalled(); + $factory = new AnnotationResourceMetadataFactory($reader->reveal(), null, $defaults); + $metadata = $factory->create(Dummy::class); + + $this->assertNull($metadata->getShortName()); + $this->assertEquals('CHANGEME!', $metadata->getDescription()); + $this->assertEquals(['get'], $metadata->getCollectionOperations()); + $this->assertEquals(['get', 'delete'], $metadata->getItemOperations()); + $this->assertTrue($metadata->getAttribute('pagination_client_enabled')); + $this->assertEquals(4, $metadata->getAttribute('pagination_items_per_page')); + $this->assertEquals(10, $metadata->getAttribute('pagination_maximum_items_per_page')); + $this->assertTrue($metadata->getAttribute('stateless')); + } + public function testCreateWithoutAttributes() { $annotation = new ApiResource([]); @@ -71,6 +119,7 @@ public function getCreateDependencies() 'subresourceOperations' => ['sub' => ['bus' => false]], 'attributes' => ['a' => 1, 'route_prefix' => '/foobar'], 'graphql' => ['foo' => 'bar'], + 'stateless' => false, ]; $annotationFull = new ApiResource($resourceData); diff --git a/tests/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactoryTest.php b/tests/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactoryTest.php index 6fb863ffa09..ac542c9175c 100644 --- a/tests/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactoryTest.php +++ b/tests/Metadata/Resource/Factory/AnnotationResourceNameCollectionFactoryTest.php @@ -38,4 +38,16 @@ public function testCreate() $this->assertEquals(new ResourceNameCollection(['foo', 'bar']), $metadata->create()); } + + /** + * @requires PHP 8.0 + */ + public function testCreateAttribute() + { + $decorated = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $decorated->create()->willReturn(new ResourceNameCollection(['foo', 'bar']))->shouldBeCalled(); + + $metadata = new AnnotationResourceNameCollectionFactory(null, [], $decorated->reveal()); + $this->assertEquals(new ResourceNameCollection(['foo', 'bar']), $metadata->create()); + } } diff --git a/tests/Metadata/Resource/Factory/ExtractorResourceMetadataFactoryTest.php b/tests/Metadata/Resource/Factory/ExtractorResourceMetadataFactoryTest.php index 0e0f394a45c..a5d1dbc552e 100644 --- a/tests/Metadata/Resource/Factory/ExtractorResourceMetadataFactoryTest.php +++ b/tests/Metadata/Resource/Factory/ExtractorResourceMetadataFactoryTest.php @@ -15,6 +15,7 @@ use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Exception\ResourceClassNotFoundException; +use ApiPlatform\Core\Metadata\Extractor\ExtractorInterface; use ApiPlatform\Core\Metadata\Extractor\XmlExtractor; use ApiPlatform\Core\Metadata\Extractor\YamlExtractor; use ApiPlatform\Core\Metadata\Resource\Factory\ExtractorResourceMetadataFactory; @@ -23,6 +24,7 @@ use ApiPlatform\Core\Metadata\Resource\Factory\ShortNameResourceMetadataFactory; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; use ApiPlatform\Core\Tests\Fixtures\DummyResourceInterface; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\FileConfigDummy; use ApiPlatform\Core\Tests\ProphecyTrait; @@ -289,4 +291,55 @@ public function testItSupportsInterfaceAsAResource() $resourceMetadata = $shortNameResourceMetadataFactory->create(DummyResourceInterface::class); $this->assertSame('DummyResourceInterface', $resourceMetadata->getShortName()); } + + public function testItFallbacksToDefaultConfiguration() + { + $defaults = [ + 'shortName' => 'Default shortname should not be ignored', + 'description' => 'CHANGEME!', + 'collection_operations' => ['get'], + 'item_operations' => ['get', 'put'], + 'attributes' => [ + 'pagination_items_per_page' => 4, + 'pagination_maximum_items_per_page' => 6, + 'stateless' => true, + ], + ]; + $resourceConfiguration = [ + Dummy::class => [ + 'shortName' => null, + 'description' => null, + 'subresourceOperations' => null, + 'itemOperations' => ['get', 'delete'], + 'attributes' => [ + 'pagination_maximum_items_per_page' => 10, + 'stateless' => false, + ], + ], + ]; + + $extractor = new class($resourceConfiguration) implements ExtractorInterface { + private $resources; + + public function __construct(array $resources) + { + $this->resources = $resources; + } + + public function getResources(): array + { + return $this->resources; + } + }; + $factory = new ExtractorResourceMetadataFactory($extractor, null, $defaults); + $metadata = $factory->create(Dummy::class); + + $this->assertNull($metadata->getShortName()); + $this->assertEquals('CHANGEME!', $metadata->getDescription()); + $this->assertEquals(['get'], $metadata->getCollectionOperations()); + $this->assertEquals(['get', 'delete'], $metadata->getItemOperations()); + $this->assertEquals(4, $metadata->getAttribute('pagination_items_per_page')); + $this->assertEquals(10, $metadata->getAttribute('pagination_maximum_items_per_page')); + $this->assertFalse($metadata->getAttribute('stateless')); + } } diff --git a/tests/Metadata/Resource/Factory/FileConfigurationMetadataFactoryProvider.php b/tests/Metadata/Resource/Factory/FileConfigurationMetadataFactoryProvider.php index 52f209016aa..39c81511c39 100644 --- a/tests/Metadata/Resource/Factory/FileConfigurationMetadataFactoryProvider.php +++ b/tests/Metadata/Resource/Factory/FileConfigurationMetadataFactoryProvider.php @@ -60,6 +60,7 @@ public function resourceMetadataProvider() '@type' => 'hydra:Operation', '@hydra:title' => 'File config Dummy', ], + 'stateless' => true, ], ]; diff --git a/tests/Metadata/Resource/Factory/OperationResourceMetadataFactoryTest.php b/tests/Metadata/Resource/Factory/OperationResourceMetadataFactoryTest.php index eeeb348d532..f2b79dd84d7 100644 --- a/tests/Metadata/Resource/Factory/OperationResourceMetadataFactoryTest.php +++ b/tests/Metadata/Resource/Factory/OperationResourceMetadataFactoryTest.php @@ -48,25 +48,29 @@ public function getMetadata(): iterable yield [new ResourceMetadata(null, null, null, ['get'], [], null, [], []), new ResourceMetadata(null, null, null, $this->getOperations(['get']), [], null, [], [])]; yield [new ResourceMetadata(null, null, null, ['put'], [], null, [], []), new ResourceMetadata(null, null, null, $this->getOperations(['put']), [], null, [], [])]; yield [new ResourceMetadata(null, null, null, ['delete'], [], null, [], []), new ResourceMetadata(null, null, null, $this->getOperations(['delete']), [], null, [], [])]; - yield [new ResourceMetadata(null, null, null, ['patch' => ['method' => 'PATCH', 'route_name' => 'patch']], [], null, [], []), new ResourceMetadata(null, null, null, ['patch' => ['method' => 'PATCH', 'route_name' => 'patch']], [], null, [], [])]; - yield [new ResourceMetadata(null, null, null, ['patch' => ['method' => 'PATCH', 'route_name' => 'patch']], [], null, [], []), new ResourceMetadata(null, null, null, ['patch' => ['method' => 'PATCH', 'route_name' => 'patch']], [], null, [], []), $jsonapi]; - yield [new ResourceMetadata(null, null, null, ['untouched' => ['method' => 'GET']], [], null, [], []), new ResourceMetadata(null, null, null, ['untouched' => ['method' => 'GET']], [], null, [], []), $jsonapi]; - yield [new ResourceMetadata(null, null, null, ['untouched_custom' => ['route_name' => 'custom_route']], [], null, [], []), new ResourceMetadata(null, null, null, ['untouched_custom' => ['route_name' => 'custom_route']], [], null, [], []), $jsonapi]; + yield [new ResourceMetadata(null, null, null, ['patch' => ['method' => 'PATCH', 'route_name' => 'patch']], [], null, [], []), new ResourceMetadata(null, null, null, ['patch' => ['method' => 'PATCH', 'route_name' => 'patch', 'stateless' => null]], [], null, [], [])]; + yield [new ResourceMetadata(null, null, null, ['patch' => ['method' => 'PATCH', 'route_name' => 'patch']], [], null, [], []), new ResourceMetadata(null, null, null, ['patch' => ['method' => 'PATCH', 'route_name' => 'patch', 'stateless' => null]], [], null, [], []), $jsonapi]; + yield [new ResourceMetadata(null, null, null, ['untouched' => ['method' => 'GET']], [], null, [], []), new ResourceMetadata(null, null, null, ['untouched' => ['method' => 'GET', 'stateless' => null]], [], null, [], []), $jsonapi]; + yield [new ResourceMetadata(null, null, null, ['untouched_custom' => ['route_name' => 'custom_route']], [], null, [], []), new ResourceMetadata(null, null, null, ['untouched_custom' => ['route_name' => 'custom_route', 'stateless' => null]], [], null, [], []), $jsonapi]; + yield [new ResourceMetadata(null, null, null, ['stateless_operation' => ['method' => 'GET', 'stateless' => true]], [], null, [], []), new ResourceMetadata(null, null, null, ['stateless_operation' => ['method' => 'GET', 'stateless' => true]], [], null, [], []), $jsonapi]; + yield [new ResourceMetadata(null, null, null, ['statefull_attribute' => ['method' => 'GET']], [], ['stateless' => false], [], []), new ResourceMetadata(null, null, null, ['statefull_attribute' => ['method' => 'GET', 'stateless' => false]], [], ['stateless' => false], [], []), $jsonapi]; // Collection operations yield [new ResourceMetadata(null, null, null, [], null, null, [], []), new ResourceMetadata(null, null, null, [], $this->getOperations(['get', 'post']), null, [], [])]; yield [new ResourceMetadata(null, null, null, [], ['get'], null, [], []), new ResourceMetadata(null, null, null, [], $this->getOperations(['get']), null, [], [])]; yield [new ResourceMetadata(null, null, null, [], ['post'], null, [], []), new ResourceMetadata(null, null, null, [], $this->getOperations(['post']), null, [], [])]; - yield [new ResourceMetadata(null, null, null, [], ['options' => ['method' => 'OPTIONS', 'route_name' => 'options']], null, [], []), new ResourceMetadata(null, null, null, [], ['options' => ['route_name' => 'options', 'method' => 'OPTIONS']], null, [], [])]; - yield [new ResourceMetadata(null, null, null, [], ['untouched' => ['method' => 'GET']], null, [], []), new ResourceMetadata(null, null, null, [], ['untouched' => ['method' => 'GET']], null, [], [])]; - yield [new ResourceMetadata(null, null, null, [], ['untouched_custom' => ['route_name' => 'custom_route']], null, [], []), new ResourceMetadata(null, null, null, [], ['untouched_custom' => ['route_name' => 'custom_route']], null, [], [])]; + yield [new ResourceMetadata(null, null, null, [], ['options' => ['method' => 'OPTIONS', 'route_name' => 'options']], null, [], []), new ResourceMetadata(null, null, null, [], ['options' => ['route_name' => 'options', 'method' => 'OPTIONS', 'stateless' => null]], null, [], [])]; + yield [new ResourceMetadata(null, null, null, [], ['untouched' => ['method' => 'GET']], null, [], []), new ResourceMetadata(null, null, null, [], ['untouched' => ['method' => 'GET', 'stateless' => null]], null, [], [])]; + yield [new ResourceMetadata(null, null, null, [], ['untouched_custom' => ['route_name' => 'custom_route']], null, [], []), new ResourceMetadata(null, null, null, [], ['untouched_custom' => ['route_name' => 'custom_route', 'stateless' => null]], null, [], [])]; + yield [new ResourceMetadata(null, null, null, [], ['statefull_operation' => ['method' => 'GET', 'stateless' => false]], null, null, []), new ResourceMetadata(null, null, null, [], ['statefull_operation' => ['method' => 'GET', 'stateless' => false]], null, null, []), $jsonapi]; + yield [new ResourceMetadata(null, null, null, [], ['stateless_attribute' => ['method' => 'GET']], ['stateless' => true], null, []), new ResourceMetadata(null, null, null, [], ['stateless_attribute' => ['method' => 'GET', 'stateless' => true]], ['stateless' => true], null, []), $jsonapi]; } private function getOperations(array $names): array { $operations = []; foreach ($names as $name) { - $operations[$name] = ['method' => strtoupper($name)]; + $operations[$name] = ['method' => strtoupper($name), 'stateless' => null]; } return $operations; diff --git a/tests/Mock/Exception/ErrorCodeSerializable.php b/tests/Mock/Exception/ErrorCodeSerializable.php new file mode 100644 index 00000000000..63bf1af86e8 --- /dev/null +++ b/tests/Mock/Exception/ErrorCodeSerializable.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Mock\Exception; + +use ApiPlatform\Core\Exception\ErrorCodeSerializableInterface; + +class ErrorCodeSerializable extends \Exception implements ErrorCodeSerializableInterface +{ + /** + * {@inheritdoc} + */ + public static function getErrorCode(): string + { + return '1234'; + } +} diff --git a/tests/OpenApi/Factory/OpenApiFactoryTest.php b/tests/OpenApi/Factory/OpenApiFactoryTest.php new file mode 100644 index 00000000000..3c8953f301f --- /dev/null +++ b/tests/OpenApi/Factory/OpenApiFactoryTest.php @@ -0,0 +1,673 @@ + + * + * For 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\OpenApi\Factory; + +use ApiPlatform\Core\Bridge\Symfony\Routing\RouterOperationPathResolver; +use ApiPlatform\Core\DataProvider\PaginationOptions; +use ApiPlatform\Core\JsonSchema\Schema; +use ApiPlatform\Core\JsonSchema\SchemaFactory; +use ApiPlatform\Core\JsonSchema\TypeFactory; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Core\Metadata\Property\SubresourceMetadata; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactory; +use ApiPlatform\Core\OpenApi\Model; +use ApiPlatform\Core\OpenApi\OpenApi; +use ApiPlatform\Core\OpenApi\Options; +use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactory; +use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactoryInterface; +use ApiPlatform\Core\Operation\UnderscorePathSegmentNameGenerator; +use ApiPlatform\Core\PathResolver\CustomOperationPathResolver; +use ApiPlatform\Core\PathResolver\OperationPathResolver; +use ApiPlatform\Core\Tests\Fixtures\DummyFilter; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Answer; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Question; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Psr\Container\ContainerInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; +use Symfony\Component\Routing\RouterInterface; +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; + +class OpenApiFactoryTest extends TestCase +{ + private const OPERATION_FORMATS = [ + 'input_formats' => ['jsonld' => ['application/ld+json']], + 'output_formats' => ['jsonld' => ['application/ld+json']], + ]; + + public function testInvoke(): void + { + $dummyMetadata = new ResourceMetadata( + 'Dummy', + 'This is a dummy.', + 'http://schema.example.com/Dummy', + [ + 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, + 'put' => ['method' => 'PUT'] + self::OPERATION_FORMATS, + 'delete' => ['method' => 'DELETE'] + self::OPERATION_FORMATS, + 'custom' => ['method' => 'HEAD', 'path' => '/foo/{id}', 'openapi_context' => ['description' => 'Custom description']] + self::OPERATION_FORMATS, + 'formats' => ['method' => 'PUT', 'path' => '/formatted/{id}', 'output_formats' => ['json' => ['application/json'], 'csv' => ['text/csv']], 'input_formats' => ['json' => ['application/json'], 'csv' => ['text/csv']]], + ], + [ + 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, + 'post' => ['method' => 'POST'] + self::OPERATION_FORMATS, + 'filtered' => ['method' => 'GET', 'filters' => ['f1', 'f2', 'f3', 'f4', 'f5'], 'path' => '/filtered'] + self::OPERATION_FORMATS, + 'paginated' => ['method' => 'GET', 'pagination_client_enabled' => true, 'pagination_client_items_per_page' => true, 'pagination_items_per_page' => 20, 'pagination_maximum_items_per_page' => 80, 'path' => '/paginated'] + self::OPERATION_FORMATS, + ], + ['pagination_client_items_per_page' => true] + ); + + $subresourceOperationFactoryProphecy = $this->prophesize(SubresourceOperationFactoryInterface::class); + $subresourceOperationFactoryProphecy->create(Argument::any())->willReturn([]); + + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate', 'enum'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT), 'This is an id.', true, false, null, null, null, true, null, null, null, null, null, null, null, ['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$'])); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is a name.', true, true, true, true, false, false, null, null, [], null, null, null, null)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is an initializable but not writable property.', true, false, true, true, false, false, null, null, [], null, true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class), 'This is a \DateTimeInterface object.', true, true, true, true, false, false, null, null, [])); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'enum', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is an enum.', true, true, true, true, false, false, null, null, ['openapi_context' => ['type' => 'string', 'enum' => ['one', 'two'], 'example' => 'one']])); + + $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + + $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); + $filters = [ + 'f1' => new DummyFilter(['name' => [ + 'property' => 'name', + 'type' => 'string', + 'required' => true, + 'strategy' => 'exact', + 'openapi' => ['example' => 'bar', 'deprecated' => true, 'allowEmptyValue' => true, 'allowReserved' => true], + ]]), + 'f2' => new DummyFilter(['ha' => [ + 'property' => 'foo', + 'type' => 'int', + 'required' => false, + 'strategy' => 'partial', + ]]), + 'f3' => new DummyFilter(['toto' => [ + 'property' => 'name', + 'type' => 'array', + 'is_collection' => true, + 'required' => true, + 'strategy' => 'exact', + ]]), + 'f4' => new DummyFilter(['order[name]' => [ + 'property' => 'name', + 'type' => 'string', + 'required' => false, + 'schema' => [ + 'type' => 'string', + 'enum' => ['asc', 'desc'], + ], + ]]), + ]; + + foreach ($filters as $filterId => $filter) { + $filterLocatorProphecy->has($filterId)->willReturn(true)->shouldBeCalled(); + $filterLocatorProphecy->get($filterId)->willReturn($filter)->shouldBeCalled(); + } + + $filterLocatorProphecy->has('f5')->willReturn(false)->shouldBeCalled(); + + $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); + + $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); + + $typeFactory = new TypeFactory(); + $schemaFactory = new SchemaFactory($typeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + $typeFactory->setSchemaFactory($schemaFactory); + + $factory = new OpenApiFactory( + $resourceNameCollectionFactoryProphecy->reveal(), + $resourceMetadataFactory, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $schemaFactory, + $typeFactory, + $operationPathResolver, + $filterLocatorProphecy->reveal(), + $subresourceOperationFactoryProphecy->reveal(), + [], + new Options('Test API', 'This is a test API.', '1.2.3', true, 'oauth2', 'authorizationCode', '/oauth/v2/token', '/oauth/v2/auth', '/oauth/v2/refresh', ['scope param'], [ + 'header' => [ + 'type' => 'header', + 'name' => 'Authorization', + ], + 'query' => [ + 'type' => 'query', + 'name' => 'key', + ], + ]), + new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination') + ); + + $dummySchema = new Schema('openapi'); + // $dummySchema = new Model\Schema(false, null, false, false, null, ['url' => 'http://schema.example.com/Dummy']); + $dummySchema->setDefinitions(new \ArrayObject([ + 'type' => 'object', + 'description' => 'This is a dummy.', + 'additionalProperties' => true, + 'properties' => [ + 'id' => new \ArrayObject([ + 'type' => 'integer', + 'description' => 'This is an id.', + 'readOnly' => true, + 'minLength' => 3, + 'maxLength' => 20, + 'pattern' => '^dummyPattern$', + ]), + 'name' => new \ArrayObject([ + 'type' => 'string', + 'description' => 'This is a name.', + ]), + 'description' => new \ArrayObject([ + 'type' => 'string', + 'description' => 'This is an initializable but not writable property.', + ]), + 'dummy_date' => new \ArrayObject([ + 'type' => 'string', + 'description' => 'This is a \DateTimeInterface object.', + 'format' => 'date-time', + 'nullable' => true, + ]), + 'enum' => new \ArrayObject([ + 'type' => 'string', + 'enum' => ['one', 'two'], + 'example' => 'one', + 'description' => 'This is an enum.', + ]), + ], + 'externalDocs' => ['url' => 'http://schema.example.com/Dummy'], + ])); + + $openApi = $factory(['base_url' => '/app_dev.php/']); + + $this->assertInstanceOf(OpenApi::class, $openApi); + $this->assertEquals($openApi->getInfo(), new Model\Info('Test API', '1.2.3', 'This is a test API.')); + $this->assertEquals($openApi->getServers(), [new Model\Server('/app_dev.php/')]); + + $components = $openApi->getComponents(); + $this->assertInstanceOf(Model\Components::class, $components); + + $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'])))), + 'header' => new Model\SecurityScheme('apiKey', 'Value for the Authorization header parameter.', 'Authorization', 'header', 'bearer'), + 'query' => new Model\SecurityScheme('apiKey', 'Value for the key query parameter.', 'key', 'query', 'bearer'), + ])); + + $paths = $openApi->getPaths(); + $dummiesPath = $paths->getPath('/dummies'); + $this->assertNotNull($dummiesPath); + foreach (['Put', 'Head', 'Trace', 'Delete', 'Options', 'Patch'] as $method) { + $this->assertNull($dummiesPath->{'get'.$method}()); + } + + $this->assertEquals($dummiesPath->getGet(), new Model\Operation( + 'getDummyCollection', + ['Dummy'], + [ + '200' => new Model\Response('Dummy collection', new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(new \ArrayObject([ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/Dummy'], + ]))), + ])), + ], + '', + 'Retrieves the collection of Dummy resources.', + null, + [ + new Model\Parameter('page', 'query', 'The collection page number', false, false, true, [ + 'type' => 'integer', + 'default' => 1, + ]), + new Model\Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + 'type' => 'integer', + 'default' => 30, + 'minimum' => 0, + ]), + new Model\Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + 'type' => 'boolean', + ]), + ] + )); + + $this->assertEquals($dummiesPath->getPost(), new Model\Operation( + 'postDummyCollection', + ['Dummy'], + [ + '201' => new Model\Response( + 'Dummy resource created', + new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy']))), + ]), + null, + 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'), + ], + '', + 'Creates a Dummy resource.', + null, + [], + new Model\RequestBody( + 'The new Dummy resource', + new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy']))), + ]), + true + ) + )); + + $dummyPath = $paths->getPath('/dummies/{id}'); + $this->assertNotNull($dummyPath); + foreach (['Post', 'Head', 'Trace', 'Options', 'Patch'] as $method) { + $this->assertNull($dummyPath->{'get'.$method}()); + } + + $this->assertEquals($dummyPath->getGet(), new Model\Operation( + 'getDummyItem', + ['Dummy'], + [ + '200' => new Model\Response( + 'Dummy resource', + new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Dummy']))), + ]) + ), + '404' => new Model\Response('Resource not found'), + ], + '', + 'Retrieves a Dummy resource.', + null, + [new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])] + )); + + $this->assertEquals($dummyPath->getPut(), new Model\Operation( + 'putDummyItem', + ['Dummy'], + [ + '200' => new Model\Response( + 'Dummy resource updated', + new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + ]), + null, + 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'), + '404' => new Model\Response('Resource not found'), + ], + '', + 'Replaces the Dummy resource.', + null, + [new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])], + new Model\RequestBody( + 'The updated Dummy resource', + new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + ]), + true + ) + )); + + $this->assertEquals($dummyPath->getDelete(), new Model\Operation( + 'deleteDummyItem', + ['Dummy'], + [ + '204' => new Model\Response('Dummy resource deleted'), + '404' => new Model\Response('Resource not found'), + ], + '', + 'Removes the Dummy resource.', + null, + [new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])] + )); + + $customPath = $paths->getPath('/foo/{id}'); + $this->assertEquals($customPath->getHead(), new Model\Operation( + 'customDummyItem', + ['Dummy'], + [ + '404' => new Model\Response('Resource not found'), + ], + '', + 'Custom description', + null, + [new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])] + )); + + $formattedPath = $paths->getPath('/formatted/{id}'); + $this->assertEquals($formattedPath->getPut(), new Model\Operation( + 'formatsDummyItem', + ['Dummy'], + [ + '200' => new Model\Response( + 'Dummy resource updated', + new \ArrayObject([ + 'application/json' => new Model\MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + 'text/csv' => new Model\MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + ]), + null, + 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'), + '404' => new Model\Response('Resource not found'), + ], + '', + 'Replaces the Dummy resource.', + null, + [new Model\Parameter('id', 'path', 'Resource identifier', true, false, false, ['type' => 'string'])], + new Model\RequestBody( + 'The updated Dummy resource', + new \ArrayObject([ + 'application/json' => new Model\MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + 'text/csv' => new Model\MediaType(new \ArrayObject(['$ref' => '#/components/schemas/Dummy'])), + ]), + true + ) + )); + + $filteredPath = $paths->getPath('/filtered'); + $this->assertEquals($filteredPath->getGet(), new Model\Operation( + 'filteredDummyCollection', + ['Dummy'], + [ + '200' => new Model\Response('Dummy collection', new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject([ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/Dummy'], + ])), + ])), + ], + '', + 'Retrieves the collection of Dummy resources.', + null, + [ + new Model\Parameter('page', 'query', 'The collection page number', false, false, true, [ + 'type' => 'integer', + 'default' => 1, + ]), + new Model\Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + 'type' => 'integer', + 'default' => 30, + 'minimum' => 0, + ]), + new Model\Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + 'type' => 'boolean', + ]), + new Model\Parameter('name', 'query', '', true, true, true, [ + 'type' => 'string', + ], 'form', false, true, 'bar'), + new Model\Parameter('ha', 'query', '', false, false, true, [ + 'type' => 'integer', + ]), + new Model\Parameter('toto', 'query', '', true, false, true, [ + 'type' => 'array', + 'items' => ['type' => 'string'], + ], 'deepObject', true), + new Model\Parameter('order[name]', 'query', '', false, false, true, [ + 'type' => 'string', + ]), + ] + )); + + $paginatedPath = $paths->getPath('/paginated'); + $this->assertEquals($paginatedPath->getGet(), new Model\Operation( + 'paginatedDummyCollection', + ['Dummy'], + [ + '200' => new Model\Response('Dummy collection', new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject([ + 'type' => 'array', + 'items' => ['$ref' => '#/components/schemas/Dummy'], + ])), + ])), + ], + '', + 'Retrieves the collection of Dummy resources.', + null, + [ + new Model\Parameter('page', 'query', 'The collection page number', false, false, true, [ + 'type' => 'integer', + 'default' => 1, + ]), + new Model\Parameter('itemsPerPage', 'query', 'The number of items per page', false, false, true, [ + 'type' => 'integer', + 'default' => 20, + 'minimum' => 0, + 'maximum' => 80, + ]), + new Model\Parameter('pagination', 'query', 'Enable or disable pagination', false, false, true, [ + 'type' => 'boolean', + ]), + ] + )); + } + + public function testOverrideDocumentation() + { + $defaultContext = ['base_url' => '/app_dev.php/']; + + $dummyMetadata = new ResourceMetadata( + 'Dummy', + 'This is a dummy.', + 'http://schema.example.com/Dummy', + [ + 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, + 'put' => ['method' => 'PUT'] + self::OPERATION_FORMATS, + 'delete' => ['method' => 'DELETE'] + self::OPERATION_FORMATS, + ], + [ + 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, + 'post' => ['method' => 'POST'] + self::OPERATION_FORMATS, + ], + [] + ); + + $subresourceOperationFactoryProphecy = $this->prophesize(SubresourceOperationFactoryInterface::class); + $subresourceOperationFactoryProphecy->create(Argument::any())->willReturn([]); + + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate'])); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT), 'This is an id.', true, false, null, null, null, true, null, null, null, null, null, null, null, ['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$'])); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is a name.', true, true, true, true, false, false, null, null, [], null, null, null, null)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is an initializable but not writable property.', true, false, true, true, false, false, null, null, [], null, true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class), 'This is a \DateTimeInterface object.', true, true, true, true, false, false, null, null, [])); + + $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); + $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); + $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); + + $typeFactory = new TypeFactory(); + $schemaFactory = new SchemaFactory($typeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + $typeFactory->setSchemaFactory($schemaFactory); + + $factory = new OpenApiFactory( + $resourceNameCollectionFactoryProphecy->reveal(), + $resourceMetadataFactory, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $schemaFactory, + $typeFactory, + $operationPathResolver, + $filterLocatorProphecy->reveal(), + $subresourceOperationFactoryProphecy->reveal(), + [], + new Options('Test API', 'This is a test API.', '1.2.3', true, 'oauth2', 'authorizationCode', '/oauth/v2/token', '/oauth/v2/auth', '/oauth/v2/refresh', ['scope param'], [ + 'header' => [ + 'type' => 'header', + 'name' => 'Authorization', + ], + 'query' => [ + 'type' => 'query', + 'name' => 'key', + ], + ]), + new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination') + ); + + $openApi = $factory(['base_url' => '/app_dev.php/']); + + $pathItem = $openApi->getPaths()->getPath('/dummies/{id}'); + $operation = $pathItem->getGet(); + + $openApi->getPaths()->addPath('/dummies/{id}', $pathItem->withGet( + $operation->withParameters(array_merge( + $operation->getParameters(), + [new Model\Parameter('fields', 'query', 'Fields to remove of the output')] + )) + )); + + $openApi = $openApi->withInfo((new Model\Info('New Title', 'v2', 'Description of my custom API'))->withExtensionProperty('info-key', 'Info value')); + $openApi = $openApi->withExtensionProperty('key', 'Custom x-key value'); + $openApi = $openApi->withExtensionProperty('x-value', 'Custom x-value value'); + + $this->assertEquals($openApi->getInfo()->getExtensionProperties(), ['x-info-key' => 'Info value']); + $this->assertEquals($openApi->getExtensionProperties(), ['x-key' => 'Custom x-key value', 'x-value' => 'Custom x-value value']); + } + + public function testSubresourceDocumentation() + { + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Question::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['answer'])); + $propertyNameCollectionFactoryProphecy->create(Answer::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['content'])); + + $questionMetadata = new ResourceMetadata( + 'Question', + 'This is a question.', + 'http://schema.example.com/Question', + ['get' => ['method' => 'GET', 'input_formats' => ['json' => ['application/json'], 'csv' => ['text/csv']], 'output_formats' => ['json' => ['application/json'], 'csv' => ['text/csv']]]] + ); + $answerMetadata = new ResourceMetadata( + 'Answer', + 'This is an answer.', + 'http://schema.example.com/Answer', + [], + ['get' => ['method' => 'GET'] + self::OPERATION_FORMATS], + [], + ['get' => ['method' => 'GET', 'input_formats' => ['xml' => ['text/xml']], 'output_formats' => ['xml' => ['text/xml']]]] + ); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Question::class)->shouldBeCalled()->willReturn($questionMetadata); + $resourceMetadataFactoryProphecy->create(Answer::class)->shouldBeCalled()->willReturn($answerMetadata); + + $subresourceMetadata = new SubresourceMetadata(Answer::class, false); + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Question::class, 'answer', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, false, Question::class, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, Answer::class)), 'This is a name.', true, true, true, true, false, false, null, null, [], $subresourceMetadata)); + + $propertyMetadataFactoryProphecy->create(Answer::class, 'content', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, false, Question::class, true, null, new Type(Type::BUILTIN_TYPE_OBJECT, false, Answer::class)), 'This is a name.', true, true, true, true, false, false, null, null, [])); + + $routeCollection = new RouteCollection(); + $routeCollection->add('api_answers_get_collection', new Route('/api/answers.{_format}')); + $routeCollection->add('api_questions_answer_get_subresource', new Route('/api/questions/{id}/answer.{_format}')); + $routeCollection->add('api_questions_get_item', new Route('/api/questions/{id}.{_format}')); + + $routerProphecy = $this->prophesize(RouterInterface::class); + $routerProphecy->getRouteCollection()->shouldBeCalled()->willReturn($routeCollection); + + $operationPathResolver = new RouterOperationPathResolver($routerProphecy->reveal(), new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator()))); + + $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); + $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); + + $subresourceOperationFactory = new SubresourceOperationFactory($resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new UnderscorePathSegmentNameGenerator()); + + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Question::class, Answer::class])); + + $typeFactory = new TypeFactory(); + $schemaFactory = new SchemaFactory($typeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + $typeFactory->setSchemaFactory($schemaFactory); + $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); + + $factory = new OpenApiFactory( + $resourceNameCollectionFactoryProphecy->reveal(), + $resourceMetadataFactory, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $schemaFactory, + $typeFactory, + $operationPathResolver, + $filterLocatorProphecy->reveal(), + $subresourceOperationFactory, + ['jsonld' => ['application/ld+json']], + new Options('Test API', 'This is a test API.', '1.2.3', true, 'oauth2', 'authorizationCode', '/oauth/v2/token', '/oauth/v2/auth', '/oauth/v2/refresh', ['scope param'], [ + 'header' => [ + 'type' => 'header', + 'name' => 'Authorization', + ], + 'query' => [ + 'type' => 'query', + 'name' => 'key', + ], + ]), + new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination') + ); + + $openApi = $factory(['base_url', '/app_dev.php/']); + + $paths = $openApi->getPaths(); + $pathItem = $paths->getPath('/questions/{id}/answer'); + + $this->assertEquals($pathItem->getGet(), new Model\Operation( + 'api_questions_answer_get_subresourceQuestionSubresource', + ['Answer', 'Question'], + [ + '200' => new Model\Response( + 'Question resource', + new \ArrayObject([ + 'application/ld+json' => new Model\MediaType(new \ArrayObject(new \ArrayObject(['$ref' => '#/components/schemas/Answer']))), + ]) + ), + ], + '', + 'Retrieves a Question resource.', + null, + [new Model\Parameter('id', 'path', 'Question identifier', true, false, false, ['type' => 'string'])] + )); + } +} diff --git a/tests/OpenApi/Serializer/OpenApiNormalizerTest.php b/tests/OpenApi/Serializer/OpenApiNormalizerTest.php new file mode 100644 index 00000000000..bed21b8eca2 --- /dev/null +++ b/tests/OpenApi/Serializer/OpenApiNormalizerTest.php @@ -0,0 +1,164 @@ + + * + * For 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\OpenApi\Serializer; + +use ApiPlatform\Core\DataProvider\PaginationOptions; +use ApiPlatform\Core\JsonSchema\SchemaFactory; +use ApiPlatform\Core\JsonSchema\TypeFactory; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Property\Factory\PropertyNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Property\PropertyMetadata; +use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\Factory\ResourceNameCollectionFactoryInterface; +use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Metadata\Resource\ResourceNameCollection; +use ApiPlatform\Core\OpenApi\Factory\OpenApiFactory; +use ApiPlatform\Core\OpenApi\Model; +use ApiPlatform\Core\OpenApi\Options; +use ApiPlatform\Core\OpenApi\Serializer\OpenApiNormalizer; +use ApiPlatform\Core\Operation\Factory\SubresourceOperationFactoryInterface; +use ApiPlatform\Core\Operation\UnderscorePathSegmentNameGenerator; +use ApiPlatform\Core\PathResolver\CustomOperationPathResolver; +use ApiPlatform\Core\PathResolver\OperationPathResolver; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use PHPUnit\Framework\TestCase; +use Prophecy\Argument; +use Psr\Container\ContainerInterface; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\NameConverter\CamelCaseToSnakeCaseNameConverter; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; + +class OpenApiNormalizerTest extends TestCase +{ + private const OPERATION_FORMATS = [ + 'input_formats' => ['jsonld' => ['application/ld+json']], + 'output_formats' => ['jsonld' => ['application/ld+json']], + ]; + + public function testNormalize() + { + $resourceNameCollectionFactoryProphecy = $this->prophesize(ResourceNameCollectionFactoryInterface::class); + $resourceNameCollectionFactoryProphecy->create()->shouldBeCalled()->willReturn(new ResourceNameCollection([Dummy::class])); + $defaultContext = ['base_url' => '/app_dev.php/']; + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['id', 'name', 'description', 'dummyDate'])); + + $dummyMetadata = new ResourceMetadata( + 'Dummy', + 'This is a dummy.', + 'http://schema.example.com/Dummy', + [ + 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, + 'put' => ['method' => 'PUT'] + self::OPERATION_FORMATS, + 'delete' => ['method' => 'DELETE'] + self::OPERATION_FORMATS, + ], + [ + 'get' => ['method' => 'GET'] + self::OPERATION_FORMATS, + 'post' => ['method' => 'POST', 'openapi_context' => ['security' => [], 'servers' => ['url' => '/test']]] + self::OPERATION_FORMATS, + ], + [] + ); + + $subresourceOperationFactoryProphecy = $this->prophesize(SubresourceOperationFactoryInterface::class); + $subresourceOperationFactoryProphecy->create(Argument::any(), Argument::any(), Argument::any())->willReturn([]); + + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(Dummy::class)->shouldBeCalled()->willReturn($dummyMetadata); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT), 'This is an id.', true, false, null, null, null, true, null, null, null, null, null, null, null, ['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$'])); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is a name.', true, true, true, true, false, false, null, null, [], null, null, null, null)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is an initializable but not writable property.', true, false, true, true, false, false, null, null, [], null, true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::any())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class), 'This is a \DateTimeInterface object.', true, true, true, true, false, false, null, null, [])); + + $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + $filterLocatorProphecy = $this->prophesize(ContainerInterface::class); + $resourceMetadataFactory = $resourceMetadataFactoryProphecy->reveal(); + $propertyNameCollectionFactory = $propertyNameCollectionFactoryProphecy->reveal(); + $propertyMetadataFactory = $propertyMetadataFactoryProphecy->reveal(); + + $typeFactory = new TypeFactory(); + $schemaFactory = new SchemaFactory($typeFactory, $resourceMetadataFactory, $propertyNameCollectionFactory, $propertyMetadataFactory, new CamelCaseToSnakeCaseNameConverter()); + $typeFactory->setSchemaFactory($schemaFactory); + + $factory = new OpenApiFactory( + $resourceNameCollectionFactoryProphecy->reveal(), + $resourceMetadataFactory, + $propertyNameCollectionFactory, + $propertyMetadataFactory, + $schemaFactory, + $typeFactory, + $operationPathResolver, + $filterLocatorProphecy->reveal(), + $subresourceOperationFactoryProphecy->reveal(), + [], + new Options('Test API', 'This is a test API.', '1.2.3', true, 'oauth2', 'authorizationCode', '/oauth/v2/token', '/oauth/v2/auth', '/oauth/v2/refresh', ['scope param'], [ + 'header' => [ + 'type' => 'header', + 'name' => 'Authorization', + ], + 'query' => [ + 'type' => 'query', + 'name' => 'key', + ], + ]), + new PaginationOptions(true, 'page', true, 'itemsPerPage', true, 'pagination') + ); + + $openApi = $factory(['base_url' => '/app_dev.php/']); + + $pathItem = $openApi->getPaths()->getPath('/dummies/{id}'); + $operation = $pathItem->getGet(); + + $openApi->getPaths()->addPath('/dummies/{id}', $pathItem->withGet( + $operation->withParameters(array_merge( + $operation->getParameters(), + [new Model\Parameter('fields', 'query', 'Fields to remove of the output')] + )) + )); + + $openApi = $openApi->withInfo((new Model\Info('New Title', 'v2', 'Description of my custom API'))->withExtensionProperty('info-key', 'Info value')); + $openApi = $openApi->withExtensionProperty('key', 'Custom x-key value'); + $openApi = $openApi->withExtensionProperty('x-value', 'Custom x-value value'); + + $encoders = [new JsonEncoder()]; + $normalizers = [new ObjectNormalizer()]; + + $serializer = new Serializer($normalizers, $encoders); + $normalizers[0]->setSerializer($serializer); + + $normalizer = new OpenApiNormalizer($normalizers[0]); + + $openApiAsArray = $normalizer->normalize($openApi); + + // Just testing normalization specifics + $this->assertEquals($openApiAsArray['x-key'], 'Custom x-key value'); + $this->assertEquals($openApiAsArray['x-value'], 'Custom x-value value'); + $this->assertEquals($openApiAsArray['info']['x-info-key'], 'Info value'); + $this->assertArrayNotHasKey('extensionProperties', $openApiAsArray); + // this key is null, should not be in the output + $this->assertArrayNotHasKey('termsOfService', $openApiAsArray['info']); + $this->assertArrayNotHasKey('paths', $openApiAsArray['paths']); + $this->assertArrayHasKey('/dummies/{id}', $openApiAsArray['paths']); + $this->assertArrayNotHasKey('servers', $openApiAsArray['paths']['/dummies/{id}']['get']); + $this->assertArrayNotHasKey('security', $openApiAsArray['paths']['/dummies/{id}']['get']); + + // Security can be disabled per-operation using an empty array + $this->assertEquals([], $openApiAsArray['paths']['/dummies']['post']['security']); + $this->assertEquals(['url' => '/test'], $openApiAsArray['paths']['/dummies']['post']['servers']); + } +} diff --git a/tests/Problem/Serializer/ConstraintViolationNormalizerTest.php b/tests/Problem/Serializer/ConstraintViolationNormalizerTest.php index 4444f0e61f0..ea4cf3baf99 100644 --- a/tests/Problem/Serializer/ConstraintViolationNormalizerTest.php +++ b/tests/Problem/Serializer/ConstraintViolationNormalizerTest.php @@ -53,7 +53,7 @@ public function testNormalize() $constraint = new NotNull(); $constraint->payload = ['severity' => 'warning', 'anotherField2' => 'aValue']; $list = new ConstraintViolationList([ - new ConstraintViolation('a', 'b', [], 'c', 'd', 'e', null, null, $constraint), + new ConstraintViolation('a', 'b', [], 'c', 'd', 'e', null, 'f24bdbad0becef97a6887238aa58221c', $constraint), new ConstraintViolation('1', '2', [], '3', '4', '5'), ]); @@ -65,6 +65,7 @@ public function testNormalize() [ 'propertyPath' => '_d', 'message' => 'a', + 'code' => 'f24bdbad0becef97a6887238aa58221c', 'payload' => [ 'severity' => 'warning', ], @@ -72,6 +73,7 @@ public function testNormalize() [ 'propertyPath' => '_4', 'message' => '1', + 'code' => null, ], ], ]; diff --git a/tests/ProphecyTrait.php b/tests/ProphecyTrait.php index 439608d5a4b..e9058776377 100644 --- a/tests/ProphecyTrait.php +++ b/tests/ProphecyTrait.php @@ -110,9 +110,7 @@ private function countProphecyAssertions(): void $this->prophecyAssertionsCounted = true; foreach ($this->prophet->getProphecies() as $objectProphecy) { - /** - * @var MethodProphecy[] $methodProphecies - */ + /** @var MethodProphecy[] $methodProphecies */ foreach ($objectProphecy->getMethodProphecies() as $methodProphecies) { foreach ($methodProphecies as $methodProphecy) { \assert($methodProphecy instanceof MethodProphecy); diff --git a/tests/Serializer/AbstractItemNormalizerTest.php b/tests/Serializer/AbstractItemNormalizerTest.php index 3f88b921c59..e4a6c491474 100644 --- a/tests/Serializer/AbstractItemNormalizerTest.php +++ b/tests/Serializer/AbstractItemNormalizerTest.php @@ -16,6 +16,7 @@ use ApiPlatform\Core\Api\IriConverterInterface; use ApiPlatform\Core\Api\ResourceClassResolverInterface; use ApiPlatform\Core\DataProvider\ItemDataProviderInterface; +use ApiPlatform\Core\DataTransformer\DataTransformerInitializerInterface; use ApiPlatform\Core\DataTransformer\DataTransformerInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Exception\ItemNotFoundException; @@ -25,6 +26,7 @@ use ApiPlatform\Core\Metadata\Property\PropertyNameCollection; use ApiPlatform\Core\Metadata\Resource\Factory\ResourceMetadataFactoryInterface; use ApiPlatform\Core\Metadata\Resource\ResourceMetadata; +use ApiPlatform\Core\Security\ResourceAccessCheckerInterface; use ApiPlatform\Core\Serializer\AbstractItemNormalizer; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\InputDto; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; @@ -33,6 +35,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyTableInheritance; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyTableInheritanceChild; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\SecuredDummy; use ApiPlatform\Core\Tests\ProphecyTrait; use Doctrine\Common\Collections\ArrayCollection; use PHPUnit\Framework\TestCase; @@ -41,7 +44,11 @@ use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\PropertyInfo\Type; use Symfony\Component\Serializer\Exception\UnexpectedValueException; +use Symfony\Component\Serializer\Mapping\AttributeMetadata; +use Symfony\Component\Serializer\Mapping\ClassMetadataInterface; +use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactoryInterface; use Symfony\Component\Serializer\NameConverter\AdvancedNameConverterInterface; +use Symfony\Component\Serializer\Normalizer\AbstractObjectNormalizer; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\Component\Serializer\SerializerInterface; @@ -113,7 +120,7 @@ public function testSupportNormalizationAndSupportDenormalization() [], [], null, - false, + null, ]); $this->assertTrue($normalizer->supportsNormalization($dummy)); @@ -180,7 +187,7 @@ public function testNormalize() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -199,6 +206,65 @@ public function testNormalize() ])); } + public function testNormalizeWithSecuredProperty() + { + $dummy = new SecuredDummy(); + $dummy->setTitle('myPublicTitle'); + $dummy->setAdminOnlyProperty('secret'); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(SecuredDummy::class, [])->willReturn(new PropertyNameCollection(['title', 'adminOnlyProperty'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'title', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', true)); + $propertyMetadataFactoryProphecy->create(SecuredDummy::class, 'adminOnlyProperty', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', true, null, null, null, null, null, null, null, ['security' => 'is_granted(\'ROLE_ADMIN\')'])); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($dummy)->willReturn('/secured_dummies/1'); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->getValue($dummy, 'title')->willReturn('myPublicTitle'); + $propertyAccessorProphecy->getValue($dummy, 'adminOnlyProperty')->willReturn('secret'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(SecuredDummy::class); + + $resourceAccessChecker = $this->prophesize(ResourceAccessCheckerInterface::class); + $resourceAccessChecker->isGranted('adminOnlyProperty', 'is_granted(\'ROLE_ADMIN\')')->willReturn(false); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('myPublicTitle', null, Argument::type('array'))->willReturn('myPublicTitle'); + + $normalizer = $this->getMockForAbstractClass(AbstractItemNormalizer::class, [ + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + null, + null, + null, + false, + [], + [], + null, + $resourceAccessChecker->reveal(), + ]); + $normalizer->setSerializer($serializerProphecy->reveal()); + + if (!interface_exists(AdvancedNameConverterInterface::class) && method_exists($normalizer, 'setIgnoredAttributes')) { + $normalizer->setIgnoredAttributes(['alias']); + } + + $expected = [ + 'title' => 'myPublicTitle', + ]; + $this->assertEquals($expected, $normalizer->normalize($dummy, null, [ + 'resources' => [], + ])); + } + public function testNormalizeReadableLinks() { $relatedDummy = new RelatedDummy(); @@ -256,7 +322,7 @@ public function testNormalizeReadableLinks() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -318,7 +384,7 @@ public function testDenormalize() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -343,13 +409,19 @@ public function testCanDenormalizeInputClassWithDifferentFieldsThanResourceClass 'output' => ['class' => DummyForAdditionalFields::class], ]; $augmentedContext = $context + ['api_denormalize' => true]; + + $preHydratedDummy = new DummyForAdditionalFieldsInput('Name Dummy'); $cleanedContext = array_diff_key($augmentedContext, [ 'input' => null, 'resource_class' => null, ]); + $cleanedContextWithObjectToPopulate = array_merge($cleanedContext, [ + AbstractObjectNormalizer::OBJECT_TO_POPULATE => $preHydratedDummy, + AbstractObjectNormalizer::DEEP_OBJECT_TO_POPULATE => true, + ]); $dummyInputDto = new DummyForAdditionalFieldsInput('Dummy Name'); - $dummy = new DummyForAdditionalFields('Dummy Name', 'dummy-name'); + $dummy = new DummyForAdditionalFields('Dummy Name', 'name-dummy'); $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); @@ -360,15 +432,17 @@ public function testCanDenormalizeInputClassWithDifferentFieldsThanResourceClass $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); $resourceClassResolverProphecy->getResourceClass(null, DummyForAdditionalFields::class)->willReturn(DummyForAdditionalFields::class); - $inputDataTransformerProphecy = $this->prophesize(DataTransformerInterface::class); + $inputDataTransformerProphecy = $this->prophesize(DataTransformerInitializerInterface::class); + $inputDataTransformerProphecy->willImplement(DataTransformerInitializerInterface::class); + $inputDataTransformerProphecy->initialize(DummyForAdditionalFieldsInput::class, $cleanedContext)->willReturn($preHydratedDummy); $inputDataTransformerProphecy->supportsTransformation($data, DummyForAdditionalFields::class, $augmentedContext)->willReturn(true); $inputDataTransformerProphecy->transform($dummyInputDto, DummyForAdditionalFields::class, $augmentedContext)->willReturn($dummy); $serializerProphecy = $this->prophesize(SerializerInterface::class); $serializerProphecy->willImplement(DenormalizerInterface::class); - $serializerProphecy->denormalize($data, DummyForAdditionalFieldsInput::class, 'json', $cleanedContext)->willReturn($dummyInputDto); + $serializerProphecy->denormalize($data, DummyForAdditionalFieldsInput::class, 'json', $cleanedContextWithObjectToPopulate)->willReturn($dummyInputDto); - $normalizer = new class($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), null, null, null, null, false, [], [$inputDataTransformerProphecy->reveal()], null) extends AbstractItemNormalizer { + $normalizer = new class($propertyNameCollectionFactoryProphecy->reveal(), $propertyMetadataFactoryProphecy->reveal(), $iriConverterProphecy->reveal(), $resourceClassResolverProphecy->reveal(), null, null, null, null, false, [], [$inputDataTransformerProphecy->reveal()], null, null) extends AbstractItemNormalizer { }; $normalizer->setSerializer($serializerProphecy->reveal()); @@ -427,7 +501,7 @@ public function testDenormalizeWritableLinks() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -489,7 +563,7 @@ public function testBadRelationType() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -547,7 +621,7 @@ public function testInnerDocumentNotAllowed() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -592,7 +666,7 @@ public function testBadType() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -634,7 +708,7 @@ public function testTypeChecksCanBeDisabled() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -680,7 +754,7 @@ public function testJsonAllowIntAsFloat() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -755,7 +829,7 @@ public function testDenormalizeBadKeyType() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -797,7 +871,7 @@ public function testNullable() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -808,7 +882,10 @@ public function testNullable() $propertyAccessorProphecy->setValue($actual, 'name', null)->shouldHaveBeenCalled(); } - public function testChildInheritedProperty() + /** + * @group legacy + */ + public function testChildInheritedProperty(): void { $dummy = new DummyTableInheritance(); $dummy->setName('foo'); @@ -854,7 +931,7 @@ public function testChildInheritedProperty() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -906,7 +983,7 @@ public function testDenormalizeRelationWithPlainId() [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -969,7 +1046,7 @@ public function testDenormalizeRelationWithPlainIdNotFound() [], [], null, - true, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -1027,7 +1104,7 @@ public function testDoNotDenormalizeRelationWithPlainIdWhenPlainIdentifiersAreNo [], [], null, - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -1114,7 +1191,7 @@ public function testNormalizationWithDataTransformer() [], [$dataTransformerProphecy->reveal(), $secondDataTransformerProphecy->reveal()], $resourceMetadataFactoryProphecy->reveal(), - false, + null, ]); $normalizer->setSerializer($serializerProphecy->reveal()); @@ -1128,4 +1205,68 @@ public function testNormalizationWithDataTransformer() $propertyAccessorProphecy->setValue($actualDummy, 'name', 'Dummy')->shouldHaveBeenCalled(); } + + public function testNormalizationWithIgnoreMetadata() + { + if (!method_exists(AttributeMetadata::class, 'setIgnore')) { + $this->markTestSkipped(); + } + + $dummy = new Dummy(); + + $dummyAttributeMetadata = new AttributeMetadata('dummy'); + $dummyAttributeMetadata->setIgnore(true); + + $classMetadataProphecy = $this->prophesize(ClassMetadataInterface::class); + $classMetadataProphecy->getAttributesMetadata()->willReturn(['dummy' => $dummyAttributeMetadata]); + + $classMetadataFactoryProphecy = $this->prophesize(ClassMetadataFactoryInterface::class); + $classMetadataFactoryProphecy->getMetadataFor(Dummy::class)->willReturn($classMetadataProphecy->reveal()); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(Dummy::class, [])->willReturn(new PropertyNameCollection(['name', 'dummy'])); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummy', [])->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), '', true)); + + $iriConverterProphecy = $this->prophesize(IriConverterInterface::class); + $iriConverterProphecy->getIriFromItem($dummy)->willReturn('/dummies/1'); + + $propertyAccessorProphecy = $this->prophesize(PropertyAccessorInterface::class); + $propertyAccessorProphecy->getValue($dummy, 'name')->willReturn('foo'); + $propertyAccessorProphecy->getValue($dummy, 'dummy')->willReturn('bar'); + + $resourceClassResolverProphecy = $this->prophesize(ResourceClassResolverInterface::class); + $resourceClassResolverProphecy->getResourceClass($dummy, null)->willReturn(Dummy::class); + + $serializerProphecy = $this->prophesize(SerializerInterface::class); + $serializerProphecy->willImplement(NormalizerInterface::class); + $serializerProphecy->normalize('foo', null, Argument::type('array'))->willReturn('foo'); + $serializerProphecy->normalize('bar', null, Argument::type('array'))->willReturn('bar'); + + $normalizer = $this->getMockForAbstractClass(AbstractItemNormalizer::class, [ + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + $iriConverterProphecy->reveal(), + $resourceClassResolverProphecy->reveal(), + $propertyAccessorProphecy->reveal(), + null, + $classMetadataFactoryProphecy->reveal(), + null, + false, + [], + [], + null, + null, + ]); + $normalizer->setSerializer($serializerProphecy->reveal()); + + $expected = [ + 'name' => 'foo', + ]; + $this->assertEquals($expected, $normalizer->normalize($dummy, null, [ + 'resources' => [], + ])); + } } diff --git a/tests/Serializer/SerializerContextBuilderTest.php b/tests/Serializer/SerializerContextBuilderTest.php index 9c963e77233..d73238214f2 100644 --- a/tests/Serializer/SerializerContextBuilderTest.php +++ b/tests/Serializer/SerializerContextBuilderTest.php @@ -48,8 +48,17 @@ protected function setUp(): void ] ); + $resourceMetadataWithPatch = new ResourceMetadata( + null, + null, + null, + ['patch' => ['method' => 'PATCH', 'input_formats' => ['json' => ['application/merge-patch+json']]]], + [] + ); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); $resourceMetadataFactoryProphecy->create('Foo')->willReturn($resourceMetadata); + $resourceMetadataFactoryProphecy->create('FooWithPatch')->willReturn($resourceMetadataWithPatch); $this->builder = new SerializerContextBuilder($resourceMetadataFactoryProphecy->reveal()); } @@ -58,32 +67,37 @@ public function testCreateFromRequest() { $request = Request::create('/foos/1'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['foo' => 'bar', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null]; + $expected = ['foo' => 'bar', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, true)); $request = Request::create('/foos'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'pot', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['foo' => 'bar', 'collection_operation_name' => 'pot', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null]; + $expected = ['foo' => 'bar', 'collection_operation_name' => 'pot', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, true)); $request = Request::create('/foos/1'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null]; + $expected = ['bar' => 'baz', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/foos', 'POST'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'post', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'collection_operation_name' => 'post', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => false, 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null]; + $expected = ['bar' => 'baz', 'collection_operation_name' => 'post', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => false, 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/foos', 'PUT'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_collection_operation_name' => 'put', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'collection_operation_name' => 'put', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => true, 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null]; + $expected = ['bar' => 'baz', 'collection_operation_name' => 'put', 'resource_class' => 'Foo', 'request_uri' => '/foos', 'api_allow_update' => true, 'operation_type' => 'collection', 'uri' => 'http://localhost/foos', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); $request = Request::create('/bars/1/foos'); $request->attributes->replace(['_api_resource_class' => 'Foo', '_api_subresource_operation_name' => 'get', '_api_format' => 'xml', '_api_mime_type' => 'text/xml']); - $expected = ['bar' => 'baz', 'subresource_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'operation_type' => 'subresource', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null]; + $expected = ['bar' => 'baz', 'subresource_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/bars/1/foos', 'operation_type' => 'subresource', 'api_allow_update' => false, 'uri' => 'http://localhost/bars/1/foos', 'output' => null, 'input' => null, 'iri_only' => false]; + $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); + + $request = Request::create('/foowithpatch/1', 'PATCH'); + $request->attributes->replace(['_api_resource_class' => 'FooWithPatch', '_api_item_operation_name' => 'patch', '_api_format' => 'json', '_api_mime_type' => 'application/json']); + $expected = ['item_operation_name' => 'patch', 'resource_class' => 'FooWithPatch', 'request_uri' => '/foowithpatch/1', 'operation_type' => 'item', 'api_allow_update' => true, 'uri' => 'http://localhost/foowithpatch/1', 'output' => null, 'input' => null, 'deep_object_to_populate' => true, 'skip_null_values' => true, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest($request, false)); } @@ -96,7 +110,7 @@ public function testThrowExceptionOnInvalidRequest() public function testReuseExistingAttributes() { - $expected = ['bar' => 'baz', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null]; + $expected = ['bar' => 'baz', 'item_operation_name' => 'get', 'resource_class' => 'Foo', 'request_uri' => '/foos/1', 'api_allow_update' => false, 'operation_type' => 'item', 'uri' => 'http://localhost/foos/1', 'output' => null, 'input' => null, 'iri_only' => false]; $this->assertEquals($expected, $this->builder->createFromRequest(Request::create('/foos/1'), false, ['resource_class' => 'Foo', 'item_operation_name' => 'get'])); } } diff --git a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php index 767860af147..3bd73151eb7 100644 --- a/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php +++ b/tests/Swagger/Serializer/DocumentationNormalizerV2Test.php @@ -43,6 +43,7 @@ use ApiPlatform\Core\Tests\Fixtures\TestBundle\Dto\OutputDto; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Answer; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Dummy; +use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\DummyPropertyWithDefaultValue; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\Question; use ApiPlatform\Core\Tests\Fixtures\TestBundle\Entity\RelatedDummy; use ApiPlatform\Core\Tests\ProphecyTrait; @@ -2800,7 +2801,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 'schema' => [ 'type' => 'array', 'items' => [ - '$ref' => '#/definitions/Dummy:300dcd476cef011532fb0ca7683395d7', + '$ref' => '#/definitions/Dummy.OutputDto', ], ], ], @@ -2825,7 +2826,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 201 => [ 'description' => 'Dummy resource created', 'schema' => [ - '$ref' => '#/definitions/Dummy:300dcd476cef011532fb0ca7683395d7', + '$ref' => '#/definitions/Dummy.OutputDto', ], ], 400 => [ @@ -2841,7 +2842,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 'in' => 'body', 'description' => 'The new Dummy resource', 'schema' => [ - '$ref' => '#/definitions/Dummy:b4f76c1a44965bd401aa23bb37618acc', + '$ref' => '#/definitions/Dummy.InputDto', ], ], ], @@ -2865,7 +2866,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 200 => [ 'description' => 'Dummy resource response', 'schema' => [ - '$ref' => '#/definitions/Dummy:300dcd476cef011532fb0ca7683395d7', + '$ref' => '#/definitions/Dummy.OutputDto', ], ], 404 => [ @@ -2891,7 +2892,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 'in' => 'body', 'description' => 'The updated Dummy resource', 'schema' => [ - '$ref' => '#/definitions/Dummy:b4f76c1a44965bd401aa23bb37618acc', + '$ref' => '#/definitions/Dummy.InputDto', ], ], ], @@ -2899,7 +2900,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void 200 => [ 'description' => 'Dummy resource updated', 'schema' => [ - '$ref' => '#/definitions/Dummy:300dcd476cef011532fb0ca7683395d7', + '$ref' => '#/definitions/Dummy.OutputDto', ], ], 400 => [ @@ -2913,7 +2914,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void ], ]), 'definitions' => new \ArrayObject([ - 'Dummy:300dcd476cef011532fb0ca7683395d7' => new \ArrayObject([ + 'Dummy.OutputDto' => new \ArrayObject([ 'type' => 'object', 'additionalProperties' => true, 'description' => 'This is a dummy.', @@ -2932,7 +2933,7 @@ private function doTestNormalizeWithInputAndOutputClass(): void ]), ], ]), - 'Dummy:b4f76c1a44965bd401aa23bb37618acc' => new \ArrayObject([ + 'Dummy.InputDto' => new \ArrayObject([ 'type' => 'object', 'additionalProperties' => true, 'description' => 'This is a dummy.', @@ -2956,4 +2957,65 @@ private function doTestNormalizeWithInputAndOutputClass(): void $this->assertEquals($expected, $normalizer->normalize($documentation, DocumentationNormalizer::FORMAT, ['base_url' => '/app_dev.php/'])); } + + /** + * @dataProvider propertyWithDefaultProvider + */ + public function testNormalizeWithDefaultProperty($expectedDefault, $expectedExample, PropertyMetadata $propertyMetadata) + { + $documentation = new Documentation(new ResourceNameCollection([DummyPropertyWithDefaultValue::class])); + + $propertyNameCollectionFactoryProphecy = $this->prophesize(PropertyNameCollectionFactoryInterface::class); + $propertyNameCollectionFactoryProphecy->create(DummyPropertyWithDefaultValue::class, Argument::any())->shouldBeCalled()->willReturn(new PropertyNameCollection(['foo'])); + + $dummyMetadata = new ResourceMetadata('DummyPropertyWithDefaultValue', null, null, ['get' => ['method' => 'GET']]); + $resourceMetadataFactoryProphecy = $this->prophesize(ResourceMetadataFactoryInterface::class); + $resourceMetadataFactoryProphecy->create(DummyPropertyWithDefaultValue::class)->shouldBeCalled()->willReturn($dummyMetadata); + + $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); + $propertyMetadataFactoryProphecy->create(DummyPropertyWithDefaultValue::class, 'foo', Argument::any())->shouldBeCalled()->willReturn($propertyMetadata); + + $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); + + $normalizer = new DocumentationNormalizer( + $resourceMetadataFactoryProphecy->reveal(), + $propertyNameCollectionFactoryProphecy->reveal(), + $propertyMetadataFactoryProphecy->reveal(), + null, + null, + $operationPathResolver + ); + + $result = $normalizer->normalize($documentation, DocumentationNormalizer::FORMAT); + + $this->assertIsArray($result); + $this->assertEquals($expectedDefault, $result['definitions']['DummyPropertyWithDefaultValue']['properties']['foo']['default']); + $this->assertEquals($expectedExample, $result['definitions']['DummyPropertyWithDefaultValue']['properties']['foo']['example']); + } + + public function propertyWithDefaultProvider() + { + yield 'default should be use for the example if it is not defined' => [ + 'default name', + 'default name', + $this->createStringPropertyMetada('default name'), + ]; + + yield 'should use default and example if they are defined' => [ + 'default name', + 'example name', + $this->createStringPropertyMetada('default name', 'example name'), + ]; + + yield 'should use default and example from swagger context if they are defined' => [ + 'swagger default', + 'swagger example', + $this->createStringPropertyMetada('default name', 'example name', ['swagger_context' => ['default' => 'swagger default', 'example' => 'swagger example']]), + ]; + } + + protected function createStringPropertyMetada($default = null, $example = null, $attributes = []): PropertyMetadata + { + return new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), null, true, true, true, true, false, false, null, null, $attributes, null, null, $default, $example); + } } diff --git a/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php b/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php index f84946e68fa..71aaebe162c 100644 --- a/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php +++ b/tests/Swagger/Serializer/DocumentationNormalizerV3Test.php @@ -113,10 +113,10 @@ private function doTestNormalize(OperationMethodResolverInterface $operationMeth $resourceMetadataFactoryProphecy->create(Dummy::class)->willReturn($dummyMetadata); $propertyMetadataFactoryProphecy = $this->prophesize(PropertyMetadataFactoryInterface::class); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::cetera())->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT), 'This is an id.', true, false, null, null, null, true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::cetera())->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, 'description', Argument::cetera())->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is an initializable but not writable property.', true, false, true, true, false, false, null, null, [], null, true)); - $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::cetera())->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class), 'This is a \DateTimeInterface object.', true, true, true, true, false, false, null, null, [])); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'id', Argument::cetera())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_INT), 'This is an id.', true, false, null, null, null, true, null, null, null, null, null, null, null, ['minLength' => 3, 'maxLength' => 20, 'pattern' => '^dummyPattern$'])); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'name', Argument::cetera())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is a name.', true, true, true, true, false, false, null, null, [], null, null, null, null)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'description', Argument::cetera())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_STRING), 'This is an initializable but not writable property.', true, false, true, true, false, false, null, null, [], null, true)); + $propertyMetadataFactoryProphecy->create(Dummy::class, 'dummyDate', Argument::cetera())->shouldBeCalled()->willReturn(new PropertyMetadata(new Type(Type::BUILTIN_TYPE_OBJECT, true, \DateTime::class), 'This is a \DateTimeInterface object.', true, true, true, true, false, false, null, null, [])); $operationPathResolver = new CustomOperationPathResolver(new OperationPathResolver(new UnderscorePathSegmentNameGenerator())); @@ -378,6 +378,9 @@ private function doTestNormalize(OperationMethodResolverInterface $operationMeth 'type' => 'integer', 'description' => 'This is an id.', 'readOnly' => true, + 'minLength' => 3, + 'maxLength' => 20, + 'pattern' => '^dummyPattern$', ]), 'name' => new \ArrayObject([ 'type' => 'string', diff --git a/tests/Util/AnnotationFilterExtractorTraitTest.php b/tests/Util/AnnotationFilterExtractorTraitTest.php index 4a329ac0f42..c3dff404b1d 100644 --- a/tests/Util/AnnotationFilterExtractorTraitTest.php +++ b/tests/Util/AnnotationFilterExtractorTraitTest.php @@ -40,7 +40,7 @@ public function testReadAnnotations() $this->assertEquals($this->extractor->getFilters($reflectionClass), [ 'annotated_api_platform_core_tests_fixtures_test_bundle_entity_dummy_car_api_platform_core_bridge_doctrine_orm_filter_date_filter' => [ - ['properties' => ['id' => 'exclude_null', 'colors' => 'exclude_null', 'name' => 'exclude_null', 'canSell' => 'exclude_null', 'availableAt' => 'exclude_null', 'secondColors' => 'exclude_null', 'thirdColors' => 'exclude_null', 'uuid' => 'exclude_null']], + ['properties' => ['id' => 'exclude_null', 'colors' => 'exclude_null', 'name' => 'exclude_null', 'canSell' => 'exclude_null', 'availableAt' => 'exclude_null', 'brand' => 'exclude_null', 'secondColors' => 'exclude_null', 'thirdColors' => 'exclude_null', 'uuid' => 'exclude_null']], DateFilter::class, ], 'annotated_api_platform_core_tests_fixtures_test_bundle_entity_dummy_car_api_platform_core_bridge_doctrine_orm_filter_boolean_filter' => [ diff --git a/tests/Util/ArrayTraitTest.php b/tests/Util/ArrayTraitTest.php new file mode 100644 index 00000000000..98671f0603f --- /dev/null +++ b/tests/Util/ArrayTraitTest.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Util; + +use ApiPlatform\Core\Util\ArrayTrait; +use PHPUnit\Framework\TestCase; + +class ArrayTraitTest extends TestCase +{ + private $arrayTraitClass; + + protected function setUp(): void + { + $this->arrayTraitClass = (new class() { + use ArrayTrait; + }); + } + + public function testIsSequentialArrayWithEmptyArray(): void + { + self::assertFalse($this->arrayTraitClass->isSequentialArray([])); + } + + public function testIsSequentialArrayWithNonNumericIndex(): void + { + self::assertFalse($this->arrayTraitClass->isSequentialArray(['foo' => 'bar'])); + } + + public function testIsSequentialArrayWithNumericNonSequentialIndex(): void + { + self::assertFalse($this->arrayTraitClass->isSequentialArray([1 => 'bar', 3 => 'foo'])); + } + + public function testIsSequentialArrayWithNumericSequentialIndex(): void + { + self::assertTrue($this->arrayTraitClass->isSequentialArray([0 => 'bar', 1 => 'foo'])); + } + + public function testArrayContainsOnlyWithDifferentTypes(): void + { + self::assertFalse($this->arrayTraitClass->arrayContainsOnly([1, 'foo'], 'string')); + } + + public function testArrayContainsOnlyWithSameType(): void + { + self::assertTrue($this->arrayTraitClass->arrayContainsOnly(['foo', 'bar'], 'string')); + } + + public function testIsSequentialArrayOfArrays(): void + { + self::assertFalse($this->arrayTraitClass->isSequentialArrayOfArrays([])); + self::assertTrue($this->arrayTraitClass->isSequentialArrayOfArrays([['foo'], ['bar']])); + } +} diff --git a/tests/Util/IriHelperTest.php b/tests/Util/IriHelperTest.php index 8e6814f2404..83e61df47c1 100644 --- a/tests/Util/IriHelperTest.php +++ b/tests/Util/IriHelperTest.php @@ -13,6 +13,7 @@ namespace ApiPlatform\Core\Tests\Util; +use ApiPlatform\Core\Api\UrlGeneratorInterface; use ApiPlatform\Core\Exception\InvalidArgumentException; use ApiPlatform\Core\Util\IriHelper; use PHPUnit\Framework\TestCase; @@ -39,7 +40,32 @@ public function testHelpers() $this->assertEquals('/hello.json?foo=bar&bar=3&page=2', IriHelper::createIri($parsed['parts'], $parsed['parameters'], 'page', 2.)); } - public function testHelpersWithAbsoluteUrl() + /** + * @group legacy + * @expectedDeprecation Passing a bool as 5th parameter to "ApiPlatform\Core\Util\IriHelper::createIri()" is deprecated since API Platform 2.6. Pass an "ApiPlatform\Core\Api\UrlGeneratorInterface" constant (int) instead. + */ + public function testLegacyHelpers() + { + $parsed = [ + 'parts' => [ + 'path' => '/hello.json', + 'query' => 'foo=bar&page=2&bar=3', + ], + 'parameters' => [ + 'foo' => 'bar', + 'bar' => '3', + ], + ]; + + $this->assertEquals($parsed, IriHelper::parseIri('/hello.json?foo=bar&page=2&bar=3', 'page')); + $this->assertEquals('/hello.json?foo=bar&bar=3&page=2', IriHelper::createIri($parsed['parts'], $parsed['parameters'], 'page', 2., false)); + } + + /** + * @group legacy + * @expectedDeprecation Passing a bool as 5th parameter to "ApiPlatform\Core\Util\IriHelper::createIri()" is deprecated since API Platform 2.6. Pass an "ApiPlatform\Core\Api\UrlGeneratorInterface" constant (int) instead. + */ + public function testLegacyHelpersWithAbsoluteUrl() { $parsed = [ 'parts' => [ @@ -70,6 +96,36 @@ public function testHelpersWithAbsoluteUrl() $this->assertEquals('https://foo:bar@localhost:443/hello.json?foo=bar&bar=3&page=2#foo', IriHelper::createIri($parsed['parts'], $parsed['parameters'], 'page', 2., true)); } + public function testHelpersWithNetworkPath() + { + $parsed = [ + 'parts' => [ + 'path' => '/hello.json', + 'query' => 'foo=bar&page=2&bar=3', + 'scheme' => 'http', + 'user' => 'foo', + 'pass' => 'bar', + 'host' => 'localhost', + 'port' => 8080, + 'fragment' => 'foo', + ], + 'parameters' => [ + 'foo' => 'bar', + 'bar' => '3', + ], + ]; + + $this->assertEquals('//foo:bar@localhost:8080/hello.json?foo=bar&bar=3&page=2#foo', IriHelper::createIri($parsed['parts'], $parsed['parameters'], 'page', 2., UrlGeneratorInterface::NET_PATH)); + + unset($parsed['parts']['scheme']); + + $this->assertEquals('//foo:bar@localhost:8080/hello.json?foo=bar&bar=3&page=2#foo', IriHelper::createIri($parsed['parts'], $parsed['parameters'], 'page', 2., UrlGeneratorInterface::NET_PATH)); + + $parsed['parts']['port'] = 443; + + $this->assertEquals('//foo:bar@localhost:443/hello.json?foo=bar&bar=3&page=2#foo', IriHelper::createIri($parsed['parts'], $parsed['parameters'], 'page', 2., UrlGeneratorInterface::NET_PATH)); + } + public function testParseIriWithInvalidUrl() { $this->expectException(InvalidArgumentException::class); diff --git a/tests/Util/RequestAttributesExtractorTest.php b/tests/Util/RequestAttributesExtractorTest.php index 37b2d082467..9288ba0986c 100644 --- a/tests/Util/RequestAttributesExtractorTest.php +++ b/tests/Util/RequestAttributesExtractorTest.php @@ -189,4 +189,22 @@ public function testOperationNotSet() { $this->assertEmpty(RequestAttributesExtractor::extractAttributes(new Request([], [], ['_api_resource_class' => 'Foo']))); } + + public function testExtractPreviousDataAttributes() + { + $object = new \stdClass(); + $request = new Request([], [], ['_api_resource_class' => 'Foo', '_api_item_operation_name' => 'get', 'previous_data' => $object]); + + $this->assertEquals( + [ + 'resource_class' => 'Foo', + 'item_operation_name' => 'get', + 'receive' => true, + 'respond' => true, + 'persist' => true, + 'previous_data' => $object, + ], + RequestAttributesExtractor::extractAttributes($request) + ); + } } diff --git a/tests/Util/SortTraitTest.php b/tests/Util/SortTraitTest.php new file mode 100644 index 00000000000..4a924a13afa --- /dev/null +++ b/tests/Util/SortTraitTest.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +declare(strict_types=1); + +namespace ApiPlatform\Core\Tests\Util; + +use ApiPlatform\Core\Util\SortTrait; +use PHPUnit\Framework\TestCase; + +/** + * @author Alan Poulain + */ +class SortTraitTest extends TestCase +{ + private function getSortTraitImplementation() + { + return new class() { + use SortTrait { + SortTrait::arrayRecursiveSort as public; + } + }; + } + + public function testArrayRecursiveSort(): void + { + $sortTrait = $this->getSortTraitImplementation(); + + $array = [ + 'second', + [ + 'second', + 'first', + ], + 'first', + ]; + + $sortTrait->arrayRecursiveSort($array, 'sort'); + + $this->assertSame([ + 'first', + 'second', + [ + 'first', + 'second', + ], + ], $array); + } +}